'use client' import React, { useState, useEffect, useCallback, useRef, useTransition, useMemo } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import dynamic from 'next/dynamic' import { Note } from '@/lib/types' import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote } from '@/app/actions/notes' import { NotesEditorialView } from '@/components/notes-editorial-view' import { MemoryEchoNotification } from '@/components/memory-echo-notification' import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast' import { Button } from '@/components/ui/button' import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu } from 'lucide-react' import { useNoteRefresh } from '@/context/NoteRefreshContext' import { useRefresh } from '@/lib/use-refresh' 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 { useEditorUI } from '@/context/editor-ui-context' import { NoteHistoryModal } from '@/components/note-history-modal' import { CreateNotebookDialog } from '@/components/create-notebook-dialog' import { toast } from 'sonner' import { AnimatePresence, motion } from 'motion/react' type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual' const NoteEditor = dynamic( () => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })), { ssr: false } ) const BatchOrganizationDialog = dynamic( () => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })), { ssr: false } ) const AutoLabelSuggestionDialog = dynamic( () => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })), { ssr: false } ) 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 noteHistory: boolean noteHistoryMode: 'manual' | 'auto' aiAssistantEnabled: boolean } interface HomeClientProps { initialNotes: Note[] initialSettings: InitialSettings } export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { const searchParams = useSearchParams() const router = useRouter() const { t } = useLanguage() const [notes, setNotes] = useState(initialNotes) const [pinnedNotes, setPinnedNotes] = useState( initialNotes.filter(n => n.isPinned) ) const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode) const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null) const [isLoading, setIsLoading] = useState(false) const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null) const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [historyNote, setHistoryNote] = useState(null) const [isCreating, startCreating] = useTransition() const [sortOrder, setSortOrder] = useState('newest') const [showSortMenu, setShowSortMenu] = useState(false) const [showInlineSearch, setShowInlineSearch] = useState(false) const [inlineSearchQuery, setInlineSearchQuery] = useState('') const [isSearching, setIsSearching] = useState(false) const inlineSearchRef = useRef(null) const searchDebounceRef = useRef | null>(null) const notesRef = useRef(notes) notesRef.current = notes const { refreshKey, triggerRefresh } = useNoteRefresh() const { refreshNotes } = useRefresh() const { labels, notebooks, refreshNotebooks } = useNotebooks() const { setControls } = useEditorUI() const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion() const [autoLabelOpen, setAutoLabelOpen] = useState(false) const [summaryDialogOpen, setSummaryDialogOpen] = useState(false) const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false) const [organizeNotebookOpen, setOrganizeNotebookOpen] = useState(false) const [selectedTagIds, setSelectedTagIds] = useState([]) const [isTagsExpanded, setIsTagsExpanded] = useState(false) const [tagSearchQuery, setTagSearchQuery] = useState('') // Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid) useEffect(() => { if (searchParams.get('forceList') === '1') { setEditingNote(null) const params = new URLSearchParams(searchParams.toString()) params.delete('forceList') const newUrl = params.toString() ? `/home?${params.toString()}` : '/home' router.replace(newUrl, { scroll: false }) } }, [searchParams, router]) const notebookFilter = searchParams.get('notebook') const handleNoteCreated = useCallback((note: Note) => { setNotes((prevNotes) => { const notebookFilter = searchParams.get('notebook') const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || [] const colorFilter = searchParams.get('color') const search = searchParams.get('search')?.trim() || null if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes if (!notebookFilter && note.notebookId) return prevNotes if (labelFilter.length > 0) { const noteLabels = note.labels || [] if (!noteLabels.some((label: string) => labelFilter.includes(label))) return prevNotes } if (colorFilter) { const labelNamesWithColor = labels .filter((label: any) => label.color === colorFilter) .map((label: any) => label.name) const noteLabels = note.labels || [] if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) return prevNotes } if (search) { router.refresh() return prevNotes } const isPinned = note.isPinned || false const pinnedNotes = prevNotes.filter(n => n.isPinned) const unpinnedNotes = prevNotes.filter(n => !n.isPinned) if (isPinned) { return [note, ...pinnedNotes, ...unpinnedNotes] } else { return [...pinnedNotes, note, ...unpinnedNotes] } }) if (!note.notebookId) { const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length if (wordCount >= 20) { setNotebookSuggestion({ noteId: note.id, content: note.content || '' }) } } }, [searchParams, labels, router]) // Always fetch fresh from server — avoids stale state after a save regardless of // whether the notes list has re-fetched yet. const handleOpenNoteFresh = useCallback(async (noteId: string, readOnly = false) => { const note = await getNoteById(noteId) if (note) setEditingNote({ note, readOnly }) }, []) const handleAddNote = () => { startCreating(async () => { try { const newNote = await createNote({ content: '', type: 'richtext', title: undefined, notebookId: notebookFilter || undefined, skipRevalidation: true }) if (!newNote) return handleNoteCreated(newNote) setEditingNote({ note: newNote, readOnly: false }) } catch { toast.error(t('notes.createFailed')) } }) } const handleOpenHistory = useCallback((note: Note) => { setHistoryNote(note) setHistoryOpen(true) }, []) const handleEnableHistory = useCallback(async (noteId: string) => { await enableNoteHistory(noteId) setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n))) setPinnedNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n))) setEditingNote((prev) => (prev?.note.id === noteId ? { ...prev, note: { ...prev.note, historyEnabled: true } } : prev)) setHistoryNote((prev) => (prev?.id === noteId ? { ...prev, historyEnabled: true } : prev)) }, []) const handleHistoryRestored = useCallback((restored: Note) => { setNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n))) setPinnedNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n))) setEditingNote((prev) => (prev?.note.id === restored.id ? { ...prev, note: restored } : prev)) setHistoryNote((prev) => (prev?.id === restored.id ? { ...prev, ...restored } : prev)) }, []) const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => { setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n)) setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n)) }, []) 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 let cancelled = false const run = async () => { // Always fetch fresh data from DB to avoid showing stale content after a save. // notesRef.current can be stale if the notes list hasn't re-fetched yet when the // user closes and re-opens the note quickly after saving. const note = await getNoteById(openNoteId) if (cancelled || !note) return setEditingNote({ note, readOnly: false }) } run() return () => { cancelled = true } }, [searchParams]) useEffect(() => { const handler = (e: Event) => { const { name } = (e as CustomEvent).detail const removeLabel = (note: Note) => { const currentLabels = note.labels || [] const updated = currentLabels.filter((l) => l.toLowerCase() !== name.toLowerCase()) if (updated.length === currentLabels.length) return note return { ...note, labels: updated.length > 0 ? updated : null } } setNotes((prev) => prev.map(removeLabel)) setPinnedNotes((prev) => prev.map(removeLabel)) } window.addEventListener('label-deleted', handler) return () => window.removeEventListener('label-deleted', handler) }, []) const prevRefreshKey = useRef(refreshKey) useEffect(() => { const search = searchParams.get('search')?.trim() || null const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || [] const colorFilter = searchParams.get('color') const notebook = searchParams.get('notebook') const isBackgroundRefresh = refreshKey > prevRefreshKey.current prevRefreshKey.current = refreshKey const hasActiveFilter = search || labelFilter.length > 0 || colorFilter const load = async () => { if (!isBackgroundRefresh) { setIsLoading(true) } let allNotes = search ? await searchNotes(search, true, notebook || undefined) : await getAllNotes(false, notebook || undefined) const sharedOnly = searchParams.get('shared') === '1' const remindersOnly = searchParams.get('reminders') === '1' if (sharedOnly) { allNotes = allNotes.filter((note: any) => note._isShared) } else if (remindersOnly) { allNotes = allNotes.filter((note: any) => note.reminder !== null) } else if (!notebook && !search) { allNotes = allNotes.filter((note: any) => !note.notebookId && !note._isShared) } if (labelFilter.length > 0) { allNotes = allNotes.filter((note: any) => labelFilter.every((label: string) => note.labels?.includes(label)) ) } if (colorFilter) { const labelNamesWithColor = labels .filter((label: any) => label.color === colorFilter) .map((label: any) => label.name) allNotes = allNotes.filter((note: any) => note.labels?.some((label: string) => labelNamesWithColor.includes(label)) ) } setNotes(prev => { const localSizeMap = new Map(prev.map(n => [n.id, n.size])) return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size })) }) setPinnedNotes(allNotes.filter((n: any) => n.isPinned)) setIsLoading(false) } if (refreshKey > 0 || hasActiveFilter) { const cancelled = { value: false } load().then(() => { if (cancelled.value) return }) return () => { cancelled.value = true } } else { let filtered = initialNotes const sharedOnly = searchParams.get('shared') === '1' const remindersOnly = searchParams.get('reminders') === '1' if (notebook) { filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared) } else if (sharedOnly) { filtered = initialNotes.filter((n: any) => n._isShared) } else if (remindersOnly) { filtered = initialNotes.filter((n: any) => n.reminder !== null) } else { filtered = initialNotes.filter((n: any) => !n.notebookId && !n._isShared) } setNotes(prev => { const localSizeMap = new Map(prev.map(n => [n.id, n.size])) return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size })) }) setPinnedNotes(filtered.filter(n => n.isPinned)) } }, [searchParams, refreshKey]) const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook')) const notebookPath = useMemo(() => { if (!currentNotebook) return [] const trail: any[] = [] let current: any = currentNotebook while (current) { trail.unshift(current) if (!current.parentId) break const parent = notebooks.find((nb: any) => nb.id === current.parentId) if (!parent) break current = parent } return trail }, [currentNotebook, notebooks]) const availableTags = useMemo(() => { const tagsMap = new Map() const carnetNotes = notes carnetNotes.forEach(note => { ;(note.labels || []).forEach(labelName => { if (!tagsMap.has(labelName)) { const labelObj = labels.find((l: any) => l.name === labelName) tagsMap.set(labelName, { id: labelObj?.id || labelName, name: labelName, type: labelObj?.type, }) } }) }) return Array.from(tagsMap.values()).sort((a, b) => { if (a.type === 'ai' && b.type !== 'ai') return -1 if (a.type !== 'ai' && b.type === 'ai') return 1 return a.name.localeCompare(b.name) }) }, [notes, labels]) const visibleTags = useMemo(() => { let filtered = availableTags if (tagSearchQuery) { filtered = availableTags.filter(t => t.name.toLowerCase().includes(tagSearchQuery.toLowerCase()) ) } else if (!isTagsExpanded) { filtered = availableTags.slice(0, 10) selectedTagIds.forEach(id => { if (!filtered.find(t => t.id === id)) { const tag = availableTags.find(t => t.id === id) if (tag) filtered.push(tag) } }) } return filtered }, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds]) const toggleTag = useCallback((tagId: string) => { setSelectedTagIds(prev => prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId] ) }, []) useEffect(() => { setControls({ openNoteComposer: () => handleAddNote(), }) return () => setControls(null) }, [setControls]) // Apply sort order to notes const sortedNotes = useMemo(() => { let sorted = [...notes] if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || '')) if (sortOrder === 'manual') sorted.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) if (selectedTagIds.length > 0) { const selectedNames = selectedTagIds .map(id => availableTags.find(t => t.id === id)?.name) .filter(Boolean) as string[] sorted = sorted.filter(note => selectedNames.every(name => (note.labels || []).includes(name)) ) } return sorted }, [notes, sortOrder, selectedTagIds, availableTags]) const sortedPinnedNotes = useMemo(() => { return sortedNotes.filter(n => n.isPinned) }, [sortedNotes]) const sortLabels: Record = { newest: t('sidebar.sortNewest'), oldest: t('sidebar.sortOldest'), alpha: t('sidebar.sortAlpha'), manual: t('sidebar.sortManual'), } const handleEditorClose = useCallback(() => { setEditingNote(null) const params = new URLSearchParams(searchParams.toString()) params.delete('openNote') const qs = params.toString() router.replace(qs ? `/home?${qs}` : '/home', { scroll: false }) // Invalidate notes cache and trigger refresh refreshNotes(searchParams.get('notebook') || null) }, [refreshNotes, router, searchParams]) // Called by NoteEditor when a save succeeds — update local state immediately // so the user sees fresh data if they reopen the note before getAllNotes() completes const handleNoteSaved = useCallback((savedNote: Note) => { setNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n)) setPinnedNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n)) setEditingNote(prev => prev?.note.id === savedNote.id ? { ...prev, note: savedNote } : prev) // Refresh sidebar note titles so the new title appears immediately refreshNotes(savedNote.notebookId || null) }, [refreshNotes]) return (
{editingNote ? ( ) : (
{/* Hamburger mobile — ouvre la sidebar */}
{currentNotebook && notebookPath.length > 0 && (
{notebookPath.map((nb: any, i: number) => ( {i > 0 && } {nb.name} ))}
)}

{currentNotebook ? currentNotebook.name : searchParams.get('shared') === '1' ? t('sidebar.sharedWithMe') : searchParams.get('reminders') === '1' ? t('sidebar.reminders') : t('notes.title')}

{currentNotebook && ( )} {/* Inline search — toggles an input within the toolbar */} {showInlineSearch ? (
{isSearching ? (
) : ( )} { const q = e.target.value setInlineSearchQuery(q) setIsSearching(true) if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current) searchDebounceRef.current = setTimeout(() => { const params = new URLSearchParams(searchParams.toString()) if (q.trim()) { params.set('search', q) } else { params.delete('search') } router.push(`/home?${params.toString()}`) setIsSearching(false) }, 300) }} onBlur={() => { if (!inlineSearchQuery) { setShowInlineSearch(false) } }} onKeyDown={e => { if (e.key === 'Escape') { if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current) setShowInlineSearch(false) setInlineSearchQuery('') setIsSearching(false) const params = new URLSearchParams(searchParams.toString()) params.delete('search') router.push(`/home?${params.toString()}`) } }} placeholder={t('search.placeholder')} className="w-36 sm:w-48 bg-transparent border-b border-foreground/20 focus:border-foreground outline-none text-[13px] text-foreground placeholder:text-muted-foreground/50 py-0.5 transition-colors" /> {inlineSearchQuery && ( )}
) : ( )} {!searchParams.get('notebook') && searchParams.get('shared') !== '1' && ( )} {currentNotebook && initialSettings.aiAssistantEnabled && ( )}
{searchParams.get('notebook') && ( )}
{availableTags.length > 0 && (
{t('labels.filterByTags')} {selectedTagIds.length > 0 && ( {selectedTagIds.length} active )}
{availableTags.length > 10 && ( setTagSearchQuery(e.target.value)} /> )}
{visibleTags.map(tag => { const isActive = selectedTagIds.includes(tag.id) return ( toggleTag(tag.id)} className={cn( 'px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2', isActive ? 'bg-foreground text-background border-foreground shadow-sm' : 'bg-card/40 border-border text-muted-foreground hover:border-foreground/30 hover:bg-card/60', )} > {tag.type === 'ai' && ( )} {tag.name} {isActive && } ) })} {availableTags.length > 10 && !tagSearchQuery && ( )} {selectedTagIds.length > 0 && ( )}
)}
{isLoading ? (
{t('general.loading')}
) : (
{sortedPinnedNotes.length > 0 && (

{t('notes.pinned')}

handleOpenNoteFresh(note.id, readOnly ?? false)} notebookName={currentNotebook?.name} onOpenHistory={handleOpenHistory} />
)} {sortedNotes.filter((note) => !note.isPinned).length > 0 && ( !note.isPinned)} onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)} notebookName={currentNotebook?.name} onOpenHistory={handleOpenHistory} /> )} {notes.length === 0 && (

{t('notes.emptyState')}

)}
)}

Memento — {new Date().getFullYear()}

)} handleOpenNoteFresh(noteId)} /> {notebookSuggestion && ( setNotebookSuggestion(null)} /> )} {batchOrganizationOpen && ( router.refresh()} /> )} {autoLabelOpen && ( { setAutoLabelOpen(open) if (!open) dismissLabelSuggestion() }} notebookId={suggestNotebookId} onLabelsCreated={() => router.refresh()} /> )} { if (historyNote) await handleEnableHistory(historyNote.id) }} onRestored={handleHistoryRestored} /> {searchParams.get('notebook') && ( )} {searchParams.get('notebook') && ( )} {currentNotebook && ( { refreshNotebooks() triggerRefresh() router.refresh() }} /> )}
) }