feat: smart note history with manual/auto modes, delete entries, i18n fixes
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m16s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m16s
- Add noteHistoryMode setting (manual default / auto) with DB migration - Manual mode: commit button in editor toolbar creates snapshots on demand - Auto mode: smart snapshots with 20-char diff threshold + 5min cooldown, structural changes (color, pin, archive, labels) bypass cooldown - Add delete individual history entries from history modal - Fix sidebar: Notes nav no longer active on notebook pages - Fix sidebar icon: replace filled Lightbulb with outlined FileText - Fix title suggestions: change from amber to sky blue color scheme - Fix hydration mismatch: add suppressHydrationWarning on locale dates - Complete i18n: add history, sort, and AI chat translations for all 16 languages - Translate French AI assistant section (40+ keys) from English to French - Update README with new features and stack info Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState, useTransition } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'
|
||||
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
||||
import {
|
||||
DndContext,
|
||||
@@ -24,17 +24,32 @@ 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 } from '@/app/actions/notes'
|
||||
import {
|
||||
updateFullOrderWithoutRevalidation,
|
||||
createNote,
|
||||
deleteNote,
|
||||
updateNote,
|
||||
toggleArchive,
|
||||
} from '@/app/actions/notes'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import {
|
||||
GripVertical,
|
||||
Hash,
|
||||
ListChecks,
|
||||
Pin,
|
||||
PinOff,
|
||||
FileText,
|
||||
Clock,
|
||||
Plus,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ListFilter,
|
||||
FolderInput,
|
||||
Archive,
|
||||
Share2,
|
||||
Check,
|
||||
Hash,
|
||||
History,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -45,8 +60,22 @@ import {
|
||||
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 { formatDistanceToNow } from 'date-fns'
|
||||
import { format, type Locale } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
|
||||
@@ -54,8 +83,14 @@ interface NotesTabsViewProps {
|
||||
notes: Note[]
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
currentNotebookId?: string | null
|
||||
noteHistoryEnabled?: boolean
|
||||
noteHistoryMode?: 'manual' | 'auto'
|
||||
onOpenHistory?: (note: Note) => void
|
||||
onEnableHistory?: () => 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',
|
||||
@@ -104,9 +139,19 @@ function getColorKey(note: Note): NoteColor {
|
||||
}
|
||||
|
||||
function getDateLocale(language: string) {
|
||||
if (language === 'fr') return fr;
|
||||
if (language === 'fa') return require('date-fns/locale').faIR;
|
||||
return enUS;
|
||||
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 ───────────────────────────────────────────────────────
|
||||
@@ -144,161 +189,445 @@ function SortableNoteListItem({
|
||||
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)
|
||||
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 200)
|
||||
: (note.content || '').substring(0, 200)
|
||||
|
||||
const dateLocale = getDateLocale(language)
|
||||
const timeAgo = formatDistanceToNow(new Date(note.updatedAt), {
|
||||
addSuffix: true,
|
||||
locale: dateLocale,
|
||||
})
|
||||
const dateStr = formatNoteDate(note.updatedAt, dateLocale)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'group relative flex cursor-pointer select-none items-stretch gap-0 transition-all duration-150',
|
||||
'border-b border-border/40 last:border-b-0',
|
||||
'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/5 dark:bg-primary/10 shadow-sm'
|
||||
: 'hover:bg-muted/50',
|
||||
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30 rounded-lg'
|
||||
? '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}
|
||||
>
|
||||
{/* Color accent bar */}
|
||||
{/* Left accent bar — solid when selected, transparent otherwise */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-1 shrink-0 transition-all duration-200',
|
||||
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
|
||||
'w-[3px] shrink-0 rounded-r-full transition-all duration-200',
|
||||
selected ? COLOR_ACCENT[ck] : 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
{/* Main card content */}
|
||||
<div className="min-w-0 flex-1 px-4 py-4">
|
||||
|
||||
{/* 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'
|
||||
{/* 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'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FileText
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 transition-colors',
|
||||
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||
{note.isPinned && (
|
||||
<Pin className="h-3 w-3 shrink-0 fill-current text-primary/70" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text content */}
|
||||
<div className="min-w-0 flex-1 py-3.5 pe-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
</div>
|
||||
<span
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
'truncate text-base font-heading font-medium transition-colors',
|
||||
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
|
||||
'shrink-0 text-[11px] tabular-nums',
|
||||
selected ? 'text-muted-foreground' : 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
{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,
|
||||
noteHistoryEnabled = false,
|
||||
onOpenHistory,
|
||||
onEnableHistory,
|
||||
}: {
|
||||
note: Note
|
||||
onPinToggle: (note: Note) => void
|
||||
onArchive: (note: Note) => void
|
||||
noteHistoryEnabled?: boolean
|
||||
onOpenHistory?: (note: Note) => void
|
||||
onEnableHistory?: () => 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 (!noteHistoryEnabled) {
|
||||
if (!onEnableHistory) {
|
||||
toast.info(ts('notes.historyDisabledDesc', "L'historique est désactivé pour votre compte."))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await onEnableHistory()
|
||||
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 && (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* Delete button - visible on hover */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="flex items-center px-2 text-red-500/60 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</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={
|
||||
noteHistoryEnabled
|
||||
? ts('notes.history', 'Historique')
|
||||
: ts('notes.enableHistory', "Activer l'historique")
|
||||
}
|
||||
onClick={() => void handleHistory()}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) {
|
||||
export function NotesTabsView({
|
||||
notes,
|
||||
onEdit,
|
||||
currentNotebookId,
|
||||
noteHistoryEnabled = false,
|
||||
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(() => {
|
||||
// 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
|
||||
// Use fresh labels from server if they've changed (e.g., global label deletion)
|
||||
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
|
||||
return {
|
||||
...fresh,
|
||||
title: p.title,
|
||||
content: p.content,
|
||||
checkItems: p.checkItems,
|
||||
isMarkdown: p.isMarkdown,
|
||||
// Always use server labels if different (for global label changes)
|
||||
labels: labelsChanged ? fresh.labels : p.labels
|
||||
}
|
||||
})
|
||||
}
|
||||
// Different set (add/remove) or reordered from server: full sync
|
||||
// CRITICAL: We MUST preserve local text edits so inline editor state isn't lost
|
||||
return notes.map((fresh) => {
|
||||
const local = prev.find((p) => p.id === fresh.id)
|
||||
if (!local) return fresh
|
||||
@@ -308,7 +637,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
title: local.title,
|
||||
content: local.content,
|
||||
checkItems: local.checkItems,
|
||||
isMarkdown: local.isMarkdown,
|
||||
labels: labelsChanged ? fresh.labels : local.labels
|
||||
}
|
||||
})
|
||||
@@ -325,7 +653,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
)
|
||||
}, [items])
|
||||
|
||||
// Listen for global label deletion and immediately update local state
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { name } = (e as CustomEvent).detail
|
||||
@@ -343,7 +670,22 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
return () => window.removeEventListener('label-deleted', handler)
|
||||
}, [])
|
||||
|
||||
// Scroll to top of sidebar on note change handled by NoteInlineEditor internally
|
||||
// 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 } }),
|
||||
@@ -372,15 +714,14 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
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: '',
|
||||
const newNote = await createNote({
|
||||
content: '',
|
||||
title: undefined,
|
||||
notebookId: currentNotebookId || undefined,
|
||||
skipRevalidation: true
|
||||
skipRevalidation: true
|
||||
})
|
||||
if (!newNote) return
|
||||
setItems((prev) => {
|
||||
@@ -396,52 +737,116 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
})
|
||||
}
|
||||
|
||||
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-2xl border border-border/60 shadow-sm"
|
||||
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 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">
|
||||
{/* ── 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 className="ms-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||
{items.length}
|
||||
</span>
|
||||
</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 hover:text-foreground"
|
||||
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') }
|
||||
title={t('notes.newNote')}
|
||||
>
|
||||
{isCreating
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Plus className="h-3.5 w-3.5" />}
|
||||
: <Plus className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable note list */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overscroll-contain p-2"
|
||||
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 py-12 px-4 text-center">
|
||||
<div className="mb-3 rounded-full bg-background p-3 shadow-sm border border-border/50">
|
||||
<FileText className="h-5 w-5 text-muted-foreground/40" />
|
||||
<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
|
||||
@@ -454,8 +859,8 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
items={items.map((n) => n.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{items.map((note) => (
|
||||
<div className="flex flex-col">
|
||||
{sortedItems.map((note) => (
|
||||
<SortableNoteListItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
@@ -475,42 +880,74 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right content panel — always in edit mode ── */}
|
||||
{/* ── Right content panel ── */}
|
||||
{selected ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
|
||||
COLOR_PANEL_BG[colorKey]
|
||||
<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}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
noteHistoryMode={noteHistoryMode}
|
||||
onOpenHistory={onOpenHistory}
|
||||
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}
|
||||
noteHistoryEnabled={noteHistoryEnabled}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onEnableHistory={onEnableHistory}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<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 min-w-0 flex-1 items-center justify-center bg-muted/10 border-l border-border/40">
|
||||
<div className="text-center px-6">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-background shadow-sm border border-border/50">
|
||||
<FileText className="h-8 w-8 text-muted-foreground/30" />
|
||||
<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>
|
||||
<h3 className="text-lg font-heading font-medium text-foreground">{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
|
||||
<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')}
|
||||
@@ -528,7 +965,7 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
{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'))}"
|
||||
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
@@ -561,4 +998,3 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user