Files
Momento/memento-note/components/home-client.tsx
Antigravity 3bdbec575a feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair
Harmonise la liste des notes et l’éditeur (titres, dates, retour),
rend la saisie rapide et les raccourcis de l’accueil utilisables
sur téléphone, et conserve le panneau latéral repliable.
2026-09-05 20:53:48 +00:00

1667 lines
70 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, bulkTrashNotes, bulkMoveNotes } 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, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe, Presentation } from 'lucide-react'
import { emitNoteChange, NOTE_CHANGE_EVENT, type NoteChangeEvent } 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'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast, showNotesTrashedToast } from '@/lib/notes/trash-toast'
import { InboxBulkBar } from '@/components/inbox-bulk-bar'
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 DashboardView = dynamic(
() => import('@/components/dashboard-view').then(m => ({ default: m.DashboardView })),
{ ssr: false }
)
const NotebookSiteDialog = dynamic(
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
{ ssr: false }
)
const NotebookSlidesDialog = dynamic(
() => import('@/components/wizard/notebook-slides-dialog').then(m => ({ default: m.NotebookSlidesDialog })),
{ 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 [tagFiltersOpen, setTagFiltersOpen] = 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 [showNotebookSlides, setShowNotebookSlides] = useState(false)
const [aiMenuOpen, setAiMenuOpen] = useState(false)
const aiMenuRef = useRef<HTMLDivElement>(null)
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
const [showOrganizer, setShowOrganizer] = useState(false)
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
const [selectedInboxIds, setSelectedInboxIds] = useState<Set<string>>(() => new Set())
const [bulkBusy, setBulkBusy] = useState(false)
const [bulkTrashOpen, setBulkTrashOpen] = 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.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
}
}
input.click()
}, [searchParams])
const notebookFilter = searchParams.get('notebook')
const isInboxView =
!notebookFilter &&
searchParams.get('shared') !== '1' &&
searchParams.get('reminders') !== '1'
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)
// On GARDE forceList dans l'URL pour distinguer "liste" du "dashboard".
// Si openNote est présent, la note a été ouverte depuis cette liste : ne pas la fermer.
useEffect(() => {
if (searchParams.get('forceList') === '1' && !searchParams.get('openNote')) {
setEditingNote(null)
}
}, [searchParams])
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) => {
if (prevNotes.some((n) => n.id === note.id)) return 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 params = new URLSearchParams(searchParams.toString())
params.set('openNote', noteId)
router.replace(`/home?${params.toString()}`, { scroll: false })
const note = await getNoteById(noteId)
if (note) setEditingNote({ note, readOnly })
}, [router, searchParams])
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((note: Note) => {
setNotePendingDelete(note)
}, [])
const confirmDeleteNoteFromList = useCallback(async () => {
const note = notePendingDelete
if (!note) return
setNotePendingDelete(null)
removeNoteFromList(note.id)
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
try {
await deleteNote(note.id, { skipRevalidation: true })
showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
} catch {
setNotes((prev) => [note, ...prev])
emitNoteChange({ type: 'created', note })
toast.error(t('general.error'))
}
}, [notePendingDelete, 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 handleToggleInboxSelect = useCallback((id: string) => {
setSelectedInboxIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const handleBulkMoveInbox = useCallback(
async (notebookId: string) => {
const moved = notes.filter((n) => selectedInboxIds.has(n.id))
const ids = moved.map((n) => n.id)
if (ids.length === 0) return
setBulkBusy(true)
for (const note of moved) {
removeNoteFromList(note.id)
emitNoteChange({ type: 'updated', note: { ...note, notebookId } })
}
setSelectedInboxIds(new Set())
try {
await bulkMoveNotes(ids, notebookId, { skipRevalidation: true })
toast.success(t('notes.bulkMovedToast', { count: ids.length }))
refreshNotebooks()
} catch {
setNotes((prev) => [...moved, ...prev])
toast.error(t('general.error'))
} finally {
setBulkBusy(false)
}
},
[notes, selectedInboxIds, removeNoteFromList, refreshNotebooks, t],
)
const confirmBulkTrashInbox = useCallback(async () => {
const moved = notes.filter((n) => selectedInboxIds.has(n.id))
const ids = moved.map((n) => n.id)
setBulkTrashOpen(false)
if (ids.length === 0) return
setBulkBusy(true)
for (const note of moved) {
removeNoteFromList(note.id)
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
}
setSelectedInboxIds(new Set())
try {
await bulkTrashNotes(ids, { skipRevalidation: true })
showNotesTrashedToast(moved, t, () => {
setNotes((prev) => [...moved, ...prev])
})
refreshNotebooks()
} catch {
setNotes((prev) => [...moved, ...prev])
for (const note of moved) {
emitNoteChange({ type: 'created', note })
}
toast.error(t('general.error'))
} finally {
setBulkBusy(false)
}
}, [notes, selectedInboxIds, removeNoteFromList, refreshNotebooks, 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])
// Écoute les mutations externes (versioning activé depuis éditeur, etc.)
useEffect(() => {
const onNoteChange = (e: Event) => {
const detail = (e as CustomEvent<NoteChangeEvent>).detail
if (!detail) return
if (detail.type === 'updated') {
patchNoteInList(detail.note.id, detail.note)
setPinnedNotes(prev => prev.map(n => n.id === detail.note.id ? { ...n, ...detail.note } : n))
} else if (detail.type === 'deleted') {
removeNoteFromList(detail.noteId)
setPinnedNotes(prev => prev.filter(n => n.id !== detail.noteId))
} else if (detail.type === 'created' && detail.note) {
setNotes((prev) => {
if (prev.some((n) => n.id === detail.note.id)) return prev
if (filterNotesForCurrentView([detail.note]).length === 0) return prev
return [detail.note, ...prev]
})
}
}
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
}, [patchNoteInList, filterNotesForCurrentView])
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) {
setEditingNote(null)
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((prev) => {
if (prev?.note.id === note.id) return { ...prev, note }
return { 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(() => {
if (!tagFiltersOpen) return availableTags.filter(tag => selectedTagIds.includes(tag.id))
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, tagFiltersOpen])
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(() => {
const seen = new Set<string>()
let sorted = notes.filter((n) => {
if (seen.has(n.id)) return false
seen.add(n.id)
return true
})
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 handleSelectAllInbox = useCallback(() => {
setSelectedInboxIds(new Set(sortedNotes.map((n) => n.id)))
}, [sortedNotes])
const handleDeselectAllInbox = useCallback(() => {
setSelectedInboxIds(new Set())
}, [])
useEffect(() => {
if (!isInboxView) {
setSelectedInboxIds(new Set())
return
}
setSelectedInboxIds((prev) => {
if (prev.size === 0) return prev
const valid = new Set(sortedNotes.map((n) => n.id))
const next = new Set([...prev].filter((id) => valid.has(id)))
return next.size === prev.size ? prev : next
})
}, [isInboxView, 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 from = searchParams.get('from')
// Ouvert depuis le dashboard (Comparer / Lier / clic note) → revenir au dashboard, pas à la liste des carnets.
if (from === 'dashboard' || searchParams.get('peekNote')) {
router.replace('/home', { scroll: false })
return
}
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
params.delete('peekNote')
params.delete('from')
const qs = params.toString()
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
if (from === 'search') {
void import('@/lib/search/restore-search').then(({ requestSearchRestore }) => {
requestSearchRestore()
})
}
}, [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 })
}, [])
// Show dashboard when no active filter/view params
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
const handleDashboardNoteSelect = useCallback((
noteId: string,
notebookId: string | null,
peekNoteId?: string | null,
) => {
const params = new URLSearchParams()
params.set('openNote', noteId)
params.set('from', 'dashboard')
// Avec aperçu split, ne pas ouvrir le panneau carnets : il mange la largeur des deux notes.
if (notebookId && !peekNoteId) params.set('notebook', notebookId)
if (peekNoteId && peekNoteId !== noteId) params.set('peekNote', peekNoteId)
router.push(`/home?${params.toString()}`)
}, [router])
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>
) : showDashboard ? (
<DashboardView onNoteSelect={handleDashboardNoteSelect} />
) : (
<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">
<div className="flex-1 min-w-0">
{currentNotebook && notebookPath.length > 1 && (
<nav
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] uppercase tracking-[.2em] font-bold mb-2 text-ink/60 min-w-0"
>
{notebookPath.slice(0, -1).map((nb: any, i: number) => (
<React.Fragment key={nb.id}>
{i > 0 && <ChevronRight size={10} className="shrink-0 text-concrete rtl:rotate-180" />}
<button
type="button"
onClick={() => {
const params = new URLSearchParams()
params.set('notebook', nb.id)
params.set('forceList', '1')
router.push(`/home?${params.toString()}`)
}}
className="min-w-0 max-w-full break-words [overflow-wrap:anywhere] text-start text-concrete hover:text-ink hover:underline underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent rounded-sm"
dir="auto"
>
{nb.name}
</button>
</React.Fragment>
))}
</nav>
)}
<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 min-w-0 break-words [overflow-wrap:anywhere]"
dir="auto"
>
{currentNotebook
? currentNotebook.name
: searchParams.get('shared') === '1'
? t('sidebar.sharedWithMe')
: searchParams.get('reminders') === '1'
? t('sidebar.reminders')
: isInboxView
? t('sidebar.inbox')
: t('notes.title')}
</h1>
</div>
</div>
<div className="flex min-w-0 flex-col gap-3 border-b border-foreground/5 pb-4 xl:flex-row xl:flex-wrap xl:items-center xl:justify-between">
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-x-4 gap-y-1 sm:gap-x-6 [&>button]:min-h-11 [&>button]:max-w-full [&>button>svg]:shrink-0">
<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 min-h-11 min-w-0 max-w-full 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 min-w-0 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 min-w-0 max-w-full flex-wrap items-center gap-x-3 gap-y-2 [&>button]:min-h-11">
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex shrink-0 border border-border/30 items-center [&>button]:flex [&>button]:h-11 [&>button]:w-11 [&>button]:items-center [&>button]:justify-center sm:[&>button]:h-8 sm:[&>button]:w-8 [&>button>svg]:h-4 [&>button>svg]:w-4">
<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) },
},
{
icon: <Presentation size={14} />,
label: t('notebook.slides') || 'Présentation',
action: () => { setShowNotebookSlides(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>
{isInboxView && notes.length > 0 && (
<InboxBulkBar
totalCount={sortedNotes.length}
selectedCount={selectedInboxIds.size}
busy={bulkBusy}
onSelectAll={handleSelectAllInbox}
onDeselectAll={handleDeselectAllInbox}
onMove={(notebookId) => { void handleBulkMoveInbox(notebookId) }}
onTrash={() => setBulkTrashOpen(true)}
/>
)}
{availableTags.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<button
type="button"
aria-expanded={tagFiltersOpen}
aria-controls="note-tag-filters"
onClick={() => setTagFiltersOpen(open => !open)}
className="flex min-h-11 items-center gap-2 rounded-md px-2 text-sm font-medium text-foreground hover:bg-brand-accent/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent"
>
<TagIcon size={16} />
<span>{t('labels.filterButton')}</span>
{selectedTagIds.length > 0 && (
<span className="bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-xs tabular-nums">
{selectedTagIds.length.toLocaleString(language)}
</span>
)}
<ChevronDown size={14} className={cn('transition-transform motion-reduce:transition-none', tagFiltersOpen && 'rotate-180')} />
</button>
{tagFiltersOpen && availableTags.length > 10 && (
<input
type="text"
placeholder={t('labels.searchTags')}
aria-label={t('labels.searchTags')}
className="max-w-full min-h-11 w-48 rounded-md border border-border bg-background text-sm outline-none focus:border-brand-accent px-3 placeholder:text-muted-foreground"
value={tagSearchQuery}
onChange={e => setTagSearchQuery(e.target.value)}
/>
)}
</div>
<div id="note-tag-filters" hidden={!tagFiltersOpen && selectedTagIds.length === 0} className={cn('flex flex-wrap gap-2 items-center', !tagFiltersOpen && selectedTagIds.length === 0 && 'hidden')}>
<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}
type="button"
aria-pressed={isActive}
onClick={() => toggleTag(tag.id)}
className={cn(
'max-w-full px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-colors border flex items-center gap-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent',
isActive
? 'bg-brand-accent/10 text-brand-accent border-brand-accent/40'
: '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'}
/>
)}
<span className="break-words [overflow-wrap:anywhere]">{tag.name}</span>
{isActive && <X size={12} className="shrink-0" />}
</motion.button>
)
})}
</AnimatePresence>
{tagFiltersOpen && 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')
: t('labels.showMore')}
</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}
selection={isInboxView ? {
selectedIds: selectedInboxIds,
onToggle: handleToggleInboxSelect,
onToggleAll: selectedInboxIds.size === sortedNotes.length
? handleDeselectAllInbox
: handleSelectAllInbox,
allSelected: sortedNotes.length > 0 && selectedInboxIds.size === sortedNotes.length,
} : undefined}
/>
)}
</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)}
/>
)}
<ConfirmDeleteNoteDialog
open={notePendingDelete != null || bulkTrashOpen}
onOpenChange={(open) => {
if (!open) {
setNotePendingDelete(null)
setBulkTrashOpen(false)
}
}}
title={bulkTrashOpen ? t('notes.confirmBulkDeleteTitle') : undefined}
description={bulkTrashOpen ? t('notes.confirmBulkDelete') : undefined}
onConfirm={bulkTrashOpen ? confirmBulkTrashInbox : confirmDeleteNoteFromList}
/>
{showNotebookSlides && currentNotebook && (
<NotebookSlidesDialog
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onClose={() => setShowNotebookSlides(false)}
/>
)}
</div>
)
}