L'ancien organisateur (OrganizeNotebookDialog) est supérieur au mien : - Crée des sous-carnets réels - Déplace les notes vers ces sous-carnets - Détecte les sous-carnets existants - Plan éditable avant exécution (renommer, retirer notes) Mon NotebookOrganizerDialog ne faisait que suggérer des tags. Restauration du bouton batch.organize avec Sparkles.
1345 lines
55 KiB
TypeScript
1345 lines
55 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useTransition, useMemo } from 'react'
|
|
import { useSearchParams, useRouter } from 'next/navigation'
|
|
import dynamic from 'next/dynamic'
|
|
import { Note } from '@/lib/types'
|
|
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
|
|
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, isClassicLayoutMode } from '@/components/notes-list-views'
|
|
import {
|
|
NOTES_LAYOUT_STORAGE_KEY,
|
|
parseNotesLayoutMode,
|
|
setNotesLayoutPreference,
|
|
} from '@/lib/notes-view-preference'
|
|
import { useNotebookSchema } from '@/hooks/use-notebook-schema'
|
|
import {
|
|
bootstrapStructuredNotebook,
|
|
ensureKanbanStatusField,
|
|
type BootstrapStructuredTarget,
|
|
} from '@/lib/structured-views/bootstrap-structured-notebook'
|
|
|
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload } from 'lucide-react'
|
|
import { emitNoteChange } from '@/lib/note-change-sync'
|
|
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
|
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
|
import { useNotebooks } from '@/context/notebooks-context'
|
|
import { cn } from '@/lib/utils'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useEditorUI } from '@/context/editor-ui-context'
|
|
import { NoteHistoryModal } from '@/components/note-history-modal'
|
|
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
|
|
import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
|
|
import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog'
|
|
import { toast } from 'sonner'
|
|
import { AnimatePresence, motion } from 'motion/react'
|
|
|
|
|
|
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
|
|
|
const NoteEditor = dynamic(
|
|
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
|
{ ssr: false }
|
|
)
|
|
const BatchOrganizationDialog = dynamic(
|
|
() => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })),
|
|
{ ssr: false }
|
|
)
|
|
const AutoLabelSuggestionDialog = dynamic(
|
|
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
|
|
{ ssr: false }
|
|
)
|
|
const NotebookSummaryDialog = dynamic(
|
|
() => import('@/components/notebook-summary-dialog').then(m => ({ default: m.NotebookSummaryDialog })),
|
|
{ ssr: false }
|
|
)
|
|
const OrganizeNotebookDialog = dynamic(
|
|
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
|
|
{ ssr: false }
|
|
)
|
|
const StructuredViewsIntro = dynamic(
|
|
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
|
{ ssr: false }
|
|
)
|
|
const StructuredViewsHelpBanner = dynamic(
|
|
() => import('@/components/structured-views/structured-views-help-banner').then(m => ({ default: m.StructuredViewsHelpBanner })),
|
|
{ ssr: false }
|
|
)
|
|
const StructuredViewsContainer = dynamic(
|
|
() => import('@/components/structured-views/structured-views-container').then(m => ({ default: m.StructuredViewsContainer })),
|
|
{ ssr: false }
|
|
)
|
|
const AddPropertyDialog = dynamic(
|
|
() => import('@/components/structured-views/add-property-dialog').then(m => ({ default: m.AddPropertyDialog })),
|
|
{ ssr: false }
|
|
)
|
|
|
|
type InitialSettings = {
|
|
showRecentNotes: boolean
|
|
noteHistory: boolean
|
|
noteHistoryMode: 'manual' | 'auto'
|
|
aiAssistantEnabled: boolean
|
|
}
|
|
|
|
interface HomeClientProps {
|
|
initialNotes: Note[]
|
|
initialSettings: InitialSettings
|
|
initialLayoutMode?: NotesLayoutMode
|
|
}
|
|
|
|
export function HomeClient({
|
|
initialNotes,
|
|
initialSettings,
|
|
initialLayoutMode = 'list',
|
|
}: HomeClientProps) {
|
|
const searchParams = useSearchParams()
|
|
const router = useRouter()
|
|
const { t } = useLanguage()
|
|
|
|
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
|
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
|
|
initialNotes.filter(n => n.isPinned)
|
|
)
|
|
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
|
|
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
|
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
const [historyNote, setHistoryNote] = useState<Note | null>(null)
|
|
const [isCreating, startCreating] = useTransition()
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
|
const [showSortMenu, setShowSortMenu] = useState(false)
|
|
const [showInlineSearch, setShowInlineSearch] = useState(false)
|
|
const [inlineSearchQuery, setInlineSearchQuery] = useState('')
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const inlineSearchRef = useRef<HTMLInputElement>(null)
|
|
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const notesRef = useRef(notes)
|
|
notesRef.current = notes
|
|
const { labels, notebooks, refreshNotebooks } = useNotebooks()
|
|
const labelsRef = useRef(labels)
|
|
labelsRef.current = labels
|
|
const initialNotesRef = useRef(initialNotes)
|
|
initialNotesRef.current = initialNotes
|
|
const { setControls } = useEditorUI()
|
|
|
|
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
|
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
|
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
|
|
const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false)
|
|
const [organizeNotebookOpen, setOrganizeNotebookOpen] = useState(false) // kept for compat — old dialog unused
|
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
|
|
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
|
|
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
|
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
|
|
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
|
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
|
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
|
const [showOrganizer, setShowOrganizer] = useState(false)
|
|
|
|
const handleExportCSV = useCallback(() => {
|
|
if (!searchParams.get('notebook')) return
|
|
window.open(`/api/notebooks/csv?notebookId=${searchParams.get('notebook')}`, '_blank')
|
|
}, [searchParams])
|
|
|
|
const handleImportCSV = useCallback(() => {
|
|
const input = document.createElement('input')
|
|
input.type = 'file'
|
|
input.accept = '.csv'
|
|
input.onchange = async (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0]
|
|
if (!file) return
|
|
const text = await file.text()
|
|
const res = await fetch(`/api/notebooks/csv?notebookId=${searchParams.get('notebook')}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ notebookId: searchParams.get('notebook'), csvData: text }),
|
|
})
|
|
const data = await res.json()
|
|
if (res.ok) {
|
|
toast.success(`${data.created} notes importées !`)
|
|
window.location.reload()
|
|
} else {
|
|
toast.error(data.error || 'Erreur')
|
|
}
|
|
}
|
|
input.click()
|
|
}, [searchParams])
|
|
|
|
const notebookFilter = searchParams.get('notebook')
|
|
const schemaHook = useNotebookSchema(notebookFilter)
|
|
const structuredModeActive = Boolean(notebookFilter && schemaHook.schema)
|
|
const wantsStructuredView = Boolean(
|
|
notebookFilter && (layoutMode === 'table' || layoutMode === 'kanban'),
|
|
)
|
|
const structuredViewMode: BootstrapStructuredTarget =
|
|
layoutMode === 'kanban' ? 'kanban' : 'table'
|
|
|
|
useEffect(() => {
|
|
if (layoutMode === 'gallery') {
|
|
setLayoutMode('grid')
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!notebookFilter && (layoutMode === 'kanban' || layoutMode === 'gallery')) {
|
|
setLayoutMode('list')
|
|
}
|
|
}, [notebookFilter, layoutMode])
|
|
|
|
useEffect(() => {
|
|
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
|
|
if (storedLayout !== initialLayoutMode) {
|
|
setLayoutMode(storedLayout)
|
|
setNotesLayoutPreference(storedLayout)
|
|
}
|
|
}, [initialLayoutMode])
|
|
|
|
useEffect(() => {
|
|
setNotesLayoutPreference(layoutMode)
|
|
}, [layoutMode])
|
|
|
|
useEffect(() => {
|
|
const onLayoutChange = (e: Event) => {
|
|
const detail = (e as CustomEvent<{ layout?: NotesLayoutMode }>).detail?.layout
|
|
if (detail === 'grid' || detail === 'list' || detail === 'table' || detail === 'kanban') {
|
|
setLayoutMode(detail)
|
|
}
|
|
}
|
|
window.addEventListener('memento-notes-layout-change', onLayoutChange)
|
|
return () => window.removeEventListener('memento-notes-layout-change', onLayoutChange)
|
|
}, [])
|
|
|
|
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
|
|
useEffect(() => {
|
|
if (searchParams.get('forceList') === '1') {
|
|
setEditingNote(null)
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.delete('forceList')
|
|
const newUrl = params.toString() ? `/home?${params.toString()}` : '/home'
|
|
router.replace(newUrl, { scroll: false })
|
|
}
|
|
}, [searchParams, router])
|
|
|
|
const fetchNotesForCurrentView = useCallback(
|
|
async (options?: { silent?: boolean }) => {
|
|
const search = searchParams.get('search')?.trim() || null
|
|
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
|
const colorFilter = searchParams.get('color')
|
|
const notebook = searchParams.get('notebook')
|
|
const sharedOnly = searchParams.get('shared') === '1'
|
|
const remindersOnly = searchParams.get('reminders') === '1'
|
|
|
|
if (!options?.silent) {
|
|
setIsLoading(true)
|
|
}
|
|
|
|
let allNotes = search
|
|
? await searchNotes(search, true, notebook || undefined)
|
|
: await getAllNotes(false, notebook || undefined)
|
|
|
|
if (sharedOnly) {
|
|
allNotes = allNotes.filter((note: Note) => note._isShared)
|
|
} else if (remindersOnly) {
|
|
allNotes = allNotes.filter((note: Note) => note.reminder !== null)
|
|
} else if (!notebook && !search) {
|
|
allNotes = allNotes.filter((note: Note) => !note.notebookId && !note._isShared)
|
|
}
|
|
|
|
if (labelFilter.length > 0) {
|
|
allNotes = allNotes.filter((note: Note) =>
|
|
labelFilter.every((label: string) => note.labels?.includes(label))
|
|
)
|
|
}
|
|
|
|
if (colorFilter) {
|
|
const labelNamesWithColor = labelsRef.current
|
|
.filter((label) => label.color === colorFilter)
|
|
.map((label) => label.name)
|
|
allNotes = allNotes.filter((note: Note) =>
|
|
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
|
|
)
|
|
}
|
|
|
|
setNotes((prev) => {
|
|
const localSizeMap = new Map(prev.map((n) => [n.id, n.size]))
|
|
return allNotes.map((n) => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
})
|
|
setPinnedNotes(allNotes.filter((n: Note) => n.isPinned))
|
|
setIsLoading(false)
|
|
},
|
|
[searchParams]
|
|
)
|
|
|
|
const filterNotesForCurrentView = useCallback((source: Note[]) => {
|
|
const notebook = searchParams.get('notebook')
|
|
const sharedOnly = searchParams.get('shared') === '1'
|
|
const remindersOnly = searchParams.get('reminders') === '1'
|
|
|
|
if (notebook) {
|
|
return source.filter((n) => n.notebookId === notebook && !n._isShared)
|
|
}
|
|
if (sharedOnly) {
|
|
return source.filter((n) => n._isShared)
|
|
}
|
|
if (remindersOnly) {
|
|
return source.filter((n) => n.reminder !== null)
|
|
}
|
|
return source.filter((n) => !n.notebookId && !n._isShared)
|
|
}, [searchParams])
|
|
|
|
const handleNoteCreated = useCallback((note: Note) => {
|
|
const search = searchParams.get('search')?.trim() || null
|
|
if (search) {
|
|
void fetchNotesForCurrentView({ silent: true })
|
|
emitNoteChange({ type: 'created', note })
|
|
return
|
|
}
|
|
|
|
setNotes((prevNotes) => {
|
|
const notebookFilter = searchParams.get('notebook')
|
|
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
|
const colorFilter = searchParams.get('color')
|
|
|
|
if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes
|
|
if (!notebookFilter && note.notebookId) return prevNotes
|
|
|
|
if (labelFilter.length > 0) {
|
|
const noteLabels = note.labels || []
|
|
if (!noteLabels.some((label: string) => labelFilter.includes(label))) return prevNotes
|
|
}
|
|
|
|
if (colorFilter) {
|
|
const labelNamesWithColor = labels
|
|
.filter((label: any) => label.color === colorFilter)
|
|
.map((label: any) => label.name)
|
|
const noteLabels = note.labels || []
|
|
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) return prevNotes
|
|
}
|
|
|
|
const isPinned = note.isPinned || false
|
|
const pinnedNotes = prevNotes.filter(n => n.isPinned)
|
|
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
|
|
|
|
if (isPinned) {
|
|
return [note, ...pinnedNotes, ...unpinnedNotes]
|
|
} else {
|
|
return [...pinnedNotes, note, ...unpinnedNotes]
|
|
}
|
|
})
|
|
emitNoteChange({ type: 'created', note })
|
|
|
|
if (!note.notebookId) {
|
|
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
|
if (wordCount >= 20) {
|
|
setNotebookSuggestion({ noteId: note.id, content: note.content || '' })
|
|
}
|
|
}
|
|
}, [searchParams, labels, fetchNotesForCurrentView])
|
|
|
|
// Always fetch fresh from server — avoids stale state after a save regardless of
|
|
// whether the notes list has re-fetched yet.
|
|
const handleOpenNoteFresh = useCallback(async (noteId: string, readOnly = false) => {
|
|
const note = await getNoteById(noteId)
|
|
if (note) setEditingNote({ note, readOnly })
|
|
}, [])
|
|
|
|
const handleAddNote = () => {
|
|
startCreating(async () => {
|
|
try {
|
|
const newNote = await createNote({
|
|
content: '',
|
|
type: 'richtext',
|
|
title: undefined,
|
|
notebookId: notebookFilter || undefined,
|
|
skipRevalidation: true
|
|
})
|
|
if (!newNote) return
|
|
handleNoteCreated(newNote)
|
|
setEditingNote({ note: newNote, readOnly: false })
|
|
} catch {
|
|
toast.error(t('notes.createFailed'))
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleAddNoteWithProperties = (prefill: Record<string, unknown>) => {
|
|
startCreating(async () => {
|
|
try {
|
|
const newNote = await createNote({
|
|
content: '',
|
|
type: 'richtext',
|
|
title: undefined,
|
|
notebookId: notebookFilter || undefined,
|
|
skipRevalidation: true,
|
|
})
|
|
if (!newNote) return
|
|
if (Object.keys(prefill).length > 0) {
|
|
await fetch(`/api/notes/${newNote.id}/properties`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ properties: prefill }),
|
|
})
|
|
schemaHook.patchNoteValuesLocal(newNote.id, prefill)
|
|
}
|
|
handleNoteCreated(newNote)
|
|
setEditingNote({ note: newNote, readOnly: false })
|
|
} catch {
|
|
toast.error(t('notes.createFailed'))
|
|
}
|
|
})
|
|
}
|
|
|
|
const structuredFieldLabels = useMemo(
|
|
() => ({
|
|
statusName: t('structuredViews.wizard.fields.status.name'),
|
|
statusOptions: t('structuredViews.wizard.fields.status.options')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean),
|
|
}),
|
|
[t],
|
|
)
|
|
|
|
const structuredBootstrapActions = useMemo(
|
|
() => ({
|
|
getSchema: () => schemaHook.schema,
|
|
enableStructuredMode: schemaHook.enableStructuredMode,
|
|
addProperty: schemaHook.addProperty,
|
|
setKanbanGroupProperty: schemaHook.setKanbanGroupProperty,
|
|
}),
|
|
[schemaHook],
|
|
)
|
|
|
|
const handleEnableStructured = useCallback(
|
|
async (target: BootstrapStructuredTarget) => {
|
|
if (!notebookFilter) return
|
|
setIsEnablingStructured(true)
|
|
try {
|
|
await bootstrapStructuredNotebook(target, structuredFieldLabels, structuredBootstrapActions)
|
|
await schemaHook.reload()
|
|
setLayoutMode(target)
|
|
toast.success(t('structuredViews.intro.enabledSuccess'))
|
|
} catch {
|
|
toast.error(t('structuredViews.enableFailed'))
|
|
} finally {
|
|
setIsEnablingStructured(false)
|
|
}
|
|
},
|
|
[notebookFilter, structuredFieldLabels, structuredBootstrapActions, schemaHook, t],
|
|
)
|
|
|
|
const handleQuickAddKanbanStatus = useCallback(async () => {
|
|
try {
|
|
await ensureKanbanStatusField(structuredFieldLabels, structuredBootstrapActions)
|
|
await schemaHook.reload()
|
|
} catch {
|
|
toast.error(t('structuredViews.enableFailed'))
|
|
}
|
|
}, [structuredFieldLabels, structuredBootstrapActions, schemaHook, t])
|
|
|
|
const selectLayoutMode = useCallback((mode: NotesLayoutMode) => {
|
|
if (mode === 'gallery') return
|
|
setLayoutMode(mode)
|
|
}, [])
|
|
|
|
const showStructuredIntro =
|
|
wantsStructuredView && !structuredModeActive && !schemaHook.loading
|
|
const showStructuredDataView = wantsStructuredView && structuredModeActive && schemaHook.schema
|
|
const showStructuredLoading = wantsStructuredView && schemaHook.loading
|
|
|
|
const classicLayoutMode: NotesClassicLayoutMode =
|
|
isClassicLayoutMode(layoutMode) ? layoutMode : 'list'
|
|
|
|
const handleOpenHistory = useCallback((note: Note) => {
|
|
setHistoryNote(note)
|
|
setHistoryOpen(true)
|
|
}, [])
|
|
|
|
const handleEnableHistory = useCallback(async (noteId: string) => {
|
|
await enableNoteHistory(noteId)
|
|
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
|
|
setPinnedNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
|
|
setEditingNote((prev) => (prev?.note.id === noteId ? { ...prev, note: { ...prev.note, historyEnabled: true } } : prev))
|
|
setHistoryNote((prev) => (prev?.id === noteId ? { ...prev, historyEnabled: true } : prev))
|
|
}, [])
|
|
|
|
const handleHistoryRestored = useCallback((restored: Note) => {
|
|
setNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
|
|
setPinnedNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
|
|
setEditingNote((prev) => (prev?.note.id === restored.id ? { ...prev, note: restored } : prev))
|
|
setHistoryNote((prev) => (prev?.id === restored.id ? { ...prev, ...restored } : prev))
|
|
}, [])
|
|
|
|
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
|
|
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
|
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
|
}, [])
|
|
|
|
const patchNoteInList = useCallback((noteId: string, patch: Partial<Note>) => {
|
|
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n)))
|
|
}, [])
|
|
|
|
const removeNoteFromList = useCallback((noteId: string) => {
|
|
setNotes((prev) => prev.filter((n) => n.id !== noteId))
|
|
setEditingNote((prev) => (prev?.note.id === noteId ? null : prev))
|
|
}, [])
|
|
|
|
const noteVisibleInCurrentView = useCallback(
|
|
(note: Pick<Note, 'notebookId' | '_isShared'>) => {
|
|
const notebook = searchParams.get('notebook')
|
|
const sharedOnly = searchParams.get('shared') === '1'
|
|
if (sharedOnly) return !!note._isShared
|
|
if (notebook) return note.notebookId === notebook && !note._isShared
|
|
return !note.notebookId && !note._isShared
|
|
},
|
|
[searchParams]
|
|
)
|
|
|
|
const handleTogglePin = useCallback(
|
|
async (note: Note) => {
|
|
const nextPinned = !note.isPinned
|
|
patchNoteInList(note.id, { isPinned: nextPinned })
|
|
emitNoteChange({ type: 'updated', note: { ...note, isPinned: nextPinned } })
|
|
try {
|
|
await togglePin(note.id, nextPinned, { skipRevalidation: true })
|
|
} catch {
|
|
patchNoteInList(note.id, { isPinned: !nextPinned })
|
|
emitNoteChange({ type: 'updated', note: { ...note, isPinned: !nextPinned } })
|
|
toast.error(t('general.error'))
|
|
}
|
|
},
|
|
[patchNoteInList, t]
|
|
)
|
|
|
|
const handleDeleteNoteFromList = useCallback(
|
|
async (note: Note) => {
|
|
removeNoteFromList(note.id)
|
|
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
|
try {
|
|
await deleteNote(note.id, { skipRevalidation: true })
|
|
toast.success(t('notes.deleted') || 'Note supprimée')
|
|
} catch {
|
|
setNotes((prev) => [note, ...prev])
|
|
emitNoteChange({ type: 'created', note })
|
|
toast.error(t('general.error'))
|
|
}
|
|
},
|
|
[removeNoteFromList, t]
|
|
)
|
|
|
|
const handleArchiveNoteFromList = useCallback(
|
|
async (note: Note) => {
|
|
const nextArchived = !note.isArchived
|
|
if (nextArchived) removeNoteFromList(note.id)
|
|
else patchNoteInList(note.id, { isArchived: nextArchived })
|
|
emitNoteChange({ type: 'updated', note: { ...note, isArchived: nextArchived } })
|
|
try {
|
|
await toggleArchive(note.id, nextArchived, { skipRevalidation: true })
|
|
toast.success(
|
|
nextArchived ? (t('notes.archived') || 'Archivée') : (t('notes.unarchived') || 'Désarchivée')
|
|
)
|
|
} catch {
|
|
if (nextArchived) setNotes((prev) => [note, ...prev])
|
|
else patchNoteInList(note.id, { isArchived: !nextArchived })
|
|
toast.error(t('general.error'))
|
|
}
|
|
},
|
|
[patchNoteInList, removeNoteFromList, t]
|
|
)
|
|
|
|
const handleMoveNoteToNotebook = useCallback(
|
|
async (note: Note, notebookId: string | null) => {
|
|
const moved = { ...note, notebookId }
|
|
if (noteVisibleInCurrentView(moved)) {
|
|
patchNoteInList(note.id, { notebookId })
|
|
} else {
|
|
removeNoteFromList(note.id)
|
|
}
|
|
emitNoteChange({ type: 'updated', note: moved })
|
|
try {
|
|
await updateNote(note.id, { notebookId }, { skipRevalidation: true })
|
|
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
|
|
} catch {
|
|
if (noteVisibleInCurrentView(note)) {
|
|
patchNoteInList(note.id, { notebookId: note.notebookId ?? null })
|
|
} else {
|
|
setNotes((prev) => [note, ...prev])
|
|
}
|
|
toast.error(t('general.error'))
|
|
}
|
|
},
|
|
[noteVisibleInCurrentView, patchNoteInList, removeNoteFromList, t]
|
|
)
|
|
|
|
const handleNoteContentPatch = useCallback((noteId: string, patch: Partial<Note>) => {
|
|
setNotes((prev) => {
|
|
const next = prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n))
|
|
const updated = next.find((n) => n.id === noteId)
|
|
if (updated) emitNoteChange({ type: 'updated', note: updated })
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const handleNoteIllustrationGenerated = useCallback(async (noteId: string) => {
|
|
const fresh = await getNoteById(noteId)
|
|
if (!fresh) return
|
|
patchNoteInList(noteId, fresh)
|
|
emitNoteChange({ type: 'updated', note: fresh })
|
|
}, [patchNoteInList])
|
|
|
|
const handleNoteIllustrationDeleted = useCallback((noteId: string) => {
|
|
patchNoteInList(noteId, { illustrationSvg: null })
|
|
setNotes((prev) => {
|
|
const updated = prev.find((n) => n.id === noteId)
|
|
if (updated) emitNoteChange({ type: 'updated', note: { ...updated, illustrationSvg: null } })
|
|
return prev
|
|
})
|
|
}, [patchNoteInList])
|
|
|
|
const handleGridReorder = useCallback(
|
|
async (orderedIds: string[]) => {
|
|
setSortOrder('manual')
|
|
setNotes((prev) => {
|
|
const orderMap = new Map(orderedIds.map((id, index) => [id, index]))
|
|
return prev.map((n) => (orderMap.has(n.id) ? { ...n, order: orderMap.get(n.id)! } : n))
|
|
})
|
|
try {
|
|
await updateFullOrderWithoutRevalidation(orderedIds)
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
},
|
|
[t],
|
|
)
|
|
|
|
|
|
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
|
|
useEffect(() => {
|
|
const openNoteId = searchParams.get('openNote')
|
|
if (!openNoteId) return
|
|
|
|
let cancelled = false
|
|
const run = async () => {
|
|
// Always fetch fresh data from DB to avoid showing stale content after a save.
|
|
// notesRef.current can be stale if the notes list hasn't re-fetched yet when the
|
|
// user closes and re-opens the note quickly after saving.
|
|
const note = await getNoteById(openNoteId)
|
|
if (cancelled || !note) return
|
|
setEditingNote({ note, readOnly: false })
|
|
}
|
|
run()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [searchParams])
|
|
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const { name } = (e as CustomEvent).detail
|
|
const removeLabel = (note: Note) => {
|
|
const currentLabels = note.labels || []
|
|
const updated = currentLabels.filter((l) => l.toLowerCase() !== name.toLowerCase())
|
|
if (updated.length === currentLabels.length) return note
|
|
return { ...note, labels: updated.length > 0 ? updated : null }
|
|
}
|
|
setNotes((prev) => prev.map(removeLabel))
|
|
setPinnedNotes((prev) => prev.map(removeLabel))
|
|
}
|
|
window.addEventListener('label-deleted', handler)
|
|
return () => window.removeEventListener('label-deleted', handler)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const search = searchParams.get('search')?.trim() || null
|
|
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
|
const colorFilter = searchParams.get('color')
|
|
const notebook = searchParams.get('notebook')
|
|
const sharedOnly = searchParams.get('shared') === '1'
|
|
const remindersOnly = searchParams.get('reminders') === '1'
|
|
const hasActiveFilter = !!(search || labelFilter.length > 0 || colorFilter || sharedOnly || remindersOnly)
|
|
|
|
if (hasActiveFilter || notebook) {
|
|
void fetchNotesForCurrentView()
|
|
return
|
|
}
|
|
|
|
const filtered = filterNotesForCurrentView(initialNotesRef.current)
|
|
setNotes((prev) => {
|
|
const localSizeMap = new Map(prev.map((n) => [n.id, n.size]))
|
|
return filtered.map((n) => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
|
})
|
|
setPinnedNotes(filtered.filter((n) => n.isPinned))
|
|
}, [searchParams, fetchNotesForCurrentView, filterNotesForCurrentView])
|
|
|
|
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
|
|
|
const notebookPath = useMemo(() => {
|
|
if (!currentNotebook) return []
|
|
const trail: any[] = []
|
|
let current: any = currentNotebook
|
|
while (current) {
|
|
trail.unshift(current)
|
|
if (!current.parentId) break
|
|
const parent = notebooks.find((nb: any) => nb.id === current.parentId)
|
|
if (!parent) break
|
|
current = parent
|
|
}
|
|
return trail
|
|
}, [currentNotebook, notebooks])
|
|
|
|
const availableTags = useMemo(() => {
|
|
const tagsMap = new Map<string, { id: string; name: string; type?: string }>()
|
|
const carnetNotes = notes
|
|
carnetNotes.forEach(note => {
|
|
;(note.labels || []).forEach(labelName => {
|
|
if (!tagsMap.has(labelName)) {
|
|
const labelObj = labels.find((l: any) => l.name === labelName)
|
|
tagsMap.set(labelName, {
|
|
id: labelObj?.id || labelName,
|
|
name: labelName,
|
|
type: labelObj?.type,
|
|
})
|
|
}
|
|
})
|
|
})
|
|
return Array.from(tagsMap.values()).sort((a, b) => {
|
|
if (a.type === 'ai' && b.type !== 'ai') return -1
|
|
if (a.type !== 'ai' && b.type === 'ai') return 1
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
}, [notes, labels])
|
|
|
|
const visibleTags = useMemo(() => {
|
|
let filtered = availableTags
|
|
if (tagSearchQuery) {
|
|
filtered = availableTags.filter(t =>
|
|
t.name.toLowerCase().includes(tagSearchQuery.toLowerCase())
|
|
)
|
|
} else if (!isTagsExpanded) {
|
|
filtered = availableTags.slice(0, 10)
|
|
selectedTagIds.forEach(id => {
|
|
if (!filtered.find(t => t.id === id)) {
|
|
const tag = availableTags.find(t => t.id === id)
|
|
if (tag) filtered.push(tag)
|
|
}
|
|
})
|
|
}
|
|
return filtered
|
|
}, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds])
|
|
|
|
const toggleTag = useCallback((tagId: string) => {
|
|
setSelectedTagIds(prev =>
|
|
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
|
|
)
|
|
}, [])
|
|
|
|
|
|
useEffect(() => {
|
|
setControls({
|
|
openNoteComposer: () => handleAddNote(),
|
|
})
|
|
return () => setControls(null)
|
|
}, [setControls])
|
|
|
|
// Apply sort order to notes
|
|
const sortedNotes = useMemo(() => {
|
|
let sorted = [...notes]
|
|
if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
|
if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
|
|
if (sortOrder === 'manual') sorted.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
|
|
if (selectedTagIds.length > 0) {
|
|
const selectedNames = selectedTagIds
|
|
.map(id => availableTags.find(t => t.id === id)?.name)
|
|
.filter(Boolean) as string[]
|
|
sorted = sorted.filter(note =>
|
|
selectedNames.every(name => (note.labels || []).includes(name))
|
|
)
|
|
}
|
|
|
|
return sorted
|
|
}, [notes, sortOrder, selectedTagIds, availableTags])
|
|
|
|
const sortedPinnedNotes = useMemo(() => {
|
|
return sortedNotes.filter(n => n.isPinned)
|
|
}, [sortedNotes])
|
|
|
|
const sortLabels: Record<SortOrder, string> = {
|
|
newest: t('sidebar.sortNewest'),
|
|
oldest: t('sidebar.sortOldest'),
|
|
alpha: t('sidebar.sortAlpha'),
|
|
manual: t('sidebar.sortManual'),
|
|
}
|
|
|
|
|
|
const handleEditorClose = useCallback(() => {
|
|
setEditingNote(null)
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.delete('openNote')
|
|
const qs = params.toString()
|
|
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
|
}, [router, searchParams])
|
|
|
|
const handleNoteSaved = useCallback((savedNote: Note) => {
|
|
setNotes((prev) => prev.map((n) => (n.id === savedNote.id ? { ...n, ...savedNote } : n)))
|
|
setEditingNote((prev) => (prev?.note.id === savedNote.id ? { ...prev, note: savedNote } : prev))
|
|
emitNoteChange({ type: 'updated', note: savedNote })
|
|
}, [])
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex w-full min-h-0 flex-1 flex-col',
|
|
editingNote ? 'h-full overflow-hidden' : 'gap-3 py-1'
|
|
)}
|
|
>
|
|
{editingNote ? (
|
|
<div className="flex flex-1 min-h-0 h-full w-full overflow-hidden">
|
|
<NoteEditor
|
|
note={editingNote.note}
|
|
readOnly={editingNote.readOnly}
|
|
onClose={handleEditorClose}
|
|
onNoteSaved={handleNoteSaved}
|
|
fullPage
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
|
|
<div
|
|
className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-10 md:pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 dark:bg-background/90 backdrop-blur-md z-30"
|
|
>
|
|
<div className="flex justify-between items-start gap-3">
|
|
{/* Hamburger mobile — ouvre la sidebar */}
|
|
<button
|
|
className="md:hidden p-2 -ms-2 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 self-start mt-1"
|
|
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
|
aria-label="Open menu"
|
|
>
|
|
<Menu size={22} />
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
{currentNotebook && notebookPath.length > 0 && (
|
|
<div
|
|
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2 text-ink/60"
|
|
>
|
|
{notebookPath.map((nb: any, i: number) => (
|
|
<React.Fragment key={nb.id}>
|
|
{i > 0 && <ChevronRight size={10} className="shrink-0 text-concrete" />}
|
|
<span className={i === notebookPath.length - 1 ? 'text-ink' : 'text-concrete'}>
|
|
{nb.name}
|
|
</span>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
)}
|
|
<h1 className="font-memento-serif text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight text-foreground leading-tight pe-4 sm:pe-12">
|
|
{currentNotebook
|
|
? currentNotebook.name
|
|
: searchParams.get('shared') === '1'
|
|
? t('sidebar.sharedWithMe')
|
|
: searchParams.get('reminders') === '1'
|
|
? t('sidebar.reminders')
|
|
: t('notes.title')}
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
|
|
<div className="flex items-center gap-6">
|
|
<button
|
|
onClick={handleAddNote}
|
|
disabled={isCreating}
|
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
|
>
|
|
<Plus size={16} />
|
|
<span>{t('notes.newNote')}</span>
|
|
</button>
|
|
|
|
{currentNotebook && (
|
|
<button
|
|
onClick={() => setCreateSubNotebookOpen(true)}
|
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
|
>
|
|
<FolderOpen size={16} />
|
|
<span>{t('notebook.createSubNotebook')}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Inline search — toggles an input within the toolbar */}
|
|
{showInlineSearch ? (
|
|
<div className="flex items-center gap-2">
|
|
{isSearching ? (
|
|
<div className="w-3.5 h-3.5 border border-muted-foreground/50 border-t-foreground rounded-full animate-spin shrink-0" />
|
|
) : (
|
|
<Search size={14} className="text-muted-foreground shrink-0" />
|
|
)}
|
|
<input
|
|
ref={inlineSearchRef}
|
|
autoFocus
|
|
type="text"
|
|
value={inlineSearchQuery}
|
|
onChange={e => {
|
|
const q = e.target.value
|
|
setInlineSearchQuery(q)
|
|
setIsSearching(true)
|
|
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
|
|
searchDebounceRef.current = setTimeout(() => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (q.trim()) {
|
|
params.set('search', q)
|
|
} else {
|
|
params.delete('search')
|
|
}
|
|
router.push(`/home?${params.toString()}`)
|
|
setIsSearching(false)
|
|
}, 300)
|
|
}}
|
|
onBlur={() => {
|
|
if (!inlineSearchQuery) {
|
|
setShowInlineSearch(false)
|
|
}
|
|
}}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Escape') {
|
|
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
|
|
setShowInlineSearch(false)
|
|
setInlineSearchQuery('')
|
|
setIsSearching(false)
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.delete('search')
|
|
router.push(`/home?${params.toString()}`)
|
|
}
|
|
}}
|
|
placeholder={t('search.placeholder')}
|
|
className="w-36 sm:w-48 bg-transparent border-b border-foreground/20 focus:border-foreground outline-none text-[13px] text-foreground placeholder:text-muted-foreground/50 py-0.5 transition-colors"
|
|
/>
|
|
{inlineSearchQuery && (
|
|
<button
|
|
onClick={() => {
|
|
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
|
|
setShowInlineSearch(false)
|
|
setInlineSearchQuery('')
|
|
setIsSearching(false)
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.delete('search')
|
|
router.push(`/home?${params.toString()}`)
|
|
}}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
setShowInlineSearch(true)
|
|
setTimeout(() => inlineSearchRef.current?.focus(), 50)
|
|
}}
|
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
|
>
|
|
<Search size={16} />
|
|
<span>{t('notes.search')}</span>
|
|
</button>
|
|
)}
|
|
|
|
{!searchParams.get('notebook') && searchParams.get('shared') !== '1' && (
|
|
<button
|
|
onClick={() => setBatchOrganizationOpen(true)}
|
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
|
>
|
|
<Sparkles size={16} />
|
|
<span>{t('notes.reorganize')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => selectLayoutMode('grid')}
|
|
className={cn(
|
|
'p-1.5 rounded-full transition-all',
|
|
layoutMode === 'grid'
|
|
? 'bg-foreground text-background shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
title={t('notes.layoutGridTitle')}
|
|
>
|
|
<LayoutGrid size={13} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectLayoutMode('list')}
|
|
className={cn(
|
|
'p-1.5 rounded-full transition-all',
|
|
layoutMode === 'list'
|
|
? 'bg-foreground text-background shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
title={t('notes.layoutListTitle')}
|
|
>
|
|
<List size={13} />
|
|
</button>
|
|
{!notebookFilter && (
|
|
<button
|
|
type="button"
|
|
onClick={() => selectLayoutMode('table')}
|
|
className={cn(
|
|
'p-1.5 rounded-full transition-all',
|
|
layoutMode === 'table'
|
|
? 'bg-foreground text-background shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
title={t('notes.layoutTableTitle')}
|
|
>
|
|
<Table size={13} />
|
|
</button>
|
|
)}
|
|
{notebookFilter && (
|
|
<>
|
|
<span className="w-px h-4 bg-border/50 mx-0.5" aria-hidden />
|
|
<button
|
|
type="button"
|
|
onClick={() => selectLayoutMode('table')}
|
|
className={cn(
|
|
'p-1.5 rounded-full transition-all',
|
|
layoutMode === 'table'
|
|
? 'bg-foreground text-background shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
title={t('structuredViews.viewTableHint')}
|
|
>
|
|
<Table size={13} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectLayoutMode('kanban')}
|
|
className={cn(
|
|
'p-1.5 rounded-full transition-all',
|
|
layoutMode === 'kanban'
|
|
? 'bg-foreground text-background shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
title={t('structuredViews.viewKanbanHint')}
|
|
>
|
|
<Columns3 size={13} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{currentNotebook && structuredModeActive && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setAddPropertyOpen(true)}
|
|
className="p-1.5 rounded-full text-muted-foreground hover:text-brand-accent transition-colors"
|
|
title={t('structuredViews.addProperty')}
|
|
>
|
|
<Plus size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{searchParams.get('notebook') && initialSettings.aiAssistantEnabled && (
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => setSummaryDialogOpen(true)}
|
|
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<FileText size={14} />
|
|
<span>{t('notebook.summary')}</span>
|
|
</button>
|
|
<span className="w-px h-3.5 bg-border/40" />
|
|
<button
|
|
onClick={() => setShowStudyPlanner(true)}
|
|
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-brand-accent transition-colors"
|
|
>
|
|
<CalendarDays size={14} />
|
|
<span>{t('wizard.studyPlanner') || 'Planning'}</span>
|
|
</button>
|
|
<span className="w-px h-3.5 bg-border/40" />
|
|
<button
|
|
onClick={() => setOrganizeNotebookOpen(true)}
|
|
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-brand-accent transition-colors"
|
|
title={t('notebook.organizeNotebookWithAITooltip')}
|
|
>
|
|
<Sparkles size={14} />
|
|
<span>{t('batch.organize')}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
{searchParams.get('notebook') && (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={handleImportCSV}
|
|
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground transition-colors"
|
|
title={t('structuredViews.importCsv') || 'Importer CSV'}
|
|
>
|
|
<Upload size={15} />
|
|
</button>
|
|
<button
|
|
onClick={handleExportCSV}
|
|
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground transition-colors"
|
|
title={t('structuredViews.exportCsv') || 'Exporter CSV'}
|
|
>
|
|
<Download size={15} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
|
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
|
>
|
|
<ArrowUpDown size={16} />
|
|
<span>{sortLabels[sortOrder]}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{availableTags.length > 0 && (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
|
<TagIcon size={12} />
|
|
<span>{t('labels.filterByTags')}</span>
|
|
{selectedTagIds.length > 0 && (
|
|
<span className="bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
|
|
{selectedTagIds.length} active
|
|
</span>
|
|
)}
|
|
</div>
|
|
{availableTags.length > 10 && (
|
|
<input
|
|
type="text"
|
|
placeholder={t('labels.searchTags')}
|
|
className="bg-transparent border-b border-foreground/10 text-[10px] outline-none focus:border-brand-accent/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-muted-foreground/40"
|
|
value={tagSearchQuery}
|
|
onChange={e => setTagSearchQuery(e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
|
|
<AnimatePresence mode="popLayout">
|
|
{visibleTags.map(tag => {
|
|
const isActive = selectedTagIds.includes(tag.id)
|
|
return (
|
|
<motion.button
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
key={tag.id}
|
|
onClick={() => toggleTag(tag.id)}
|
|
className={cn(
|
|
'px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2',
|
|
isActive
|
|
? 'bg-foreground text-background border-foreground shadow-sm'
|
|
: 'bg-card/40 border-border text-muted-foreground hover:border-foreground/30 hover:bg-card/60',
|
|
)}
|
|
>
|
|
{tag.type === 'ai' && (
|
|
<Sparkles
|
|
size={10}
|
|
className={isActive ? 'text-brand-accent' : 'text-brand-accent/60'}
|
|
/>
|
|
)}
|
|
{tag.name}
|
|
{isActive && <X size={10} />}
|
|
</motion.button>
|
|
)
|
|
})}
|
|
</AnimatePresence>
|
|
|
|
{availableTags.length > 10 && !tagSearchQuery && (
|
|
<button
|
|
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
|
|
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60 hover:text-foreground transition-colors border border-dashed border-border rounded-full"
|
|
>
|
|
{isTagsExpanded
|
|
? t('labels.showLess')
|
|
: `+ ${availableTags.length - 10} more`}
|
|
</button>
|
|
)}
|
|
|
|
{selectedTagIds.length > 0 && (
|
|
<button
|
|
onClick={() => setSelectedTagIds([])}
|
|
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-red-500 hover:underline ms-auto"
|
|
>
|
|
{t('labels.clearAll')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20">
|
|
{isLoading ? (
|
|
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
|
) : showStructuredLoading ? (
|
|
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
|
) : showStructuredIntro ? (
|
|
<StructuredViewsIntro
|
|
target={structuredViewMode}
|
|
enabling={isEnablingStructured}
|
|
onEnable={() => void handleEnableStructured(structuredViewMode)}
|
|
/>
|
|
) : showStructuredDataView && schemaHook.schema && notebookFilter ? (
|
|
<>
|
|
<StructuredViewsHelpBanner notebookId={notebookFilter} mode={structuredViewMode} />
|
|
<StructuredViewsContainer
|
|
mode={structuredViewMode}
|
|
notes={sortedNotes}
|
|
schema={schemaHook.schema}
|
|
noteValues={schemaHook.noteValues}
|
|
notebookColor={currentNotebook?.color}
|
|
onOpen={(note) => handleOpenNoteFresh(note.id, false)}
|
|
onNoteValuesPatch={schemaHook.patchNoteValuesLocal}
|
|
onCreateNote={handleAddNoteWithProperties}
|
|
onSetKanbanGroupProperty={(id) => void schemaHook.setKanbanGroupProperty(id)}
|
|
onQuickAddKanbanStatus={() => void handleQuickAddKanbanStatus()}
|
|
onDeleteProperty={async (propertyId) => {
|
|
await schemaHook.deleteProperty(propertyId)
|
|
toast.success(t('structuredViews.deletePropertySuccess'))
|
|
}}
|
|
/>
|
|
</>
|
|
) : notes.length === 0 ? (
|
|
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
|
<p className="font-memento-serif text-xl italic text-muted-foreground">
|
|
{t('notes.emptyState')}
|
|
</p>
|
|
<button
|
|
onClick={handleAddNote}
|
|
disabled={isCreating}
|
|
className="px-6 py-2 border border-foreground text-[13px] uppercase tracking-[0.2em] hover:bg-foreground hover:text-background transition-all"
|
|
>
|
|
{t('notes.createFirst')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<NotesListViews
|
|
notes={sortedNotes}
|
|
pinnedNotes={sortedPinnedNotes}
|
|
layoutMode={classicLayoutMode}
|
|
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
|
onOpenHistory={handleOpenHistory}
|
|
notebookName={currentNotebook?.name}
|
|
onTogglePin={handleTogglePin}
|
|
onDeleteNote={handleDeleteNoteFromList}
|
|
onArchiveNote={handleArchiveNoteFromList}
|
|
onMoveToNotebook={handleMoveNoteToNotebook}
|
|
onNotePatch={handleNoteContentPatch}
|
|
onNoteIllustrationGenerated={handleNoteIllustrationGenerated}
|
|
onNoteIllustrationDeleted={handleNoteIllustrationDeleted}
|
|
onGridReorder={handleGridReorder}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 border-t border-foreground/5 text-center mt-auto">
|
|
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-medium">
|
|
Memento — {new Date().getFullYear()}
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
)}
|
|
|
|
{notebookSuggestion && (
|
|
<NotebookSuggestionToast
|
|
noteId={notebookSuggestion.noteId}
|
|
noteContent={notebookSuggestion.content}
|
|
onDismiss={() => setNotebookSuggestion(null)}
|
|
/>
|
|
)}
|
|
|
|
{batchOrganizationOpen && (
|
|
<BatchOrganizationDialog
|
|
open={batchOrganizationOpen}
|
|
onOpenChange={setBatchOrganizationOpen}
|
|
onNotesMoved={() => fetchNotesForCurrentView({ silent: true })}
|
|
/>
|
|
)}
|
|
|
|
{autoLabelOpen && (
|
|
<AutoLabelSuggestionDialog
|
|
open={autoLabelOpen}
|
|
onOpenChange={(open) => {
|
|
setAutoLabelOpen(open)
|
|
if (!open) dismissLabelSuggestion()
|
|
}}
|
|
notebookId={suggestNotebookId}
|
|
onLabelsCreated={() => fetchNotesForCurrentView({ silent: true })}
|
|
/>
|
|
)}
|
|
|
|
<NoteHistoryModal
|
|
open={historyOpen}
|
|
onOpenChange={setHistoryOpen}
|
|
note={historyNote}
|
|
enabled={!!historyNote?.historyEnabled}
|
|
onEnableHistory={async () => { if (historyNote) await handleEnableHistory(historyNote.id) }}
|
|
onRestored={handleHistoryRestored}
|
|
/>
|
|
|
|
{searchParams.get('notebook') && (
|
|
<NotebookSummaryDialog
|
|
open={summaryDialogOpen}
|
|
onOpenChange={setSummaryDialogOpen}
|
|
notebookId={searchParams.get('notebook')}
|
|
notebookName={currentNotebook?.name}
|
|
/>
|
|
)}
|
|
{searchParams.get('notebook') && (
|
|
<CreateNotebookDialog
|
|
open={createSubNotebookOpen}
|
|
onOpenChange={setCreateSubNotebookOpen}
|
|
parentNotebookId={searchParams.get('notebook')}
|
|
/>
|
|
)}
|
|
{currentNotebook && (
|
|
<OrganizeNotebookDialog
|
|
open={organizeNotebookOpen}
|
|
onOpenChange={setOrganizeNotebookOpen}
|
|
notebookId={currentNotebook.id}
|
|
notebookName={currentNotebook.name}
|
|
onDone={() => {
|
|
refreshNotebooks()
|
|
}}
|
|
/>
|
|
)}
|
|
{notebookFilter && schemaHook.schema && (
|
|
<AddPropertyDialog
|
|
open={addPropertyOpen}
|
|
onClose={() => setAddPropertyOpen(false)}
|
|
onSubmit={async (name, type, options) => {
|
|
await schemaHook.addProperty(name, type, options)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showStudyPlanner && currentNotebook && (
|
|
<StudyPlannerDialog
|
|
notebookId={currentNotebook.id}
|
|
notebookName={currentNotebook.name}
|
|
onClose={() => setShowStudyPlanner(false)}
|
|
/>
|
|
)}
|
|
|
|
{showOrganizer && currentNotebook && (
|
|
<NotebookOrganizerDialog
|
|
notebookId={currentNotebook.id}
|
|
notebookName={currentNotebook.name}
|
|
onClose={() => setShowOrganizer(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|