209 lines
9.0 KiB
TypeScript
209 lines
9.0 KiB
TypeScript
/**
|
|
* RollbackConfirmModal — Shows revision history and confirms rollback.
|
|
* Rollback creates a new revision (non-destructive) per spec §15.3.
|
|
*/
|
|
import { useState, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { X, RotateCcw, GitCommit, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import type { CanvasPage, CanvasPageRevision } from '../types/canvas';
|
|
|
|
interface RollbackConfirmModalProps {
|
|
page: CanvasPage | null;
|
|
revisions: CanvasPageRevision[];
|
|
isLoading: boolean;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onRollback: (targetRevision: number) => Promise<void>;
|
|
}
|
|
|
|
export function RollbackConfirmModal({ page, revisions, isLoading, isOpen, onClose, onRollback }: RollbackConfirmModalProps) {
|
|
const [mounted, setMounted] = useState(false);
|
|
useEffect(() => setMounted(true), []);
|
|
|
|
const [selected, setSelected] = useState<number | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
const handleRollback = async () => {
|
|
if (!selected) return;
|
|
setSubmitting(true);
|
|
try {
|
|
await onRollback(selected);
|
|
setSuccess(true);
|
|
setTimeout(() => {
|
|
setSuccess(false);
|
|
onClose();
|
|
setSelected(null);
|
|
}, 1800);
|
|
} catch {
|
|
// stay open
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const commitKindColors: Record<string, string> = {
|
|
prompt: '#60a5fa',
|
|
merge: '#a78bfa',
|
|
rollback: '#fbbf24',
|
|
manual_edit: '#34d399',
|
|
};
|
|
|
|
const content = (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<>
|
|
<motion.div
|
|
key="rollback-backdrop"
|
|
className="fixed inset-0 z-40"
|
|
style={{ background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(4px)' }}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={onClose}
|
|
/>
|
|
<motion.div
|
|
key="rollback-modal"
|
|
className="fixed z-50 left-1/2 top-1/2 w-full max-w-lg"
|
|
initial={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
|
|
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
|
|
exit={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div
|
|
className="rounded-2xl p-6"
|
|
style={{
|
|
background: 'rgba(12, 13, 20, 0.98)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
|
|
maxHeight: '80vh',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-xl bg-amber-500/15 border border-amber-500/25 flex items-center justify-center">
|
|
<RotateCcw className="w-4 h-4 text-amber-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-zinc-100">Revision History</h2>
|
|
<p className="text-xs text-zinc-500">Select a revision to roll back to</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Non-destructive note */}
|
|
<div
|
|
className="flex items-start gap-3 p-3 rounded-xl mb-4 flex-shrink-0"
|
|
style={{ background: 'rgba(251,191,36,0.07)', border: '1px solid rgba(251,191,36,0.18)' }}
|
|
>
|
|
<AlertTriangle className="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
|
|
<p className="text-xs text-zinc-300 leading-relaxed">
|
|
Rollback is <span className="text-amber-300 font-medium">non-destructive</span> — it creates a new
|
|
revision restoring the canvas to that state. Current revision {page?.headRevision} is preserved in history.
|
|
</p>
|
|
</div>
|
|
|
|
{success ? (
|
|
<div className="flex flex-col items-center gap-3 py-8">
|
|
<CheckCircle2 className="w-10 h-10 text-green-400" />
|
|
<p className="text-sm text-zinc-200 font-medium">Rolled back to revision {selected}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Revision list */}
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-2 mb-4">
|
|
{isLoading && (
|
|
<div className="p-4 text-xs text-zinc-500">Loading revision history…</div>
|
|
)}
|
|
{!isLoading && revisions.map((rev) => {
|
|
const color = commitKindColors[rev.commitKind] ?? '#60a5fa';
|
|
const isCurrent = rev.revisionNumber === page?.headRevision;
|
|
const isSelected = selected === rev.revisionNumber;
|
|
return (
|
|
<button
|
|
key={rev.revisionId}
|
|
disabled={isCurrent}
|
|
onClick={() => setSelected(isSelected ? null : rev.revisionNumber)}
|
|
className="w-full flex items-start gap-3 p-3.5 rounded-xl text-left transition-all"
|
|
style={{
|
|
background: isSelected
|
|
? 'rgba(59,130,246,0.1)'
|
|
: 'rgba(255,255,255,0.025)',
|
|
border: `1px solid ${isSelected ? 'rgba(59,130,246,0.35)' : 'rgba(255,255,255,0.07)'}`,
|
|
opacity: isCurrent ? 0.6 : 1,
|
|
cursor: isCurrent ? 'default' : 'pointer',
|
|
}}
|
|
>
|
|
<GitCommit className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" style={{ color }} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-mono font-semibold text-zinc-200">
|
|
rev.{rev.revisionNumber}
|
|
</span>
|
|
{isCurrent && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-300 border border-blue-500/25">
|
|
current
|
|
</span>
|
|
)}
|
|
<span
|
|
className="text-[10px] px-1.5 rounded-full"
|
|
style={{ background: `${color}18`, color }}
|
|
>
|
|
{rev.commitKind}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-zinc-300 mt-0.5 truncate">{rev.commitSummary ?? 'Revision event'}</p>
|
|
<p className="text-[10px] text-zinc-600 mt-0.5">
|
|
{rev.actorId} · {new Date(rev.createdAt).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
{isSelected && (
|
|
<div className="w-4 h-4 rounded-full bg-blue-500/25 border border-blue-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
<div className="w-2 h-2 rounded-full bg-blue-400" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
{!isLoading && revisions.length === 0 && (
|
|
<div className="p-4 text-xs text-zinc-500">No revisions have been committed yet.</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 flex-shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={onClose}
|
|
className="flex-1 border-white/10 bg-white/5 hover:bg-white/10 text-zinc-300"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => void handleRollback()}
|
|
disabled={!selected || submitting}
|
|
className="flex-1 bg-amber-600 hover:bg-amber-500 text-white"
|
|
>
|
|
{submitting ? 'Rolling back…' : selected ? `Restore rev.${selected}` : 'Select a revision'}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
|
|
if (!mounted) return null;
|
|
return createPortal(content, document.body);
|
|
}
|