Add safe database migration workflow and note history infrastructure.
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s

This introduces guarded migrations with automatic backups, fixes note creation after DB reset, and wires snapshot/restore history across notes surfaces.
This commit is contained in:
Sepehr Ramezani
2026-04-28 16:50:57 +02:00
parent 0fbb8aa599
commit ed807d3b2a
23 changed files with 8052 additions and 0 deletions

View File

@@ -0,0 +1,219 @@
'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>
)
}