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.
1667 lines
70 KiB
TypeScript
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 — {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>
|
|
)
|
|
}
|