feat: Built the Oracle Tab
This commit is contained in:
208
app/src/oracle/components/RollbackConfirmModal.tsx
Normal file
208
app/src/oracle/components/RollbackConfirmModal.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user