All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
544 lines
23 KiB
TypeScript
544 lines
23 KiB
TypeScript
'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<void>
|
|
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 (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
|
{t('noteHistory.title')}
|
|
</p>
|
|
<p className="text-sm text-foreground font-medium">
|
|
{entry.title || t('noteHistory.untitled')}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
|
{t('noteHistory.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: require('isomorphic-dompurify').sanitize(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,
|
|
}: NoteHistoryModalProps) {
|
|
const { t, language } = useLanguage()
|
|
const [entries, setEntries] = useState<NoteHistoryEntry[]>([])
|
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
const [isLoading, setIsLoading] = 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) {
|
|
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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<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 pe-10">
|
|
<DialogTitle className="flex items-center gap-2 text-sm">
|
|
<History className="h-4 w-4 text-primary" />
|
|
{t('notes.history') || 'Historique'}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-muted-foreground mt-0.5 truncate">
|
|
{noteTitle}
|
|
</DialogDescription>
|
|
</div>
|
|
|
|
{/* ── Body ── */}
|
|
{!enabled ? (
|
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
|
<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="me-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-[210px_1fr]">
|
|
{/* ── Left: Version list ── */}
|
|
<div className="max-h-[65vh] overflow-y-auto border-e 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 pe-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 end-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={(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>
|
|
|
|
{/* ── 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>
|
|
) : (
|
|
<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>
|
|
)}
|
|
|
|
{/* ── 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>
|
|
)
|
|
}
|