feat: architectural grid editor fullPage + slash commands + doc info panel + AI title

This commit is contained in:
Antigravity
2026-05-07 22:29:02 +00:00
parent 0d8252aec0
commit e458b63115
126 changed files with 7652 additions and 1110 deletions

View File

@@ -1,31 +1,31 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
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 } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote } from '@/app/actions/notes'
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
import { NotesViewToggle } from '@/components/notes-view-toggle'
import { NotesEditorialView } from '@/components/notes-editorial-view'
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 { 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 { 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'
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
type SortOrder = 'newest' | 'oldest' | 'alpha'
// Lazy-load heavy dialogs — uniquement chargés à la demande
const NoteEditor = dynamic(
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
{ ssr: false }
@@ -41,7 +41,7 @@ const AutoLabelSuggestionDialog = dynamic(
type InitialSettings = {
showRecentNotes: boolean
notesViewMode: 'masonry' | 'tabs'
notesViewMode: 'masonry' | 'tabs' | 'list'
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
}
@@ -63,11 +63,14 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
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 [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()
@@ -81,9 +84,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}
}, [shouldSuggestLabels, suggestNotebookId])
const notebookFilter = searchParams.get('notebook')
const isInbox = !notebookFilter
// 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')
@@ -123,10 +137,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}
})
// NOTE: No triggerRefresh() — note is added optimistically above.
// triggerRefresh() → getAllNotes() can return stale Next.js cache (note
// created with skipRevalidation:true) and overwrite the freshly-added note.
if (!note.notebookId) {
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
if (wordCount >= 20) {
@@ -140,6 +150,25 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
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)
@@ -147,7 +176,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
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))
@@ -168,24 +196,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
useReminderCheck(notes)
// Handle ?openNote=ID — open a note from a notification click
useEffect(() => {
const openNoteId = searchParams.get('openNote')
if (!openNoteId) return
const openNote = async () => {
// Try to find the note in current state first
const existing = notes.find(n => n.id === openNoteId)
if (existing) {
setEditingNote({ note: existing, readOnly: false })
} else {
// Fetch from server
const fetched = await getNoteById(openNoteId)
if (fetched) {
setEditingNote({ note: fetched, readOnly: false })
}
}
// Clean URL — remove openNote param
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
@@ -195,7 +219,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
// Listen for global label deletion and immediately update local state
useEffect(() => {
const handler = (e: Event) => {
const { name } = (e as CustomEvent).detail
@@ -215,8 +238,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
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) || []
@@ -227,8 +248,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
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 () => {
@@ -243,14 +262,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
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)
@@ -260,7 +277,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
)
}
// 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 }))
@@ -269,20 +285,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
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 }))
@@ -298,148 +311,120 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
useEffect(() => {
setControls({
isTabsMode: notesViewMode === 'tabs',
openNoteComposer: () => {},
openNoteComposer: () => handleAddNote(),
})
return () => setControls(null)
}, [notesViewMode, setControls])
const handleNoteCreatedWrapper = (note: any) => {
handleNoteCreated(note)
// 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 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'
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 px-2 py-6 sm:px-4 md:px-8'
isTabs ? 'gap-3 py-1' : 'h-full'
)}
>
{/* 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>
<span className="text-sm font-medium text-muted-foreground mt-2">({notes.length})</span>
</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>
<span className="text-sm font-medium text-muted-foreground mt-2">{notes.length} {notes.length === 1 ? (t('notes.note') || 'note') : (t('notes.notes') || 'notes')}</span>
</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>
{editingNote ? (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={handleEditorClose}
fullPage
/>
) : (
<>
{!isTabs && (
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
/>
)}
<div className={cn(
'px-12 pt-12 pb-8 flex flex-col gap-6',
isEditorialMode ? 'sticky top-0 bg-background/80 backdrop-blur-md z-30' : ''
)}>
<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>
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
<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">{sortLabels[sortOrder]}</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={isTabs ? notes : notes.filter((note) => !note.isPinned)}
notes={sortedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
@@ -448,14 +433,54 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onEnableHistory={handleEnableHistory}
onNoteCreated={handleNoteCreated}
/>
</div>
)}
) : (
<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>
)}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && !isTabs && (
<div className="text-center py-8 text-gray-500">
{t('notes.emptyState')}
</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 &mdash; {new Date().getFullYear()}
</p>
</footer>
</>
)}
@@ -489,14 +514,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
/>
)}
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
<NoteHistoryModal
open={historyOpen}
onOpenChange={setHistoryOpen}