Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
Replaced ~100+ hardcoded French and English text strings across 30+ components with proper i18n t() calls. Added 57 new translation keys to all 15 locale files (ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh). Key changes: - contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders) - ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history) - note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist) - lab-skeleton.tsx: French loading text → t() - admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed - New AI chat component, agent cards, sidebar, settings panel i18n cleanup Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
425 lines
16 KiB
TypeScript
425 lines
16 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 { 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, 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'
|
|
|
|
// 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 {
|
|
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, 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 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)
|
|
|
|
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')}
|
|
/>
|
|
</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)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|