← back to Norma
components/drafts/VersionHistory.tsx
188 lines
'use client';
import { useState, useEffect } from 'react';
import { History, RotateCcw, Plus } from 'lucide-react';
interface DraftVersion {
id: string;
version_number: number;
subject: string;
body_html: string;
body_text?: string;
change_summary?: string;
created_at: string;
created_by?: string;
}
interface VersionHistoryProps {
draftId: string;
onRestore: (version: DraftVersion) => void;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZoneName: 'short',
});
}
export default function VersionHistory({ draftId, onRestore }: VersionHistoryProps) {
const [versions, setVersions] = useState<DraftVersion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [restoring, setRestoring] = useState<string | null>(null);
async function fetchVersions() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/drafts/${draftId}/versions`);
if (!res.ok) throw new Error('Failed to load versions');
const data = await res.json();
setVersions(data.versions ?? data ?? []);
} catch {
setError('Failed to load version history.');
} finally {
setLoading(false);
}
}
useEffect(() => { fetchVersions(); }, [draftId]); // eslint-disable-line react-hooks/exhaustive-deps
async function handleSaveVersion() {
setSaving(true);
try {
const res = await fetch(`/api/drafts/${draftId}/versions`, { method: 'POST' });
if (!res.ok) throw new Error('Save failed');
await fetchVersions();
} catch {
alert('Failed to save version snapshot.');
} finally {
setSaving(false);
}
}
async function handleRestore(version: DraftVersion) {
if (!confirm(`Restore to version ${version.version_number}? Current unsaved changes will be lost.`)) return;
setRestoring(version.id);
onRestore(version);
setRestoring(null);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 shrink-0"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-2">
<History size={15} style={{ color: 'var(--color-text-secondary)' }} aria-hidden="true" />
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
Version History
</h3>
{!loading && (
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
({versions.length})
</span>
)}
</div>
<button
type="button"
onClick={handleSaveVersion}
disabled={saving}
className="btn btn-secondary btn-sm"
title="Save current state as a version snapshot"
>
<Plus size={12} aria-hidden="true" />
{saving ? 'Saving...' : 'Save Version'}
</button>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="flex justify-center py-6">
<span className="spinner" aria-label="Loading versions" />
</div>
)}
{error && (
<p className="text-sm text-center px-4 py-6" style={{ color: 'var(--color-error)' }}>
{error}
</p>
)}
{!loading && !error && versions.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-10">
<History size={24} style={{ color: 'var(--color-text-muted)' }} />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
No saved versions yet.
</p>
<p className="text-xs text-center px-6" style={{ color: 'var(--color-text-muted)' }}>
Click "Save Version" to create a manual snapshot.
</p>
</div>
)}
{!loading && !error && versions.length > 0 && (
<ul className="divide-y" style={{ borderColor: 'var(--color-border)' }}>
{versions.map((v, i) => (
<li
key={v.id}
className="flex items-start justify-between gap-3 px-4 py-3"
style={{
backgroundColor: i === 0 ? 'rgba(16, 185, 129, 0.04)' : 'transparent',
}}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className="text-xs font-semibold"
style={{ color: i === 0 ? 'var(--color-primary)' : 'var(--color-text)' }}
>
v{v.version_number}
</span>
{i === 0 && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: 'rgba(16, 185, 129, 0.15)',
color: 'var(--color-primary)',
border: '1px solid rgba(16, 185, 129, 0.3)',
}}
>
Latest
</span>
)}
</div>
<p className="text-xs truncate mb-0.5" style={{ color: 'var(--color-text-secondary)' }}>
{v.change_summary ?? 'Manual snapshot'}
</p>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{formatDate(v.created_at)}
</p>
</div>
<button
type="button"
onClick={() => handleRestore(v)}
disabled={restoring === v.id}
className="btn btn-ghost btn-sm shrink-0"
title={`Restore version ${v.version_number}`}
>
<RotateCcw size={12} aria-hidden="true" />
<span className="hidden sm:inline">
{restoring === v.id ? 'Restoring...' : 'Restore'}
</span>
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}