feat: add AI-powered notebook organization with preview dialog
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m24s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m24s
This commit is contained in:
244
memento-note/app/actions/organize-notebook.ts
Normal file
244
memento-note/app/actions/organize-notebook.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export interface OrganizationNote {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface OrganizationGroup {
|
||||
name: string
|
||||
isNew: boolean
|
||||
existingId?: string
|
||||
notes: OrganizationNote[]
|
||||
}
|
||||
|
||||
export interface OrganizationPlan {
|
||||
notebookId: string
|
||||
groups: OrganizationGroup[]
|
||||
}
|
||||
|
||||
export interface AnalyzeResult {
|
||||
success: boolean
|
||||
plan?: OrganizationPlan
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ExecuteResult {
|
||||
success: boolean
|
||||
created: number
|
||||
moved: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a notebook's notes with AI and suggest a sub-notebook organization plan.
|
||||
*/
|
||||
export async function analyzeNotebookForOrganization(notebookId: string): Promise<AnalyzeResult> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { success: false, error: 'Non autorisé' }
|
||||
|
||||
try {
|
||||
// 1. Fetch the target notebook (ensure ownership)
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId: session.user.id, trashedAt: null },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
if (!notebook) return { success: false, error: 'Carnet introuvable' }
|
||||
|
||||
// 2. Fetch all notes in this notebook
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
notebookId,
|
||||
userId: session.user.id,
|
||||
trashedAt: null,
|
||||
isArchived: false,
|
||||
},
|
||||
select: { id: true, title: true, content: true, labels: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
if (notes.length < 2) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Ce carnet contient moins de 2 notes — il n\'y a rien à organiser.',
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch existing sub-notebooks
|
||||
const existingSubs = await prisma.notebook.findMany({
|
||||
where: { parentId: notebookId, userId: session.user.id, trashedAt: null },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
// 4. Build prompt context — truncate content to save tokens
|
||||
const notesContext = notes.map((n, i) => {
|
||||
const title = n.title || `Note sans titre ${i + 1}`
|
||||
const content = (n.content || '').slice(0, 300).replace(/\n+/g, ' ')
|
||||
let labelsStr = ''
|
||||
if (n.labels) {
|
||||
try {
|
||||
const parsed = JSON.parse(n.labels as string)
|
||||
if (Array.isArray(parsed)) labelsStr = parsed.join(', ')
|
||||
} catch {}
|
||||
}
|
||||
return `[${n.id}] "${title}"${labelsStr ? ` (tags: ${labelsStr})` : ''} — ${content}`
|
||||
}).join('\n')
|
||||
|
||||
const existingSubsContext = existingSubs.length > 0
|
||||
? `\nSous-carnets existants:\n${existingSubs.map(s => `- "${s.name}" (id: ${s.id})`).join('\n')}`
|
||||
: '\nIl n\'y a pas encore de sous-carnets.'
|
||||
|
||||
const prompt = `Tu es un assistant d'organisation de notes. Analyse les notes suivantes du carnet "${notebook.name}" et regroupe-les par thème en proposant des sous-carnets.
|
||||
${existingSubsContext}
|
||||
|
||||
Notes à organiser:
|
||||
${notesContext}
|
||||
|
||||
RÈGLES IMPORTANTES:
|
||||
- Regroupe les notes par thème ou sujet similaire.
|
||||
- Propose entre 2 et 6 groupes maximum.
|
||||
- Si un sous-carnet existant correspond déjà à un thème, utilise-le (indique son id).
|
||||
- Les noms de groupe doivent être courts (2-4 mots), clairs et en français.
|
||||
- N'inclus PAS toutes les notes si certaines sont trop générales ou ne correspondent à aucun groupe clair — laisse-les de côté.
|
||||
- Réponds UNIQUEMENT avec du JSON valide, sans markdown, sans explication.
|
||||
|
||||
Format de réponse JSON attendu:
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"name": "Nom du sous-carnet",
|
||||
"existingId": "id-si-sous-carnet-existant-ou-null",
|
||||
"noteIds": ["id1", "id2", "id3"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// 5. Call AI
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
const rawResponse = await provider.generateText(prompt)
|
||||
|
||||
// 6. Parse AI response
|
||||
let parsed: { groups: Array<{ name: string; existingId?: string | null; noteIds: string[] }> }
|
||||
try {
|
||||
// Clean possible markdown code blocks
|
||||
const cleaned = rawResponse.trim().replace(/^```json\n?/, '').replace(/\n?```$/, '')
|
||||
parsed = JSON.parse(cleaned)
|
||||
} catch {
|
||||
return { success: false, error: 'L\'IA n\'a pas pu générer un plan valide. Réessayez.' }
|
||||
}
|
||||
|
||||
if (!parsed?.groups || !Array.isArray(parsed.groups)) {
|
||||
return { success: false, error: 'Réponse IA invalide.' }
|
||||
}
|
||||
|
||||
// 7. Build the OrganizationPlan
|
||||
const noteMap = new Map(notes.map(n => [n.id, n]))
|
||||
const existingSubMap = new Map(existingSubs.map(s => [s.id, s]))
|
||||
|
||||
const groups: OrganizationGroup[] = parsed.groups
|
||||
.filter(g => g.name && Array.isArray(g.noteIds) && g.noteIds.length > 0)
|
||||
.map(g => {
|
||||
const existingId = g.existingId && existingSubMap.has(g.existingId) ? g.existingId : undefined
|
||||
const groupNotes = g.noteIds
|
||||
.filter(id => noteMap.has(id))
|
||||
.map(id => {
|
||||
const n = noteMap.get(id)!
|
||||
return { id: n.id, title: n.title || 'Note sans titre' }
|
||||
})
|
||||
return {
|
||||
name: g.name,
|
||||
isNew: !existingId,
|
||||
existingId,
|
||||
notes: groupNotes,
|
||||
}
|
||||
})
|
||||
.filter(g => g.notes.length > 0)
|
||||
|
||||
if (groups.length === 0) {
|
||||
return { success: false, error: 'L\'IA n\'a pas trouvé de groupes thématiques distincts.' }
|
||||
}
|
||||
|
||||
return { success: true, plan: { notebookId, groups } }
|
||||
} catch (err) {
|
||||
console.error('[organize-notebook] Analysis error:', err)
|
||||
return { success: false, error: 'Une erreur s\'est produite lors de l\'analyse.' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an approved organization plan:
|
||||
* - Creates missing sub-notebooks
|
||||
* - Moves notes to the correct sub-notebook
|
||||
*/
|
||||
export async function executeNotebookOrganization(plan: OrganizationPlan): Promise<ExecuteResult> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { success: false, created: 0, moved: 0, error: 'Non autorisé' }
|
||||
|
||||
try {
|
||||
// Verify notebook ownership
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: plan.notebookId, userId: session.user.id, trashedAt: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!notebook) return { success: false, created: 0, moved: 0, error: 'Carnet introuvable' }
|
||||
|
||||
let created = 0
|
||||
let moved = 0
|
||||
|
||||
for (const group of plan.groups) {
|
||||
let targetNotebookId: string
|
||||
|
||||
if (!group.isNew && group.existingId) {
|
||||
// Use existing sub-notebook
|
||||
targetNotebookId = group.existingId
|
||||
} else {
|
||||
// Create new sub-notebook
|
||||
const highestOrder = await prisma.notebook.findFirst({
|
||||
where: { parentId: plan.notebookId, userId: session.user.id },
|
||||
orderBy: { order: 'desc' },
|
||||
select: { order: true },
|
||||
})
|
||||
const nextOrder = (highestOrder?.order ?? -1) + 1
|
||||
|
||||
const newSub = await prisma.notebook.create({
|
||||
data: {
|
||||
name: group.name.trim(),
|
||||
icon: '📁',
|
||||
color: '#75B2D6',
|
||||
order: nextOrder,
|
||||
parentId: plan.notebookId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
targetNotebookId = newSub.id
|
||||
created++
|
||||
}
|
||||
|
||||
// Move notes
|
||||
const noteIds = group.notes.map(n => n.id)
|
||||
if (noteIds.length > 0) {
|
||||
await prisma.note.updateMany({
|
||||
where: {
|
||||
id: { in: noteIds },
|
||||
userId: session.user.id,
|
||||
},
|
||||
data: { notebookId: targetNotebookId },
|
||||
})
|
||||
moved += noteIds.length
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
return { success: true, created, moved }
|
||||
} catch (err) {
|
||||
console.error('[organize-notebook] Execute error:', err)
|
||||
return { success: false, created: 0, moved: 0, error: 'Erreur lors de l\'exécution du plan.' }
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ const NotebookSummaryDialog = dynamic(
|
||||
() => import('@/components/notebook-summary-dialog').then(m => ({ default: m.NotebookSummaryDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const OrganizeNotebookDialog = dynamic(
|
||||
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
type InitialSettings = {
|
||||
showRecentNotes: boolean
|
||||
@@ -88,6 +92,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
|
||||
const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false)
|
||||
const [organizeNotebookOpen, setOrganizeNotebookOpen] = useState(false)
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
|
||||
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||||
@@ -591,6 +596,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<span>{t('notes.reorganize') || 'Réorganiser les notes'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentNotebook && initialSettings.aiAssistantEnabled && (
|
||||
<button
|
||||
onClick={() => setOrganizeNotebookOpen(true)}
|
||||
className="flex items-center gap-2 text-[13px] text-blueprint font-medium hover:opacity-70 transition-opacity"
|
||||
title="Organiser ce carnet avec l'IA"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
<span>Organiser</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{searchParams.get('notebook') && (
|
||||
@@ -804,6 +820,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
parentNotebookId={searchParams.get('notebook')}
|
||||
/>
|
||||
)}
|
||||
{currentNotebook && (
|
||||
<OrganizeNotebookDialog
|
||||
open={organizeNotebookOpen}
|
||||
onOpenChange={setOrganizeNotebookOpen}
|
||||
notebookId={currentNotebook.id}
|
||||
notebookName={currentNotebook.name}
|
||||
onDone={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
444
memento-note/components/organize-notebook-dialog.tsx
Normal file
444
memento-note/components/organize-notebook-dialog.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Sparkles, X, CheckCircle2, FolderPlus, Folder, ChevronDown, ChevronUp, Loader2, AlertCircle, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
analyzeNotebookForOrganization,
|
||||
executeNotebookOrganization,
|
||||
type OrganizationGroup,
|
||||
type OrganizationPlan,
|
||||
} from '@/app/actions/organize-notebook'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface OrganizeNotebookDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
notebookId: string
|
||||
notebookName: string
|
||||
onDone?: () => void
|
||||
}
|
||||
|
||||
type Step = 'idle' | 'analyzing' | 'preview' | 'executing' | 'done'
|
||||
|
||||
export function OrganizeNotebookDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
notebookId,
|
||||
notebookName,
|
||||
onDone,
|
||||
}: OrganizeNotebookDialogProps) {
|
||||
const [step, setStep] = useState<Step>('idle')
|
||||
const [plan, setPlan] = useState<OrganizationPlan | null>(null)
|
||||
const [editableGroups, setEditableGroups] = useState<OrganizationGroup[]>([])
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<{ created: number; moved: number } | null>(null)
|
||||
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
setStep('analyzing')
|
||||
setError(null)
|
||||
setPlan(null)
|
||||
|
||||
const res = await analyzeNotebookForOrganization(notebookId)
|
||||
|
||||
if (!res.success || !res.plan) {
|
||||
setError(res.error ?? 'Erreur inconnue')
|
||||
setStep('idle')
|
||||
return
|
||||
}
|
||||
|
||||
setPlan(res.plan)
|
||||
setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] })))
|
||||
setExpandedGroups(new Set(res.plan.groups.map((_, i) => i)))
|
||||
setStep('preview')
|
||||
}, [notebookId])
|
||||
|
||||
const handleRenameGroup = (idx: number, name: string) => {
|
||||
setEditableGroups(prev => prev.map((g, i) => i === idx ? { ...g, name } : g))
|
||||
}
|
||||
|
||||
const handleToggleNote = (groupIdx: number, noteId: string) => {
|
||||
setEditableGroups(prev => prev.map((g, i) => {
|
||||
if (i !== groupIdx) return g
|
||||
const has = g.notes.some(n => n.id === noteId)
|
||||
return {
|
||||
...g,
|
||||
notes: has ? g.notes.filter(n => n.id !== noteId) : g.notes,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveGroup = (idx: number) => {
|
||||
setEditableGroups(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const toggleExpand = (idx: number) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(idx) ? next.delete(idx) : next.add(idx)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!plan) return
|
||||
setStep('executing')
|
||||
|
||||
const finalPlan: OrganizationPlan = {
|
||||
notebookId: plan.notebookId,
|
||||
groups: editableGroups.filter(g => g.notes.length > 0 && g.name.trim()),
|
||||
}
|
||||
|
||||
const res = await executeNotebookOrganization(finalPlan)
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.error ?? 'Erreur inconnue')
|
||||
setStep('preview')
|
||||
return
|
||||
}
|
||||
|
||||
setResult({ created: res.created, moved: res.moved })
|
||||
setStep('done')
|
||||
toast.success(`Carnet organisé — ${res.created} sous-carnet(s) créé(s), ${res.moved} note(s) déplacée(s)`)
|
||||
onDone?.()
|
||||
}, [plan, editableGroups, onDone])
|
||||
|
||||
const handleClose = () => {
|
||||
if (step === 'analyzing' || step === 'executing') return
|
||||
onOpenChange(false)
|
||||
// Reset after close animation
|
||||
setTimeout(() => {
|
||||
setStep('idle')
|
||||
setPlan(null)
|
||||
setEditableGroups([])
|
||||
setError(null)
|
||||
setResult(null)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const totalNotes = editableGroups.reduce((acc, g) => acc + g.notes.length, 0)
|
||||
const newSubNbs = editableGroups.filter(g => g.isNew).length
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 bg-ink/30 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 60 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 60 }}
|
||||
transition={{ type: 'spring', stiffness: 280, damping: 30 }}
|
||||
className="fixed right-0 top-0 bottom-0 z-50 w-[460px] bg-card border-l border-border shadow-2xl flex flex-col"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-border/60 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blueprint/10 flex items-center justify-center">
|
||||
<Sparkles size={16} className="text-blueprint" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[13px] font-semibold text-ink">Organiser le carnet</h2>
|
||||
<p className="text-[11px] text-muted-ink truncate max-w-[240px]">{notebookName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={step === 'analyzing' || step === 'executing'}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-muted-ink hover:text-ink hover:bg-foreground/5 transition-colors disabled:opacity-30"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
{/* IDLE */}
|
||||
{step === 'idle' && (
|
||||
<motion.div
|
||||
key="idle"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="p-6 space-y-5"
|
||||
>
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-xl bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800/50">
|
||||
<AlertCircle size={14} className="text-rose-500 mt-0.5 shrink-0" />
|
||||
<p className="text-[12px] text-rose-600 dark:text-rose-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<p className="text-[13px] text-ink leading-relaxed">
|
||||
L'IA va analyser les notes de ce carnet et vous proposer un plan de réorganisation en sous-carnets thématiques.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
'Regroupement par sujet ou thème',
|
||||
'Création de sous-carnets manquants',
|
||||
'Aperçu complet avant modification',
|
||||
].map(item => (
|
||||
<li key={item} className="flex items-center gap-2 text-[12px] text-muted-ink">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blueprint shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ANALYZING */}
|
||||
{step === 'analyzing' && (
|
||||
<motion.div
|
||||
key="analyzing"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center justify-center p-12 gap-6 min-h-[300px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-2xl bg-blueprint/10 flex items-center justify-center">
|
||||
<Sparkles size={28} className="text-blueprint" />
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
className="absolute -inset-2 rounded-2xl border-2 border-transparent border-t-blueprint/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-[14px] font-medium text-ink">Analyse en cours…</p>
|
||||
<p className="text-[12px] text-muted-ink">L'IA lit vos notes et identifie les thèmes</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2].map(i => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.2 }}
|
||||
className="w-1.5 h-1.5 rounded-full bg-blueprint"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* PREVIEW */}
|
||||
{step === 'preview' && (
|
||||
<motion.div
|
||||
key="preview"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-5 space-y-4"
|
||||
>
|
||||
{/* Summary bar */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-blueprint/5 border border-blueprint/20">
|
||||
<Sparkles size={12} className="text-blueprint shrink-0" />
|
||||
<p className="text-[11px] text-blueprint font-medium">
|
||||
{editableGroups.length} groupe(s) · {totalNotes} note(s) · {newSubNbs} nouveau(x) sous-carnet(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-xl bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800/50">
|
||||
<AlertCircle size={14} className="text-rose-500 mt-0.5 shrink-0" />
|
||||
<p className="text-[12px] text-rose-600 dark:text-rose-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups */}
|
||||
<div className="space-y-3">
|
||||
{editableGroups.map((group, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="rounded-xl border border-border bg-background overflow-hidden"
|
||||
>
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center shrink-0">
|
||||
{group.isNew
|
||||
? <FolderPlus size={13} className="text-blueprint" />
|
||||
: <Folder size={13} className="text-muted-ink" />
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
value={group.name}
|
||||
onChange={e => handleRenameGroup(idx, e.target.value)}
|
||||
className="flex-1 bg-transparent text-[12px] font-semibold text-ink outline-none focus:text-blueprint transition-colors min-w-0"
|
||||
/>
|
||||
{group.isNew && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-blueprint/10 text-blueprint shrink-0">
|
||||
Nouveau
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => toggleExpand(idx)}
|
||||
className="w-6 h-6 rounded flex items-center justify-center text-muted-ink hover:text-ink transition-colors"
|
||||
>
|
||||
{expandedGroups.has(idx) ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveGroup(idx)}
|
||||
className="w-6 h-6 rounded flex items-center justify-center text-muted-ink hover:text-rose-500 transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes list */}
|
||||
<AnimatePresence>
|
||||
{expandedGroups.has(idx) && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border-t border-border/40 px-3 pb-2 pt-1 space-y-0.5">
|
||||
{group.notes.map(note => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="flex items-center gap-2 py-1.5 rounded-lg px-2 hover:bg-foreground/3 transition-colors group cursor-pointer"
|
||||
onClick={() => handleToggleNote(idx, note.id)}
|
||||
>
|
||||
<div className="w-4 h-4 rounded border border-border flex items-center justify-center shrink-0 group-hover:border-blueprint/50 transition-colors">
|
||||
<Check size={10} className="text-blueprint" />
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-ink truncate group-hover:text-ink transition-colors">
|
||||
{note.title || 'Note sans titre'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Collapsed count */}
|
||||
{!expandedGroups.has(idx) && (
|
||||
<div className="px-4 pb-2.5 text-[11px] text-muted-ink/60">
|
||||
{group.notes.length} note(s)
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* EXECUTING */}
|
||||
{step === 'executing' && (
|
||||
<motion.div
|
||||
key="executing"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center p-12 gap-5 min-h-[300px]"
|
||||
>
|
||||
<Loader2 size={32} className="text-blueprint animate-spin" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-[14px] font-medium text-ink">Organisation en cours…</p>
|
||||
<p className="text-[12px] text-muted-ink">Création des sous-carnets et déplacement des notes</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* DONE */}
|
||||
{step === 'done' && (
|
||||
<motion.div
|
||||
key="done"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center justify-center p-12 gap-5 min-h-[300px]"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-emerald-50 dark:bg-emerald-950/30 flex items-center justify-center">
|
||||
<CheckCircle2 size={32} className="text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-[15px] font-semibold text-ink">Carnet organisé !</p>
|
||||
{result && (
|
||||
<p className="text-[12px] text-muted-ink">
|
||||
{result.created} sous-carnet(s) créé(s) · {result.moved} note(s) déplacée(s)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="px-5 py-4 border-t border-border/60 shrink-0">
|
||||
{step === 'idle' && (
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-ink text-paper text-[13px] font-semibold hover:opacity-85 transition-opacity"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Analyser avec l'IA
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step === 'preview' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setStep('idle'); setError(null) }}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-[13px] font-medium text-muted-ink hover:text-ink transition-colors"
|
||||
>
|
||||
Recommencer
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={editableGroups.filter(g => g.notes.length > 0).length === 0}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-all',
|
||||
editableGroups.filter(g => g.notes.length > 0).length > 0
|
||||
? 'bg-ink text-paper hover:opacity-85'
|
||||
: 'bg-foreground/10 text-muted-ink cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Check size={14} />
|
||||
Valider
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-border text-[13px] font-medium text-muted-ink hover:text-ink transition-colors"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user