Files
Project_Velocity/app/src/oracle/components/RollbackConfirmModal.tsx
2026-04-11 19:33:13 +05:30

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);
}