Files
Momento/memento-note/components/home-client.tsx
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
2026-06-28 07:32:57 +00:00

1425 lines
59 KiB
TypeScript

'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, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, isClassicLayoutMode } from '@/components/notes-list-views'
import {
NOTES_LAYOUT_STORAGE_KEY,
parseNotesLayoutMode,
setNotesLayoutPreference,
} from '@/lib/notes-view-preference'
import { useNotebookSchema } from '@/hooks/use-notebook-schema'
import {
bootstrapStructuredNotebook,
ensureKanbanStatusField,
type BootstrapStructuredTarget,
} from '@/lib/structured-views/bootstrap-structured-notebook'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
import { emitNoteChange } from '@/lib/note-change-sync'
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 { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-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 }
)
const NotebookSiteDialog = dynamic(
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
{ ssr: false }
)
const StructuredViewsIntro = dynamic(
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
{ ssr: false }
)
const StructuredViewsWizard = dynamic(
() => import('@/components/structured-views/structured-views-wizard').then(m => ({ default: m.StructuredViewsWizard })),
{ ssr: false }
)
const StructuredViewsHelpBanner = dynamic(
() => import('@/components/structured-views/structured-views-help-banner').then(m => ({ default: m.StructuredViewsHelpBanner })),
{ ssr: false }
)
const StructuredViewsContainer = dynamic(
() => import('@/components/structured-views/structured-views-container').then(m => ({ default: m.StructuredViewsContainer })),
{ ssr: false }
)
const AddPropertyDialog = dynamic(
() => import('@/components/structured-views/add-property-dialog').then(m => ({ default: m.AddPropertyDialog })),
{ ssr: false }
)
type InitialSettings = {
showRecentNotes: boolean
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
aiAssistantEnabled: boolean
}
interface HomeClientProps {
initialNotes: Note[]
initialSettings: InitialSettings
initialLayoutMode?: NotesLayoutMode
}
export function HomeClient({
initialNotes,
initialSettings,
initialLayoutMode = 'list',
}: HomeClientProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { t, language } = useLanguage()
const [notes, setNotes] = useState<Note[]>(initialNotes)
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
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<Note | null>(null)
const [isCreating, startCreating] = useTransition()
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
const [showInlineSearch, setShowInlineSearch] = useState(false)
const [inlineSearchQuery, setInlineSearchQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const inlineSearchRef = useRef<HTMLInputElement>(null)
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const notesRef = useRef(notes)
notesRef.current = notes
const { labels, notebooks, refreshNotebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const labelsRef = useRef(labels)
labelsRef.current = labels
const initialNotesRef = useRef(initialNotes)
initialNotesRef.current = initialNotes
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) // kept for compat — old dialog unused
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
const [tagSearchQuery, setTagSearchQuery] = useState('')
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
const [showNotebookSite, setShowNotebookSite] = useState(false)
const [aiMenuOpen, setAiMenuOpen] = useState(false)
const aiMenuRef = useRef<HTMLDivElement>(null)
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
const [showOrganizer, setShowOrganizer] = useState(false)
const handleExportCSV = useCallback(() => {
if (!searchParams.get('notebook')) return
window.open(`/api/notebooks/csv?notebookId=${searchParams.get('notebook')}`, '_blank')
}, [searchParams])
const handleImportCSV = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.csv'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const text = await file.text()
const res = await fetch(`/api/notebooks/csv?notebookId=${searchParams.get('notebook')}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId: searchParams.get('notebook'), csvData: text }),
})
const data = await res.json()
if (res.ok) {
toast.success(`${data.created} notes importées !`)
window.location.reload()
} else {
toast.error(data.error || 'Erreur')
}
}
input.click()
}, [searchParams])
const notebookFilter = searchParams.get('notebook')
const schemaHook = useNotebookSchema(notebookFilter)
const structuredModeActive = Boolean(notebookFilter && schemaHook.schema)
const wantsStructuredView = Boolean(
notebookFilter && (layoutMode === 'table' || layoutMode === 'kanban'),
)
const structuredViewMode: BootstrapStructuredTarget =
layoutMode === 'kanban' ? 'kanban' : 'table'
useEffect(() => {
if (layoutMode === 'gallery') {
setLayoutMode('grid')
}
}, [])
useEffect(() => {
if (!notebookFilter && (layoutMode === 'kanban' || layoutMode === 'gallery')) {
setLayoutMode('list')
}
}, [notebookFilter, layoutMode])
useEffect(() => {
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
if (storedLayout !== initialLayoutMode) {
setLayoutMode(storedLayout)
setNotesLayoutPreference(storedLayout)
}
}, [initialLayoutMode])
useEffect(() => {
setNotesLayoutPreference(layoutMode)
}, [layoutMode])
useEffect(() => {
const onLayoutChange = (e: Event) => {
const detail = (e as CustomEvent<{ layout?: NotesLayoutMode }>).detail?.layout
if (detail === 'grid' || detail === 'list' || detail === 'table' || detail === 'kanban') {
setLayoutMode(detail)
}
}
window.addEventListener('memento-notes-layout-change', onLayoutChange)
return () => window.removeEventListener('memento-notes-layout-change', onLayoutChange)
}, [])
// 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 fetchNotesForCurrentView = useCallback(
async (options?: { silent?: boolean }) => {
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 sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
if (!options?.silent) {
setIsLoading(true)
}
let allNotes = search
? await searchNotes(search, true, notebook || undefined).then(r => { window.dispatchEvent(new Event('ai-usage-changed')); return r })
: await getAllNotes(false, notebook || undefined)
if (sharedOnly) {
allNotes = allNotes.filter((note: Note) => note._isShared)
} else if (remindersOnly) {
allNotes = allNotes.filter((note: Note) => note.reminder !== null)
} else if (!notebook && !search) {
allNotes = allNotes.filter((note: Note) => !note.notebookId && !note._isShared)
}
if (labelFilter.length > 0) {
allNotes = allNotes.filter((note: Note) =>
labelFilter.every((label: string) => note.labels?.includes(label))
)
}
if (colorFilter) {
const labelNamesWithColor = labelsRef.current
.filter((label) => label.color === colorFilter)
.map((label) => label.name)
allNotes = allNotes.filter((note: Note) =>
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: Note) => n.isPinned))
setIsLoading(false)
},
[searchParams]
)
const filterNotesForCurrentView = useCallback((source: Note[]) => {
const notebook = searchParams.get('notebook')
const sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
if (notebook) {
return source.filter((n) => n.notebookId === notebook && !n._isShared)
}
if (sharedOnly) {
return source.filter((n) => n._isShared)
}
if (remindersOnly) {
return source.filter((n) => n.reminder !== null)
}
return source.filter((n) => !n.notebookId && !n._isShared)
}, [searchParams])
const handleNoteCreated = useCallback((note: Note) => {
const search = searchParams.get('search')?.trim() || null
if (search) {
void fetchNotesForCurrentView({ silent: true })
emitNoteChange({ type: 'created', note })
return
}
setNotes((prevNotes) => {
const notebookFilter = searchParams.get('notebook')
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
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
}
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]
}
})
emitNoteChange({ type: 'created', note })
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, fetchNotesForCurrentView])
// 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 handleAddNoteWithProperties = (prefill: Record<string, unknown>) => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
type: 'richtext',
title: undefined,
notebookId: notebookFilter || undefined,
skipRevalidation: true,
})
if (!newNote) return
if (Object.keys(prefill).length > 0) {
await fetch(`/api/notes/${newNote.id}/properties`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ properties: prefill }),
})
schemaHook.patchNoteValuesLocal(newNote.id, prefill)
}
handleNoteCreated(newNote)
setEditingNote({ note: newNote, readOnly: false })
} catch {
toast.error(t('notes.createFailed'))
}
})
}
const structuredFieldLabels = useMemo(
() => ({
statusName: t('structuredViews.wizard.fields.status.name'),
statusOptions: t('structuredViews.wizard.fields.status.options')
.split('\n')
.map((line) => line.trim())
.filter(Boolean),
}),
[t],
)
const structuredBootstrapActions = useMemo(
() => ({
getSchema: () => schemaHook.schema,
enableStructuredMode: schemaHook.enableStructuredMode,
addProperty: schemaHook.addProperty,
setKanbanGroupProperty: schemaHook.setKanbanGroupProperty,
}),
[schemaHook],
)
const handleEnableStructured = useCallback(
async (target: BootstrapStructuredTarget) => {
if (!notebookFilter) return
setIsEnablingStructured(true)
try {
await bootstrapStructuredNotebook(target, structuredFieldLabels, structuredBootstrapActions)
await schemaHook.reload()
setLayoutMode(target)
toast.success(t('structuredViews.intro.enabledSuccess'))
} catch {
toast.error(t('structuredViews.enableFailed'))
} finally {
setIsEnablingStructured(false)
}
},
[notebookFilter, structuredFieldLabels, structuredBootstrapActions, schemaHook, t],
)
const handleQuickAddKanbanStatus = useCallback(async () => {
try {
await ensureKanbanStatusField(structuredFieldLabels, structuredBootstrapActions)
await schemaHook.reload()
} catch {
toast.error(t('structuredViews.enableFailed'))
}
}, [structuredFieldLabels, structuredBootstrapActions, schemaHook, t])
const selectLayoutMode = useCallback((mode: NotesLayoutMode) => {
if (mode === 'gallery') return
setLayoutMode(mode)
}, [])
const showStructuredIntro =
wantsStructuredView && !structuredModeActive && !schemaHook.loading
const showStructuredDataView = wantsStructuredView && structuredModeActive && schemaHook.schema
const showStructuredLoading = wantsStructuredView && schemaHook.loading
const classicLayoutMode: NotesClassicLayoutMode =
isClassicLayoutMode(layoutMode) ? layoutMode : 'list'
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))
}, [])
const patchNoteInList = useCallback((noteId: string, patch: Partial<Note>) => {
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n)))
}, [])
const removeNoteFromList = useCallback((noteId: string) => {
setNotes((prev) => prev.filter((n) => n.id !== noteId))
setEditingNote((prev) => (prev?.note.id === noteId ? null : prev))
}, [])
const noteVisibleInCurrentView = useCallback(
(note: Pick<Note, 'notebookId' | '_isShared'>) => {
const notebook = searchParams.get('notebook')
const sharedOnly = searchParams.get('shared') === '1'
if (sharedOnly) return !!note._isShared
if (notebook) return note.notebookId === notebook && !note._isShared
return !note.notebookId && !note._isShared
},
[searchParams]
)
const handleTogglePin = useCallback(
async (note: Note) => {
const nextPinned = !note.isPinned
patchNoteInList(note.id, { isPinned: nextPinned })
emitNoteChange({ type: 'updated', note: { ...note, isPinned: nextPinned } })
try {
await togglePin(note.id, nextPinned, { skipRevalidation: true })
} catch {
patchNoteInList(note.id, { isPinned: !nextPinned })
emitNoteChange({ type: 'updated', note: { ...note, isPinned: !nextPinned } })
toast.error(t('general.error'))
}
},
[patchNoteInList, t]
)
const handleDeleteNoteFromList = useCallback(
async (note: Note) => {
removeNoteFromList(note.id)
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
try {
await deleteNote(note.id, { skipRevalidation: true })
toast.success(t('notes.deleted') || 'Note supprimée')
} catch {
setNotes((prev) => [note, ...prev])
emitNoteChange({ type: 'created', note })
toast.error(t('general.error'))
}
},
[removeNoteFromList, t]
)
const handleArchiveNoteFromList = useCallback(
async (note: Note) => {
const nextArchived = !note.isArchived
if (nextArchived) removeNoteFromList(note.id)
else patchNoteInList(note.id, { isArchived: nextArchived })
emitNoteChange({ type: 'updated', note: { ...note, isArchived: nextArchived } })
try {
await toggleArchive(note.id, nextArchived, { skipRevalidation: true })
toast.success(
nextArchived ? (t('notes.archived') || 'Archivée') : (t('notes.unarchived') || 'Désarchivée')
)
} catch {
if (nextArchived) setNotes((prev) => [note, ...prev])
else patchNoteInList(note.id, { isArchived: !nextArchived })
toast.error(t('general.error'))
}
},
[patchNoteInList, removeNoteFromList, t]
)
const handleMoveNoteToNotebook = useCallback(
async (note: Note, notebookId: string | null) => {
const moved = { ...note, notebookId }
if (noteVisibleInCurrentView(moved)) {
patchNoteInList(note.id, { notebookId })
} else {
removeNoteFromList(note.id)
}
emitNoteChange({ type: 'updated', note: moved })
try {
await updateNote(note.id, { notebookId }, { skipRevalidation: true })
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
} catch {
if (noteVisibleInCurrentView(note)) {
patchNoteInList(note.id, { notebookId: note.notebookId ?? null })
} else {
setNotes((prev) => [note, ...prev])
}
toast.error(t('general.error'))
}
},
[noteVisibleInCurrentView, patchNoteInList, removeNoteFromList, t]
)
const handleNoteContentPatch = useCallback((noteId: string, patch: Partial<Note>) => {
setNotes((prev) => {
const next = prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n))
const updated = next.find((n) => n.id === noteId)
if (updated) emitNoteChange({ type: 'updated', note: updated })
return next
})
}, [])
const handleNoteIllustrationGenerated = useCallback(async (noteId: string) => {
const fresh = await getNoteById(noteId)
if (!fresh) return
patchNoteInList(noteId, fresh)
emitNoteChange({ type: 'updated', note: fresh })
}, [patchNoteInList])
const handleNoteIllustrationDeleted = useCallback((noteId: string) => {
patchNoteInList(noteId, { illustrationSvg: null })
setNotes((prev) => {
const updated = prev.find((n) => n.id === noteId)
if (updated) emitNoteChange({ type: 'updated', note: { ...updated, illustrationSvg: null } })
return prev
})
}, [patchNoteInList])
const handleGridReorder = useCallback(
async (orderedIds: string[]) => {
setSortOrder('manual')
setNotes((prev) => {
const orderMap = new Map(orderedIds.map((id, index) => [id, index]))
return prev.map((n) => (orderMap.has(n.id) ? { ...n, order: orderMap.get(n.id)! } : n))
})
try {
await updateFullOrderWithoutRevalidation(orderedIds)
} catch {
toast.error(t('general.error'))
}
},
[t],
)
// 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)
}, [])
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 sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
const hasActiveFilter = !!(search || labelFilter.length > 0 || colorFilter || sharedOnly || remindersOnly)
if (hasActiveFilter || notebook) {
void fetchNotesForCurrentView()
return
}
const filtered = filterNotesForCurrentView(initialNotesRef.current)
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, fetchNotesForCurrentView, filterNotesForCurrentView])
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<string, { id: string; name: string; type?: string }>()
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<SortOrder, string> = {
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 })
}, [router, searchParams])
const handleNoteSaved = useCallback((savedNote: Note) => {
setNotes((prev) => prev.map((n) => (n.id === savedNote.id ? { ...n, ...savedNote } : n)))
setEditingNote((prev) => (prev?.note.id === savedNote.id ? { ...prev, note: savedNote } : prev))
emitNoteChange({ type: 'updated', note: savedNote })
}, [])
return (
<div
className={cn(
'flex w-full min-h-0 flex-1 flex-col',
editingNote ? 'h-full overflow-hidden' : 'gap-3 py-1'
)}
>
{editingNote ? (
<div className="flex flex-1 min-h-0 h-full w-full overflow-hidden">
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={handleEditorClose}
onNoteSaved={handleNoteSaved}
fullPage
/>
</div>
) : (
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
<div
className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-10 md:pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 dark:bg-background/90 backdrop-blur-md z-30"
>
<div className="flex justify-between items-start gap-3">
{/* Hamburger mobile — ouvre la sidebar */}
<button
className="md:hidden p-2 -ms-2 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 self-start mt-1"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label="Open menu"
>
<Menu size={22} />
</button>
<div className="flex-1 min-w-0">
{currentNotebook && notebookPath.length > 0 && (
<div
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2 text-ink/60"
>
{notebookPath.map((nb: any, i: number) => (
<React.Fragment key={nb.id}>
{i > 0 && <ChevronRight size={10} className="shrink-0 text-concrete" />}
<span className={i === notebookPath.length - 1 ? 'text-ink' : 'text-concrete'}>
{nb.name}
</span>
</React.Fragment>
))}
</div>
)}
<h1 className="font-memento-serif text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight text-foreground leading-tight pe-4 sm:pe-12">
{currentNotebook
? currentNotebook.name
: searchParams.get('shared') === '1'
? t('sidebar.sharedWithMe')
: searchParams.get('reminders') === '1'
? t('sidebar.reminders')
: t('notes.title')}
</h1>
</div>
</div>
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
<div className="flex items-center gap-6">
<button
onClick={handleAddNote}
disabled={isCreating}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<Plus size={16} />
<span>{t('notes.newNote')}</span>
</button>
{currentNotebook && (
<button
onClick={() => setCreateSubNotebookOpen(true)}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<FolderOpen size={16} />
<span>{t('notebook.createSubNotebook')}</span>
</button>
)}
{/* Inline search — toggles an input within the toolbar */}
{showInlineSearch ? (
<div className="flex items-center gap-2">
{isSearching ? (
<div className="w-3.5 h-3.5 border border-muted-foreground/50 border-t-foreground rounded-full animate-spin shrink-0" />
) : (
<Search size={14} className="text-muted-foreground shrink-0" />
)}
<input
ref={inlineSearchRef}
autoFocus
type="text"
value={inlineSearchQuery}
onChange={e => {
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 && (
<button
onClick={() => {
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()}`)
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={12} />
</button>
)}
</div>
) : (
<button
onClick={() => {
setShowInlineSearch(true)
setTimeout(() => inlineSearchRef.current?.focus(), 50)
}}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<Search size={16} />
<span>{t('notes.search')}</span>
</button>
)}
{!searchParams.get('notebook') && searchParams.get('shared') !== '1' && (
<button
onClick={() => setBatchOrganizationOpen(true)}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<Sparkles size={16} />
<span>{t('notes.reorganize')}</span>
</button>
)}
</div>
<div className="flex items-center gap-4 flex-wrap">
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
<button
type="button"
onClick={() => selectLayoutMode('grid')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'grid'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('notes.layoutGridTitle')}
>
<LayoutGrid size={13} />
</button>
<button
type="button"
onClick={() => selectLayoutMode('list')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'list'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('notes.layoutListTitle')}
>
<List size={13} />
</button>
{!notebookFilter && (
<button
type="button"
onClick={() => selectLayoutMode('table')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'table'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('notes.layoutTableTitle')}
>
<Table size={13} />
</button>
)}
{notebookFilter && (
<>
<span className="w-px h-4 bg-border/50 mx-0.5" aria-hidden />
<button
type="button"
onClick={() => selectLayoutMode('table')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'table'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('structuredViews.viewTableHint')}
>
<Table size={13} />
</button>
<button
type="button"
onClick={() => selectLayoutMode('kanban')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'kanban'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('structuredViews.viewKanbanHint')}
>
<Columns3 size={13} />
</button>
</>
)}
</div>
{currentNotebook && structuredModeActive && (
<button
type="button"
onClick={() => setAddPropertyOpen(true)}
className="p-1.5 rounded-full text-muted-foreground hover:text-brand-accent transition-colors"
title={t('structuredViews.addProperty')}
>
<Plus size={16} />
</button>
)}
{searchParams.get('notebook') && initialSettings.aiAssistantEnabled && (
<div className="relative" ref={aiMenuRef}>
<button
onClick={() => setAiMenuOpen(o => !o)}
className={cn(
'flex items-center gap-1.5 text-[12px] font-medium transition-colors px-2.5 py-1.5 rounded-lg border',
aiMenuOpen
? 'bg-brand-accent/10 border-brand-accent/30 text-brand-accent'
: 'border-border/50 text-muted-foreground hover:text-foreground hover:border-border',
)}
>
<Sparkles size={13} />
<span>IA</span>
<ChevronDown size={11} className={cn('transition-transform', aiMenuOpen && 'rotate-180')} />
</button>
{aiMenuOpen && (
<>
{/* overlay invisible pour fermer au clic extérieur */}
<div className="fixed inset-0 z-10" onClick={() => setAiMenuOpen(false)} />
<div className="absolute right-0 top-full mt-1.5 z-20 w-52 bg-popover border border-border rounded-xl shadow-lg overflow-hidden">
{[
{
icon: <FileText size={14} />,
label: t('notebook.summary') || 'Résumé du carnet',
action: () => { setSummaryDialogOpen(true); setAiMenuOpen(false) },
},
{
icon: <CalendarDays size={14} />,
label: t('wizard.studyPlanner') || 'Planning de révision',
action: () => { setShowStudyPlanner(true); setAiMenuOpen(false) },
},
{
icon: <Sparkles size={14} />,
label: t('batch.organize') || 'Organiser avec l\'IA',
action: () => { setOrganizeNotebookOpen(true); setAiMenuOpen(false) },
},
{
icon: <TagIcon size={14} />,
label: t('wizard.autoTags') || 'Tags automatiques',
action: () => { setShowOrganizer(true); setAiMenuOpen(false) },
},
{
icon: <Globe size={14} />,
label: t('notebookSite.shortTitle') || 'Site web',
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
},
].map((item, i) => (
<button
key={i}
onClick={item.action}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-[12px] text-foreground hover:bg-muted/60 transition-colors text-left"
>
<span className="text-muted-foreground">{item.icon}</span>
{item.label}
</button>
))}
</div>
</>
)}
</div>
)}
{searchParams.get('notebook') && (
<div className="flex items-center gap-1">
<button
onClick={handleImportCSV}
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground transition-colors"
title={t('structuredViews.importCsv') || 'Importer CSV'}
>
<Upload size={15} />
</button>
<button
onClick={handleExportCSV}
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground transition-colors"
title={t('structuredViews.exportCsv') || 'Exporter CSV'}
>
<Download size={15} />
</button>
</div>
)}
<button
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<ArrowUpDown size={16} />
<span>{sortLabels[sortOrder]}</span>
</button>
</div>
</div>
{availableTags.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
<TagIcon size={12} />
<span>{t('labels.filterByTags')}</span>
{selectedTagIds.length > 0 && (
<span className="bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
{selectedTagIds.length} active
</span>
)}
</div>
{availableTags.length > 10 && (
<input
type="text"
placeholder={t('labels.searchTags')}
className="bg-transparent border-b border-foreground/10 text-[10px] outline-none focus:border-brand-accent/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-muted-foreground/40"
value={tagSearchQuery}
onChange={e => setTagSearchQuery(e.target.value)}
/>
)}
</div>
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
<AnimatePresence mode="popLayout">
{visibleTags.map(tag => {
const isActive = selectedTagIds.includes(tag.id)
return (
<motion.button
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
key={tag.id}
onClick={() => 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' && (
<Sparkles
size={10}
className={isActive ? 'text-brand-accent' : 'text-brand-accent/60'}
/>
)}
{tag.name}
{isActive && <X size={10} />}
</motion.button>
)
})}
</AnimatePresence>
{availableTags.length > 10 && !tagSearchQuery && (
<button
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60 hover:text-foreground transition-colors border border-dashed border-border rounded-full"
>
{isTagsExpanded
? t('labels.showLess')
: `+ ${availableTags.length - 10} more`}
</button>
)}
{selectedTagIds.length > 0 && (
<button
onClick={() => setSelectedTagIds([])}
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-red-500 hover:underline ms-auto"
>
{t('labels.clearAll')}
</button>
)}
</div>
</div>
)}
</div>
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : showStructuredLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : showStructuredIntro ? (
showStructuredWizard ? (
<StructuredViewsWizard
open={showStructuredWizard}
onClose={() => setShowStructuredWizard(false)}
onComplete={(view) => {
setShowStructuredWizard(false)
if (view !== 'gallery') setLayoutMode(view)
void schemaHook.reload()
}}
structuredModeActive={structuredModeActive}
enableStructuredMode={schemaHook.enableStructuredMode}
addProperty={schemaHook.addProperty}
setKanbanGroupProperty={schemaHook.setKanbanGroupProperty}
initialGoal={structuredViewMode === 'kanban' ? 'tasks' : undefined}
/>
) : (
<StructuredViewsIntro
target={structuredViewMode}
enabling={isEnablingStructured}
onEnable={() => void handleEnableStructured(structuredViewMode)}
onOpenWizard={() => setShowStructuredWizard(true)}
/>
)
) : showStructuredDataView && schemaHook.schema && notebookFilter ? (
<>
<StructuredViewsHelpBanner notebookId={notebookFilter} mode={structuredViewMode} />
<StructuredViewsContainer
mode={structuredViewMode}
notes={sortedNotes}
schema={schemaHook.schema}
noteValues={schemaHook.noteValues}
notebookColor={currentNotebook?.color}
onOpen={(note) => handleOpenNoteFresh(note.id, false)}
onNoteValuesPatch={schemaHook.patchNoteValuesLocal}
onCreateNote={handleAddNoteWithProperties}
onSetKanbanGroupProperty={(id) => void schemaHook.setKanbanGroupProperty(id)}
onQuickAddKanbanStatus={() => void handleQuickAddKanbanStatus()}
onDeleteProperty={async (propertyId) => {
await schemaHook.deleteProperty(propertyId)
toast.success(t('structuredViews.deletePropertySuccess'))
}}
/>
</>
) : notes.length === 0 ? (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-memento-serif text-xl italic text-muted-foreground">
{t('notes.emptyState')}
</p>
<button
onClick={handleAddNote}
disabled={isCreating}
className="px-6 py-2 border border-foreground text-[13px] uppercase tracking-[0.2em] hover:bg-foreground hover:text-background transition-all"
>
{t('notes.createFirst')}
</button>
</div>
) : (
<NotesListViews
notes={sortedNotes}
pinnedNotes={sortedPinnedNotes}
layoutMode={classicLayoutMode}
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
onOpenHistory={handleOpenHistory}
notebookName={currentNotebook?.name}
onTogglePin={handleTogglePin}
onDeleteNote={handleDeleteNoteFromList}
onArchiveNote={handleArchiveNoteFromList}
onMoveToNotebook={handleMoveNoteToNotebook}
onNotePatch={handleNoteContentPatch}
onNoteIllustrationGenerated={handleNoteIllustrationGenerated}
onNoteIllustrationDeleted={handleNoteIllustrationDeleted}
onGridReorder={handleGridReorder}
/>
)}
</div>
<footer className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 border-t border-foreground/5 text-center mt-auto">
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-medium">
Memento &mdash; {new Date().getFullYear()}
</p>
</footer>
</div>
)}
{notebookSuggestion && (
<NotebookSuggestionToast
noteId={notebookSuggestion.noteId}
noteContent={notebookSuggestion.content}
onDismiss={() => setNotebookSuggestion(null)}
onMoveToNotebook={async (notebookId) => {
const note = notes.find((n) => n.id === notebookSuggestion.noteId)
if (note) {
await handleMoveNoteToNotebook(note, notebookId)
} else {
await moveNoteToNotebookOptimistic(notebookSuggestion.noteId, notebookId)
}
}}
/>
)}
{batchOrganizationOpen && (
<BatchOrganizationDialog
open={batchOrganizationOpen}
onOpenChange={setBatchOrganizationOpen}
onNotesMoved={() => fetchNotesForCurrentView({ silent: true })}
/>
)}
{autoLabelOpen && (
<AutoLabelSuggestionDialog
open={autoLabelOpen}
onOpenChange={(open) => {
setAutoLabelOpen(open)
if (!open) dismissLabelSuggestion()
}}
notebookId={suggestNotebookId}
onLabelsCreated={() => fetchNotesForCurrentView({ silent: true })}
/>
)}
<NoteHistoryModal
open={historyOpen}
onOpenChange={setHistoryOpen}
note={historyNote}
enabled={!!historyNote?.historyEnabled}
onEnableHistory={async () => { if (historyNote) await handleEnableHistory(historyNote.id) }}
onRestored={handleHistoryRestored}
/>
{searchParams.get('notebook') && (
<NotebookSummaryDialog
open={summaryDialogOpen}
onOpenChange={setSummaryDialogOpen}
notebookId={searchParams.get('notebook')}
notebookName={currentNotebook?.name}
/>
)}
{searchParams.get('notebook') && (
<CreateNotebookDialog
open={createSubNotebookOpen}
onOpenChange={setCreateSubNotebookOpen}
parentNotebookId={searchParams.get('notebook')}
/>
)}
{currentNotebook && (
<OrganizeNotebookDialog
open={organizeNotebookOpen}
onOpenChange={setOrganizeNotebookOpen}
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onDone={() => {
refreshNotebooks()
}}
/>
)}
{notebookFilter && schemaHook.schema && (
<AddPropertyDialog
open={addPropertyOpen}
onClose={() => setAddPropertyOpen(false)}
onSubmit={async (name, type, options) => {
await schemaHook.addProperty(name, type, options)
}}
/>
)}
{showStudyPlanner && currentNotebook && (
<StudyPlannerDialog
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onClose={() => setShowStudyPlanner(false)}
/>
)}
{showOrganizer && currentNotebook && (
<NotebookOrganizerDialog
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onClose={() => setShowOrganizer(false)}
/>
)}
{showNotebookSite && currentNotebook && (
<NotebookSiteDialog
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onClose={() => setShowNotebookSite(false)}
/>
)}
</div>
)
}