Files
Momento/memento-note/components/notes-tabs-view.tsx
sepehr f93752de14
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
fix: dynamic note restore without page reload + fix note list sync bugs
- NoteHistoryModal: remove window.location.reload(), use onRestored(restored) callback
- NotesTabsView: revert sync derivation to useEffect, add prevNotesRef to detect
  server-side content changes (restore) vs local edits — fixes note disappear bug
  and cross-notebook notes appearing after refresh
- NoteInlineEditor key: include updatedAt so restoration remounts editor with fresh content
- note-card: render title/content/labels from note prop directly, not optimisticNote
2026-05-02 20:18:18 +02:00

1075 lines
38 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useState, useTransition, useRef } from 'react'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
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, NoteType } 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,
deleteNote,
updateNote,
toggleArchive,
} from '@/app/actions/notes'
import { useNotebooks } from '@/context/notebooks-context'
import {
GripVertical,
ListChecks,
Pin,
PinOff,
FileText,
Plus,
Loader2,
Trash2,
ListFilter,
FolderInput,
Archive,
Share2,
Check,
Hash,
History,
PanelRightClose,
PanelRightOpen,
Bell,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ReminderDialog } from '@/components/reminder-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { toast } from 'sonner'
import { format, type Locale } 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
noteHistoryMode?: 'manual' | 'auto'
onOpenHistory?: (note: Note) => void
onEnableHistory?: (noteId: string) => Promise<void>
onNoteCreated?: (note: Note) => void
}
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
// 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
}
function formatNoteDate(date: Date | string, locale: Locale): string {
const d = typeof date === 'string' ? new Date(date) : date
return format(d, 'd MMM yyyy', { locale })
}
function countWords(content: string | null | undefined): number {
if (!content) return 0
return content.trim().split(/\s+/).filter(Boolean).length
}
// ─── Sortable List Item ───────────────────────────────────────────────────────
function SortableNoteListItem({
note,
selected,
onSelect,
onDelete,
reorderLabel,
deleteLabel,
language,
untitledLabel,
}: {
note: Note
selected: boolean
onSelect: () => void
onDelete: () => void
reorderLabel: string
deleteLabel: 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, 200)
: (note.content || '').substring(0, 200)
const dateLocale = getDateLocale(language)
const dateStr = formatNoteDate(note.updatedAt, dateLocale)
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex cursor-pointer select-none items-stretch transition-all duration-150',
'border-b border-border/50 last:border-b-0',
selected
? 'bg-primary/[0.06] dark:bg-primary/10'
: 'bg-background hover:bg-muted/40 dark:hover:bg-muted/20',
isDragging && 'opacity-75 shadow-lg ring-1 ring-primary/20'
)}
onClick={onSelect}
role="option"
aria-selected={selected}
>
{/* Left accent bar — solid when selected, transparent otherwise */}
<div
className={cn(
'w-[3px] shrink-0 rounded-r-full transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent'
)}
/>
{/* Main card content */}
<div className="min-w-0 flex-1 px-4 py-4">
{/* Row 1: type icon + date */}
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-3.5 w-3.5 shrink-0',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
)}
/>
) : (
<FileText
className={cn(
'h-3.5 w-3.5 shrink-0',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
)}
/>
)}
{note.isPinned && (
<Pin className="h-3 w-3 shrink-0 fill-current text-primary/70" />
)}
</div>
<span
suppressHydrationWarning
className={cn(
'shrink-0 text-[11px] tabular-nums',
selected ? 'text-muted-foreground' : 'text-muted-foreground/60'
)}
>
{dateStr}
</span>
</div>
{/* Row 2: title */}
<p
dir="auto"
className={cn(
'mb-1.5 text-[13.5px] leading-snug transition-colors',
selected
? 'font-semibold text-foreground'
: 'font-medium text-foreground/85 group-hover:text-foreground'
)}
>
{title}
</p>
{/* Row 3: snippet */}
{snippet && (
<p dir="auto" className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
{snippet}
</p>
)}
{/* Row 4: label chips */}
{Array.isArray(note.labels) && note.labels.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{note.labels.slice(0, 3).map((label) => (
<span
key={label}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium leading-none transition-colors',
selected
? 'border-primary/25 text-primary/70'
: 'border-border text-muted-foreground/65 group-hover:border-border/80'
)}
>
<span className="h-1 w-1 rounded-full bg-current opacity-60" />
{label}
</span>
))}
{note.labels.length > 3 && (
<span className="inline-flex items-center rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground/50">
+{note.labels.length - 3}
</span>
)}
</div>
)}
</div>
{/* Actions column: drag + delete on hover */}
<div className="flex flex-col items-center justify-between py-3 pe-2 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="cursor-grab p-1 text-muted-foreground/30 active:cursor-grabbing"
aria-label={reorderLabel}
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="p-1 text-muted-foreground/40 hover:text-destructive"
aria-label={deleteLabel}
title={deleteLabel}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
// ─── Note Meta Sidebar ────────────────────────────────────────────────────────
function SidebarSection({ title }: { title: string }) {
return (
<div className="mb-3 flex items-center gap-2">
<p className="text-[10px] font-black uppercase tracking-[0.12em] text-muted-foreground whitespace-nowrap">
{title}
</p>
<div className="flex-1 h-px bg-border/60" />
</div>
)
}
function SidebarActionBtn({
icon,
label,
onClick,
disabled = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all duration-150",
disabled
? "cursor-not-allowed text-muted-foreground/60 opacity-70"
: "text-foreground/70 hover:bg-sky-50 hover:text-sky-700"
)}
>
<span
className={cn(
"transition-colors",
disabled ? "text-muted-foreground/60" : "text-muted-foreground group-hover:text-sky-600"
)}
>
{icon}
</span>
{label}
</button>
)
}
function NoteMetaSidebar({
note,
onPinToggle,
onArchive,
onOpenHistory,
onEnableHistory,
onUpdateReminder,
}: {
note: Note
onPinToggle: (note: Note) => void
onArchive: (note: Note) => void
onOpenHistory?: (note: Note) => void
onEnableHistory?: (noteId: string) => Promise<void>
onUpdateReminder?: (noteId: string, reminder: Date | null) => void
}) {
const { t } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const [moveOpen, setMoveOpen] = useState(false)
const [isMoving, setIsMoving] = useState(false)
const [showReminder, setShowReminder] = useState(false)
// t() returns the key itself when not found — use this wrapper for safe fallbacks
const ts = (key: string, fallback: string) => {
const v = t(key as Parameters<typeof t>[0])
return v === key ? fallback : v
}
const wordCount = countWords(note.content)
const noteTypeLabel =
note.type === 'checklist'
? ts('notes.typeChecklist', 'Checklist')
: note.isMarkdown
? ts('notes.typeMarkdown', 'Markdown')
: ts('notes.typeText', 'Text')
const handleMoveToNotebook = async (notebookId: string | null) => {
setIsMoving(true)
try {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setMoveOpen(false)
toast.success(ts('notebookSuggestion.movedToNotebook', 'Note déplacée'))
} catch {
toast.error(ts('notes.moveFailed', 'Déplacement échoué'))
} finally {
setIsMoving(false)
}
}
const handleHistory = async () => {
if (!note.historyEnabled) {
if (!onEnableHistory) {
toast.info(ts('notes.historyDisabledDesc', "L'historique est désactivé pour cette note."))
return
}
try {
await onEnableHistory(note.id)
toast.success(ts('notes.historyEnabled', 'Historique activé'))
onOpenHistory?.(note)
} catch {
toast.error(ts('general.error', 'Erreur'))
}
return
}
onOpenHistory?.(note)
}
return (
<aside className="flex w-56 shrink-0 flex-col bg-muted border-l border-slate-300 border-t-2 border-t-primary/40 overflow-y-auto shadow-[-6px_0_16px_-4px_rgba(0,0,0,0.08)]">
{/* ── DOCUMENT INFO ── */}
<div className="px-4 pt-5 pb-4 border-b border-border">
<SidebarSection title={ts('notes.documentInfo', 'Document Info')} />
<div className="space-y-3">
{/* Type */}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.type', 'Type')}</p>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
note.type === 'checklist'
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: note.isMarkdown
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-card border-slate-200 text-slate-600"
)}
>
{note.type === 'checklist'
? <ListChecks className="h-3 w-3" />
: <FileText className="h-3 w-3" />}
{noteTypeLabel}
</span>
</div>
{/* Word count — discreet inline row */}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.wordCount', 'Mots')}</p>
<p className="text-[13px] font-semibold text-foreground/70 tabular-nums">
{wordCount.toLocaleString()} <span className="text-[10px] font-normal text-muted-foreground">{ts('notes.words', 'mots')}</span>
</p>
</div>
{/* Labels */}
{Array.isArray(note.labels) && note.labels.length > 0 && (
<div>
<p className="mb-1.5 text-[11px] font-semibold text-muted-foreground">{ts('notes.labels', 'Labels')}</p>
<div className="flex flex-wrap gap-1">
{note.labels.map((label) => (
<span
key={label}
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card px-2 py-0.5 text-[11px] font-medium text-foreground/70"
>
<Hash className="h-2.5 w-2.5" />
{label}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* ── ACTIONS ── */}
<div className="px-4 pt-4 pb-5 flex-1">
<SidebarSection title={ts('notes.actions', 'Actions')} />
<div className="space-y-0.5">
{/* Move to notebook */}
<Popover open={moveOpen} onOpenChange={setMoveOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium text-foreground/70 hover:bg-sky-50 hover:text-sky-700 transition-all duration-150"
>
<span className="text-muted-foreground group-hover:text-sky-600 transition-colors">
{isMoving
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <FolderInput className="h-3.5 w-3.5" />}
</span>
{t('notebookSuggestion.moveToNotebook')}
</button>
</PopoverTrigger>
<PopoverContent side="left" align="start" className="w-52 p-1.5">
<div className="mb-1 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
{t('notebookSuggestion.moveToNotebook')}
</div>
<button
type="button"
onClick={() => handleMoveToNotebook(null)}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
!note.notebookId ? 'text-primary' : 'text-foreground/70'
)}
>
{!note.notebookId
? <Check className="h-3 w-3 shrink-0" />
: <span className="h-3 w-3 shrink-0" />}
{t('notes.generalNotes')}
</button>
{notebooks.map((nb) => (
<button
key={nb.id}
type="button"
onClick={() => handleMoveToNotebook(nb.id)}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
note.notebookId === nb.id ? 'text-primary' : 'text-foreground/70'
)}
>
{note.notebookId === nb.id
? <Check className="h-3 w-3 shrink-0" />
: <span className="h-3 w-3 shrink-0" />}
{nb.name}
</button>
))}
</PopoverContent>
</Popover>
{/* Pin / Unpin */}
<SidebarActionBtn
icon={note.isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
label={note.isPinned ? t('notes.unpin') : t('notes.pin')}
onClick={() => onPinToggle(note)}
/>
{/* Archive */}
<SidebarActionBtn
icon={<Archive className="h-3.5 w-3.5" />}
label={t('notes.archive')}
onClick={() => onArchive(note)}
/>
{/* Reminder */}
{onUpdateReminder && (
<>
<SidebarActionBtn
icon={<Bell className={cn("h-3.5 w-3.5", note.reminder && "text-primary")} />}
label={t('reminder.setReminder')}
onClick={() => setShowReminder(true)}
/>
<div onClick={(e) => e.stopPropagation()}>
<ReminderDialog
open={showReminder}
onOpenChange={setShowReminder}
currentReminder={note.reminder ? new Date(note.reminder) : null}
onSave={(date) => {
onUpdateReminder(note.id, date)
setShowReminder(false)
}}
onRemove={() => {
onUpdateReminder(note.id, null)
setShowReminder(false)
}}
/>
</div>
</>
)}
{/* History */}
<SidebarActionBtn
icon={<History className="h-3.5 w-3.5" />}
label={
note.historyEnabled
? ts('notes.history', 'Historique')
: ts('notes.enableHistory', "Activer l'historique")
}
onClick={() => void handleHistory()}
/>
</div>
</div>
</aside>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function NotesTabsView({
notes,
onEdit,
currentNotebookId,
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
onNoteCreated,
}: NotesTabsViewProps) {
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefreshOptional()
const [items, setItems] = useState<Note[]>(notes)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isCreating, startCreating] = useTransition()
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>('date-desc')
const [sidebarOpen, setSidebarOpen] = useState(true)
const prevNotesRef = useRef<Note[]>(notes)
useEffect(() => {
setItems((prev) => {
const prevIds = prev.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',')
const merge = (fresh: Note, local: Note) => {
// Detect if the server explicitly changed content since last sync
const prevServer = prevNotesRef.current.find((n) => n.id === fresh.id)
const serverContentChanged = prevServer ? prevServer.content !== fresh.content : false
const serverTitleChanged = prevServer ? prevServer.title !== fresh.title : false
const serverCheckItemsChanged = prevServer
? JSON.stringify(prevServer.checkItems) !== JSON.stringify(fresh.checkItems)
: false
const labelsChanged =
JSON.stringify(fresh.labels?.slice().sort()) !==
JSON.stringify(local.labels?.slice().sort())
return {
...fresh,
title: serverTitleChanged ? fresh.title : local.title || fresh.title,
content: serverContentChanged ? fresh.content : local.content,
checkItems: serverCheckItemsChanged ? fresh.checkItems : local.checkItems,
labels: labelsChanged ? fresh.labels : local.labels,
}
}
let result: Note[]
if (prevIds === incomingIds) {
result = prev.map((local) => {
const fresh = notes.find((n) => n.id === local.id)
return fresh ? merge(fresh, local) : local
})
} else {
result = notes.map((fresh) => {
const local = prev.find((p) => p.id === fresh.id)
return local ? merge(fresh, local) : fresh
})
}
return result
})
prevNotesRef.current = notes
}, [notes])
useEffect(() => {
if (items.length === 0) {
setSelectedId(null)
return
}
setSelectedId((prev) =>
prev && items.some((n) => n.id === prev) ? prev : items[0].id
)
}, [items])
useEffect(() => {
const handler = (e: Event) => {
const { name } = (e as CustomEvent).detail
if (!name) return
setItems((prev) =>
prev.map((note) => {
const currentLabels = note.labels || []
const updated = currentLabels.filter((l) => l.toLowerCase() !== name.toLowerCase())
if (updated.length === currentLabels.length) return note
return { ...note, labels: updated.length > 0 ? updated : null }
})
)
}
window.addEventListener('label-deleted', handler)
return () => window.removeEventListener('label-deleted', handler)
}, [])
// Sorted display items — pinned notes always float to the top
const sortedItems = useMemo(() => {
const pinned = items.filter(n => n.isPinned)
const unpinned = items.filter(n => !n.isPinned)
const sortFn = (a: Note, b: Note) => {
if (sortOrder === 'date-desc') return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
if (sortOrder === 'date-asc') return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
if (sortOrder === 'title-asc') return (a.title || '').localeCompare(b.title || '')
if (sortOrder === 'title-desc') return (b.title || '').localeCompare(a.title || '')
return 0
}
return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)]
}, [items, sortOrder])
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'
const handleCreateNote = (noteType: NoteType = 'richtext') => {
startCreating(async () => {
try {
const newNote = await createNote({
content: noteType === 'checklist' ? '' : '',
type: noteType,
checkItems: noteType === 'checklist' ? [{ id: Date.now().toString(), text: '', checked: false }] : undefined,
title: undefined,
notebookId: currentNotebookId || undefined,
skipRevalidation: true
})
if (!newNote) return
setItems((prev) => {
const pinned = prev.filter(n => n.isPinned)
const unpinned = prev.filter(n => !n.isPinned)
return [...pinned, newNote, ...unpinned]
})
setSelectedId(newNote.id)
onNoteCreated?.(newNote)
triggerRefresh()
} catch {
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
}
})
}
const handlePinToggle = async (note: Note) => {
const next = !note.isPinned
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
try {
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
triggerRefresh()
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
} catch {
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))
toast.error(t('notes.updateFailed') || 'Mise à jour échouée')
}
}
const handleArchive = async (note: Note) => {
try {
await toggleArchive(note.id, true)
setItems((prev) => prev.filter((n) => n.id !== note.id))
setSelectedId((prev) => (prev === note.id ? null : prev))
triggerRefresh()
toast.success(t('notes.archived') || 'Note archivée')
} catch {
toast.error(t('notes.archiveFailed') || 'Archivage échoué')
}
}
return (
<div
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-xl border border-border/70 shadow-sm"
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
data-testid="notes-grid-tabs"
>
{/* ── Left panel: note list ── */}
<div className="flex w-80 shrink-0 flex-col border-r border-border/60 bg-background">
{/* Header */}
<div className="flex items-center justify-between border-b border-border/60 bg-background/95 px-4 py-3.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold tracking-tight text-foreground">
{t('notes.title')}
</span>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
{items.length}
</span>
</div>
<div className="flex items-center gap-1">
{/* Sort / filter button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary',
sortOrder !== 'date-desc' && 'text-primary bg-primary/8'
)}
title={t('notes.sort') || 'Trier'}
>
<ListFilter className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60 py-1">
{t('notes.sortBy') || 'Trier par'}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={sortOrder} onValueChange={(v) => setSortOrder(v as SortOrder)}>
<DropdownMenuRadioItem value="date-desc" className="text-[13px]">
{t('notes.sortDateDesc') || 'Date (récent)'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="date-asc" className="text-[13px]">
{t('notes.sortDateAsc') || 'Date (ancien)'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-asc" className="text-[13px]">
{t('notes.sortTitleAsc') || 'Titre A → Z'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-desc" className="text-[13px]">
{t('notes.sortTitleDesc') || 'Titre Z → A'}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* New note button — dropdown to choose type */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
disabled={isCreating}
title={t('notes.newNote')}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem onClick={() => handleCreateNote('richtext')}>
<FileText className="h-4 w-4 mr-2" />
{t('notes.newNote') || 'Note'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCreateNote('checklist')}>
<ListChecks className="h-4 w-4 mr-2" />
{t('notes.newChecklist') || 'Checklist'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Scrollable note list */}
<div
className="flex-1 overflow-y-auto overscroll-contain bg-background"
role="listbox"
aria-label={t('notes.viewTabs')}
>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="mb-4 rounded-2xl border border-border/60 bg-muted/30 p-4">
<FileText className="h-6 w-6 text-muted-foreground/40" />
</div>
<p className="text-sm font-medium text-muted-foreground">{t('notes.emptyStateTabs') || 'Aucune note'}</p>
<p className="mt-1 text-xs text-muted-foreground/60">{t('notes.createFirst') || 'Créez votre première note'}</p>
</div>
) : (
<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">
{sortedItems.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
selected={note.id === selectedId}
onSelect={() => setSelectedId(note.id)}
onDelete={() => setNoteToDelete(note)}
reorderLabel={t('notes.reorderTabs')}
deleteLabel={t('notes.delete')}
language={language}
untitledLabel={t('notes.untitled')}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
</div>
{/* ── Right content panel ── */}
{selected ? (
<div className="flex min-w-0 flex-1 overflow-hidden">
{/* Editor */}
<div
className={cn(
'relative flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-b',
COLOR_PANEL_BG[colorKey]
)}
>
<NoteInlineEditor
key={`${selected.id}-${String(selected.updatedAt)}`}
note={selected}
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
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))
triggerRefresh()
}}
/>
{/* Toggle sidebar button — top-right of editor, always visible */}
<button
type="button"
onClick={() => setSidebarOpen((v) => !v)}
title={sidebarOpen ? 'Masquer le panneau' : 'Afficher le panneau'}
className="absolute top-3 right-3 z-20 flex h-7 w-7 items-center justify-center rounded-md border border-border/70 bg-background/90 backdrop-blur-sm shadow-sm text-muted-foreground hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
>
{sidebarOpen
? <PanelRightClose className="h-3.5 w-3.5" />
: <PanelRightOpen className="h-3.5 w-3.5" />}
</button>
</div>
{/* Meta sidebar — collapsible */}
{sidebarOpen && (
<NoteMetaSidebar
note={selected}
onPinToggle={handlePinToggle}
onArchive={handleArchive}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
onUpdateReminder={async (noteId, reminder) => {
try {
await updateNote(noteId, { reminder })
setItems((prev) =>
prev.map((n) => (n.id === noteId ? { ...n, reminder } : n))
)
if (reminder) {
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
} else {
toast.info(t('reminder.removeReminder'))
}
triggerRefresh()
} catch {
toast.error(t('general.error'))
}
}}
/>
)}
</div>
) : (
<div className="flex min-w-0 flex-1 items-center justify-center bg-muted/10">
<div className="px-10 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-border/60 bg-background shadow-sm">
<FileText className="h-7 w-7 text-muted-foreground/30" />
</div>
<p className="text-sm font-medium text-foreground/60">
{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}
</p>
<p className="mt-1.5 text-xs text-muted-foreground/50 max-w-[200px] mx-auto leading-relaxed">
{items.length === 0
? t('notes.emptyNotebookDesc')
: t('notes.selectOrCreateNote')}
</p>
</div>
</div>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={!!noteToDelete} onOpenChange={() => setNoteToDelete(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</DialogTitle>
<DialogDescription>
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
{noteToDelete && (
<span className="mt-2 block font-medium text-foreground">
&quot;{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}&quot;
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setNoteToDelete(null)}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
if (!noteToDelete) return
try {
await deleteNote(noteToDelete.id, { skipRevalidation: true })
setItems((prev) => prev.filter((n) => n.id !== noteToDelete.id))
setSelectedId((prev) => (prev === noteToDelete.id ? null : prev))
setNoteToDelete(null)
triggerRefresh()
toast.success(t('notes.deleted'))
} catch {
toast.error(t('notes.deleteFailed'))
}
}}
>
{t('notes.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}