455 lines
17 KiB
TypeScript
455 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useSearchParams, useRouter } from 'next/navigation'
|
|
import dynamic from 'next/dynamic'
|
|
import { Note } from '@/lib/types'
|
|
import { getAISettings } from '@/app/actions/ai-settings'
|
|
import { getAllNotes, searchNotes } 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 } 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 { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { LabelFilter } from '@/components/label-filter'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useHomeView } from '@/context/home-view-context'
|
|
|
|
// 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'
|
|
}
|
|
|
|
interface HomeClientProps {
|
|
/** Notes pré-chargées côté serveur — hydratées immédiatement sans loading spinner */
|
|
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 [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 { refreshKey } = 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]
|
|
}
|
|
})
|
|
|
|
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 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)
|
|
|
|
// 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'
|
|
|
|
// Pour le refreshKey (mutations), toujours recharger
|
|
// Pour les filtres, charger depuis le serveur
|
|
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
|
|
|
|
const load = async () => {
|
|
setIsLoading(true)
|
|
let allNotes = search
|
|
? await searchNotes(search, semanticMode, notebook || undefined)
|
|
: await getAllNotes()
|
|
|
|
// Filtre notebook côté client
|
|
if (notebook) {
|
|
allNotes = allNotes.filter((note: any) => note.notebookId === notebook)
|
|
} else {
|
|
allNotes = allNotes.filter((note: any) => !note.notebookId)
|
|
}
|
|
|
|
// 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 => n.notebookId === notebook)
|
|
} else {
|
|
filtered = initialNotes.filter(n => !n.notebookId)
|
|
}
|
|
// 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'))
|
|
const [showNoteInput, setShowNoteInput] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setControls({
|
|
isTabsMode: notesViewMode === 'tabs',
|
|
openNoteComposer: () => setShowNoteInput(true),
|
|
})
|
|
return () => setControls(null)
|
|
}, [notesViewMode, setControls])
|
|
|
|
const getNotebookIcon = (iconName: string) => {
|
|
const ICON_MAP: Record<string, any> = {
|
|
'folder': Folder,
|
|
'briefcase': Briefcase,
|
|
'document': FileText,
|
|
'lightning': Zap,
|
|
'chart': BarChart3,
|
|
'globe': Globe,
|
|
'sparkle': Sparkles,
|
|
'book': Book,
|
|
'heart': Heart,
|
|
'crown': Crown,
|
|
'music': Music,
|
|
'building': Building2,
|
|
'flight_takeoff': Plane,
|
|
}
|
|
return ICON_MAP[iconName] || Folder
|
|
}
|
|
|
|
const handleNoteCreatedWrapper = (note: any) => {
|
|
handleNoteCreated(note)
|
|
setShowNoteInput(false)
|
|
}
|
|
|
|
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"
|
|
/>
|
|
{!isTabs && (
|
|
<Button
|
|
onClick={() => setShowNoteInput(!showNoteInput)}
|
|
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
{t('notes.addNote') || 'Add Note'}
|
|
</Button>
|
|
)}
|
|
</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>
|
|
)}
|
|
{!isTabs && (
|
|
<Button
|
|
onClick={() => setShowNoteInput(!showNoteInput)}
|
|
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
{t('notes.newNote')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showNoteInput && (
|
|
<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}
|
|
forceExpanded={true}
|
|
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 && (
|
|
<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')}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
|
|
<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)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|