'use client' import { useMemo, useState, useTransition, useEffect, useCallback } from 'react' import { DndContext, DragOverlay, PointerSensor, TouchSensor, closestCenter, useSensor, useSensors, type DragEndEvent, type DragStartEvent, } from '@dnd-kit/core' import { SortableContext, arrayMove, rectSortingStrategy, useSortable, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' import type { Note } from '@/lib/types' import { NotesEditorialView } from '@/components/notes-editorial-view' import type { NoteCollectionActions } from '@/lib/note-change-sync' import { getNoteDisplayTitle, getNoteFeedImage, getNotePlainExcerpt, prepareNoteIllustrationForGrid } from '@/lib/note-preview' import { useLanguage } from '@/lib/i18n' import { useNotebooks } from '@/context/notebooks-context' import { useLabelsQuery } from '@/lib/query-hooks' import { updateNote } from '@/app/actions/notes' import { getAISettings } from '@/app/actions/ai-settings' import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration' import { LabelBadge } from '@/components/label-badge' import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date' import { motion } from 'motion/react' import { MoveToNotebookPicker } from '@/components/move-to-notebook-picker' import { toast } from 'sonner' import { Pin, FileText, Link2, CheckSquare, ChevronUp, ChevronDown, Wind, Trash2, FolderOpen, Sparkles, Loader2, } from 'lucide-react' import { cn } from '@/lib/utils' import { formatDistanceToNow } from 'date-fns' import { fr } from 'date-fns/locale/fr' import { enUS } from 'date-fns/locale/en-US' export type NotesLayoutMode = 'grid' | 'list' | 'table' | 'kanban' | 'gallery' export type NotesClassicLayoutMode = 'grid' | 'list' | 'table' export function isClassicLayoutMode(mode: NotesLayoutMode): mode is NotesClassicLayoutMode { return mode === 'grid' || mode === 'list' || mode === 'table' } export type NotesViewType = 'notes' | 'tasks' type TaskItem = { id: string noteId: string noteTitle: string text: string completed: boolean lineIndex: number } function getNoteTasksStats(content: string) { const lines = (content || '').split('\n') let total = 0 let completed = 0 for (const line of lines) { const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/) if (match) { total++ if (match[1].toLowerCase() === 'x') completed++ } } return { completed, total } } function extractTasksFromNotes(notes: Note[]): TaskItem[] { const tasks: TaskItem[] = [] for (const note of notes) { const title = note.title?.trim() || 'Sans titre' const lines = (note.content || '').split('\n') lines.forEach((line, idx) => { const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/) if (match) { tasks.push({ id: `${note.id}-${idx}`, noteId: note.id, noteTitle: title, text: match[2].trim(), completed: match[1].toLowerCase() === 'x', lineIndex: idx, }) } }) } return tasks } function getNotebookColor(notebookId: string | null | undefined, name?: string) { const colors = [ { bg: 'bg-[#A47148]/5 dark:bg-[#A47148]/10', border: 'border-[#A47148]/20', text: 'text-[#A47148]' }, { bg: 'bg-emerald-500/5 dark:bg-emerald-500/10', border: 'border-emerald-500/15', text: 'text-emerald-600 dark:text-emerald-400' }, { bg: 'bg-indigo-500/5 dark:bg-indigo-500/10', border: 'border-indigo-500/15', text: 'text-indigo-600 dark:text-indigo-400' }, { bg: 'bg-blue-500/5 dark:bg-blue-500/10', border: 'border-blue-500/15', text: 'text-blue-600 dark:text-blue-400' }, { bg: 'bg-amber-500/5 dark:bg-amber-500/10', border: 'border-amber-500/15', text: 'text-amber-600 dark:text-amber-400' }, { bg: 'bg-rose-500/5 dark:bg-rose-500/10', border: 'border-rose-500/15', text: 'text-rose-600 dark:text-rose-400' }, ] const key = name || notebookId || '' const idx = Math.abs(key.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)) % colors.length return colors[idx] } function NoteLabelsRow({ labelNames, allLabels, max = 3, }: { labelNames: string[] | null | undefined allLabels: { name: string; type?: 'ai' | 'user' }[] max?: number }) { if (!labelNames?.length) return null return (
{labelNames.slice(0, max).map((labelName) => { const def = allLabels.find((l) => l.name === labelName) return ( ) })} {labelNames.length > max && ( +{labelNames.length - max} )}
) } function NoteGridIllustrationButton({ busy, onClick, className, }: { busy: boolean onClick: (e: React.MouseEvent) => void className?: string }) { const { t } = useLanguage() return ( ) } function NoteGridThumbnail({ note, aiIllustrationEnabled, onNoteIllustrationGenerated, }: { note: Note aiIllustrationEnabled?: boolean onNoteIllustrationGenerated?: (noteId: string) => void | Promise }) { const { t } = useLanguage() const [busy, setBusy] = useState(false) const img = getNoteFeedImage(note) const handleGenerateSvg = async (e: React.MouseEvent) => { e.stopPropagation() if (!aiIllustrationEnabled || busy || img) return setBusy(true) try { const res = await generateNoteIllustrationSvg(note.id, { skipRevalidation: true }) if (!res.ok) { toast.error(res.error) } else { toast.success(t('notes.illustrationGenerated') || 'Illustration générée') await onNoteIllustrationGenerated?.(note.id) } } finally { setBusy(false) } } const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100' if (img) { return ( ) } if (note.illustrationSvg) { return ( <>
{aiIllustrationEnabled && ( )} ) } return ( <>
{aiIllustrationEnabled && ( )} ) } export type { NoteCollectionActions } from '@/lib/note-change-sync' type NotesListViewsProps = { notes: Note[] pinnedNotes?: Note[] viewType: NotesViewType layoutMode: NotesLayoutMode onOpen: (note: Note, readOnly?: boolean) => void onOpenHistory?: (note: Note) => void notebookName?: string onGridReorder?: (orderedIds: string[]) => void | Promise } & Partial export function NotesListViews({ notes, pinnedNotes = [], viewType, layoutMode, onOpen, onOpenHistory, notebookName, onTogglePin, onDeleteNote, onArchiveNote, onMoveToNotebook, onNotePatch, onNoteIllustrationGenerated, onGridReorder, }: NotesListViewsProps) { const { t, language } = useLanguage() const { data: session } = useSession() const { notebooks } = useNotebooks() const { data: allLabels = [] } = useLabelsQuery() const [, startTransition] = useTransition() const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'tasks' | 'modified' | null>(null) const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null) const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false) useEffect(() => { if (!session?.user?.id) { setAiIllustrationEnabled(false) return } getAISettings(session.user.id) .then((s) => setAiIllustrationEnabled(s.paragraphRefactor !== false)) .catch(() => setAiIllustrationEnabled(false)) }, [session?.user?.id]) const untitled = t('notes.untitled') const dateLocale = language === 'fr' ? fr : enUS const allDisplayNotes = useMemo(() => { const unpinned = notes.filter((n) => !n.isPinned) return [...pinnedNotes, ...unpinned] }, [notes, pinnedNotes]) const extractTasks = useMemo(() => extractTasksFromNotes(allDisplayNotes), [allDisplayNotes]) const completedTasksCount = extractTasks.filter((task) => task.completed).length const handleToggleTask = (task: TaskItem) => { const note = allDisplayNotes.find((n) => n.id === task.noteId) if (!note) return const lines = (note.content || '').split('\n') const line = lines[task.lineIndex] if (!line) return const nextChar = task.completed ? ' ' : 'x' lines[task.lineIndex] = line.replace(/\[([ xX])\]/, `[${nextChar}]`) startTransition(async () => { await updateNote(note.id, { content: lines.join('\n') }, { skipRevalidation: true }) onNotePatch?.(note.id, { content: lines.join('\n') }) }) } const handleSort = (field: 'title' | 'notebook' | 'tasks' | 'modified') => { if (sortColumn !== field) { setSortColumn(field) setSortDirection('asc') } else if (sortDirection === 'asc') { setSortDirection('desc') } else { setSortColumn(null) setSortDirection(null) } } const sortedNotes = useMemo(() => { if (!sortColumn || !sortDirection) return allDisplayNotes const copy = [...allDisplayNotes] return copy.sort((a, b) => { let valA: string | number = '' let valB: string | number = '' if (sortColumn === 'title') { valA = getNoteDisplayTitle(a, untitled).toLowerCase() valB = getNoteDisplayTitle(b, untitled).toLowerCase() } else if (sortColumn === 'notebook') { valA = notebooks.find((nb) => nb.id === a.notebookId)?.name?.toLowerCase() || '' valB = notebooks.find((nb) => nb.id === b.notebookId)?.name?.toLowerCase() || '' } else if (sortColumn === 'tasks') { valA = getNoteTasksStats(a.content || '').completed valB = getNoteTasksStats(b.content || '').completed } else { valA = new Date(a.updatedAt).getTime() valB = new Date(b.updatedAt).getTime() } if (valA < valB) return sortDirection === 'asc' ? -1 : 1 if (valA > valB) return sortDirection === 'asc' ? 1 : -1 return 0 }) }, [allDisplayNotes, sortColumn, sortDirection, notebooks, untitled]) const SortIcon = ({ field }: { field: typeof sortColumn }) => sortColumn === field ? ( sortDirection === 'asc' ? : ) : null if (viewType === 'tasks') { return (
{t('notes.tasksHeader')}
{t('notes.tasksSummary') .replace('{count}', String(extractTasks.length)) .replace('{completed}', String(completedTasksCount))}
{extractTasks.length > 0 ? (
{extractTasks.map((task) => (
{task.text}
{t('notes.taskFromNote').replace('{title}', task.noteTitle)}
))}
) : (

{t('notes.tasksEmptyTitle')}

{t('notes.tasksEmptyHint')}

)}
) } if (layoutMode === 'grid') { return ( !n.isPinned)} untitled={untitled} allLabels={allLabels} notebooks={notebooks} aiIllustrationEnabled={aiIllustrationEnabled} onOpen={onOpen} onTogglePin={onTogglePin} onDeleteNote={onDeleteNote} onMoveToNotebook={onMoveToNotebook} onNoteIllustrationGenerated={onNoteIllustrationGenerated} onGridReorder={onGridReorder} pinnedLabel={t('notes.pinned')} /> ) } if (layoutMode === 'table') { return (
{sortedNotes.map((note) => { const title = getNoteDisplayTitle(note, untitled) const nb = notebooks.find((n) => n.id === note.notebookId) const nbColor = getNotebookColor(note.notebookId, nb?.name) const stats = getNoteTasksStats(note.content || '') return ( onOpen(note)} className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group" > ) })}
handleSort('title')} > {t('notes.tableTitle')} handleSort('notebook')} > {t('notes.tableNotebook')} {t('notes.tableLabels')} handleSort('tasks')} > {t('notes.tableTasks')} handleSort('modified')} > {t('notes.tableModified')}
{note.isPinned && } {title} {nb ? ( {nb.name} ) : ( )} {stats.total > 0 ? ( {stats.completed}/{stats.total} ) : ( )} {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
) } return (
{pinnedNotes.length > 0 && (

{t('notes.pinned')}

)} {notes.filter((n) => !n.isPinned).length > 0 && ( !n.isPinned)} onOpen={onOpen} notebookName={notebookName} onOpenHistory={onOpenHistory} onTogglePin={onTogglePin} onDeleteNote={onDeleteNote} onArchiveNote={onArchiveNote} onMoveToNotebook={onMoveToNotebook} onNotePatch={onNotePatch} onNoteIllustrationGenerated={onNoteIllustrationGenerated} /> )}
) } function formatGridCardDate(date: Date | string, language: string): string { const d = typeof date === 'string' ? new Date(date) : date const locale = language === 'fr' ? fr : enUS if (language === 'fa') { return formatAbsoluteDateLocalized(d, language, 'd MMM yyyy', locale) } const month = d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' }) const day = d.getUTCDate() const year = d.getUTCFullYear() return `${month.toUpperCase()} ${day}, ${year}` } type GridCardSharedProps = { note: Note index: number untitled: string allLabels: { name: string; type?: 'ai' | 'user' }[] notebooks: { id: string; name: string }[] aiIllustrationEnabled?: boolean onOpen: (note: Note) => void onTogglePin?: (note: Note) => void | Promise onDeleteNote?: (note: Note) => void | Promise onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise onNoteIllustrationGenerated?: (noteId: string) => void | Promise isOverlay?: boolean } function NotesMasonryGrid({ pinnedNotes, unpinnedNotes, pinnedLabel, onGridReorder, ...cardProps }: { pinnedNotes: Note[] unpinnedNotes: Note[] pinnedLabel: string onGridReorder?: (orderedIds: string[]) => void | Promise } & Omit) { const [activeId, setActiveId] = useState(null) const displayNotes = useMemo( () => [...pinnedNotes, ...unpinnedNotes], [pinnedNotes, unpinnedNotes], ) const activeNote = useMemo( () => displayNotes.find((n) => n.id === activeId) ?? null, [displayNotes, activeId], ) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }), ) const handleDragStart = useCallback((event: DragStartEvent) => { setActiveId(event.active.id as string) }, []) const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event setActiveId(null) if (!over || active.id === over.id || !onGridReorder) return const activeIdx = displayNotes.findIndex((n) => n.id === active.id) const overIdx = displayNotes.findIndex((n) => n.id === over.id) if (activeIdx === -1 || overIdx === -1) return const activeNoteItem = displayNotes[activeIdx] const overNoteItem = displayNotes[overIdx] if (activeNoteItem.isPinned !== overNoteItem.isPinned) return const reordered = arrayMove(displayNotes, activeIdx, overIdx) onGridReorder(reordered.map((n) => n.id)) }, [displayNotes, onGridReorder], ) const sortEnabled = Boolean(onGridReorder) return (
{pinnedNotes.length > 0 && (

{pinnedLabel}

)} {unpinnedNotes.length > 0 && ( )}
{activeNote ? (
) : null}
) } function NotesGridSection({ notes, sortEnabled, indexOffset = 0, untitled, allLabels, notebooks, aiIllustrationEnabled, onOpen, onTogglePin, onDeleteNote, onMoveToNotebook, onNoteIllustrationGenerated, className, }: Omit & { notes: Note[] sortEnabled?: boolean indexOffset?: number className?: string }) { const ids = useMemo(() => notes.map((n) => n.id), [notes]) const grid = (
{notes.map((note, index) => sortEnabled ? ( ) : ( ), )}
) if (!sortEnabled) return grid return ( {grid} ) } function SortableGridCard(props: GridCardSharedProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.note.id, }) const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, } return (
) } function GridCard({ note, index, untitled, allLabels, notebooks, aiIllustrationEnabled, onOpen, onTogglePin, onDeleteNote, onMoveToNotebook, onNoteIllustrationGenerated, isOverlay = false, }: GridCardSharedProps) { const router = useRouter() const { t, language } = useLanguage() const title = getNoteDisplayTitle(note, untitled) const excerpt = getNotePlainExcerpt(note, 110) const stats = getNoteTasksStats(note.content || '') const formattedDate = formatGridCardDate(note.updatedAt, language) const handlePinClick = (e: React.MouseEvent) => { e.stopPropagation() onTogglePin?.(note) } const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation() onDeleteNote?.(note) } const handleBrainstormClick = (e: React.MouseEvent) => { e.stopPropagation() const seed = `${title}\n\n${excerpt}`.trim() || title router.push(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`) } return ( onOpen(note)} className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative h-full" >
{note.isPinned && (
)} {stats.total > 0 && (
{stats.completed}/{stats.total} ✓
)}

{title}

{excerpt || '\u00A0'}

{formattedDate}
{onMoveToNotebook && ( onMoveToNotebook(note, notebookId)} align="end" preferDropUp > )}
) }