feat: note history modal with restore, diff comparison, and dynamic UI updates
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m14s

- Complete rewrite of note-history-modal: version list with inline restore/delete,
  split diff view with synced scrolling, rich preview for markdown/richtext/checklist
- Fix restore not updating editor: use key={id-updatedAt} on NoteInlineEditor to
  force remount on restore, and add sync useEffect to reset local state on prop changes
- Add sync useEffect in NoteEditor for external note updates
- Add historyEnabled to NOTE_LIST_SELECT (no more 'Activer' on every modal open)
- Replace browser confirm() with shadcn AlertDialog for delete confirmation
- Add i18n keys: currentVersion, compareVersions, diffTitle, diffSelectHint, deleteVersionDesc
- Install diff + @types/diff for visual line diffing
- Fix MasonryGrid render-phase sync to preserve local sizes
- Update notes-tabs-view merge logic for restored note propagation
- Remove debug console.log from restoreNoteVersion
This commit is contained in:
2026-05-02 16:51:12 +02:00
parent bd4dd0e9eb
commit 3818eb8237
13 changed files with 1012 additions and 302 deletions

View File

@@ -156,6 +156,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
setPinnedNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
setEditingNote((prev) => (prev?.note.id === restored.id ? { ...prev, note: restored } : prev))
setHistoryNote((prev) => (prev?.id === restored.id ? { ...prev, ...restored } : prev))
}, [])
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {

View File

@@ -181,20 +181,14 @@ export function MasonryGrid({
// Local notes state for optimistic size/order updates
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
const prevNotesRef = useRef<Note[]>(notes);
useEffect(() => {
setLocalNotes(prev => {
const prevIds = prev.map(n => n.id).join(',')
const incomingIds = notes.map(n => n.id).join(',')
if (prevIds === incomingIds) {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
}
// Notes added/removed: full sync but preserve local sizes
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
}, [notes]);
if (notes !== prevNotesRef.current) {
const localSizeMap = new Map(localNotes.map(n => [n.id, n.size]));
const newLocalNotes = notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }));
setLocalNotes(newLocalNotes);
prevNotesRef.current = notes;
}
const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]);
const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]);

View File

