Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
949 lines
34 KiB
TypeScript
949 lines
34 KiB
TypeScript
'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 (
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{labelNames.slice(0, max).map((labelName) => {
|
|
const def = allLabels.find((l) => l.name === labelName)
|
|
return (
|
|
<LabelBadge key={labelName} label={labelName} type={def?.type} variant="default" />
|
|
)
|
|
})}
|
|
{labelNames.length > max && (
|
|
<span className="text-[8.5px] font-mono text-muted-foreground font-bold shrink-0 bg-muted/50 px-1 py-0.5 rounded">
|
|
+{labelNames.length - max}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NoteGridIllustrationButton({
|
|
busy,
|
|
onClick,
|
|
className,
|
|
}: {
|
|
busy: boolean
|
|
onClick: (e: React.MouseEvent) => void
|
|
className?: string
|
|
}) {
|
|
const { t } = useLanguage()
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
|
|
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
|
|
className={cn(
|
|
'absolute bottom-2 end-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10',
|
|
className,
|
|
)}
|
|
onClick={onClick}
|
|
disabled={busy}
|
|
>
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function NoteGridThumbnail({
|
|
note,
|
|
aiIllustrationEnabled,
|
|
onNoteIllustrationGenerated,
|
|
}: {
|
|
note: Note
|
|
aiIllustrationEnabled?: boolean
|
|
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
|
|
}) {
|
|
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 (
|
|
<img
|
|
src={img}
|
|
alt=""
|
|
className="w-full h-full object-cover mix-blend-multiply dark:mix-blend-normal opacity-85 grayscale contrast-115 group-hover/card:scale-105 group-hover/card:grayscale-0 group-hover/card:opacity-100 transition-all duration-500"
|
|
/>
|
|
)
|
|
}
|
|
if (note.illustrationSvg) {
|
|
return (
|
|
<>
|
|
<div
|
|
className="absolute inset-0 w-full h-full overflow-hidden bg-[#F5F0E8] dark:bg-muted/30"
|
|
dangerouslySetInnerHTML={{ __html: prepareNoteIllustrationForGrid(note.illustrationSvg) }}
|
|
aria-hidden
|
|
/>
|
|
{aiIllustrationEnabled && (
|
|
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
return (
|
|
<>
|
|
<div className="w-full h-full flex items-center justify-center bg-muted/40 text-muted-foreground/50">
|
|
<FileText size={28} strokeWidth={1.25} />
|
|
</div>
|
|
{aiIllustrationEnabled && (
|
|
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
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<void>
|
|
} & Partial<NoteCollectionActions>
|
|
|
|
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' ? <ChevronUp size={12} /> : <ChevronDown size={12} />
|
|
) : null
|
|
|
|
if (viewType === 'tasks') {
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
|
<div className="flex items-center justify-between pb-3 border-b border-foreground/5">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-muted-foreground">
|
|
{t('notes.tasksHeader')}
|
|
</span>
|
|
</div>
|
|
<div className="text-[11px] font-mono font-bold text-foreground bg-foreground/[0.03] dark:bg-white/5 py-1 px-3 rounded-full">
|
|
{t('notes.tasksSummary')
|
|
.replace('{count}', String(extractTasks.length))
|
|
.replace('{completed}', String(completedTasksCount))}
|
|
</div>
|
|
</div>
|
|
|
|
{extractTasks.length > 0 ? (
|
|
<div className="overflow-hidden border border-border/40 rounded-2xl bg-card/30 shadow-sm">
|
|
<div className="divide-y divide-foreground/[0.04]">
|
|
{extractTasks.map((task) => (
|
|
<div
|
|
key={task.id}
|
|
className="p-4 flex items-center justify-between gap-4 hover:bg-foreground/[0.01] transition-all group"
|
|
>
|
|
<div className="flex items-center gap-3.5 flex-grow min-w-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleToggleTask(task)}
|
|
className={cn(
|
|
'w-5 h-5 rounded-md border flex items-center justify-center transition-all shrink-0',
|
|
task.completed
|
|
? 'bg-brand-accent border-brand-accent text-white'
|
|
: 'border-border hover:border-brand-accent/60 bg-transparent',
|
|
)}
|
|
>
|
|
{task.completed && <span className="text-xs font-bold">✓</span>}
|
|
</button>
|
|
<span
|
|
className={cn(
|
|
'text-[13px] font-light leading-relaxed truncate',
|
|
task.completed && 'line-through text-muted-foreground',
|
|
)}
|
|
>
|
|
{task.text}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="text-[9.5px] uppercase font-mono tracking-wider text-muted-foreground max-w-[140px] truncate">
|
|
{t('notes.taskFromNote').replace('{title}', task.noteTitle)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const note = allDisplayNotes.find((n) => n.id === task.noteId)
|
|
if (note) onOpen(note)
|
|
}}
|
|
className="p-1.5 rounded-full hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-all"
|
|
title={t('notes.openSourceNote')}
|
|
>
|
|
<Link2 size={13} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
|
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center border border-border/40">
|
|
<CheckSquare size={18} className="text-muted-foreground/60" />
|
|
</div>
|
|
<p className="font-memento-serif text-lg italic text-muted-foreground">{t('notes.tasksEmptyTitle')}</p>
|
|
<p className="text-xs text-muted-foreground/70 max-w-sm leading-relaxed">{t('notes.tasksEmptyHint')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (layoutMode === 'grid') {
|
|
return (
|
|
<NotesMasonryGrid
|
|
pinnedNotes={pinnedNotes}
|
|
unpinnedNotes={notes.filter((n) => !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 (
|
|
<div className="max-w-6xl mx-auto">
|
|
<div className="overflow-x-auto border border-border/40 rounded-2xl bg-card/30 shadow-sm">
|
|
<table className="w-full text-left border-collapse min-w-[720px]">
|
|
<thead>
|
|
<tr className="border-b border-border/30">
|
|
<th
|
|
className="w-[32%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
|
onClick={() => handleSort('title')}
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
{t('notes.tableTitle')} <SortIcon field="title" />
|
|
</span>
|
|
</th>
|
|
<th
|
|
className="w-[15%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
|
onClick={() => handleSort('notebook')}
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
{t('notes.tableNotebook')} <SortIcon field="notebook" />
|
|
</span>
|
|
</th>
|
|
<th className="w-[22%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground">
|
|
{t('notes.tableLabels')}
|
|
</th>
|
|
<th
|
|
className="w-[12%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
|
onClick={() => handleSort('tasks')}
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
{t('notes.tableTasks')} <SortIcon field="tasks" />
|
|
</span>
|
|
</th>
|
|
<th
|
|
className="w-[13%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
|
onClick={() => handleSort('modified')}
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
{t('notes.tableModified')} <SortIcon field="modified" />
|
|
</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-foreground/[0.03]">
|
|
{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 (
|
|
<tr
|
|
key={note.id}
|
|
onClick={() => onOpen(note)}
|
|
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group"
|
|
>
|
|
<td className="px-4 py-2 font-memento-serif text-[13px] font-medium truncate max-w-[280px]">
|
|
<span className="inline-flex items-center gap-2 truncate group-hover:text-brand-accent transition-colors">
|
|
{note.isPinned && <Pin size={11} className="text-amber-500 fill-amber-500 shrink-0" />}
|
|
{title}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
{nb ? (
|
|
<span
|
|
className={cn(
|
|
'inline-block px-2 py-0.5 rounded-full text-[9px] font-bold tracking-wide border truncate max-w-[125px]',
|
|
nbColor.bg,
|
|
nbColor.border,
|
|
nbColor.text,
|
|
)}
|
|
>
|
|
{nb.name}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground text-[10px]">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={3} />
|
|
</td>
|
|
<td className="px-4 py-2 font-mono text-[10.5px] font-bold text-foreground/80">
|
|
{stats.total > 0 ? (
|
|
<span className={stats.completed === stats.total ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}>
|
|
{stats.completed}/{stats.total} <span className="text-[9px] font-sans">✓</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground/40">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-[10.5px] font-mono text-muted-foreground whitespace-nowrap">
|
|
{formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-3xl space-y-16">
|
|
{pinnedNotes.length > 0 && (
|
|
<div className="mb-6">
|
|
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-2">
|
|
{t('notes.pinned')}
|
|
</h2>
|
|
<NotesEditorialView
|
|
notes={pinnedNotes}
|
|
onOpen={onOpen}
|
|
notebookName={notebookName}
|
|
onOpenHistory={onOpenHistory}
|
|
onTogglePin={onTogglePin}
|
|
onDeleteNote={onDeleteNote}
|
|
onArchiveNote={onArchiveNote}
|
|
onMoveToNotebook={onMoveToNotebook}
|
|
onNotePatch={onNotePatch}
|
|
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
|
/>
|
|
</div>
|
|
)}
|
|
{notes.filter((n) => !n.isPinned).length > 0 && (
|
|
<NotesEditorialView
|
|
notes={notes.filter((n) => !n.isPinned)}
|
|
onOpen={onOpen}
|
|
notebookName={notebookName}
|
|
onOpenHistory={onOpenHistory}
|
|
onTogglePin={onTogglePin}
|
|
onDeleteNote={onDeleteNote}
|
|
onArchiveNote={onArchiveNote}
|
|
onMoveToNotebook={onMoveToNotebook}
|
|
onNotePatch={onNotePatch}
|
|
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<void>
|
|
onDeleteNote?: (note: Note) => void | Promise<void>
|
|
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
|
|
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
|
|
isOverlay?: boolean
|
|
}
|
|
|
|
function NotesMasonryGrid({
|
|
pinnedNotes,
|
|
unpinnedNotes,
|
|
pinnedLabel,
|
|
onGridReorder,
|
|
...cardProps
|
|
}: {
|
|
pinnedNotes: Note[]
|
|
unpinnedNotes: Note[]
|
|
pinnedLabel: string
|
|
onGridReorder?: (orderedIds: string[]) => void | Promise<void>
|
|
} & Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'>) {
|
|
const [activeId, setActiveId] = useState<string | null>(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 (
|
|
<DndContext
|
|
id="notes-grid-masonry"
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="max-w-6xl mx-auto space-y-8">
|
|
{pinnedNotes.length > 0 && (
|
|
<div>
|
|
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-1">
|
|
{pinnedLabel}
|
|
</h2>
|
|
<NotesGridSection
|
|
notes={pinnedNotes}
|
|
sortEnabled={sortEnabled}
|
|
{...cardProps}
|
|
/>
|
|
</div>
|
|
)}
|
|
{unpinnedNotes.length > 0 && (
|
|
<NotesGridSection
|
|
notes={unpinnedNotes}
|
|
sortEnabled={sortEnabled}
|
|
indexOffset={pinnedNotes.length}
|
|
{...cardProps}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease' }}>
|
|
{activeNote ? (
|
|
<div className="cursor-grabbing shadow-2xl rounded-2xl opacity-95">
|
|
<GridCard note={activeNote} index={0} isOverlay {...cardProps} />
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
)
|
|
}
|
|
|
|
function NotesGridSection({
|
|
notes,
|
|
sortEnabled,
|
|
indexOffset = 0,
|
|
untitled,
|
|
allLabels,
|
|
notebooks,
|
|
aiIllustrationEnabled,
|
|
onOpen,
|
|
onTogglePin,
|
|
onDeleteNote,
|
|
onMoveToNotebook,
|
|
onNoteIllustrationGenerated,
|
|
className,
|
|
}: Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'> & {
|
|
notes: Note[]
|
|
sortEnabled?: boolean
|
|
indexOffset?: number
|
|
className?: string
|
|
}) {
|
|
const ids = useMemo(() => notes.map((n) => n.id), [notes])
|
|
|
|
const grid = (
|
|
<div className={cn('grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-stretch', className)}>
|
|
{notes.map((note, index) =>
|
|
sortEnabled ? (
|
|
<SortableGridCard
|
|
key={note.id}
|
|
note={note}
|
|
index={indexOffset + index}
|
|
untitled={untitled}
|
|
allLabels={allLabels}
|
|
notebooks={notebooks}
|
|
aiIllustrationEnabled={aiIllustrationEnabled}
|
|
onOpen={onOpen}
|
|
onTogglePin={onTogglePin}
|
|
onDeleteNote={onDeleteNote}
|
|
onMoveToNotebook={onMoveToNotebook}
|
|
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
|
/>
|
|
) : (
|
|
<GridCard
|
|
key={note.id}
|
|
note={note}
|
|
index={indexOffset + index}
|
|
untitled={untitled}
|
|
allLabels={allLabels}
|
|
notebooks={notebooks}
|
|
aiIllustrationEnabled={aiIllustrationEnabled}
|
|
onOpen={onOpen}
|
|
onTogglePin={onTogglePin}
|
|
onDeleteNote={onDeleteNote}
|
|
onMoveToNotebook={onMoveToNotebook}
|
|
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
|
/>
|
|
),
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
if (!sortEnabled) return grid
|
|
|
|
return (
|
|
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
|
{grid}
|
|
</SortableContext>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
{...attributes}
|
|
{...listeners}
|
|
className={cn(
|
|
'touch-none cursor-grab active:cursor-grabbing h-full',
|
|
isDragging && 'opacity-40',
|
|
)}
|
|
>
|
|
<GridCard {...props} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<motion.div
|
|
initial={isOverlay ? false : { opacity: 0, y: 15 }}
|
|
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
|
|
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
|
|
onClick={() => 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"
|
|
>
|
|
<div className="aspect-[16/10] shrink-0 bg-muted/30 border-b border-border/20 overflow-hidden relative">
|
|
<NoteGridThumbnail
|
|
note={note}
|
|
aiIllustrationEnabled={aiIllustrationEnabled}
|
|
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
|
/>
|
|
{note.isPinned && (
|
|
<div className="absolute top-3 start-3 bg-background/90 backdrop-blur-sm p-1.5 rounded-full shadow-sm border border-border/40 text-amber-500">
|
|
<Pin size={11} className="fill-amber-500" />
|
|
</div>
|
|
)}
|
|
{stats.total > 0 && (
|
|
<div className="absolute top-3 end-3 bg-background/90 backdrop-blur-sm py-1 px-2.5 rounded-full shadow-sm border border-border/40 text-[9.5px] font-mono font-bold text-muted-foreground">
|
|
{stats.completed}/{stats.total} ✓
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="p-5 flex flex-col flex-1 min-h-[11.5rem]">
|
|
<div className="space-y-2.5 flex-1">
|
|
<div className="min-h-[1.125rem]">
|
|
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={2} />
|
|
</div>
|
|
<h3 className="font-memento-serif text-base font-semibold text-foreground leading-snug truncate group-hover/card:text-brand-accent transition-colors">
|
|
{title}
|
|
</h3>
|
|
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 font-light min-h-[3.75rem]">
|
|
{excerpt || '\u00A0'}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center justify-between pt-3 mt-auto border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
|
|
<span>{formattedDate}</span>
|
|
<div className="flex items-center gap-1 opacity-0 group-hover/card:opacity-100 transition-opacity">
|
|
<button
|
|
type="button"
|
|
onClick={handleBrainstormClick}
|
|
className="p-1 px-2 rounded-full hover:bg-brand-accent/10 text-brand-accent transition-all flex items-center gap-1 cursor-pointer"
|
|
title={t('notes.brainstormThisIdea') || 'Brainstormer cette idée'}
|
|
aria-label={t('notes.brainstormThisIdeaAria') || t('notes.brainstormThisIdea') || 'Brainstormer cette idée'}
|
|
>
|
|
<Wind size={11} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handlePinClick}
|
|
className="p-1 px-2 rounded-full hover:bg-muted text-foreground transition-all cursor-pointer"
|
|
title={note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
|
|
aria-label={note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
|
|
>
|
|
<Pin size={11} className={note.isPinned ? 'fill-amber-500 text-amber-500' : ''} />
|
|
</button>
|
|
{onMoveToNotebook && (
|
|
<MoveToNotebookPicker
|
|
notebooks={notebooks}
|
|
currentNotebookId={note.notebookId}
|
|
onSelect={(notebookId) => onMoveToNotebook(note, notebookId)}
|
|
align="end"
|
|
preferDropUp
|
|
>
|
|
<button
|
|
type="button"
|
|
className="p-1 px-2 rounded-full hover:bg-muted text-foreground transition-all cursor-pointer"
|
|
title={t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
|
|
aria-label={t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
|
|
>
|
|
<FolderOpen size={11} />
|
|
</button>
|
|
</MoveToNotebookPicker>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleDeleteClick}
|
|
className="p-1 px-2 rounded-full hover:bg-rose-50 dark:hover:bg-rose-500/10 text-rose-500 transition-all cursor-pointer"
|
|
title={t('notes.delete') || 'Supprimer'}
|
|
aria-label={t('notes.delete') || 'Supprimer'}
|
|
>
|
|
<Trash2 size={11} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|