- Layout mode (Grid/List/Table/Kanban) déplacé à droite - Sort à droite avec icône compacte - Toutes les actions IA (Résumé, Planning, Organiser) + CSV dans un menu ⋯ - Plus de boutons éparpillés — tout est dans un seul dropdown - Layout respecte le design system existant
1270 lines
53 KiB
TypeScript
1270 lines
53 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useTransition, useMemo } from 'react'
|
|
import { useSearchParams, useRouter } from 'next/navigation'
|
|
import dynamic from 'next/dynamic'
|
|
import { Note } from '@/lib/types'
|
|
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
|
|
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, 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, MoreHorizontal } 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'
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
|
|
|
|
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-5">
|
|
<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>
|
|
)}
|
|
|
|
{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-brand-accent font-medium hover:opacity-70 transition-opacity"
|
|
>
|
|
<Sparkles size={16} />
|
|
<span>{t('notes.reorganize')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{/* Layout mode */}
|
|
<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 && (
|
|
<>
|
|
<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>
|
|
)}
|
|
|
|
{/* Sort */}
|
|
<button
|
|
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
|
|
className="flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ArrowUpDown size={14} />
|
|
<span className="hidden sm:inline">{sortLabels[sortOrder]}</span>
|
|
</button>
|
|
|
|
{/* AI + utils dropdown */}
|
|
{searchParams.get('notebook') && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="p-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-foreground/5 transition-all" title="Plus d'actions">
|
|
<MoreHorizontal size={16} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
{initialSettings.aiAssistantEnabled && (
|
|
<>
|
|
<DropdownMenuItem onClick={() => setSummaryDialogOpen(true)}>
|
|
<FileText className="h-4 w-4 me-2" />
|
|
{t('notebook.summary')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setShowStudyPlanner(true)}>
|
|
<CalendarDays className="h-4 w-4 me-2" />
|
|
{t('wizard.studyPlanner') || 'Planning'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setOrganizeNotebookOpen(true)}>
|
|
<Sparkles className="h-4 w-4 me-2 text-brand-accent" />
|
|
{t('batch.organize')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
<DropdownMenuItem onClick={handleImportCSV}>
|
|
<Upload className="h-4 w-4 me-2" />
|
|
{t('structuredViews.importCsv') || 'Importer CSV'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleExportCSV}>
|
|
<Download className="h-4 w-4 me-2" />
|
|
{t('structuredViews.exportCsv') || 'Exporter CSV'}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|