'use client' import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react' import { fr } from 'date-fns/locale/fr' import { enUS } from 'date-fns/locale/en-US' import { faIR } from 'date-fns/locale/fa-IR' import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date' 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 { Button } from '@/components/ui/button' import { 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' interface NoteHistoryModalProps { open: boolean onOpenChange: (open: boolean) => void note: Note | null enabled: boolean onEnableHistory: () => Promise onRestored: (note: Note) => void } type ViewMode = 'preview' | 'diff' function getDateLocale(language: string) { if (language === 'fr') return fr if (language === 'fa') return faIR return enUS } function fmtDate(date: Date | string, language: string): string { const d = typeof date === 'string' ? new Date(date) : date return formatAbsoluteDateLocalized(d, language, 'd MMM yyyy HH:mm', getDateLocale(language)) } function VersionPreview({ entry, language }: { entry: NoteHistoryEntry; language: string }) { const { t } = useLanguage() const isMd = entry.type === 'markdown' || entry.isMarkdown const isCl = entry.type === 'checklist' const isRt = entry.type === 'richtext' return (

{t('noteHistory.title')}

{entry.title || t('noteHistory.untitled')}

{t('noteHistory.content')}

{isCl && entry.checkItems ? (
{entry.checkItems.map((item, i) => (
{item.checked && '✓'} {item.text}
))}
) : isMd ? (
) : isRt ? (
) : (
{entry.content || ''}
)}
) } 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 onScroll: () => void }) { return (

{label}

{lines.map((line, i) => (
{line.value || '\u00A0'}
))}
) } export function NoteHistoryModal({ open, onOpenChange, note, enabled, onEnableHistory, onRestored, }: NoteHistoryModalProps) { const { t, language } = useLanguage() const [entries, setEntries] = useState([]) const [selectedId, setSelectedId] = useState(null) const [isLoading, setIsLoading] = useState(false) const [isRestoring, setIsRestoring] = useState(false) const [isEnabling, startEnableTx] = useTransition() const [viewMode, setViewMode] = useState('preview') const [diffLeftId, setDiffLeftId] = useState(null) const [diffRightId, setDiffRightId] = useState(null) const [deleteTargetId, setDeleteTargetId] = useState(null) const [currentEntryId, setCurrentEntryId] = useState(null) const leftScrollRef = useRef(null) const rightScrollRef = useRef(null) const isScrollSyncing = useRef(false) // Reset state when modal closes or note changes const prevNoteId = useRef(null) useEffect(() => { 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) 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 }, [open, note?.id]) // 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 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 = () => { startEnableTx(async () => { try { await onEnableHistory() toast.success(t('notes.historyEnabled') || 'Historique activé') } catch { 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 ( {/* ── Header ── */}
{t('notes.history') || 'Historique'} {noteTitle}
{/* ── Body ── */} {!enabled ? (

{t('notes.historyDisabledTitle') || 'Historique des versions'}

{t('notes.historyDisabledDesc') || "Activez l'historique pour enregistrer les versions."}

) : isLoading ? (
{t('general.loading')}
) : entries.length === 0 ? (

{t('notes.historyEmpty') || 'Aucune version disponible'}

) : (
{/* ── Left: Version list ── */}
{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 (
{ 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' )} >
v{entry.version} {isCurrent && ( {t('notes.currentVersion') || 'actuelle'} )}

{fmtDate(entry.createdAt, language)}

{viewMode === 'preview' && ( )}
) })} {viewMode === 'diff' && entries.length >= 2 && (

{t('notes.diffSelectHint') || 'Cliquez sur 2 versions pour comparer'}

{diffLeftEntry ? `v${diffLeftEntry.version}` : '—'} {diffRightEntry ? `v${diffRightEntry.version}` : '—'} {isDiffReady && ( )}
)}
{/* ── Right: Preview / Diff ── */}
{viewMode === 'preview' ? ( selectedEntry ? ( ) : (

{t('notes.historySelectVersion') || 'Sélectionnez une version pour la prévisualiser'}

) ) : isDiffReady && diffResult ? (

{t('notes.diffTitle') || 'Comparaison'} : v{diffLeftEntry!.version} → v{diffRightEntry!.version}

handleSyncScroll('left')} /> handleSyncScroll('right')} />
) : (

{t('notes.diffSelectHint') || 'Cliquez sur 2 versions pour comparer'}

)}
)} {/* ── Footer: actions ── */} {enabled && !isLoading && entries.length >= 2 && (
)}
{ if (!open) setDeleteTargetId(null) }}> {t('notes.deleteVersionConfirm') || 'Supprimer cette version définitivement ?'} {t('notes.deleteVersionDesc') || 'Cette action est irréversible. La version sera définitivement supprimée de l\'historique.'} {t('general.cancel') || 'Annuler'} {t('general.delete') || 'Supprimer'}
) }