/** * @fileoverview Version History Panel component * * Slide-in panel for viewing and managing content version history. * Displays version list with timestamps, previews, and action buttons. * * @module @writenex/astro/client/components/VersionHistory/VersionHistoryPanel */ import { AlertTriangle, Clock, History, Loader2, RefreshCw, Tag, Trash2, X, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { VersionEntry } from "../../../types"; import { useSharedVersionApi } from "../../context/ApiContext"; import { useFocusTrap } from "../../hooks/useFocusTrap"; import { type DiffData, useVersionHistory, } from "../../hooks/useVersionHistory"; import { DiffViewer } from "./DiffViewer"; import { VersionActions } from "./VersionActions"; import "./VersionHistoryPanel.css"; /** * Props for the VersionHistoryPanel component */ interface VersionHistoryPanelProps { /** Whether the panel is open */ isOpen: boolean; /** Callback to close the panel */ onClose: () => void; /** Collection name */ collection: string | null; /** Content ID (slug) */ contentId: string | null; /** Current content body for comparison (reserved for future use) */ currentContent: string; /** Callback when content is restored */ onRestore: (content: string) => void; } /** * Format timestamp for display */ function formatTimestamp(timestamp: string): string { const date = new Date(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } /** * Format file size for display */ function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** * Version History Panel component * * @component * @example * ```tsx * setShowVersionHistory(false)} * apiBase={apiBase} * collection="blog" * contentId="my-post" * currentContent={content.body} * onRestore={handleRestore} * /> * ``` */ export function VersionHistoryPanel({ isOpen, onClose, collection, contentId, currentContent: _currentContent, onRestore, }: VersionHistoryPanelProps): React.ReactElement | null { const versionApi = useSharedVersionApi(); const { versions, loading, error, refresh, restoreVersion, deleteVersion, clearVersions, getDiff, } = useVersionHistory(versionApi, collection, contentId); const [selectedVersionId, setSelectedVersionId] = useState( null ); const [diffData, setDiffData] = useState(null); const [showDiff, setShowDiff] = useState(false); const [actionLoading, setActionLoading] = useState(null); const [showClearAllConfirm, setShowClearAllConfirm] = useState(false); // Fetch versions when panel opens useEffect(() => { if (isOpen && collection && contentId) { refresh(); } }, [isOpen, collection, contentId, refresh]); // Reset state when panel closes useEffect(() => { if (!isOpen) { setSelectedVersionId(null); setDiffData(null); setShowDiff(false); setShowClearAllConfirm(false); } }, [isOpen]); if (!isOpen) return null; const handleVersionClick = (versionId: string) => { setSelectedVersionId(selectedVersionId === versionId ? null : versionId); }; const handleRestore = async (versionId: string) => { setActionLoading("restore"); try { const content = await restoreVersion(versionId); if (content) { onRestore(content); setSelectedVersionId(null); } } finally { setActionLoading(null); } }; const handleCompare = async (versionId: string) => { setActionLoading("compare"); try { const data = await getDiff(versionId); if (data) { setDiffData(data); setShowDiff(true); } } finally { setActionLoading(null); } }; const handleDelete = async (versionId: string) => { setActionLoading("delete"); try { await deleteVersion(versionId); setSelectedVersionId(null); } finally { setActionLoading(null); } }; const handleClearAll = async () => { setActionLoading("clearAll"); try { await clearVersions(); setSelectedVersionId(null); setShowClearAllConfirm(false); } finally { setActionLoading(null); } }; const handleDownload = (version: VersionEntry) => { // Create a blob with the version content // We need to fetch the full version content first getDiff(version.id).then((data) => { if (data) { const blob = new Blob([data.version.content], { type: "text/markdown", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${contentId}-${version.id}.md`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } }); }; const selectedVersion = versions.find((v) => v.id === selectedVersionId); return ( <>
{/* Header */}

Version History

{/* Content */}
{loading && versions.length === 0 ? (
Loading versions...
) : error ? (
{error}
) : versions.length === 0 ? (

No versions yet

Versions are created automatically when you save
) : (
{versions.map((version) => ( handleVersionClick(version.id)} /> ))}
)}
{/* Actions for selected version */} {selectedVersion && ( handleRestore(selectedVersion.id)} onCompare={() => handleCompare(selectedVersion.id)} onDownload={() => handleDownload(selectedVersion)} onDelete={() => handleDelete(selectedVersion.id)} loading={actionLoading} /> )}
{/* Diff Viewer Modal */} {showDiff && diffData && ( setShowDiff(false)} /> )} {/* Clear All Confirmation Modal */} {showClearAllConfirm && ( setShowClearAllConfirm(false)} loading={actionLoading === "clearAll"} /> )} ); } /** * Version item component */ function VersionItem({ version, isSelected, onClick, }: { version: VersionEntry; isSelected: boolean; onClick: () => void; }): React.ReactElement { return ( ); } /** * Props for ClearAllConfirmModal component */ interface ClearAllConfirmModalProps { /** Number of versions to be deleted */ versionCount: number; /** Callback when user confirms */ onConfirm: () => void; /** Callback when user cancels */ onCancel: () => void; /** Loading state */ loading?: boolean; } /** * Confirmation modal for clearing all version history */ function ClearAllConfirmModal({ versionCount, onConfirm, onCancel, loading, }: ClearAllConfirmModalProps): React.ReactElement { const triggerRef = useRef(null); const cancelButtonRef = useRef(null); // Store the trigger element when modal mounts useEffect(() => { triggerRef.current = document.activeElement as HTMLElement; }, []); // Focus trap for accessibility const { containerRef } = useFocusTrap({ enabled: true, onEscape: loading ? undefined : onCancel, returnFocusTo: triggerRef.current, }); // Focus cancel button when modal opens useEffect(() => { setTimeout(() => { cancelButtonRef.current?.focus(); }, 50); }, []); const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget && !loading) onCancel(); }; return (

Clear All History

Are you sure you want to delete all {versionCount} version {versionCount !== 1 ? "s" : ""} for this content? This action cannot be undone.

); }