Files
Momento/memento-note/components/home-client.tsx
Antigravity 03e6a62b80
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m12s
feat: migrate semantic search to pgvector + full-text search
Replace JSON-string embeddings with native pgvector(1536) storage and
add PostgreSQL full-text search (tsvector/GIN) with Reciprocal Rank Fusion
for hybrid keyword + semantic ranking.

Changes:
- NoteEmbedding.embedding: String → vector(1536) via pgvector
- NoteEmbedding: added updatedAt for reindex tracking
- Note: added tsv (tsvector) with auto-update trigger for FTS
- semantic-search.service: hybrid FTS + vector search with RRF fusion
- embedding.service: toVectorString() for pgvector SQL literals
- Removed JS-side cosine similarity loops (now DB-side via <=>)
- Added HNSW index on NoteEmbedding.embedding (cosine distance)
- Added GIN index on Note.tsv for FTS queries

Schema migration in: prisma/migrations/20260512120000_pgvector_and_fts_search/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 07:03:56 +00:00

838 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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'
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() ? `/?${params.toString()}` : '/'
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 (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',
}
const handleEditorClose = useCallback(() => {
setEditingNote(null)
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
const qs = params.toString()
router.replace(qs ? `/?${qs}` : '/', { 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 pr-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(`/?${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(`/?${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(`/?${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-blueprint font-medium hover:opacity-70 transition-opacity"
title="Organiser ce carnet avec l'IA"
>
<Sparkles size={16} />
<span>Organiser</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') : "Activez l'Assistant IA dans les paramètres pour résumer"}
>
<FileText size={16} />
<span>{t('notebook.summary') || 'Summarize'}</span>
</button>
)}
<button
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : '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-memento-blue/10 text-memento-blue 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-memento-blue/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-memento-blue' : 'text-memento-blue/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 ml-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 &mdash; {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>
)
}