Files
Momento/Momento-main/momento/memento-note/components/note-history-modal.tsx
Sepehr Ramezani ed807d3b2a
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
Add safe database migration workflow and note history infrastructure.
This introduces guarded migrations with automatic backups, fixes note creation after DB reset, and wires snapshot/restore history across notes surfaces.
2026-04-28 17:14:26 +02:00

220 lines
7.6 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 } from 'lucide-react'
import { toast } from 'sonner'
import { getNoteHistory, restoreNoteVersion } 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()
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()
toast.success(t('notes.historyEnabled') || 'History activé')
} catch (error) {
console.error('Failed to enable history:', 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 ? (
<div className="space-y-3 px-6 py-8">
<p className="text-sm text-muted-foreground">
{t('notes.historyDisabledDesc') || "L'historique est désactivé pour votre compte."}
</p>
<Button onClick={handleEnable} disabled={isEnabling}>
{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) => (
<button
key={entry.id}
type="button"
onClick={() => setSelectedId(entry.id)}
className={cn(
'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'
)}
>
<p className="text-xs font-semibold text-foreground">
v{entry.version}
</p>
<p className="text-[11px] text-muted-foreground">
{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>
))}
</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>
)
}