All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
841 lines
35 KiB
TypeScript
841 lines
35 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 } from '@/app/actions/notes'
|
||
import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||
|
||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||
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 } from 'lucide-react'
|
||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||
import { useRefresh } from '@/lib/use-refresh'
|
||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||
import { useNotebooks } from '@/context/notebooks-context'
|
||
import { cn } from '@/lib/utils'
|
||
import { useLanguage } from '@/lib/i18n'
|
||
import { useEditorUI } from '@/context/editor-ui-context'
|
||
import { NoteHistoryModal } from '@/components/note-history-modal'
|
||
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
|
||
import { toast } from 'sonner'
|
||
import { AnimatePresence, motion } from 'motion/react'
|
||
|
||
|
||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||
|
||
const NoteEditor = dynamic(
|
||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
||
{ ssr: false }
|
||
)
|
||
const BatchOrganizationDialog = dynamic(
|
||
() => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })),
|
||
{ ssr: false }
|
||
)
|
||
const AutoLabelSuggestionDialog = dynamic(
|
||
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
|
||
{ ssr: false }
|
||
)
|
||
const NotebookSummaryDialog = dynamic(
|
||
() => import('@/components/notebook-summary-dialog').then(m => ({ default: m.NotebookSummaryDialog })),
|
||
{ ssr: false }
|
||
)
|
||
const OrganizeNotebookDialog = dynamic(
|
||
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
|
||
{ ssr: false }
|
||
)
|
||
|
||
type InitialSettings = {
|
||
showRecentNotes: boolean
|
||
noteHistory: boolean
|
||
noteHistoryMode: 'manual' | 'auto'
|
||
aiAssistantEnabled: boolean
|
||
}
|
||
|
||
interface HomeClientProps {
|
||
initialNotes: Note[]
|
||
initialSettings: InitialSettings
|
||
}
|
||
|
||
export function HomeClient({ initialNotes, initialSettings }: 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 inlineSearchRef = useRef<HTMLInputElement>(null)
|
||
const notesRef = useRef(notes)
|
||
notesRef.current = notes
|
||
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
||
const { refreshNotes } = useRefresh()
|
||
const { labels, notebooks, refreshNotebooks } = useNotebooks()
|
||
const { setControls } = useEditorUI()
|
||
|
||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
|
||
const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false)
|
||
const [organizeNotebookOpen, setOrganizeNotebookOpen] = useState(false)
|
||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
|
||
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
|
||
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||
|
||
useEffect(() => {
|
||
// Auto-trigger disabled — user opens manually from AI panel
|
||
// if (shouldSuggestLabels && suggestNotebookId) {
|
||
// setAutoLabelOpen(true)
|
||
// }
|
||
}, [shouldSuggestLabels, suggestNotebookId])
|
||
|
||
// 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 notebookFilter = searchParams.get('notebook')
|
||
const handleNoteCreated = useCallback((note: Note) => {
|
||
setNotes((prevNotes) => {
|
||
const notebookFilter = searchParams.get('notebook')
|
||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||
const colorFilter = searchParams.get('color')
|
||
const search = searchParams.get('search')?.trim() || null
|
||
|
||
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
|
||
}
|
||
|
||
if (search) {
|
||
router.refresh()
|
||
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]
|
||
}
|
||
})
|
||
|
||
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, router])
|
||
|
||
// 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') || 'Failed to create note')
|
||
}
|
||
})
|
||
}
|
||
|
||
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))
|
||
}, [])
|
||
|
||
useReminderCheck(notes)
|
||
|
||
// 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)
|
||
}, [])
|
||
|
||
const prevRefreshKey = useRef(refreshKey)
|
||
|
||
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 isBackgroundRefresh = refreshKey > prevRefreshKey.current
|
||
prevRefreshKey.current = refreshKey
|
||
|
||
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
|
||
|
||
const load = async () => {
|
||
if (!isBackgroundRefresh) {
|
||
setIsLoading(true)
|
||
}
|
||
let allNotes = search
|
||
? await searchNotes(search, true, notebook || undefined)
|
||
: await getAllNotes(false, notebook || undefined)
|
||
|
||
const sharedOnly = searchParams.get('shared') === '1'
|
||
const remindersOnly = searchParams.get('reminders') === '1'
|
||
|
||
if (sharedOnly) {
|
||
allNotes = allNotes.filter((note: any) => note._isShared)
|
||
} else if (remindersOnly) {
|
||
allNotes = allNotes.filter((note: any) => note.reminder !== null)
|
||
} else if (!notebook && !search) {
|
||
allNotes = allNotes.filter((note: any) => !note.notebookId && !note._isShared)
|
||
}
|
||
|
||
if (labelFilter.length > 0) {
|
||
allNotes = allNotes.filter((note: any) =>
|
||
labelFilter.every((label: string) => note.labels?.includes(label))
|
||
)
|
||
}
|
||
|
||
if (colorFilter) {
|
||
const labelNamesWithColor = labels
|
||
.filter((label: any) => label.color === colorFilter)
|
||
.map((label: any) => label.name)
|
||
allNotes = allNotes.filter((note: any) =>
|
||
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: any) => n.isPinned))
|
||
setIsLoading(false)
|
||
}
|
||
|
||
if (refreshKey > 0 || hasActiveFilter) {
|
||
const cancelled = { value: false }
|
||
load().then(() => { if (cancelled.value) return })
|
||
return () => { cancelled.value = true }
|
||
} else {
|
||
let filtered = initialNotes
|
||
const sharedOnly = searchParams.get('shared') === '1'
|
||
const remindersOnly = searchParams.get('reminders') === '1'
|
||
if (notebook) {
|
||
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
||
} else if (sharedOnly) {
|
||
filtered = initialNotes.filter((n: any) => n._isShared)
|
||
} else if (remindersOnly) {
|
||
filtered = initialNotes.filter((n: any) => n.reminder !== null)
|
||
} else {
|
||
filtered = initialNotes.filter((n: any) => !n.notebookId && !n._isShared)
|
||
}
|
||
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))
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [searchParams, refreshKey])
|
||
|
||
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') || 'Plus récentes',
|
||
oldest: t('sidebar.sortOldest') || 'Plus anciennes',
|
||
alpha: t('sidebar.sortAlpha') || 'A → Z',
|
||
manual: t('sidebar.sortManual') || 'Libre',
|
||
}
|
||
|
||
|
||
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 })
|
||
// Invalidate notes cache and trigger refresh
|
||
refreshNotes(searchParams.get('notebook') || null)
|
||
}, [refreshNotes, router, searchParams])
|
||
|
||
// Called by NoteEditor when a save succeeds — update local state immediately
|
||
// so the user sees fresh data if they reopen the note before getAllNotes() completes
|
||
const handleNoteSaved = useCallback((savedNote: Note) => {
|
||
setNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n))
|
||
setPinnedNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n))
|
||
setEditingNote(prev => prev?.note.id === savedNote.id ? { ...prev, note: savedNote } : prev)
|
||
// Refresh sidebar note titles so the new title appears immediately
|
||
refreshNotes(savedNote.notebookId || null)
|
||
}, [refreshNotes])
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex w-full min-h-0 flex-1 flex-col gap-3 py-1'
|
||
)}
|
||
>
|
||
{editingNote ? (
|
||
<NoteEditor
|
||
note={editingNote.note}
|
||
readOnly={editingNote.readOnly}
|
||
onClose={handleEditorClose}
|
||
onNoteSaved={handleNoteSaved}
|
||
fullPage
|
||
/>
|
||
) : (
|
||
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
|
||
<div
|
||
className="px-12 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">
|
||
<div>
|
||
{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-4xl font-medium tracking-tight text-foreground leading-tight pe-12">
|
||
{currentNotebook
|
||
? currentNotebook.name
|
||
: searchParams.get('shared') === '1'
|
||
? (t('sidebar.sharedWithMe') || 'Partagées avec moi')
|
||
: searchParams.get('reminders') === '1'
|
||
? (t('sidebar.reminders') || 'Rappels')
|
||
: 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') || 'Add Note'}</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') || 'Nouveau sous-carnet'}</span>
|
||
</button>
|
||
)}
|
||
|
||
{/* Inline search — toggles an input within the toolbar */}
|
||
{showInlineSearch ? (
|
||
<div className="flex items-center gap-2">
|
||
<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)
|
||
const params = new URLSearchParams(searchParams.toString())
|
||
if (q.trim()) {
|
||
params.set('search', q)
|
||
} else {
|
||
params.delete('search')
|
||
}
|
||
router.push(`/home?${params.toString()}`)
|
||
}}
|
||
onBlur={() => {
|
||
if (!inlineSearchQuery) {
|
||
setShowInlineSearch(false)
|
||
}
|
||
}}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Escape') {
|
||
setShowInlineSearch(false)
|
||
setInlineSearchQuery('')
|
||
const params = new URLSearchParams(searchParams.toString())
|
||
params.delete('search')
|
||
router.push(`/home?${params.toString()}`)
|
||
}
|
||
}}
|
||
placeholder={t('search.placeholder') || 'Rechercher...'}
|
||
className="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={() => {
|
||
setShowInlineSearch(false)
|
||
setInlineSearchQuery('')
|
||
const params = new URLSearchParams(searchParams.toString())
|
||
params.delete('search')
|
||
router.push(`/home?${params.toString()}`)
|
||
}}
|
||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
<span className="text-[11px]">×</span>
|
||
</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') || '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') || 'Réorganiser les notes'}</span>
|
||
</button>
|
||
)}
|
||
|
||
{currentNotebook && initialSettings.aiAssistantEnabled && (
|
||
<button
|
||
onClick={() => setOrganizeNotebookOpen(true)}
|
||
className="flex items-center gap-2 text-[13px] text-brand-accent font-medium hover:opacity-70 transition-opacity"
|
||
title={t('notebook.organizeNotebookWithAITooltip')}
|
||
>
|
||
<Sparkles size={16} />
|
||
<span>{t('batch.organize')}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-6">
|
||
{searchParams.get('notebook') && (
|
||
<button
|
||
onClick={() => setSummaryDialogOpen(true)}
|
||
disabled={!initialSettings.aiAssistantEnabled}
|
||
className={cn(
|
||
"flex items-center gap-2 text-[13px] font-medium transition-opacity",
|
||
initialSettings.aiAssistantEnabled ? "text-foreground hover:opacity-70" : "text-muted-foreground opacity-50 cursor-not-allowed"
|
||
)}
|
||
title={initialSettings.aiAssistantEnabled ? t('notebook.summary') : t('notebook.assistantRequiredForSummarize')}
|
||
>
|
||
<FileText size={16} />
|
||
<span>{t('notebook.summary') || 'Summarize'}</span>
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
|
||
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||
>
|
||
<ArrowUpDown size={16} />
|
||
<span>{sortLabels[sortOrder]}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{availableTags.length > 0 && (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||
<TagIcon size={12} />
|
||
<span>{t('labels.filterByTags') || 'Filter by Tags'}</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') || 'Search tags...'}
|
||
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') || 'Show less')
|
||
: `+ ${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') || 'Clear all'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="px-12 flex-1 pb-20">
|
||
{isLoading ? (
|
||
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
||
) : (
|
||
<div className="max-w-3xl space-y-16">
|
||
{sortedPinnedNotes.length > 0 && (
|
||
<div className="mb-6">
|
||
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-2">
|
||
{t('notes.pinned')}
|
||
</h2>
|
||
<NotesEditorialView
|
||
notes={sortedPinnedNotes}
|
||
onOpen={(note: Note, readOnly?: boolean) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||
notebookName={currentNotebook?.name}
|
||
onOpenHistory={handleOpenHistory}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{sortedNotes.filter((note) => !note.isPinned).length > 0 && (
|
||
<NotesEditorialView
|
||
notes={sortedNotes.filter((note) => !note.isPinned)}
|
||
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||
notebookName={currentNotebook?.name}
|
||
onOpenHistory={handleOpenHistory}
|
||
/>
|
||
)}
|
||
|
||
{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') || 'This notebook is waiting for its first vision.'}
|
||
</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') || 'Begin Drawing'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<footer className="px-12 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>
|
||
)}
|
||
|
||
<MemoryEchoNotification onOpenNote={(noteId) => handleOpenNoteFresh(noteId)} />
|
||
|
||
{notebookSuggestion && (
|
||
<NotebookSuggestionToast
|
||
noteId={notebookSuggestion.noteId}
|
||
noteContent={notebookSuggestion.content}
|
||
onDismiss={() => setNotebookSuggestion(null)}
|
||
/>
|
||
)}
|
||
|
||
{batchOrganizationOpen && (
|
||
<BatchOrganizationDialog
|
||
open={batchOrganizationOpen}
|
||
onOpenChange={setBatchOrganizationOpen}
|
||
onNotesMoved={() => router.refresh()}
|
||
/>
|
||
)}
|
||
|
||
{autoLabelOpen && (
|
||
<AutoLabelSuggestionDialog
|
||
open={autoLabelOpen}
|
||
onOpenChange={(open) => {
|
||
setAutoLabelOpen(open)
|
||
if (!open) dismissLabelSuggestion()
|
||
}}
|
||
notebookId={suggestNotebookId}
|
||
onLabelsCreated={() => router.refresh()}
|
||
/>
|
||
)}
|
||
|
||
<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()
|
||
triggerRefresh()
|
||
router.refresh()
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|