- Hydration mismatch @dnd-kit: ajouter id="notes-tabs-dnd" et id="masonry-dnd" aux DndContext pour éviter les IDs auto-incrémentaux non-déterministes (DndDescribedBy-0 server vs DndDescribedBy-3 client) - setState in render: refactorer handleDragEnd dans MasonryGrid — remplacer le double setLocalNotes() par arrayMove direct + ref pour la persistance (évite Cannot update Router while rendering MasonryGrid) - DialogTitle manquant: ajouter DialogHeader+DialogTitle sr-only dans le loading state de AutoLabelSuggestionDialog (Radix accessibility requirement) - Ajouter useRef pour tracker localNotes sans capturer de stale closure
437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState, useTransition } from 'react'
|
|
import {
|
|
DndContext,
|
|
type DragEndEvent,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
closestCenter,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
SortableContext,
|
|
arrayMove,
|
|
verticalListSortingStrategy,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
} from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
|
import { cn } from '@/lib/utils'
|
|
import { NoteInlineEditor } from '@/components/note-inline-editor'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
|
import { updateFullOrderWithoutRevalidation, createNote } from '@/app/actions/notes'
|
|
import {
|
|
GripVertical,
|
|
Hash,
|
|
ListChecks,
|
|
Pin,
|
|
FileText,
|
|
Clock,
|
|
Plus,
|
|
Loader2,
|
|
} from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { toast } from 'sonner'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
import { fr } from 'date-fns/locale/fr'
|
|
import { enUS } from 'date-fns/locale/en-US'
|
|
|
|
interface NotesTabsViewProps {
|
|
notes: Note[]
|
|
onEdit?: (note: Note, readOnly?: boolean) => void
|
|
currentNotebookId?: string | null
|
|
}
|
|
|
|
// Color accent strip for each note
|
|
const COLOR_ACCENT: Record<NoteColor, string> = {
|
|
default: 'bg-primary',
|
|
red: 'bg-red-400',
|
|
orange: 'bg-orange-400',
|
|
yellow: 'bg-amber-400',
|
|
green: 'bg-emerald-400',
|
|
teal: 'bg-teal-400',
|
|
blue: 'bg-sky-400',
|
|
purple: 'bg-violet-400',
|
|
pink: 'bg-fuchsia-400',
|
|
gray: 'bg-gray-400',
|
|
}
|
|
|
|
// Background tint gradient for selected note panel
|
|
const COLOR_PANEL_BG: Record<NoteColor, string> = {
|
|
default: 'from-background to-background',
|
|
red: 'from-red-50/60 dark:from-red-950/20 to-background',
|
|
orange: 'from-orange-50/60 dark:from-orange-950/20 to-background',
|
|
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
|
|
green: 'from-emerald-50/60 dark:from-emerald-950/20 to-background',
|
|
teal: 'from-teal-50/60 dark:from-teal-950/20 to-background',
|
|
blue: 'from-sky-50/60 dark:from-sky-950/20 to-background',
|
|
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
|
|
pink: 'from-fuchsia-50/60 dark:from-fuchsia-950/20 to-background',
|
|
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
|
|
}
|
|
|
|
const COLOR_ICON: Record<NoteColor, string> = {
|
|
default: 'text-primary',
|
|
red: 'text-red-500',
|
|
orange: 'text-orange-500',
|
|
yellow: 'text-amber-500',
|
|
green: 'text-emerald-500',
|
|
teal: 'text-teal-500',
|
|
blue: 'text-sky-500',
|
|
purple: 'text-violet-500',
|
|
pink: 'text-fuchsia-500',
|
|
gray: 'text-gray-500',
|
|
}
|
|
|
|
function getColorKey(note: Note): NoteColor {
|
|
return (typeof note.color === 'string' && note.color in NOTE_COLORS
|
|
? note.color
|
|
: 'default') as NoteColor
|
|
}
|
|
|
|
function getDateLocale(language: string) {
|
|
if (language === 'fr') return fr;
|
|
if (language === 'fa') return require('date-fns/locale').faIR;
|
|
return enUS;
|
|
}
|
|
|
|
// ─── Sortable List Item ───────────────────────────────────────────────────────
|
|
|
|
function SortableNoteListItem({
|
|
note,
|
|
selected,
|
|
onSelect,
|
|
reorderLabel,
|
|
language,
|
|
untitledLabel,
|
|
}: {
|
|
note: Note
|
|
selected: boolean
|
|
onSelect: () => void
|
|
reorderLabel: string
|
|
language: string
|
|
untitledLabel: string
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
id: note.id,
|
|
})
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 50 : undefined,
|
|
}
|
|
|
|
const ck = getColorKey(note)
|
|
const title = getNoteDisplayTitle(note, untitledLabel)
|
|
const snippet =
|
|
note.type === 'checklist'
|
|
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 150)
|
|
: (note.content || '').substring(0, 150)
|
|
|
|
const dateLocale = getDateLocale(language)
|
|
const timeAgo = formatDistanceToNow(new Date(note.updatedAt), {
|
|
addSuffix: true,
|
|
locale: dateLocale,
|
|
})
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={cn(
|
|
'group relative flex cursor-pointer select-none items-stretch gap-0 rounded-xl transition-all duration-150',
|
|
'border',
|
|
selected
|
|
? 'border-primary/20 bg-primary/5 dark:bg-primary/10 shadow-sm'
|
|
: 'border-transparent hover:border-border/60 hover:bg-muted/50',
|
|
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30'
|
|
)}
|
|
onClick={onSelect}
|
|
role="option"
|
|
aria-selected={selected}
|
|
>
|
|
{/* Color accent bar */}
|
|
<div
|
|
className={cn(
|
|
'w-1 shrink-0 rounded-s-xl transition-all duration-200',
|
|
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
|
|
)}
|
|
/>
|
|
|
|
{/* Drag handle */}
|
|
<button
|
|
type="button"
|
|
className="flex cursor-grab items-center px-1.5 text-muted-foreground/30 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
|
|
aria-label={reorderLabel}
|
|
{...attributes}
|
|
{...listeners}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<GripVertical className="h-3.5 w-3.5" />
|
|
</button>
|
|
|
|
{/* Note type icon */}
|
|
<div className="flex items-center py-4 pe-1">
|
|
{note.type === 'checklist' ? (
|
|
<ListChecks
|
|
className={cn(
|
|
'h-4 w-4 shrink-0 transition-colors',
|
|
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
|
)}
|
|
/>
|
|
) : (
|
|
<FileText
|
|
className={cn(
|
|
'h-4 w-4 shrink-0 transition-colors',
|
|
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Text content */}
|
|
<div className="min-w-0 flex-1 py-3.5 pe-3">
|
|
<div className="flex items-center gap-2">
|
|
<p
|
|
className={cn(
|
|
'truncate text-sm font-medium transition-colors',
|
|
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
|
|
)}
|
|
>
|
|
{title}
|
|
</p>
|
|
{note.isPinned && (
|
|
<Pin className="h-3 w-3 shrink-0 fill-current text-primary" aria-label="Épinglée" />
|
|
)}
|
|
</div>
|
|
{snippet && (
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">{snippet}</p>
|
|
)}
|
|
<div className="mt-1.5 flex items-center gap-2">
|
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
|
|
<Clock className="h-2.5 w-2.5" />
|
|
{timeAgo}
|
|
</span>
|
|
{Array.isArray(note.labels) && note.labels.length > 0 && (
|
|
<>
|
|
<span className="text-muted-foreground/30">·</span>
|
|
<div className="flex items-center gap-1">
|
|
<Hash className="h-2.5 w-2.5 text-muted-foreground/40" />
|
|
<span className="truncate text-[11px] text-muted-foreground/50">
|
|
{note.labels.slice(0, 2).join(', ')}
|
|
{note.labels.length > 2 && ` +${note.labels.length - 2}`}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
|
|
export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) {
|
|
const { t, language } = useLanguage()
|
|
const [items, setItems] = useState<Note[]>(notes)
|
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
const [isCreating, startCreating] = useTransition()
|
|
|
|
useEffect(() => {
|
|
// Only reset when notes are added or removed, NOT on content/field changes
|
|
// Field changes arrive through onChange -> setItems already
|
|
setItems((prev) => {
|
|
const prevIds = prev.map((n) => n.id).join(',')
|
|
const incomingIds = notes.map((n) => n.id).join(',')
|
|
if (prevIds === incomingIds) {
|
|
// Same set of notes: merge only structural fields (pin, color, archive)
|
|
return prev.map((p) => {
|
|
const fresh = notes.find((n) => n.id === p.id)
|
|
if (!fresh) return p
|
|
return { ...fresh, title: p.title, content: p.content, labels: p.labels }
|
|
})
|
|
}
|
|
// Different set (add/remove): full sync
|
|
return notes
|
|
})
|
|
}, [notes])
|
|
|
|
useEffect(() => {
|
|
if (items.length === 0) {
|
|
setSelectedId(null)
|
|
return
|
|
}
|
|
setSelectedId((prev) =>
|
|
prev && items.some((n) => n.id === prev) ? prev : items[0].id
|
|
)
|
|
}, [items])
|
|
|
|
// Scroll to top of sidebar on note change handled by NoteInlineEditor internally
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
)
|
|
|
|
const handleDragEnd = useCallback(
|
|
async (event: DragEndEvent) => {
|
|
const { active, over } = event
|
|
if (!over || active.id === over.id) return
|
|
const oldIndex = items.findIndex((n) => n.id === active.id)
|
|
const newIndex = items.findIndex((n) => n.id === over.id)
|
|
if (oldIndex < 0 || newIndex < 0) return
|
|
const reordered = arrayMove(items, oldIndex, newIndex)
|
|
setItems(reordered)
|
|
try {
|
|
await updateFullOrderWithoutRevalidation(reordered.map((n) => n.id))
|
|
} catch {
|
|
setItems(notes)
|
|
toast.error(t('notes.moveFailed'))
|
|
}
|
|
},
|
|
[items, notes, t]
|
|
)
|
|
|
|
const selected = items.find((n) => n.id === selectedId) ?? null
|
|
const colorKey = selected ? getColorKey(selected) : 'default'
|
|
|
|
/** Create a new blank note, add it to the sidebar and select it immediately */
|
|
const handleCreateNote = () => {
|
|
startCreating(async () => {
|
|
try {
|
|
const newNote = await createNote({
|
|
content: '',
|
|
title: undefined,
|
|
notebookId: currentNotebookId || undefined,
|
|
skipRevalidation: true
|
|
})
|
|
if (!newNote) return
|
|
setItems((prev) => [newNote, ...prev])
|
|
setSelectedId(newNote.id)
|
|
} catch {
|
|
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
|
|
}
|
|
})
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div
|
|
className="flex min-h-[240px] flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-muted/20 px-6 py-12 text-center"
|
|
data-testid="notes-grid-tabs-empty"
|
|
>
|
|
<p className="max-w-md text-sm text-muted-foreground">{t('notes.emptyStateTabs')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-2xl border border-border/60 shadow-sm"
|
|
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
|
|
data-testid="notes-grid-tabs"
|
|
>
|
|
{/* ── Left sidebar: note list ── */}
|
|
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 bg-muted/20">
|
|
{/* Sidebar header with note count + new note button */}
|
|
<div className="border-b border-border/40 px-3 py-2.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
|
|
{t('notes.title')}
|
|
<span className="ms-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
|
{items.length}
|
|
</span>
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
|
onClick={handleCreateNote}
|
|
disabled={isCreating}
|
|
title={t('notes.newNote') || 'Nouvelle note'}
|
|
>
|
|
{isCreating
|
|
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
: <Plus className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrollable note list */}
|
|
<div
|
|
className="flex-1 overflow-y-auto overscroll-contain p-2"
|
|
role="listbox"
|
|
aria-label={t('notes.viewTabs')}
|
|
>
|
|
<DndContext
|
|
id="notes-tabs-dnd"
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={items.map((n) => n.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="flex flex-col gap-0.5">
|
|
{items.map((note) => (
|
|
<SortableNoteListItem
|
|
key={note.id}
|
|
note={note}
|
|
selected={note.id === selectedId}
|
|
onSelect={() => setSelectedId(note.id)}
|
|
reorderLabel={t('notes.reorderTabs')}
|
|
language={language}
|
|
untitledLabel={t('notes.untitled')}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Right content panel — always in edit mode ── */}
|
|
{selected ? (
|
|
<div
|
|
className={cn(
|
|
'flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
|
|
COLOR_PANEL_BG[colorKey]
|
|
)}
|
|
>
|
|
<NoteInlineEditor
|
|
key={selected.id}
|
|
note={selected}
|
|
colorKey={colorKey}
|
|
defaultPreviewMode={true}
|
|
onChange={(noteId, fields) => {
|
|
setItems((prev) =>
|
|
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
|
|
)
|
|
}}
|
|
onDelete={(noteId) => {
|
|
setItems((prev) => prev.filter((n) => n.id !== noteId))
|
|
setSelectedId((prev) => (prev === noteId ? null : prev))
|
|
}}
|
|
onArchive={(noteId) => {
|
|
setItems((prev) => prev.filter((n) => n.id !== noteId))
|
|
setSelectedId((prev) => (prev === noteId ? null : prev))
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-1 items-center justify-center text-muted-foreground/40">
|
|
<p className="text-sm">{t('notes.selectNote') || 'Sélectionnez une note'}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|