From 916fb78dfb711c40898b910e9665599f7c85d96b Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 10 May 2026 10:52:26 +0000 Subject: [PATCH] feat: hierarchical notebook system - trash, selectors, breadcrumb, sidebar tree - Schema: soft delete with trashedAt on Notebook model - API: PATCH/GET notebooks support trashedAt filtering with cascade - Sidebar: recursive tree rendering with collapse/expand, visual guides, hover actions - HierarchicalNotebookSelector: portal-based dropdown with search, breadcrumbs, dropUp support - AI chat: context selector with Toutes mes notes + notebook selector - Agent detail: flat selects replaced with HierarchicalNotebookSelector - Breadcrumb: notebook path display on home page - Trash view: card grid with countdown, restore/permanent delete - CSS: design tokens (ink, paper, blueprint, concrete, etc.) - Types: parentId, trashedAt added to Notebook interface --- memento-note/app/(main)/agents/page.tsx | 5 +- memento-note/app/(main)/trash/page.tsx | 22 +- .../app/(main)/trash/trash-client.tsx | 291 ++++++++ memento-note/app/actions/notes.ts | 47 +- memento-note/app/api/notebooks/[id]/route.ts | 72 +- memento-note/app/api/notebooks/route.ts | 2 +- memento-note/app/globals.css | 11 + .../components/agents/agent-detail-view.tsx | 41 +- memento-note/components/ai-chat.tsx | 58 +- .../components/contextual-ai-chat.tsx | 44 +- .../components/create-notebook-dialog.tsx | 29 +- .../hierarchical-notebook-selector.tsx | 240 +++++++ memento-note/components/home-client.tsx | 50 +- memento-note/components/note-card.tsx | 72 +- .../note-editor/note-editor-context.tsx | 2 +- .../components/note-inline-editor.tsx | 2 +- memento-note/components/sidebar.tsx | 664 ++++++++++++------ memento-note/context/notebooks-context.tsx | 55 ++ memento-note/lib/types.ts | 1 + memento-note/prisma/schema.prisma | 2 + 20 files changed, 1319 insertions(+), 391 deletions(-) create mode 100644 memento-note/app/(main)/trash/trash-client.tsx create mode 100644 memento-note/components/hierarchical-notebook-selector.tsx diff --git a/memento-note/app/(main)/agents/page.tsx b/memento-note/app/(main)/agents/page.tsx index 5f99b81..e8b6af0 100644 --- a/memento-note/app/(main)/agents/page.tsx +++ b/memento-note/app/(main)/agents/page.tsx @@ -13,8 +13,9 @@ export default async function AgentsPage() { const [agents, notebooks] = await Promise.all([ getAgents(), prisma.notebook.findMany({ - where: { userId }, - orderBy: { order: 'asc' } + where: { userId, trashedAt: null }, + orderBy: { order: 'asc' }, + select: { id: true, name: true, icon: true, parentId: true, trashedAt: true } }) ]) diff --git a/memento-note/app/(main)/trash/page.tsx b/memento-note/app/(main)/trash/page.tsx index d529911..7a7419d 100644 --- a/memento-note/app/(main)/trash/page.tsx +++ b/memento-note/app/(main)/trash/page.tsx @@ -1,21 +1,13 @@ -import { getTrashedNotes } from '@/app/actions/notes' -import { MasonryGrid } from '@/components/masonry-grid' -import { TrashHeader } from '@/components/trash-header' -import { TrashEmptyState } from './trash-empty-state' +import { getTrashedNotes, getTrashedNotebooks } from '@/app/actions/notes' +import { TrashClient } from './trash-client' export const dynamic = 'force-dynamic' export default async function TrashPage() { - const notes = await getTrashedNotes() + const [notes, notebooks] = await Promise.all([ + getTrashedNotes(), + getTrashedNotebooks(), + ]) - return ( -
- - {notes.length > 0 ? ( - - ) : ( - - )} -
- ) + return } diff --git a/memento-note/app/(main)/trash/trash-client.tsx b/memento-note/app/(main)/trash/trash-client.tsx new file mode 100644 index 0000000..b02de45 --- /dev/null +++ b/memento-note/app/(main)/trash/trash-client.tsx @@ -0,0 +1,291 @@ +'use client' + +import { useState, useMemo } from 'react' +import { useRouter } from 'next/navigation' +import { motion, AnimatePresence } from 'motion/react' +import { + Trash2, + RotateCcw, + X, + FileText, + Folder, + Search, + Clock, + AlertCircle, +} from 'lucide-react' +import { Note, Notebook } from '@/lib/types' +import { restoreNote, permanentDeleteNote, emptyTrash } from '@/app/actions/notes' +import { restoreNotebook, permanentDeleteNotebook } from '@/context/notebooks-context' +import { useLanguage } from '@/lib/i18n' +import { toast } from 'sonner' +import { useNotebooks } from '@/context/notebooks-context' + +type FilterType = 'all' | 'notes' | 'notebooks' + +interface TrashItem { + id: string + title: string + itemType: 'note' | 'notebook' + deletedAt: string | null + content?: string + notesCount?: number +} + +function getDaysRemaining(deletedAt?: string | null): number { + if (!deletedAt) return 30 + const deletedDate = new Date(deletedAt) + const now = new Date() + const diffTime = now.getTime() - deletedDate.getTime() + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) + return Math.max(0, 30 - diffDays) +} + +export function TrashClient({ + initialNotes, + initialNotebooks, +}: { + initialNotes: Note[] + initialNotebooks: any[] +}) { + const { t } = useLanguage() + const router = useRouter() + const { restoreNotebook: restoreNb, permanentDeleteNotebook: permDeleteNb } = useNotebooks() + + const [notes, setNotes] = useState(initialNotes) + const [notebooks, setNotebooks] = useState(initialNotebooks) + const [searchQuery, setSearchQuery] = useState('') + const [filterType, setFilterType] = useState('all') + const [isEmptying, setIsEmptying] = useState(false) + + const items: TrashItem[] = useMemo(() => { + const all: TrashItem[] = [ + ...notes.map(n => ({ + id: n.id, + title: n.title || t('notes.untitled') || 'Untitled', + itemType: 'note' as const, + deletedAt: (n as any).trashedAt || null, + content: n.content, + })), + ...notebooks.map((nb: any) => ({ + id: nb.id, + title: nb.name, + itemType: 'notebook' as const, + deletedAt: nb.trashedAt || null, + notesCount: nb._count?.notes ?? 0, + })), + ] + + return all + .filter(item => { + const matchesSearch = item.title.toLowerCase().includes(searchQuery.toLowerCase()) + const matchesType = + filterType === 'all' || + (filterType === 'notes' && item.itemType === 'note') || + (filterType === 'notebooks' && item.itemType === 'notebook') + return matchesSearch && matchesType + }) + .sort((a, b) => { + const dateA = a.deletedAt ? new Date(a.deletedAt).getTime() : 0 + const dateB = b.deletedAt ? new Date(b.deletedAt).getTime() : 0 + return dateB - dateA + }) + }, [notes, notebooks, searchQuery, filterType, t]) + + const handleRestore = async (item: TrashItem) => { + try { + if (item.itemType === 'note') { + await restoreNote(item.id) + setNotes(prev => prev.filter(n => n.id !== item.id)) + } else { + await restoreNb(item.id) + setNotebooks(prev => prev.filter((nb: any) => nb.id !== item.id)) + } + toast.success(t('trash.restoreSuccess') || 'Restored successfully') + } catch { + toast.error(t('trash.restoreError') || 'Failed to restore') + } + } + + const handlePermanentDelete = async (item: TrashItem) => { + try { + if (item.itemType === 'note') { + await permanentDeleteNote(item.id) + setNotes(prev => prev.filter(n => n.id !== item.id)) + } else { + await permDeleteNb(item.id) + setNotebooks(prev => prev.filter((nb: any) => nb.id !== item.id)) + } + toast.success(t('trash.permanentDeleteSuccess') || 'Permanently deleted') + } catch { + toast.error(t('trash.deleteError') || 'Failed to delete') + } + } + + const handleEmptyTrash = async () => { + if (!window.confirm(t('trash.emptyTrashConfirm') || 'Empty trash? This is irreversible.')) return + setIsEmptying(true) + try { + await emptyTrash() + setNotes([]) + setNotebooks([]) + toast.success(t('trash.emptyTrashSuccess') || 'Trash emptied') + } catch { + toast.error(t('trash.deleteError') || 'Failed to empty trash') + } finally { + setIsEmptying(false) + } + } + + return ( +
+
+
+
+

+ {t('sidebar.trash') || 'Trash'} +

+

+ {t('trash.autoDelete30') || 'Auto-delete after 30 days'} +

+
+ + {items.length > 0 && ( + + )} +
+ +
+
+ + setSearchQuery(e.target.value)} + className="w-full bg-white dark:bg-white/5 border border-border/40 rounded-2xl pl-12 pr-6 py-4 text-sm outline-none focus:ring-4 ring-foreground/5 transition-all shadow-sm" + /> +
+ +
+ {(['all', 'notes', 'notebooks'] as FilterType[]).map(type => ( + + ))} +
+
+
+ +
+ {items.length > 0 ? ( +
+ + {items.map(item => { + const daysLeft = getDaysRemaining(item.deletedAt) + return ( + +
+ +
+ +
+
+ {item.itemType === 'note' ? : } +
+
+ + +
+
+ +
+

+ {item.title} +

+
+
+ {daysLeft} {t('trash.daysRemaining') || 'DAYS LEFT'} +
+ {item.deletedAt && ( + + {new Date(item.deletedAt).toLocaleDateString()} + + )} +
+
+ + {item.itemType === 'note' && item.content ? ( +
+ {item.content.replace(/[#*`]/g, '').slice(0, 200)} +
+ ) : ( +
+
+ {t('trash.notebookContentPreserved') || 'Notebook content preserved'} +
+
+ )} +
+ ) + })} +
+
+ ) : ( +
+
+ +
+
+

+ {t('trash.empty') || 'Trash is empty'} +

+

+ {t('trash.emptyDescription') || 'Deleted items will appear here. They are kept for 30 days before permanent deletion.'} +

+
+
+ )} +
+ +
+ +

+ {t('trash.notebookRestoreHint') || 'Restoring a notebook also restores all its notes.'} +

+
+
+ ) +} diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index 2feacc8..7f2d8a4 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -1053,7 +1053,6 @@ export async function emptyTrash() { if (!session?.user?.id) throw new Error('Unauthorized'); try { - // Fetch trashed notes with images before deleting const trashedNotes = await prisma.note.findMany({ where: { userId: session.user.id, @@ -1069,7 +1068,6 @@ export async function emptyTrash() { } }) - // Clean up image files for all deleted notes for (const note of trashedNotes) { const imageUrls = parseImageUrls(note.images) if (imageUrls.length > 0) { @@ -1077,6 +1075,13 @@ export async function emptyTrash() { } } + await prisma.notebook.deleteMany({ + where: { + userId: session.user.id, + trashedAt: { not: null } + } + }) + await syncLabels(session.user.id, []) revalidatePath('/trash') revalidatePath('/') @@ -1137,14 +1142,44 @@ export async function getTrashCount() { if (!session?.user?.id) return 0; try { - return await prisma.note.count({ + const [noteCount, notebookCount] = await Promise.all([ + prisma.note.count({ + where: { + userId: session.user.id, + trashedAt: { not: null } + } + }), + prisma.notebook.count({ + where: { + userId: session.user.id, + trashedAt: { not: null } + } + }) + ]) + return noteCount + notebookCount + } catch { + return 0 + } +} + +export async function getTrashedNotebooks() { + const session = await auth(); + if (!session?.user?.id) return []; + + try { + return await prisma.notebook.findMany({ where: { userId: session.user.id, trashedAt: { not: null } - } + }, + include: { + _count: { select: { notes: true } } + }, + orderBy: { trashedAt: 'desc' } }) - } catch { - return 0 + } catch (error) { + console.error('Error fetching trashed notebooks:', error) + return [] } } diff --git a/memento-note/app/api/notebooks/[id]/route.ts b/memento-note/app/api/notebooks/[id]/route.ts index 5a1310a..a7f85d6 100644 --- a/memento-note/app/api/notebooks/[id]/route.ts +++ b/memento-note/app/api/notebooks/[id]/route.ts @@ -3,7 +3,19 @@ import prisma from '@/lib/prisma' import { auth } from '@/auth' import { revalidatePath } from 'next/cache' -// PATCH /api/notebooks/[id] - Update a notebook +async function getDescendantIds(notebookId: string): Promise { + const ids: string[] = [] + const children = await prisma.notebook.findMany({ + where: { parentId: notebookId }, + select: { id: true }, + }) + for (const child of children) { + ids.push(child.id) + ids.push(...await getDescendantIds(child.id)) + } + return ids +} + export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -16,9 +28,8 @@ export async function PATCH( try { const { id } = await params const body = await request.json() - const { name, icon, color, order } = body + const { name, icon, color, order, trashedAt } = body - // Verify ownership const existing = await prisma.notebook.findUnique({ where: { id }, select: { userId: true } @@ -38,32 +49,30 @@ export async function PATCH( ) } - // Build update data const updateData: any = {} if (name !== undefined) updateData.name = name.trim() if (icon !== undefined) updateData.icon = icon if (color !== undefined) updateData.color = color if (order !== undefined) updateData.order = order + if (trashedAt !== undefined) updateData.trashedAt = trashedAt - // Update notebook - const notebook = await prisma.notebook.update({ - where: { id }, - data: updateData, - include: { - labels: true, - _count: { - select: { notes: true } - } - } - }) + if (trashedAt !== undefined) { + const descendantIds = await getDescendantIds(id) + const allIds = [id, ...descendantIds] + await prisma.notebook.updateMany({ + where: { id: { in: allIds }, userId: session.user.id }, + data: { trashedAt }, + }) + } else { + await prisma.notebook.update({ + where: { id }, + data: updateData, + }) + } - revalidatePath('/') + try { revalidatePath('/') } catch {} - return NextResponse.json({ - success: true, - ...notebook, - notesCount: notebook._count.notes - }) + return NextResponse.json({ success: true }) } catch (error) { console.error('Error updating notebook:', error) return NextResponse.json( @@ -73,7 +82,6 @@ export async function PATCH( } } -// DELETE /api/notebooks/[id] - Delete a notebook export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -86,16 +94,9 @@ export async function DELETE( try { const { id } = await params - // Verify ownership and get notebook info const notebook = await prisma.notebook.findUnique({ where: { id }, - select: { - userId: true, - name: true, - _count: { - select: { notes: true, labels: true } - } - } + select: { userId: true, name: true } }) if (!notebook) { @@ -112,18 +113,13 @@ export async function DELETE( ) } - // Delete notebook (cascade will handle labels and notes) - await prisma.notebook.delete({ - where: { id } - }) + await prisma.notebook.delete({ where: { id } }) - revalidatePath('/') + try { revalidatePath('/') } catch {} return NextResponse.json({ success: true, - message: `Notebook "${notebook.name}" deleted`, - notesCount: notebook._count.notes, - labelsCount: notebook._count.labels + message: `Notebook "${notebook.name}" permanently deleted`, }) } catch (error) { console.error('Error deleting notebook:', error) diff --git a/memento-note/app/api/notebooks/route.ts b/memento-note/app/api/notebooks/route.ts index 1f4b075..791d967 100644 --- a/memento-note/app/api/notebooks/route.ts +++ b/memento-note/app/api/notebooks/route.ts @@ -31,7 +31,7 @@ export async function GET(request: NextRequest) { try { const notebooks = await prisma.notebook.findMany({ - where: { userId: session.user.id }, + where: { userId: session.user.id, trashedAt: null }, include: { labels: { orderBy: { name: 'asc' } }, _count: { diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 31cd3d9..75a0617 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -24,6 +24,17 @@ --color-background-light: var(--color-memento-paper); --color-background-dark: #202020; + /* Design tokens from architectural-grid 10 */ + --color-ink: #1C1C1C; + --color-paper: #F2F0E9; + --color-muted-ink: rgba(28, 28, 28, 0.6); + --color-concrete: #8D8D8D; + --color-blueprint: #75B2D6; + --color-ochre: #D4A373; + --color-sage: #A3B18A; + --color-rust: #9B2226; + --color-glass: rgba(255, 255, 255, 0.4); + --font-sans: var(--font-inter); --font-heading: var(--font-memento-serif), ui-serif, Georgia, "Times New Roman", serif; --shadow-level-1: 0 2px 4px rgba(0, 0, 0, 0.04), 0 4px 8px rgba(0, 0, 0, 0.06); diff --git a/memento-note/components/agents/agent-detail-view.tsx b/memento-note/components/agents/agent-detail-view.tsx index 570df9c..3ff5da3 100644 --- a/memento-note/components/agents/agent-detail-view.tsx +++ b/memento-note/components/agents/agent-detail-view.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo, useRef, useCallback, useEffect } from 'react' +import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { motion } from 'motion/react' import { ArrowLeft, @@ -30,6 +30,7 @@ import { BookOpen, LifeBuoy, } from 'lucide-react' +import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector' import { toast } from 'sonner' import { useLanguage } from '@/lib/i18n' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -488,21 +489,12 @@ export function AgentDetailView({ {showSourceNotebook && (
- + !nb.trashedAt)} + selectedId={sourceNotebookId || null} + onSelect={(id) => { setSourceNotebookId(id); setSourceNoteIds([]) }} + placeholder={t('agents.form.selectNotebook') || 'Select notebook...'} + />
)} @@ -648,17 +640,12 @@ export function AgentDetailView({ {type !== 'slide-generator' && type !== 'excalidraw-generator' && (
- + !nb.trashedAt)} + selectedId={targetNotebookId || null} + onSelect={(id) => setTargetNotebookId(id)} + placeholder={t('agents.form.inbox') || 'Inbox'} + />
)} diff --git a/memento-note/components/ai-chat.tsx b/memento-note/components/ai-chat.tsx index 29e99f0..8180f9c 100644 --- a/memento-note/components/ai-chat.tsx +++ b/memento-note/components/ai-chat.tsx @@ -11,7 +11,7 @@ import { useLanguage } from '@/lib/i18n' import { MarkdownContent } from '@/components/markdown-content' import { useWebSearchAvailable } from '@/hooks/use-web-search-available' import { useNotebooks } from '@/context/notebooks-context' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector' import { toast } from 'sonner' import { createConversation } from '@/app/actions/chat-actions' @@ -352,29 +352,39 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b {/* Input Area & Tone Controls (Only in Chat tab) */}
{/* Context Scope */} -
- {t('ai.discussionContextLabel')} - +
+ Source du Contexte + + +
+
+ + Carnet +
+
+ + !nb.trashedAt)} + selectedId={chatScope !== 'all' ? chatScope : null} + onSelect={(id) => setChatScope(id)} + placeholder={t('ai.selectNotebook') || 'Inclure un carnet...'} + dropUp + />
{/* Tone Selection */} diff --git a/memento-note/components/contextual-ai-chat.tsx b/memento-note/components/contextual-ai-chat.tsx index e621259..2880344 100644 --- a/memento-note/components/contextual-ai-chat.tsx +++ b/memento-note/components/contextual-ai-chat.tsx @@ -46,6 +46,7 @@ import { SelectValue, } from '@/components/ui/select' import { getNotebookIcon } from '@/lib/notebook-icon' +import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector' import { scrapePageText } from '@/app/actions/scrape' // ── Helpers ────────────────────────────────────────────────────────────────── @@ -111,7 +112,7 @@ interface ContextualAIChatProps { /** Whether the last action has been applied (so we can show undo) */ lastActionApplied?: boolean /** Notebooks available for scope selection */ - notebooks?: Array<{ id: string; name: string }> + notebooks?: Array<{ id: string; name: string; parentId?: string | null; trashedAt?: any }> /** Extra classes forwarded to the aside root element */ className?: string /** How to embed generated diagram images (markdown vs rich text HTML) */ @@ -679,21 +680,32 @@ export function ContextualAIChat({
- +
+ +
+
+ + Carnet +
+
+ !nb.trashedAt)} + selectedId={chatScope !== 'note' && chatScope !== 'all' ? chatScope : null} + onSelect={(id) => setChatScope(id)} + placeholder="Inclure un carnet..." + className="w-full" + dropUp + /> +
diff --git a/memento-note/components/create-notebook-dialog.tsx b/memento-note/components/create-notebook-dialog.tsx index e94dda6..7e67b4f 100644 --- a/memento-note/components/create-notebook-dialog.tsx +++ b/memento-note/components/create-notebook-dialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { motion, AnimatePresence } from 'motion/react' import { useLanguage } from '@/lib/i18n' import { useNotebooks } from '@/context/notebooks-context' @@ -15,10 +15,17 @@ export function CreateNotebookDialog({ open, onOpenChange, parentNotebookId }: C const { t } = useLanguage() const { createNotebookOptimistic, notebooks } = useNotebooks() const [name, setName] = useState('') - const [selectedParentId, setSelectedParentId] = useState(parentNotebookId ?? null) + const [selectedParentId, setSelectedParentId] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) - const rootNotebooks = notebooks.filter(nb => !nb.parentId) + const rootNotebooks = notebooks.filter(nb => !nb.parentId && !nb.trashedAt) + + useEffect(() => { + if (open) { + setSelectedParentId(parentNotebookId ?? null) + setName('') + } + }, [open, parentNotebookId]) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -42,10 +49,12 @@ export function CreateNotebookDialog({ open, onOpenChange, parentNotebookId }: C const handleClose = () => { setName('') - setSelectedParentId(parentNotebookId ?? null) + setSelectedParentId(null) onOpenChange?.(false) } + const isSubNotebook = !!parentNotebookId + return ( {open && ( @@ -66,10 +75,16 @@ export function CreateNotebookDialog({ open, onOpenChange, parentNotebookId }: C className="relative w-full max-w-md bg-[#F2F0E9] dark:bg-zinc-900 border border-black/10 dark:border-white/10 shadow-2xl rounded-2xl p-8" >

- {t('notebook.createNew') || 'Nouveau carnet'} + {isSubNotebook + ? (t('notebook.createSubNotebook') || 'Nouveau sous-carnet') + : (t('notebook.createNew') || 'Nouveau carnet') + }

- {t('notebook.createDescription') || 'Donnez un nom à votre nouveau carnet.'} + {isSubNotebook && parentNotebookId + ? `${t('notebook.under') || 'Sous'} ${notebooks.find(nb => nb.id === parentNotebookId)?.name || ''}` + : (t('notebook.createDescription') || 'Donnez un nom à votre nouveau carnet.') + }

@@ -87,7 +102,7 @@ export function CreateNotebookDialog({ open, onOpenChange, parentNotebookId }: C />
- {!parentNotebookId && rootNotebooks.length > 0 && ( + {!isSubNotebook && rootNotebooks.length > 0 && (