All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState, useTransition } from 'react'
|
|
import { formatDistanceToNow } 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 { toast } from 'sonner'
|
|
import { getNoteHistory, restoreNoteVersion, deleteNoteHistoryEntry } from '@/app/actions/notes'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
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<void>
|
|
onRestored: (note: Note) => void
|
|
}
|
|
|
|
function getDateLocale(language: string) {
|
|
if (language === 'fr') return fr
|
|
return enUS
|
|
}
|
|
|
|
export function NoteHistoryModal({
|
|
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)
|
|
|
|
useEffect(() => {
|
|
if (!open || !note || !enabled) return
|
|
|
|
let cancelled = false
|
|
setIsLoading(true)
|
|
getNoteHistory(note.id, 50)
|
|
.then((result) => {
|
|
if (cancelled) return
|
|
setEntries(result)
|
|
setSelectedId(result[0]?.id ?? null)
|
|
})
|
|
.catch((error) => {
|
|
console.error('Failed to load note history:', error)
|
|
toast.error(t('general.error'))
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setIsLoading(false)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [open, note, enabled, t])
|
|
|
|
const selectedEntry = useMemo(
|
|
() => entries.find((entry) => entry.id === selectedId) ?? null,
|
|
[entries, selectedId]
|
|
)
|
|
|
|
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 handleEnable = () => {
|
|
startEnabling(async () => {
|
|
try {
|
|
await onEnableHistory()
|
|
setJustEnabled(true)
|
|
toast.success(t('notes.historyEnabled') || 'History activé')
|
|
} catch (error) {
|
|
console.error('Failed to enable history:', error)
|
|
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'))
|
|
}
|
|
})
|
|
}
|
|
|
|
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">
|
|
<History className="h-4 w-4 text-primary" />
|
|
{t('notes.history') || 'Historique'}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{note?.title || t('notes.untitled') || 'Sans titre'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{!enabled || justEnabled ? (
|
|
<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>
|
|
) : (
|
|
<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'
|
|
)}
|
|
>
|
|
<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"
|
|
title={t('notes.deleteVersion') || 'Supprimer'}
|
|
>
|
|
<Trash2 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>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('notes.historySelectVersion') || 'Sélectionnez une version pour prévisualiser son contenu'}
|
|
</p>
|
|
)}
|
|
</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>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|