All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m11s
Chat (AIChat floating widget): conversationId was never captured from the API response, so every message created a new conversation with no context. Now creates the conversation upfront before streaming (same pattern as ChatContainer) so the ID persists across messages. Note history: was stored globally in UserAISettings, so enabling history on one note enabled it for ALL notes. Now each Note has its own historyEnabled boolean field. The "Enable history" action only affects the specific note. A migration adds the column with default false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
997 lines
34 KiB
TypeScript
997 lines
34 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useState, useTransition } 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 } 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,
|
|
} from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
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>
|
|
}
|
|
|
|
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
|
|
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 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,
|
|
}: {
|
|
note: Note
|
|
onPinToggle: (note: Note) => void
|
|
onArchive: (note: Note) => void
|
|
onOpenHistory?: (note: Note) => void
|
|
onEnableHistory?: (noteId: string) => Promise<void>
|
|
}) {
|
|
const { t } = useLanguage()
|
|
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
|
const [moveOpen, setMoveOpen] = useState(false)
|
|
const [isMoving, setIsMoving] = 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)}
|
|
disabled
|
|
/>
|
|
|
|
{/* Archive */}
|
|
<SidebarActionBtn
|
|
icon={<Archive className="h-3.5 w-3.5" />}
|
|
label={t('notes.archive')}
|
|
onClick={() => onArchive(note)}
|
|
/>
|
|
|
|
{/* 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,
|
|
}: 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)
|
|
|
|
useEffect(() => {
|
|
setItems((prev) => {
|
|
const prevIds = prev.map((n) => n.id).join(',')
|
|
const incomingIds = notes.map((n) => n.id).join(',')
|
|
if (prevIds === incomingIds) {
|
|
return prev.map((p) => {
|
|
const fresh = notes.find((n) => n.id === p.id)
|
|
if (!fresh) return p
|
|
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
|
|
return {
|
|
...fresh,
|
|
title: p.title,
|
|
content: p.content,
|
|
checkItems: p.checkItems,
|
|
labels: labelsChanged ? fresh.labels : p.labels
|
|
}
|
|
})
|
|
}
|
|
return notes.map((fresh) => {
|
|
const local = prev.find((p) => p.id === fresh.id)
|
|
if (!local) return fresh
|
|
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort())
|
|
return {
|
|
...fresh,
|
|
title: local.title,
|
|
content: local.content,
|
|
checkItems: local.checkItems,
|
|
labels: labelsChanged ? fresh.labels : local.labels
|
|
}
|
|
})
|
|
})
|
|
}, [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 (does NOT affect persisted order)
|
|
const sortedItems = useMemo(() => {
|
|
if (sortOrder === 'date-desc') return [...items]
|
|
return [...items].sort((a, b) => {
|
|
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
|
|
})
|
|
}, [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 = () => {
|
|
startCreating(async () => {
|
|
try {
|
|
const newNote = await createNote({
|
|
content: '',
|
|
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)
|
|
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 })
|
|
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 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
|
|
onClick={handleCreateNote}
|
|
disabled={isCreating}
|
|
title={t('notes.newNote')}
|
|
>
|
|
{isCreating
|
|
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
: <Plus className="h-4 w-4" />}
|
|
</Button>
|
|
</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}
|
|
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}
|
|
/>
|
|
)}
|
|
</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">
|
|
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
|
|
</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>
|
|
)
|
|
}
|