Files
Momento/memento-note/components/home-client.tsx
Antigravity 0784c94242
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note)
avec activation guidée, tableau éditable, kanban et suppression de colonnes.
Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN.
Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la
robustesse du serveur MCP (config, validation, rate-limit, métriques).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:03:16 +00:00

1309 lines
53 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, type NotesViewType, isClassicLayoutMode } from '@/components/notes-list-views'
import {
NOTES_LAYOUT_STORAGE_KEY,
NOTES_VIEW_TYPE_STORAGE_KEY,
parseNotesLayoutMode,
parseNotesViewType,
setNotesLayoutPreference,
setNotesViewTypePreference,
} 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, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3 } 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 { 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 StructuredViewsIntro = dynamic(
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
{ 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
initialViewType?: NotesViewType
}
export function HomeClient({
initialNotes,
initialSettings,
initialLayoutMode = 'list',
initialViewType = 'notes',
}: HomeClientProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { t } = 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 } = 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)
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
const [tagSearchQuery, setTagSearchQuery] = useState('')
const [viewType, setViewType] = useState<NotesViewType>(initialViewType)
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
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))
const storedViewType = parseNotesViewType(localStorage.getItem(NOTES_VIEW_TYPE_STORAGE_KEY))
if (storedLayout !== initialLayoutMode) {
setLayoutMode(storedLayout)
setNotesLayoutPreference(storedLayout)
}
if (storedViewType !== initialViewType) {
setViewType(storedViewType)
setNotesViewTypePreference(storedViewType)
}
}, [initialLayoutMode, initialViewType])
useEffect(() => {
setNotesViewTypePreference(viewType)
}, [viewType])
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)
setViewType('notes')
}
}
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)
: 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)
setViewType('notes')
}, [])
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 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 gap-3 py-1'
)}
>
{editingNote ? (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={handleEditorClose}
onNoteSaved={handleNoteSaved}
fullPage
/>
) : (
<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>
)}
{currentNotebook && initialSettings.aiAssistantEnabled && (
<button
onClick={() => setOrganizeNotebookOpen(true)}
className="flex items-center gap-2 text-[13px] text-brand-accent font-medium hover:opacity-70 transition-opacity"
title={t('notebook.organizeNotebookWithAITooltip')}
>
<Sparkles size={16} />
<span>{t('batch.organize')}</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">
<button
type="button"
onClick={() => setViewType('notes')}
className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
viewType === 'notes'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('notes.viewNotes')}
</button>
<button
type="button"
onClick={() => setViewType('tasks')}
className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
viewType === 'tasks'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('notes.viewTasks')}
</button>
</div>
{viewType === 'notes' && (
<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>
)}
{viewType === 'notes' && 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') && (
<button
onClick={() => setSummaryDialogOpen(true)}
disabled={!initialSettings.aiAssistantEnabled}
className={cn(
"flex items-center gap-2 text-[13px] font-medium transition-opacity",
initialSettings.aiAssistantEnabled ? "text-foreground hover:opacity-70" : "text-muted-foreground opacity-50 cursor-not-allowed"
)}
title={initialSettings.aiAssistantEnabled ? t('notebook.summary') : t('notebook.assistantRequiredForSummarize')}
>
<FileText size={16} />
<span>{t('notebook.summary')}</span>
</button>
)}
<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 ? (
<StructuredViewsIntro
target={structuredViewMode}
enabling={isEnablingStructured}
onEnable={() => void handleEnableStructured(structuredViewMode)}
/>
) : 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}
viewType={viewType}
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}
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)}
/>
)}
{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)
}}
/>
)}
</div>
)
}