530 lines
21 KiB
TypeScript
530 lines
21 KiB
TypeScript
'use client'
|
|
|
|
import { 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 { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
|
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 } from 'lucide-react'
|
|
import { useLabels } from '@/context/LabelContext'
|
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
|
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 { useHomeView } from '@/context/home-view-context'
|
|
import { NoteHistoryModal } from '@/components/note-history-modal'
|
|
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 }
|
|
)
|
|
|
|
type InitialSettings = {
|
|
showRecentNotes: boolean
|
|
notesViewMode: 'masonry' | 'tabs' | 'list'
|
|
noteHistory: boolean
|
|
noteHistoryMode: 'manual' | 'auto'
|
|
}
|
|
|
|
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 [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
|
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 { refreshKey, triggerRefresh } = useNoteRefresh()
|
|
const { labels } = useLabels()
|
|
const { setControls } = useHomeView()
|
|
|
|
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
|
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (shouldSuggestLabels && suggestNotebookId) {
|
|
setAutoLabelOpen(true)
|
|
}
|
|
}, [shouldSuggestLabels, suggestNotebookId])
|
|
|
|
// BUG FIX: forceList param from sidebar carnet click → reset to editorial view
|
|
useEffect(() => {
|
|
const forceList = searchParams.get('forceList')
|
|
if (forceList === '1') {
|
|
setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev))
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.delete('forceList')
|
|
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
|
|
router.replace(newUrl, { scroll: false })
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [searchParams])
|
|
|
|
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])
|
|
|
|
const handleOpenNote = (noteId: string) => {
|
|
const note = notes.find(n => n.id === noteId)
|
|
if (note) setEditingNote({ note, readOnly: false })
|
|
}
|
|
|
|
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)
|
|
|
|
useEffect(() => {
|
|
const openNoteId = searchParams.get('openNote')
|
|
if (!openNoteId) return
|
|
|
|
const openNote = async () => {
|
|
const existing = notes.find(n => n.id === openNoteId)
|
|
if (existing) {
|
|
setEditingNote({ note: existing, readOnly: false })
|
|
} else {
|
|
const fetched = await getNoteById(openNoteId)
|
|
if (fetched) {
|
|
setEditingNote({ note: fetched, readOnly: false })
|
|
}
|
|
}
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.delete('openNote')
|
|
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
|
|
}
|
|
|
|
openNote()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [searchParams])
|
|
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const { name } = (e as CustomEvent).detail
|
|
if (!name) return
|
|
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 semanticMode = searchParams.get('semantic') === 'true'
|
|
|
|
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, semanticMode, notebook || undefined)
|
|
: await getAllNotes(false, notebook || undefined)
|
|
|
|
if (!notebook && !search) {
|
|
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
|
}
|
|
|
|
if (labelFilter.length > 0) {
|
|
allNotes = allNotes.filter((note: any) =>
|
|
note.labels?.some((label: string) => labelFilter.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
|
|
if (notebook) {
|
|
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
|
} 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 { notebooks } = useNotebooks()
|
|
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
|
|
|
useEffect(() => {
|
|
setControls({
|
|
isTabsMode: notesViewMode === 'tabs',
|
|
openNoteComposer: () => handleAddNote(),
|
|
})
|
|
return () => setControls(null)
|
|
}, [notesViewMode, setControls])
|
|
|
|
// Apply sort order to notes
|
|
const sortedNotes = useMemo(() => {
|
|
const 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 || ''))
|
|
return sorted
|
|
}, [notes, sortOrder])
|
|
|
|
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 isTabs = notesViewMode === 'tabs'
|
|
const isEditorialMode = !isTabs
|
|
|
|
const handleEditorClose = useCallback(() => {
|
|
setEditingNote(null)
|
|
triggerRefresh()
|
|
}, [triggerRefresh])
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex w-full min-h-0 flex-1 flex-col',
|
|
isTabs ? 'gap-3 py-1' : 'h-full'
|
|
)}
|
|
>
|
|
{editingNote ? (
|
|
<NoteEditor
|
|
note={editingNote.note}
|
|
readOnly={editingNote.readOnly}
|
|
onClose={handleEditorClose}
|
|
fullPage
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className={cn(
|
|
'px-12 pt-12 pb-8 flex flex-col gap-6',
|
|
isEditorialMode ? 'sticky top-0 bg-background/90 backdrop-blur-md z-30 border-b border-foreground/5' : ''
|
|
)}>
|
|
<div className="flex justify-between items-start">
|
|
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pr-12">
|
|
{currentNotebook ? currentNotebook.name : t('notes.title')}
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
|
|
<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>
|
|
|
|
{/* Sort order */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowSortMenu(s => !s)}
|
|
className="flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground font-medium transition-opacity"
|
|
title={t('sidebar.sortOrder') || 'Sort order'}
|
|
>
|
|
<ArrowUpDown size={14} />
|
|
<span className="hidden sm:inline text-[11px] uppercase tracking-wider font-bold">
|
|
{sortOrder === 'newest' ? 'Plus récentes' : sortOrder === 'oldest' ? 'Plus anciennes' : 'A → Z'}
|
|
</span>
|
|
</button>
|
|
<AnimatePresence>
|
|
{showSortMenu && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9, y: -4 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9, y: -4 }}
|
|
className="absolute right-0 top-full mt-2 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
|
>
|
|
{(['newest', 'oldest', 'alpha'] as SortOrder[]).map(order => (
|
|
<button
|
|
key={order}
|
|
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
|
|
className={cn(
|
|
'w-full text-left px-4 py-2 text-[12px] transition-colors',
|
|
sortOrder === order
|
|
? 'font-bold text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
|
)}
|
|
>
|
|
{sortLabels[order]}
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</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>
|
|
) : isTabs ? (
|
|
<NotesMainSection
|
|
viewMode={notesViewMode}
|
|
notes={sortedNotes}
|
|
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
|
onSizeChange={handleSizeChange}
|
|
currentNotebookId={searchParams.get('notebook')}
|
|
noteHistoryMode={noteHistoryMode}
|
|
onOpenHistory={handleOpenHistory}
|
|
onEnableHistory={handleEnableHistory}
|
|
onNoteCreated={handleNoteCreated}
|
|
/>
|
|
) : (
|
|
<div className="max-w-3xl space-y-16">
|
|
{sortedPinnedNotes.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-6 px-2">
|
|
{t('notes.pinned')}
|
|
</h2>
|
|
<NotesEditorialView
|
|
notes={sortedPinnedNotes}
|
|
onOpen={(note: Note, readOnly?: boolean) => setEditingNote({ note, readOnly: 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) => setEditingNote({ note, readOnly })}
|
|
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>
|
|
</>
|
|
)}
|
|
|
|
<MemoryEchoNotification onOpenNote={handleOpenNote} />
|
|
|
|
{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}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|