Files
openmaic/OpenMAIC/components/whiteboard/whiteboard-history.tsx
T

168 lines
6.7 KiB
TypeScript

'use client';
import { useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { RotateCcw } from 'lucide-react';
import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
import { useStageStore } from '@/lib/store';
import { useCanvasStore } from '@/lib/store/canvas';
import { createStageAPI } from '@/lib/api/stage-api';
import { elementFingerprint } from '@/lib/utils/element-fingerprint';
import { toast } from 'sonner';
import { useI18n } from '@/lib/hooks/use-i18n';
interface WhiteboardHistoryProps {
readonly isOpen: boolean;
readonly onClose: () => void;
}
/**
* Whiteboard history dropdown panel.
* Shows a list of saved whiteboard snapshots with timestamps and element counts.
* Clicking "Restore" replaces the current whiteboard content with the snapshot.
*/
export function WhiteboardHistory({ isOpen, onClose }: WhiteboardHistoryProps) {
const { t } = useI18n();
const snapshots = useWhiteboardHistoryStore((s) => s.snapshots);
const isClearing = useCanvasStore.use.whiteboardClearing();
const panelRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
onClose();
}
};
// Delay listener so the click that opens the panel doesn't immediately close it
const id = setTimeout(() => document.addEventListener('mousedown', handler), 0);
return () => {
clearTimeout(id);
document.removeEventListener('mousedown', handler);
};
}, [isOpen, onClose]);
const handleRestore = (index: number) => {
// P1: Block restore while a clear animation is in flight — the pending
// delete/update would overwrite the restored content moments later.
if (isClearing) {
toast.error(t('whiteboard.restoreError'));
return;
}
const snapshot = useWhiteboardHistoryStore.getState().getSnapshot(index);
if (!snapshot) return;
const stageStore = useStageStore;
const stageAPI = createStageAPI(stageStore);
// Get or create whiteboard
const wbResult = stageAPI.whiteboard.get();
if (!wbResult.success || !wbResult.data) {
return;
}
const whiteboardId = wbResult.data.id;
// P2a: Skip no-op restores — if the snapshot matches what's already
// on screen, restoring would be a no-op.
const restoredElementsKey = snapshot.fingerprint;
const currentKey = elementFingerprint(wbResult.data.elements ?? []);
if (restoredElementsKey === currentKey) {
toast.success(t('whiteboard.restored'));
onClose();
return;
}
// Save current content before overwriting so the user can undo the restore
const currentElements = wbResult.data.elements ?? [];
if (currentElements.length > 0) {
useWhiteboardHistoryStore.getState().pushSnapshot(currentElements);
}
// Transactional restore: replace all elements in one update() call
// instead of looping delete/add which produces intermediate states.
const result = stageAPI.whiteboard.update({ elements: snapshot.elements }, whiteboardId);
if (!result.success) {
console.error('Failed to restore whiteboard snapshot:', result.error);
// P3: Dedicated restoreError key (not clearError)
toast.error(t('whiteboard.restoreError') + (result.error ?? ''));
return;
}
toast.success(t('whiteboard.restored'));
onClose();
};
const formatTime = (ts: number) => {
const d = new Date(ts);
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`;
};
return (
<AnimatePresence>
{isOpen && (
<motion.div
ref={panelRef}
initial={{ opacity: 0, y: -8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-2 z-[130] w-72 max-h-80 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col"
>
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{t('whiteboard.history')}
</span>
<span className="text-xs text-gray-400">
{snapshots.length > 0 ? `${snapshots.length}` : ''}
</span>
</div>
{/* Snapshot list */}
<div className="flex-1 overflow-y-auto">
{snapshots.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
{t('whiteboard.noHistory')}
</div>
) : (
<div className="py-1">
{[...snapshots].reverse().map((snap, reverseIdx) => {
const realIdx = snapshots.length - 1 - reverseIdx;
return (
<div
key={`${snap.timestamp}-${realIdx}`}
className="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">
{`#${realIdx + 1}`}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{formatTime(snap.timestamp)} ·{' '}
{t('whiteboard.elementCount', { count: snap.elements.length })}
</div>
</div>
<button
type="button"
onClick={() => handleRestore(realIdx)}
disabled={isClearing}
className="ml-2 px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<RotateCcw className="w-3 h-3" />
{t('whiteboard.restore')}
</button>
</div>
);
})}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
}