@@ -535,7 +535,7 @@ export const NoteCard = memo(function NoteCard({
)}
{/* Fusion Badge */}
{optimisticNote.aiProvider === 'fusion' && (
{note.aiProvider === 'fusion' && optimisticNote.autoGenerated !== null && (
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative mb-2 w-fit">
<Link2 className="h-2.5 w-2.5" />
{t('memoryEcho.fused')}
@@ -550,28 +550,28 @@ export const NoteCard = memo(function NoteCard({
)}
{/* Title */}
{optimisticNote.title && (
{note.title && (
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight flex items-center gap-2">
{(() => {
const TypeIcon = NOTE_TYPE_ICONS[optimisticNote.type] || AlignLeft
const TypeIcon = NOTE_TYPE_ICONS[note.type] || AlignLeft
return <TypeIcon className="h-4 w-4 shrink-0 text-muted-foreground/50" />
})()}
<span className="min-w-0 truncate">{optimisticNote.title}</span>
<span className="min-w-0 truncate">{note.title}</span>
</h3>
)}
{/* Search Match Type Badge */}
{optimisticNote.matchType && (
{note.matchType && (
<Badge
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
variant={note.matchType === 'exact' ? 'default' : 'secondary'}
className={cn(
'mb-2 text-xs',
optimisticNote.matchType === 'exact'
note.matchType === 'exact'
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
)}
>
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
{t(`semanticSearch.${note.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
</Badge>
)}
@@ -597,12 +597,12 @@ export const NoteCard = memo(function NoteCard({
)}
{/* Images Component */}
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
<NoteImages images={note.images || []} title={note.title} />
{/* Link Previews */}
{Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
{Array.isArray(note.links) && note.links.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{optimisticNote.links.map((link, idx) => (
{note.links.map((link: any, idx: number) => (
<a
key={idx}
href={link.url}
@@ -627,26 +627,26 @@ export const NoteCard = memo(function NoteCard({
)}
{/* Content */}
{optimisticNote.type === 'checklist' ? (
{note.type === 'checklist' ? (
<NoteChecklist
items={localCheckItems || optimisticNote.checkItems || []}
items={localCheckItems || optimisticNote.checkItems || note.checkItems || []}
onToggleItem={handleCheckItem}
/>
) : optimisticNote.type === 'richtext' ? (
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: optimisticNote.content || '' }} />
) : note.type === 'richtext' ? (
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: note.content || '' }} />
) : (
<div className="text-sm text-foreground line-clamp-10">
<MarkdownContent
content={optimisticNote.content}
content={note.content}
className="prose-h1:text-xl prose-h1:font-semibold prose-h1:leading-snug prose-h1:mt-1 prose-h1:mb-2 prose-h2:text-lg prose-h2:font-medium prose-h3:text-base prose-p:text-sm prose-p:leading-relaxed"
/>
</div>
)}
{/* Labels - using shared LabelBadge component */}
{optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && (
{note.notebookId && Array.isArray(note.labels) && note.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{optimisticNote.labels.map((label) => (
{note.labels.map((label: string) => (
<LabelBadge key={label} label={label} />
))}
</div>
@@ -691,7 +691,7 @@ export const NoteCard = memo(function NoteCard({
onRestore={handleRestore}
onPermanentDelete={handlePermanentDelete}
onOpenHistory={() => onOpenHistory?.(note)}
historyEnabled={noteHistoryEnabled}
historyEnabled={!!note.historyEnabled}
noteId={note.id}
currentReminder={reminderDate}
onUpdateReminder={handleUpdateReminder}

View File

@@ -92,6 +92,24 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
const fileInputRef = useRef<HTMLInputElement>(null)
const prevNoteRef = useRef(note)
useEffect(() => {
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
setTitle(note.title || '')
setContent(note.content)
setCheckItems(note.checkItems || [])
setLabels(note.labels || [])
setImages(note.images || [])
setLinks(note.links || [])
setColor(note.color)
setSize(note.size || 'small')
setNoteType(note.type)
setShowMarkdownPreview(note.type === 'markdown')
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
}
prevNoteRef.current = note
}, [note])
// Update context notebookId when note changes
useEffect(() => {
setContextNotebookId(note.notebookId || null)

View File

@@ -1,21 +1,25 @@
'use client'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { History, Loader2, RotateCcw, Trash2, GitBranchPlus, Check } from 'lucide-react'
import * as Diff from 'diff'
import {
History, Loader2, RotateCcw, Trash2, GitBranchPlus, Check, GitCompare, X,
} from 'lucide-react'
import { toast } from 'sonner'
import { getNoteHistory, restoreNoteVersion, deleteNoteHistoryEntry } from '@/app/actions/notes'
import {
getNoteHistory, restoreNoteVersion, deleteNoteHistoryEntry,
} from '@/app/actions/notes'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { MarkdownContent } from '@/components/markdown-content'
import { useLanguage } from '@/lib/i18n'
import type { Note, NoteHistoryEntry } from '@/lib/types'
import { cn } from '@/lib/utils'
@@ -29,29 +33,147 @@ interface NoteHistoryModalProps {
onRestored: (note: Note) => void
}
type ViewMode = 'preview' | 'diff'
function getDateLocale(language: string) {
if (language === 'fr') return fr
return enUS
return language === 'fr' ? fr : enUS
}
function fmtDate(date: Date | string, language: string): string {
const d = typeof date === 'string' ? new Date(date) : date
return format(d, 'd MMM yyyy HH:mm', { locale: getDateLocale(language) })
}
function VersionPreview({ entry, language }: { entry: NoteHistoryEntry; language: string }) {
const isMd = entry.type === 'markdown' || entry.isMarkdown
const isCl = entry.type === 'checklist'
const isRt = entry.type === 'richtext'
return (
<div className="space-y-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
{language === 'fr' ? 'Titre' : 'Title'}
</p>
<p className="text-sm text-foreground font-medium">
{entry.title || (language === 'fr' ? 'Sans titre' : 'Untitled')}
</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
{language === 'fr' ? 'Contenu' : 'Content'}
</p>
<div className="rounded-md border border-border/70 bg-muted/30 p-3 text-sm text-foreground overflow-auto max-h-[48vh]">
{isCl && entry.checkItems ? (
<div className="space-y-1">
{entry.checkItems.map((item, i) => (
<div key={i} className="flex items-center gap-2">
<span className={cn(
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]',
item.checked ? 'border-primary bg-primary/20 text-primary' : 'border-border'
)}>{item.checked && '✓'}</span>
<span className={cn(item.checked && 'line-through text-muted-foreground')}>{item.text}</span>
</div>
))}
</div>
) : isMd ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<MarkdownContent content={entry.content || ''} />
</div>
) : isRt ? (
<div dangerouslySetInnerHTML={{ __html: entry.content || '' }} />
) : (
<pre className="whitespace-pre-wrap font-sans">{entry.content || ''}</pre>
)}
</div>
</div>
</div>
)
}
interface DiffLine { type: 'added' | 'removed' | 'unchanged'; value: string }
function computeLineDiff(oldText: string, newText: string) {
const changes = Diff.diffLines(oldText || '', newText || '')
const left: DiffLine[] = []
const right: DiffLine[] = []
for (const change of changes) {
const lines = change.value.replace(/\n$/, '').split('\n')
if (change.added) {
for (const line of lines) { right.push({ type: 'added', value: line }); left.push({ type: 'unchanged', value: '' }) }
} else if (change.removed) {
for (const line of lines) { left.push({ type: 'removed', value: line }); right.push({ type: 'unchanged', value: '' }) }
} else {
for (const line of lines) { left.push({ type: 'unchanged', value: line }); right.push({ type: 'unchanged', value: line }) }
}
}
return { left, right }
}
function DiffPanel({ lines, label, scrollRef, onScroll }: {
lines: DiffLine[]; label: string
scrollRef: React.RefObject<HTMLDivElement | null>
onScroll: () => void
}) {
return (
<div className="flex-1 min-w-0 flex flex-col">
<p className="text-[11px] font-semibold text-muted-foreground mb-1.5 shrink-0">{label}</p>
<div
ref={scrollRef}
onScroll={onScroll}
className="rounded-md border border-border/70 bg-muted/30 overflow-auto max-h-[48vh] text-xs font-mono leading-relaxed flex-1"
>
{lines.map((line, i) => (
<div
key={i}
className={cn(
'px-2.5 py-px min-h-[1.4em]',
line.type === 'added' && 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
line.type === 'removed' && 'bg-red-500/15 text-red-700 dark:text-red-300'
)}
>
{line.value || '\u00A0'}
</div>
))}
</div>
</div>
)
}
export function NoteHistoryModal({
open,
onOpenChange,
note,
enabled,
onEnableHistory,
onRestored,
open, onOpenChange, note, enabled, onEnableHistory, onRestored,
}: NoteHistoryModalProps) {
const { t, language } = useLanguage()
const [entries, setEntries] = useState<NoteHistoryEntry[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isRestoring, startRestoring] = useTransition()
const [isEnabling, startEnabling] = useTransition()
const [justEnabled, setJustEnabled] = useState(false)
const [isRestoring, setIsRestoring] = useState(false)
const [isEnabling, startEnableTx] = useTransition()
const [viewMode, setViewMode] = useState<ViewMode>('preview')
const [diffLeftId, setDiffLeftId] = useState<string | null>(null)
const [diffRightId, setDiffRightId] = useState<string | null>(null)
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
const [currentEntryId, setCurrentEntryId] = useState<string | null>(null)
const leftScrollRef = useRef<HTMLDivElement>(null)
const rightScrollRef = useRef<HTMLDivElement>(null)
const isScrollSyncing = useRef(false)
// Reset state when modal closes or note changes
const prevNoteId = useRef<string | null>(null)
useEffect(() => {
if (!open || !note || !enabled) return
if (!open) {
setEntries([])
setSelectedId(null)
setViewMode('preview')
setDiffLeftId(null)
setDiffRightId(null)
setCurrentEntryId(null)
setIsLoading(false)
return
}
if (!note) return
if (note.id === prevNoteId.current && entries.length > 0) return
prevNoteId.current = note.id
let cancelled = false
setIsLoading(true)
@@ -60,220 +182,358 @@ export function NoteHistoryModal({
if (cancelled) return
setEntries(result)
setSelectedId(result[0]?.id ?? null)
setCurrentEntryId(result[0]?.id ?? null)
})
.catch((error) => {
console.error('Failed to load note history:', error)
toast.error(t('general.error'))
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
.catch(() => toast.error(t('general.error')))
.finally(() => { if (!cancelled) setIsLoading(false) })
return () => {
cancelled = true
}
}, [open, note, enabled, t])
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, note?.id])
const selectedEntry = useMemo(
() => entries.find((entry) => entry.id === selectedId) ?? null,
[entries, selectedId]
// Separate effect: fetch when enabled flips to true for this note
useEffect(() => {
if (!open || !note || !enabled) return
if (entries.length > 0) return
let cancelled = false
setIsLoading(true)
getNoteHistory(note.id, 50)
.then((result) => {
if (cancelled) return
setEntries(result)
setSelectedId(result[0]?.id ?? null)
setCurrentEntryId(result[0]?.id ?? null)
})
.catch(() => toast.error(t('general.error')))
.finally(() => { if (!cancelled) setIsLoading(false) })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled])
const currentVersion = useMemo(
() => entries.find((e) => e.id === currentEntryId) ?? null,
[entries, currentEntryId]
)
const handleRestore = () => {
if (!note || !selectedEntry) return
startRestoring(async () => {
try {
const restored = await restoreNoteVersion(note.id, selectedEntry.id)
onRestored(restored)
toast.success(t('notes.historyRestored') || 'Version restaurée')
} catch (error) {
console.error('Failed to restore history entry:', error)
toast.error(t('general.error'))
}
})
}
const selectedEntry = useMemo(
() => entries.find((e) => e.id === selectedId) ?? null,
[entries, selectedId]
)
const diffLeftEntry = useMemo(() => entries.find((e) => e.id === diffLeftId) ?? null, [entries, diffLeftId])
const diffRightEntry = useMemo(() => entries.find((e) => e.id === diffRightId) ?? null, [entries, diffRightId])
const diffResult = useMemo(() => {
if (!diffLeftEntry || !diffRightEntry) return null
return computeLineDiff(diffLeftEntry.content, diffRightEntry.content)
}, [diffLeftEntry, diffRightEntry])
const isDiffReady = !!(diffLeftId && diffRightId && diffLeftId !== diffRightId)
const handleRestore = useCallback(async (entryId: string) => {
if (!note) return
const entry = entries.find((e) => e.id === entryId)
if (!entry) return
setIsRestoring(true)
try {
const restored = await restoreNoteVersion(note.id, entryId)
setCurrentEntryId(entryId)
toast.success(t('notes.historyRestored') || 'Version restaurée')
onOpenChange(false)
onRestored(restored)
} catch {
toast.error(t('general.error'))
} finally {
setIsRestoring(false)
}
}, [note, entries, onOpenChange, t, onRestored])
const handleDelete = useCallback((entryId: string) => {
setDeleteTargetId(entryId)
}, [])
const confirmDelete = useCallback(async () => {
if (!note || !deleteTargetId) return
const entryId = deleteTargetId
setDeleteTargetId(null)
setIsRestoring(true)
try {
await deleteNoteHistoryEntry(note.id, entryId)
setEntries((prev) => prev.filter((e) => e.id !== entryId))
if (selectedId === entryId) setSelectedId(entries[0]?.id ?? null)
if (diffLeftId === entryId) setDiffLeftId(null)
if (diffRightId === entryId) setDiffRightId(null)
toast.success(t('notes.versionDeleted') || 'Version supprimée')
} catch {
toast.error(t('general.error'))
} finally {
setIsRestoring(false)
}
}, [note, entries, selectedId, diffLeftId, diffRightId, deleteTargetId, t])
const handleEnable = () => {
startEnabling(async () => {
startEnableTx(async () => {
try {
await onEnableHistory()
setJustEnabled(true)
toast.success(t('notes.historyEnabled') || 'History activé')
} catch (error) {
console.error('Failed to enable history:', error)
toast.success(t('notes.historyEnabled') || 'Historique activé')
} catch {
toast.error(t('general.error'))
}
})
}
useEffect(() => {
if (!justEnabled) return
const timer = setTimeout(() => setJustEnabled(false), 1800)
return () => clearTimeout(timer)
}, [justEnabled])
const handleDeleteEntry = (entryId: string) => {
if (!note) return
if (!confirm(t('notes.deleteVersionConfirm') || 'Supprimer cette version définitivement ?')) return
startRestoring(async () => {
try {
await deleteNoteHistoryEntry(note.id, entryId)
setEntries((prev) => prev.filter((e) => e.id !== entryId))
if (selectedId === entryId) {
setSelectedId(null)
}
toast.success(t('notes.versionDeleted') || 'Version supprimée')
} catch (error) {
console.error('Failed to delete history entry:', error)
toast.error(t('general.error'))
}
})
const handleSyncScroll = (source: 'left' | 'right') => {
if (isScrollSyncing.current) return
isScrollSyncing.current = true
const src = source === 'left' ? leftScrollRef.current : rightScrollRef.current
const tgt = source === 'left' ? rightScrollRef.current : leftScrollRef.current
if (src && tgt) {
const ratio = src.scrollTop / (src.scrollHeight - src.clientHeight || 1)
tgt.scrollTop = ratio * (tgt.scrollHeight - tgt.clientHeight)
}
requestAnimationFrame(() => { isScrollSyncing.current = false })
}
const noteTitle = note?.title || t('notes.untitled') || 'Sans titre'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl p-0">
<DialogHeader className="border-b border-border/60 px-6 py-4">
<DialogTitle className="flex items-center gap-2">
<DialogContent
className={cn(
'p-0 gap-0 sm:!max-w-none',
viewMode === 'diff' && isDiffReady ? 'w-[92vw] max-w-[1500px]' : 'w-auto min-w-[500px] max-w-[800px]'
)}
>
{/* ── Header ── */}
<div className="border-b border-border/60 px-5 py-3 pr-10">
<DialogTitle className="flex items-center gap-2 text-sm">
<History className="h-4 w-4 text-primary" />
{t('notes.history') || 'Historique'}
</DialogTitle>
<DialogDescription>
{note?.title || t('notes.untitled') || 'Sans titre'}
<DialogDescription className="text-xs text-muted-foreground mt-0.5 truncate">
{noteTitle}
</DialogDescription>
</DialogHeader>
</div>
{!enabled || justEnabled ? (
{/* ── Body ── */}
{!enabled ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
{justEnabled ? (
<>
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 mb-5 animate-in zoom-in-50 duration-300">
<Check className="h-8 w-8 text-emerald-500" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5 animate-in fade-in slide-in-from-bottom-2 duration-300">
{t('notes.historyEnabledTitle') || 'Historique activé !'}
</h3>
<p className="text-sm text-muted-foreground max-w-xs animate-in fade-in slide-in-from-bottom-3 duration-500 leading-relaxed">
{t('notes.historyEnabledDesc') || "Les versions de cette note seront désormais enregistrées."}
</p>
</>
) : (
<>
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 mb-5">
<GitBranchPlus className="h-8 w-8 text-primary" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5">
{t('notes.historyDisabledTitle') || 'Historique des versions'}
</h3>
<p className="text-sm text-muted-foreground max-w-xs mb-6 leading-relaxed">
{t('notes.historyDisabledDesc') || "Suivez les modifications de cette note au fil du temps. Activez l'historique pour commencer à enregistrer des versions."}
</p>
<Button onClick={handleEnable} disabled={isEnabling} size="lg" className="rounded-full px-8">
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('notes.enableHistory') || "Activer l'historique"}
</Button>
</>
)}
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 mb-5">
<GitBranchPlus className="h-8 w-8 text-primary" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5">
{t('notes.historyDisabledTitle') || 'Historique des versions'}
</h3>
<p className="text-sm text-muted-foreground max-w-xs mb-6 leading-relaxed">
{t('notes.historyDisabledDesc') || "Activez l'historique pour enregistrer les versions."}
</p>
<Button onClick={handleEnable} disabled={isEnabling} size="lg" className="rounded-full px-8">
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('notes.enableHistory') || "Activer l'historique"}
</Button>
</div>
) : isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t('general.loading')}
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<History className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">{t('notes.historyEmpty') || 'Aucune version disponible'}</p>
</div>
) : (
<div className="grid grid-cols-[260px_1fr] gap-0">
<div className="max-h-[60vh] overflow-y-auto border-r border-border/60 p-3">
{isLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('general.loading')}
</div>
) : entries.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">
{t('notes.historyEmpty') || 'Aucune version disponible'}
</p>
) : (
<div className="space-y-1">
{entries.map((entry) => (
<div
key={entry.id}
className={cn(
'group/entry relative w-full rounded-md border px-2.5 py-2 text-left transition-colors',
selectedId === entry.id
? 'border-primary/40 bg-primary/8'
: 'border-border/70 hover:bg-muted/60'
<div className="grid grid-cols-[210px_1fr]">
{/* ── Left: Version list ── */}
<div className="max-h-[65vh] overflow-y-auto border-r border-border/60 p-2 space-y-1">
{entries.map((entry) => {
const isCurrent = entry.version === currentVersion?.version
const isSelected = viewMode === 'preview' && selectedId === entry.id
const isDL = entry.id === diffLeftId
const isDR = entry.id === diffRightId
const isDiffSel = viewMode === 'diff' && (isDL || isDR)
return (
<div
key={entry.id}
onClick={() => {
if (viewMode === 'diff') {
if (!diffLeftId) { setDiffLeftId(entry.id) }
else if (!diffRightId && entry.id !== diffLeftId) { setDiffRightId(entry.id) }
else { setDiffLeftId(entry.id); setDiffRightId(null) }
} else {
setSelectedId(entry.id)
}
}}
className={cn(
'group/entry relative rounded-md border px-2.5 py-1.5 cursor-pointer transition-colors',
isDiffSel
? isDL ? 'border-red-400/50 bg-red-500/10' : 'border-emerald-400/50 bg-emerald-500/10'
: isSelected ? 'border-primary/40 bg-primary/8' : 'border-transparent hover:bg-muted/60'
)}
>
<div className="flex items-center gap-1.5 pr-10">
<span className="text-xs font-semibold text-foreground">v{entry.version}</span>
{isCurrent && (
<span className="rounded bg-primary/15 px-1.5 py-px text-[10px] font-medium text-primary whitespace-nowrap">
{t('notes.currentVersion') || 'actuelle'}
</span>
)}
</div>
<p className="text-[11px] text-muted-foreground" suppressHydrationWarning>
{fmtDate(entry.createdAt, language)}
</p>
<div className={cn(
'absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5',
isSelected || isDiffSel ? 'opacity-100' : 'opacity-0 group-hover/entry:opacity-100'
)}>
{viewMode === 'preview' && (
<button
type="button" onClick={(e) => { e.stopPropagation(); handleRestore(entry.id) }}
disabled={isRestoring}
className="rounded p-1 text-muted-foreground/60 hover:text-primary hover:bg-primary/10"
title={t('notes.restore') || 'Restaurer'}
>
{isRestoring ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
</button>
)}
>
<button
type="button"
onClick={() => setSelectedId(entry.id)}
className="w-full text-left"
>
<p className="text-xs font-semibold text-foreground">
v{entry.version}
</p>
<p className="text-[11px] text-muted-foreground" suppressHydrationWarning>
{formatDistanceToNow(new Date(entry.createdAt), {
addSuffix: true,
locale: getDateLocale(language),
})}
</p>
{entry.reason && (
<p className="mt-1 line-clamp-1 text-[11px] text-muted-foreground">
{entry.reason}
</p>
)}
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleDeleteEntry(entry.id) }}
className="absolute right-1.5 top-1.5 rounded p-0.5 text-muted-foreground/40 opacity-0 transition-opacity hover:text-red-500 group-hover/entry:opacity-100"
type="button" onClick={(e) => { e.stopPropagation(); handleDelete(entry.id) }}
className="rounded p-1 text-muted-foreground/60 hover:text-red-500 hover:bg-red-500/10"
title={t('notes.deleteVersion') || 'Supprimer'}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)
})}
{viewMode === 'diff' && entries.length >= 2 && (
<div className="mt-2 border-t border-border/40 pt-2 px-1">
<p className="text-[10px] text-muted-foreground mb-1.5">
{t('notes.diffSelectHint') || 'Cliquez sur 2 versions pour comparer'}
</p>
<div className="flex items-center gap-2 text-[11px]">
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-red-400 inline-block" />
{diffLeftEntry ? `v${diffLeftEntry.version}` : '—'}
</span>
<span className="text-muted-foreground"></span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-400 inline-block" />
{diffRightEntry ? `v${diffRightEntry.version}` : '—'}
</span>
{isDiffReady && (
<button
type="button"
onClick={() => { setDiffLeftId(null); setDiffRightId(null) }}
className="ml-auto p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
)}
</div>
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
{selectedEntry ? (
<div className="space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('notes.title') || 'Titre'}
</p>
<p className="mt-1 text-sm text-foreground">
{selectedEntry.title || t('notes.untitled') || 'Sans titre'}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('notes.content') || 'Contenu'}
</p>
<pre className="mt-1 whitespace-pre-wrap rounded-md border border-border/70 bg-muted/30 p-3 text-sm text-foreground">
{selectedEntry.content || ''}
</pre>
{/* ── Right: Preview / Diff ── */}
<div className="max-h-[65vh] overflow-y-auto px-5 py-4">
{viewMode === 'preview' ? (
selectedEntry ? (
<VersionPreview entry={selectedEntry} language={language} />
) : (
<p className="text-sm text-muted-foreground py-8 text-center">
{t('notes.historySelectVersion') || 'Sélectionnez une version pour la prévisualiser'}
</p>
)
) : isDiffReady && diffResult ? (
<div className="space-y-3">
<p className="text-xs font-semibold text-muted-foreground">
{t('notes.diffTitle') || 'Comparaison'} : v{diffLeftEntry!.version} v{diffRightEntry!.version}
</p>
<div className="flex gap-3">
<DiffPanel
lines={diffResult.left}
label={`v${diffLeftEntry!.version}${fmtDate(diffLeftEntry!.createdAt, language)}`}
scrollRef={leftScrollRef}
onScroll={() => handleSyncScroll('left')}
/>
<DiffPanel
lines={diffResult.right}
label={`v${diffRightEntry!.version}${fmtDate(diffRightEntry!.createdAt, language)}`}
scrollRef={rightScrollRef}
onScroll={() => handleSyncScroll('right')}
/>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t('notes.historySelectVersion') || 'Sélectionnez une version pour prévisualiser son contenu'}
</p>
<div className="flex flex-col items-center justify-center py-12 text-center">
<GitCompare className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">
{t('notes.diffSelectHint') || 'Cliquez sur 2 versions pour comparer'}
</p>
</div>
)}
</div>
</div>
)}
<DialogFooter className="border-t border-border/60 px-6 py-3">
{enabled && selectedEntry && (
<Button onClick={handleRestore} disabled={isRestoring}>
{isRestoring
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <RotateCcw className="mr-2 h-4 w-4" />}
{t('notes.restore') || 'Restaurer'}
</Button>
)}
</DialogFooter>
{/* ── Footer: actions ── */}
{enabled && !isLoading && entries.length >= 2 && (
<div className="flex items-center justify-end border-t border-border/60 px-5 py-2.5">
<div className="flex items-center gap-1 rounded-lg border border-border/60 p-0.5">
<button
type="button"
onClick={() => { setViewMode('preview'); setDiffLeftId(null); setDiffRightId(null) }}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
viewMode === 'preview' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
{t('notes.history') || 'Historique'}
</button>
<button
type="button"
onClick={() => {
setViewMode('diff')
if (!diffLeftId && entries.length >= 2) {
setDiffLeftId(entries[1].id)
setDiffRightId(entries[0].id)
}
}}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors inline-flex items-center gap-1',
viewMode === 'diff' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<GitCompare className="h-3 w-3" />
{t('notes.compareVersions') || 'Comparer'}
</button>
</div>
</div>
)}
</DialogContent>
<AlertDialog open={!!deleteTargetId} onOpenChange={(open) => { if (!open) setDeleteTargetId(null) }}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>{t('notes.deleteVersionConfirm') || 'Supprimer cette version définitivement ?'}</AlertDialogTitle>
<AlertDialogDescription>
{t('notes.deleteVersionDesc') || 'Cette action est irréversible. La version sera définitivement supprimée de l\'historique.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('general.cancel') || 'Annuler'}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={confirmDelete}>
{t('general.delete') || 'Supprimer'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}

View File

@@ -139,6 +139,22 @@ export function NoteInlineEditor({
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
const noteContentRef = useRef(note.content)
const noteTitleRef = useRef(note.title)
useEffect(() => {
if (note.content !== noteContentRef.current || note.title !== noteTitleRef.current) {
clearTimeout(saveTimerRef.current)
setIsDirty(false)
setTitle(note.title || '')
setContent(note.content || '')
setCheckItems(note.checkItems || [])
setNoteType(note.type)
pendingRef.current = { title: note.title || '', content: note.content || '', checkItems: note.checkItems || [], isMarkdown: note.type === 'markdown', noteType: note.type }
noteContentRef.current = note.content
noteTitleRef.current = note.title
}
}, [note])
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
@@ -639,7 +655,7 @@ export function NoteInlineEditor({
if (note.historyEnabled) {
onOpenHistory(note)
} else if (onEnableHistory) {
onEnableHistory(note.id).then(() => onOpenHistory(note))
onEnableHistory(note.id).then(() => onOpenHistory({ ...note, historyEnabled: true }))
}
}}
>

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'
import { useCallback, useEffect, useMemo, useState, useTransition, useRef } from 'react'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import {
DndContext,
@@ -641,34 +641,48 @@ export function NotesTabsView({
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(',')
const merge = (fresh: Note, local: Note) => {
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort())
return {
...fresh,
title: local.title || fresh.title,
content: local.content,
checkItems: local.checkItems,
labels: labelsChanged ? fresh.labels : local.labels
}
const prevNotesRef = useRef<Note[]>(notes)
if (notes !== prevNotesRef.current) {
const prevIds = items.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',')
const merge = (fresh: Note, local: Note, oldFresh?: Note) => {
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort())
const contentChangedOnServer = oldFresh && oldFresh.content !== fresh.content
const titleChangedOnServer = oldFresh && oldFresh.title !== fresh.title
const checkItemsChangedOnServer = oldFresh && JSON.stringify(oldFresh.checkItems) !== JSON.stringify(fresh.checkItems)
return {
...fresh,
title: titleChangedOnServer ? fresh.title : (local.title || fresh.title),
content: contentChangedOnServer ? fresh.content : local.content,
checkItems: checkItemsChangedOnServer ? fresh.checkItems : local.checkItems,
labels: labelsChanged ? fresh.labels : local.labels
}
if (prevIds === incomingIds) {
return prev.map((p) => {
const fresh = notes.find((n) => n.id === p.id)
if (!fresh) return p
return merge(fresh, p)
})
}
return notes.map((fresh) => {
const local = prev.find((p) => p.id === fresh.id)
if (!local) return fresh
return merge(fresh, local)
}
let newItems: Note[]
if (prevIds === incomingIds) {
newItems = items.map((local) => {
const fresh = notes.find((n) => n.id === local.id)
if (!fresh) return local
const oldFresh = prevNotesRef.current.find((n) => n.id === local.id)
return merge(fresh, local, oldFresh)
})
})
}, [notes])
} else {
newItems = notes.map((fresh) => {
const local = items.find((p) => p.id === fresh.id)
if (!local) return fresh
const oldFresh = prevNotesRef.current.find((n) => n.id === fresh.id)
return merge(fresh, local, oldFresh)
})
}
setItems(newItems)
prevNotesRef.current = notes
}
useEffect(() => {
if (items.length === 0) {
@@ -932,7 +946,7 @@ export function NotesTabsView({
)}
>
<NoteInlineEditor
key={selected.id}
key={`${selected.id}-${String(selected.updatedAt)}`}
note={selected}
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}