From 91b120111260c95182352bd0db1ef4436d88b7f3 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Fri, 8 May 2026 14:31:08 +0000 Subject: [PATCH] refactor: split NoteEditor into focused components + consolidate contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: NoteEditor Split (64KB → 9 focused components) - components/note-editor/: types.ts, context, toolbar, title-block, content-area, metadata-section, full-page, dialog compositions - Maintains backwards compatibility via re-export from note-editor.tsx Phase 2: Context Consolidation (5 → 3 contexts) - NotebooksContext absorbs LabelContext (labels CRUD) - EditorUIContext merges HomeViewContext + NotebookDragContext - Removed: LabelContext, home-view-context, notebook-drag-context Phase 3: React Query Infrastructure - Added QueryProvider with @tanstack/react-query - lib/query-keys.ts: centralized query key definitions - lib/query-hooks.ts: useNotes, useNotebooksQuery, useLabelsQuery - lib/use-refresh.ts: hybrid invalidateQueries + triggerRefresh helper - NotebooksContext: invalidateQueries on mutations (with triggerRefresh fallback) Co-Authored-By: Claude Opus 4.7 --- memento-note/app/actions/notes.ts | 5 +- memento-note/components/header-wrapper.tsx | 4 +- memento-note/components/header.tsx | 4 +- memento-note/components/home-client.tsx | 65 +- memento-note/components/label-badge.tsx | 4 +- memento-note/components/label-filter.tsx | 4 +- .../components/label-management-dialog.tsx | 4 +- memento-note/components/label-manager.tsx | 13 +- memento-note/components/label-selector.tsx | 4 +- memento-note/components/masonry-grid.tsx | 4 +- memento-note/components/note-card.tsx | 6 +- memento-note/components/note-editor/index.tsx | 37 + .../note-editor/note-content-area.tsx | 179 ++++ .../note-editor/note-editor-context.tsx | 796 ++++++++++++++++++ .../note-editor/note-editor-dialog.tsx | 345 ++++++++ .../note-editor/note-editor-full-page.tsx | 146 ++++ .../note-editor/note-editor-toolbar.tsx | 307 +++++++ .../note-editor/note-metadata-section.tsx | 50 ++ .../note-editor/note-title-block.tsx | 118 +++ memento-note/components/note-editor/types.ts | 174 ++++ .../components/note-inline-editor.tsx | 5 +- memento-note/components/note-input.tsx | 3 +- memento-note/components/notebooks-list.tsx | 8 +- memento-note/components/providers-wrapper.tsx | 19 +- memento-note/components/query-provider.tsx | 26 + memento-note/context/LabelContext.tsx | 145 ---- memento-note/context/editor-ui-context.tsx | 74 ++ memento-note/context/home-view-context.tsx | 36 - .../context/notebook-drag-context.tsx | 64 -- memento-note/context/notebooks-context.tsx | 151 +++- memento-note/lib/query-hooks.ts | 77 ++ memento-note/lib/query-keys.ts | 23 + memento-note/lib/use-refresh.ts | 46 + memento-note/package.json | 2 + memento-note/prisma/schema.prisma | 2 + 35 files changed, 2606 insertions(+), 344 deletions(-) create mode 100644 memento-note/components/note-editor/index.tsx create mode 100644 memento-note/components/note-editor/note-content-area.tsx create mode 100644 memento-note/components/note-editor/note-editor-context.tsx create mode 100644 memento-note/components/note-editor/note-editor-dialog.tsx create mode 100644 memento-note/components/note-editor/note-editor-full-page.tsx create mode 100644 memento-note/components/note-editor/note-editor-toolbar.tsx create mode 100644 memento-note/components/note-editor/note-metadata-section.tsx create mode 100644 memento-note/components/note-editor/note-title-block.tsx create mode 100644 memento-note/components/note-editor/types.ts create mode 100644 memento-note/components/query-provider.tsx delete mode 100644 memento-note/context/LabelContext.tsx create mode 100644 memento-note/context/editor-ui-context.tsx delete mode 100644 memento-note/context/home-view-context.tsx delete mode 100644 memento-note/context/notebook-drag-context.tsx create mode 100644 memento-note/lib/query-hooks.ts create mode 100644 memento-note/lib/query-keys.ts create mode 100644 memento-note/lib/use-refresh.ts diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index 2672dc7..4887261 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -37,6 +37,7 @@ const NOTE_LIST_SELECT = { checkItems: true, labels: true, images: true, + illustrationSvg: true, links: true, reminder: true, isReminderDone: true, @@ -789,6 +790,7 @@ export async function updateNote(id: string, data: { checkItems?: CheckItem[] | null labels?: string[] | null images?: string[] | null + illustrationSvg?: string | null links?: any[] | null reminder?: Date | null isMarkdown?: boolean @@ -844,6 +846,7 @@ export async function updateNote(id: string, data: { // labels handled by syncNoteLabels below delete updateData.labels if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null + if ('illustrationSvg' in data) updateData.illustrationSvg = data.illustrationSvg if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null if ('notebookId' in data) updateData.notebookId = data.notebookId // Explicitly handle size to ensure it propagates @@ -852,7 +855,7 @@ export async function updateNote(id: string, data: { // Only update contentUpdatedAt for actual content changes, NOT for property changes // (size, color, isPinned, isArchived are properties, not content) // skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save - const contentFields = ['title', 'content', 'checkItems', 'images', 'links'] + const contentFields = ['title', 'content', 'checkItems', 'images', 'links', 'illustrationSvg'] const isContentChange = contentFields.some(field => field in data) if (isContentChange && !options?.skipContentTimestamp) { updateData.contentUpdatedAt = new Date() diff --git a/memento-note/components/header-wrapper.tsx b/memento-note/components/header-wrapper.tsx index efceb8f..dc7961d 100644 --- a/memento-note/components/header-wrapper.tsx +++ b/memento-note/components/header-wrapper.tsx @@ -3,7 +3,7 @@ import { Suspense } from 'react' import { Header } from './header' import { useSearchParams, useRouter } from 'next/navigation' -import { useLabels } from '@/context/LabelContext' +import { useNotebooks } from '@/context/notebooks-context' interface HeaderWrapperProps { onColorFilterChange?: (color: string | null) => void @@ -13,7 +13,7 @@ interface HeaderWrapperProps { function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) { const searchParams = useSearchParams() const router = useRouter() - const { labels } = useLabels() + const { labels } = useNotebooks() const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || [] const selectedColor = searchParams.get('color') || null diff --git a/memento-note/components/header.tsx b/memento-note/components/header.tsx index 54d14b5..aa44470 100644 --- a/memento-note/components/header.tsx +++ b/memento-note/components/header.tsx @@ -21,7 +21,7 @@ import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, S import Link from 'next/link' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { cn } from '@/lib/utils' -import { useLabels } from '@/context/LabelContext' +import { useNotebooks } from '@/context/notebooks-context' import { LabelFilter } from './label-filter' import { NotificationPanel } from './notification-panel' import { updateTheme } from '@/app/actions/profile' @@ -53,7 +53,7 @@ export function Header({ const pathname = usePathname() const router = useRouter() const searchParams = useSearchParams() - const { labels, setNotebookId } = useLabels() + const { labels, setNotebookId } = useNotebooks() const { t } = useLanguage() const { data: session } = useSession() diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index 859dcb7..cd68eaa 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -12,14 +12,13 @@ import { MemoryEchoNotification } from '@/components/memory-echo-notification' import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast' import { Button } from '@/components/ui/button' import { Plus, ArrowUpDown } from 'lucide-react' -import { useLabels } from '@/context/LabelContext' import { useNoteRefresh } from '@/context/NoteRefreshContext' import { useReminderCheck } from '@/hooks/use-reminder-check' import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion' import { useNotebooks } from '@/context/notebooks-context' import { cn } from '@/lib/utils' import { useLanguage } from '@/lib/i18n' -import { useHomeView } from '@/context/home-view-context' +import { useEditorUI } from '@/context/editor-ui-context' import { NoteHistoryModal } from '@/components/note-history-modal' import { toast } from 'sonner' import { AnimatePresence, motion } from 'motion/react' @@ -71,9 +70,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { const [isCreating, startCreating] = useTransition() const [sortOrder, setSortOrder] = useState('newest') const [showSortMenu, setShowSortMenu] = useState(false) + const notesRef = useRef(notes) + notesRef.current = notes const { refreshKey, triggerRefresh } = useNoteRefresh() - const { labels } = useLabels() - const { setControls } = useHomeView() + const { labels, notebooks } = useNotebooks() + const { setControls } = useEditorUI() const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion() const [autoLabelOpen, setAutoLabelOpen] = useState(false) @@ -84,18 +85,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { } }, [shouldSuggestLabels, suggestNotebookId]) - // BUG FIX: forceList param from sidebar carnet click → reset to editorial view + // Sidebar carnet / inbox: forceList → liste éditoriale + fermer l'éditeur plein écran (comme la ref. architectural-grid) useEffect(() => { const forceList = searchParams.get('forceList') - if (forceList === '1') { - setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev)) - const params = new URLSearchParams(searchParams.toString()) - params.delete('forceList') - const newUrl = params.toString() ? `/?${params.toString()}` : '/' - router.replace(newUrl, { scroll: false }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams]) + if (forceList !== '1') return + setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev)) + setEditingNote(null) + const params = new URLSearchParams(searchParams.toString()) + params.delete('forceList') + const newUrl = params.toString() ? `/?${params.toString()}` : '/' + router.replace(newUrl, { scroll: false }) + }, [searchParams, router]) const notebookFilter = searchParams.get('notebook') const handleNoteCreated = useCallback((note: Note) => { @@ -196,27 +196,25 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { useReminderCheck(notes) + // Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.) useEffect(() => { const openNoteId = searchParams.get('openNote') if (!openNoteId) return - const openNote = async () => { - const existing = notes.find(n => n.id === openNoteId) - if (existing) { - setEditingNote({ note: existing, readOnly: false }) - } else { - const fetched = await getNoteById(openNoteId) - if (fetched) { - setEditingNote({ note: fetched, readOnly: false }) - } - } - const params = new URLSearchParams(searchParams.toString()) - params.delete('openNote') - router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false }) + let cancelled = false + const run = async () => { + const existing = notesRef.current.find(n => n.id === openNoteId) + const note = existing ?? (await getNoteById(openNoteId)) + if (cancelled || !note) return + setEditingNote(prev => { + if (prev?.note.id === note.id && prev.readOnly === false) return prev + return { note, readOnly: false } + }) + } + run() + return () => { + cancelled = true } - - openNote() - // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]) useEffect(() => { @@ -305,7 +303,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, refreshKey]) - const { notebooks } = useNotebooks() const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook')) useEffect(() => { @@ -340,8 +337,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { const handleEditorClose = useCallback(() => { setEditingNote(null) + const params = new URLSearchParams(searchParams.toString()) + params.delete('openNote') + const qs = params.toString() + router.replace(qs ? `/?${qs}` : '/', { scroll: false }) triggerRefresh() - }, [triggerRefresh]) + }, [triggerRefresh, router, searchParams]) return (
([]) diff --git a/memento-note/components/label-management-dialog.tsx b/memento-note/components/label-management-dialog.tsx index 58e2e05..ced2595 100644 --- a/memento-note/components/label-management-dialog.tsx +++ b/memento-note/components/label-management-dialog.tsx @@ -14,7 +14,7 @@ import { import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react' import { LABEL_COLORS, LabelColorName } from '@/lib/types' import { cn } from '@/lib/utils' -import { useLabels } from '@/context/LabelContext' +import { useNotebooks } from '@/context/notebooks-context' import { useLanguage } from '@/lib/i18n' import { useNoteRefresh } from '@/context/NoteRefreshContext' @@ -26,7 +26,7 @@ export interface LabelManagementDialogProps { export function LabelManagementDialog(props: LabelManagementDialogProps = {}) { const { open, onOpenChange } = props - const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels() + const { labels, isLoading: loading, addLabel, updateLabel, deleteLabel } = useNotebooks() const { t, language } = useLanguage() const { triggerRefresh } = useNoteRefresh() const [confirmDeleteId, setConfirmDeleteId] = useState(null) diff --git a/memento-note/components/label-manager.tsx b/memento-note/components/label-manager.tsx index 3fc7da7..28c7d50 100644 --- a/memento-note/components/label-manager.tsx +++ b/memento-note/components/label-manager.tsx @@ -16,7 +16,7 @@ import { Badge } from './ui/badge' import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react' import { LABEL_COLORS, LabelColorName } from '@/lib/types' import { cn } from '@/lib/utils' -import { useLabels, Label } from '@/context/LabelContext' +import { useNotebooks } from '@/context/notebooks-context' import { useLanguage } from '@/lib/i18n' interface LabelManagerProps { @@ -26,7 +26,7 @@ interface LabelManagerProps { } export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelManagerProps) { - const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useLabels() + const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useNotebooks() const { t } = useLanguage() const [open, setOpen] = useState(false) const [newLabel, setNewLabel] = useState('') @@ -45,18 +45,11 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana if (trimmed && !selectedLabels.includes(trimmed)) { try { - // NotebookId is REQUIRED for label creation (PRD R2) - if (!notebookId) { - setErrorMessage(t('labels.notebookRequired')) - console.error(t('labels.notebookRequired')) - return - } - // Get existing label color or use random const existingLabel = labels.find(l => l.name === trimmed) const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)] - await addLabel(trimmed, color, notebookId) + await addLabel(trimmed, color) const updated = [...selectedLabels, trimmed] setSelectedLabels(updated) setNewLabel('') diff --git a/memento-note/components/label-selector.tsx b/memento-note/components/label-selector.tsx index 6cf6abd..b28e1c0 100644 --- a/memento-note/components/label-selector.tsx +++ b/memento-note/components/label-selector.tsx @@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Tag, Plus, Check } from 'lucide-react' import { cn } from '@/lib/utils' -import { useLabels } from '@/context/LabelContext' +import { useNotebooks } from '@/context/notebooks-context' import { LabelBadge } from './label-badge' import { useLanguage } from '@/lib/i18n' @@ -26,7 +26,7 @@ export function LabelSelector({ triggerLabel, align = 'start', }: LabelSelectorProps) { - const { labels, loading, addLabel } = useLabels() + const { labels, isLoading: loading, addLabel } = useNotebooks() const { t } = useLanguage() const [search, setSearch] = useState('') diff --git a/memento-note/components/masonry-grid.tsx b/memento-note/components/masonry-grid.tsx index 25cad3e..afb6541 100644 --- a/memento-note/components/masonry-grid.tsx +++ b/memento-note/components/masonry-grid.tsx @@ -22,7 +22,7 @@ import { CSS } from '@dnd-kit/utilities'; import { Note } from '@/lib/types'; import { NoteCard } from './note-card'; import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes'; -import { useNotebookDrag } from '@/context/notebook-drag-context'; +import { useEditorUI } from '@/context/editor-ui-context'; import { useLanguage } from '@/lib/i18n'; import { useCardSizeMode } from '@/hooks/use-card-size-mode'; import dynamic from 'next/dynamic'; @@ -175,7 +175,7 @@ export function MasonryGrid({ }: MasonryGridProps) { const { t } = useLanguage(); const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null); - const { startDrag, endDrag, draggedNoteId } = useNotebookDrag(); + const { startDrag, endDrag, draggedNoteId } = useEditorUI(); const cardSizeMode = useCardSizeMode(); const isUniformMode = cardSizeMode === 'uniform'; diff --git a/memento-note/components/note-card.tsx b/memento-note/components/note-card.tsx index 537124d..553cdd5 100644 --- a/memento-note/components/note-card.tsx +++ b/memento-note/components/note-card.tsx @@ -58,10 +58,9 @@ const ConnectionsOverlay = dynamic(() => import('./connections-overlay').then(m const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false }) const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false }) import { useConnectionsCompare } from '@/hooks/use-connections-compare' -import { useLabels } from '@/context/LabelContext' +import { useNotebooks } from '@/context/notebooks-context' import { useNoteRefresh } from '@/context/NoteRefreshContext' import { useLanguage } from '@/lib/i18n' -import { useNotebooks } from '@/context/notebooks-context' import { toast } from 'sonner' // Mapping of supported languages to date-fns locales @@ -172,11 +171,10 @@ export const NoteCard = memo(function NoteCard({ }: NoteCardProps) { const router = useRouter() const searchParams = useSearchParams() - const { refreshLabels } = useLabels() const { triggerRefresh } = useNoteRefresh() const { data: session } = useSession() const { t, language } = useLanguage() - const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks() + const { notebooks, moveNoteToNotebookOptimistic, refreshLabels } = useNotebooks() const [, startTransition] = useTransition() const [isDeleting, setIsDeleting] = useState(false) const [isHidden, setIsHidden] = useState(false) diff --git a/memento-note/components/note-editor/index.tsx b/memento-note/components/note-editor/index.tsx new file mode 100644 index 0000000..14cba37 --- /dev/null +++ b/memento-note/components/note-editor/index.tsx @@ -0,0 +1,37 @@ +'use client' + +import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context' +import { NoteEditorFullPage } from './note-editor-full-page' +import { NoteEditorDialog } from './note-editor-dialog' +import { Note } from '@/lib/types' + +interface NoteEditorProps { + note: Note + readOnly?: boolean + onClose: () => void + fullPage?: boolean +} + +export function NoteEditor({ note, readOnly, onClose, fullPage = false }: NoteEditorProps) { + return ( + + {fullPage ? ( + + ) : ( + + )} + + ) +} + +// Re-export context hook for backwards compatibility +export { useNoteEditorContext } from './note-editor-context' + +// Re-export sub-components for advanced usage +export { NoteEditorFullPage } from './note-editor-full-page' +export { NoteEditorDialog } from './note-editor-dialog' +export { NoteEditorProvider } from './note-editor-context' +export { NoteTitleBlock } from './note-title-block' +export { NoteContentArea } from './note-content-area' +export { NoteMetadataSection } from './note-metadata-section' +export { NoteEditorToolbar } from './note-editor-toolbar' \ No newline at end of file diff --git a/memento-note/components/note-editor/note-content-area.tsx b/memento-note/components/note-editor/note-content-area.tsx new file mode 100644 index 0000000..5598e15 --- /dev/null +++ b/memento-note/components/note-editor/note-content-area.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useNoteEditorContext } from './note-editor-context' +import { RichTextEditor } from '@/components/rich-text-editor' +import { MarkdownContent } from '@/components/markdown-content' +import { MarkdownSlashCommands } from '@/components/markdown-slash-commands' +import { GhostTags } from '@/components/ghost-tags' +import { Textarea } from '@/components/ui/textarea' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { X, Plus } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' +import { cn } from '@/lib/utils' + +export function NoteContentArea() { + const { state, actions, readOnly, fullPage, textareaRef } = useNoteEditorContext() + const { t } = useLanguage() + + const uploadImageFile = async (file: File) => { + const formData = new FormData() + formData.append('file', file) + const response = await fetch('/api/upload', { method: 'POST', body: formData }) + if (!response.ok) throw new Error('Upload failed') + const data = await response.json() + return data.url + } + + if (state.noteType === 'richtext') { + if (fullPage) { + return ( +
+ actions.setContent(v)} + className="min-h-[280px]" + onImageUpload={uploadImageFile} + /> +
+ ) + } + return ( + + ) + } + + if (state.noteType === 'markdown' && state.showMarkdownPreview) { + return ( +
!readOnly && actions.setShowMarkdownPreview(false)} + > + + {!readOnly && ( +

+ Cliquez pour éditer +

+ )} +
+ ) + } + + if (state.noteType === 'markdown' || state.noteType === 'text') { + if (fullPage) { + return ( +
+