All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m11s
Chat (AIChat floating widget): conversationId was never captured from the API response, so every message created a new conversation with no context. Now creates the conversation upfront before streaming (same pattern as ChatContainer) so the ID persists across messages. Note history: was stored globally in UserAISettings, so enabling history on one note enabled it for ALL notes. Now each Note has its own historyEnabled boolean field. The "Enable history" action only affects the specific note. A migration adds the column with default false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
479 lines
18 KiB
TypeScript
479 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { useSearchParams, useRouter } from 'next/navigation'
|
|
import dynamic from 'next/dynamic'
|
|
import { Note } from '@/lib/types'
|
|
import { getAllNotes, searchNotes, enableNoteHistory } from '@/app/actions/notes'
|
|
import { NoteInput } from '@/components/note-input'
|
|
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
|
import { NotesViewToggle } from '@/components/notes-view-toggle'
|
|
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
|
import { FavoritesSection } from '@/components/favorites-section'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Wand2, ChevronRight, Plus, FileText } 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 { getNotebookIcon } from '@/lib/notebook-icon'
|
|
import { cn } from '@/lib/utils'
|
|
import { LabelFilter } from '@/components/label-filter'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useHomeView } from '@/context/home-view-context'
|
|
import { NoteHistoryModal } from '@/components/note-history-modal'
|
|
|
|
// Lazy-load heavy dialogs — uniquement chargés à la demande
|
|
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'
|
|
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) // false by default — data is pre-loaded
|
|
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 { 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])
|
|
|
|
const notebookFilter = searchParams.get('notebook')
|
|
const isInbox = !notebookFilter
|
|
|
|
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]
|
|
}
|
|
})
|
|
|
|
triggerRefresh()
|
|
|
|
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, triggerRefresh])
|
|
|
|
const handleOpenNote = (noteId: string) => {
|
|
const note = notes.find(n => n.id === noteId)
|
|
if (note) setEditingNote({ note, readOnly: false })
|
|
}
|
|
|
|
const handleOpenHistory = useCallback((note: Note) => {
|
|
setHistoryNote(note)
|
|
setHistoryOpen(true)
|
|
}, [])
|
|
|
|
const handleEnableHistory = useCallback(async (noteId: string) => {
|
|
await enableNoteHistory(noteId)
|
|
// Update the specific note in state
|
|
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))
|
|
}, [])
|
|
|
|
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))
|
|
}, [])
|
|
|
|
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)
|
|
|
|
// Listen for global label deletion and immediately update local state
|
|
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)
|
|
|
|
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
|
|
// Les notes initiales suffisent sans filtre
|
|
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
|
|
|
|
// Pour le refreshKey (mutations), toujours recharger
|
|
// Pour les filtres, charger depuis le serveur
|
|
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()
|
|
|
|
// Filtre notebook côté client
|
|
// Shared notes appear ONLY in inbox (general notes), not in notebooks
|
|
if (notebook) {
|
|
allNotes = allNotes.filter((note: any) => note.notebookId === notebook && !note._isShared)
|
|
} else {
|
|
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
|
}
|
|
|
|
// Filtre labels
|
|
if (labelFilter.length > 0) {
|
|
allNotes = allNotes.filter((note: any) =>
|
|
note.labels?.some((label: string) => labelFilter.includes(label))
|
|
)
|
|
}
|
|
|
|
// Filtre couleur
|
|
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))
|
|
)
|
|
}
|
|
|
|
// Merger avec les tailles locales pour ne pas écraser les modifications
|
|
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)
|
|
}
|
|
|
|
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
|
|
if (refreshKey > 0 || hasActiveFilter) {
|
|
const cancelled = { value: false }
|
|
load().then(() => { if (cancelled.value) return })
|
|
return () => { cancelled.value = true }
|
|
} else {
|
|
// Données initiales : filtrage inbox/notebook côté client seulement
|
|
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)
|
|
}
|
|
// Merger avec les tailles déjà modifiées localement
|
|
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: () => {},
|
|
})
|
|
return () => setControls(null)
|
|
}, [notesViewMode, setControls])
|
|
|
|
const handleNoteCreatedWrapper = (note: any) => {
|
|
handleNoteCreated(note)
|
|
}
|
|
|
|
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<span>{t('nav.notebooks')}</span>
|
|
<ChevronRight className="w-4 h-4" />
|
|
<span className="font-medium text-primary">{notebookName}</span>
|
|
</div>
|
|
)
|
|
|
|
const isTabs = notesViewMode === 'tabs'
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex w-full min-h-0 flex-1 flex-col',
|
|
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
|
|
)}
|
|
>
|
|
{/* Notebook Specific Header */}
|
|
{currentNotebook ? (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
|
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
|
)}
|
|
>
|
|
<Breadcrumbs notebookName={currentNotebook.name} />
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-5">
|
|
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
|
|
{(() => {
|
|
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
|
|
return (
|
|
<Icon
|
|
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
|
|
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
|
|
/>
|
|
)
|
|
})()}
|
|
</div>
|
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
|
<LabelFilter
|
|
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
|
onFilterChange={(newLabels) => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
|
else params.delete('labels')
|
|
router.push(`/?${params.toString()}`)
|
|
}}
|
|
className="border-gray-200"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
|
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
|
)}
|
|
>
|
|
{!isTabs && <div className="mb-1 h-5" />}
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-5">
|
|
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
|
|
<FileText className="w-8 h-8 text-primary" />
|
|
</div>
|
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
|
<LabelFilter
|
|
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
|
onFilterChange={(newLabels) => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
|
else params.delete('labels')
|
|
router.push(`/?${params.toString()}`)
|
|
}}
|
|
className="border-gray-200"
|
|
/>
|
|
{isInbox && !isLoading && notes.length >= 2 && (
|
|
<Button
|
|
onClick={() => setBatchOrganizationOpen(true)}
|
|
variant="outline"
|
|
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
|
|
title={t('batch.organizeWithAI')}
|
|
>
|
|
<Wand2 className="h-4 w-4 text-purple-600" />
|
|
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
|
</Button>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isTabs && (
|
|
<div
|
|
className={cn(
|
|
'animate-in fade-in slide-in-from-top-4 duration-300',
|
|
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
|
|
)}
|
|
>
|
|
<NoteInput
|
|
onNoteCreated={handleNoteCreatedWrapper}
|
|
fullWidth={isTabs}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
|
) : (
|
|
<>
|
|
<FavoritesSection
|
|
pinnedNotes={pinnedNotes}
|
|
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
|
onSizeChange={handleSizeChange}
|
|
/>
|
|
|
|
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
|
|
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
|
|
<NotesMainSection
|
|
viewMode={notesViewMode}
|
|
notes={notes.filter((note) => !note.isPinned)}
|
|
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
|
onSizeChange={handleSizeChange}
|
|
currentNotebookId={searchParams.get('notebook')}
|
|
noteHistoryMode={noteHistoryMode}
|
|
onOpenHistory={handleOpenHistory}
|
|
onEnableHistory={handleEnableHistory}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && !isTabs && (
|
|
<div className="text-center py-8 text-gray-500">
|
|
{t('notes.emptyState')}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<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()}
|
|
/>
|
|
)}
|
|
|
|
{editingNote && (
|
|
<NoteEditor
|
|
note={editingNote.note}
|
|
readOnly={editingNote.readOnly}
|
|
onClose={() => setEditingNote(null)}
|
|
/>
|
|
)}
|
|
|
|
<NoteHistoryModal
|
|
open={historyOpen}
|
|
onOpenChange={setHistoryOpen}
|
|
note={historyNote}
|
|
enabled={!!historyNote?.historyEnabled}
|
|
onEnableHistory={async () => { if (historyNote) await handleEnableHistory(historyNote.id) }}
|
|
onRestored={handleHistoryRestored}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|