All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
841 lines
34 KiB
TypeScript
841 lines
34 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'))
|
||
}
|
||
})
|
||
}
|
||
|
||
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'),
|
||
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 })
|
||
// 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')
|
||
: searchParams.get('reminders') === '1'
|
||
? t('sidebar.reminders')
|
||
: t('notes.title')}
|
||
</h1>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
|
||
<div className="flex items-center gap-6">
|
||
<button
|
||
onClick={handleAddNote}
|
||
disabled={isCreating}
|
||
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||
>
|
||
<Plus size={16} />
|
||
<span>{t('notes.newNote')}</span>
|
||
</button>
|
||
|
||
{currentNotebook && (
|
||
<button
|
||
onClick={() => setCreateSubNotebookOpen(true)}
|
||
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||
>
|
||
<FolderOpen size={16} />
|
||
<span>{t('notebook.createSubNotebook')}</span>
|
||
</button>
|
||
)}
|
||
|
||
{/* Inline search — toggles an input within the toolbar */}
|
||
{showInlineSearch ? (
|
||
<div className="flex items-center gap-2">
|
||
<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')}
|
||
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')}</span>
|
||
</button>
|
||
)}
|
||
|
||
{!searchParams.get('notebook') && searchParams.get('shared') !== '1' && (
|
||
<button
|
||
onClick={() => setBatchOrganizationOpen(true)}
|
||
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||
>
|
||
<Sparkles size={16} />
|
||
<span>{t('notes.reorganize')}</span>
|
||
</button>
|
||
)}
|
||
|
||
{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')}</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')}</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-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')}
|
||
</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>
|
||
)}
|
||
</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>
|
||
)
|
||
}
|