2026-04-02 11:40:04 +05:30
|
|
|
"use client";
|
|
|
|
|
|
2026-04-03 10:42:21 +05:30
|
|
|
import { Check, ChevronRight, Clock, Copy, RotateCcw } from "lucide-react";
|
2026-04-03 13:14:40 +05:30
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
|
import { toast } from "sonner";
|
2026-04-02 11:40:04 +05:30
|
|
|
import { Button } from "@/components/ui/button";
|
2026-04-03 13:14:40 +05:30
|
|
|
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
2026-04-03 10:42:21 +05:30
|
|
|
import { Separator } from "@/components/ui/separator";
|
2026-04-02 11:40:04 +05:30
|
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
|
|
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
2026-04-03 13:14:40 +05:30
|
|
|
import { cn } from "@/lib/utils";
|
2026-04-02 11:40:04 +05:30
|
|
|
|
|
|
|
|
interface DocumentVersionSummary {
|
|
|
|
|
version_number: number;
|
|
|
|
|
title: string;
|
|
|
|
|
content_hash: string;
|
|
|
|
|
created_at: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface VersionHistoryProps {
|
|
|
|
|
documentId: number;
|
|
|
|
|
documentType: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 10:42:21 +05:30
|
|
|
const VERSION_DOCUMENT_TYPES = new Set(["LOCAL_FOLDER_FILE", "OBSIDIAN_CONNECTOR"]);
|
|
|
|
|
|
|
|
|
|
export function isVersionableType(documentType: string) {
|
|
|
|
|
return VERSION_DOCUMENT_TYPES.has(documentType);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DIALOG_CLASSES =
|
|
|
|
|
"select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col md:flex-row p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]";
|
|
|
|
|
|
2026-04-02 11:40:04 +05:30
|
|
|
export function VersionHistoryButton({ documentId, documentType }: VersionHistoryProps) {
|
2026-04-03 10:42:21 +05:30
|
|
|
if (!isVersionableType(documentType)) return null;
|
2026-04-02 11:40:04 +05:30
|
|
|
|
|
|
|
|
return (
|
2026-04-03 10:42:21 +05:30
|
|
|
<Dialog>
|
|
|
|
|
<DialogTrigger asChild>
|
2026-04-02 11:40:04 +05:30
|
|
|
<Button variant="ghost" size="sm" className="gap-1.5 text-xs">
|
|
|
|
|
<Clock className="h-3.5 w-3.5" />
|
|
|
|
|
Versions
|
|
|
|
|
</Button>
|
2026-04-03 10:42:21 +05:30
|
|
|
</DialogTrigger>
|
|
|
|
|
<DialogContent className={DIALOG_CLASSES}>
|
|
|
|
|
<DialogTitle className="sr-only">Version History</DialogTitle>
|
2026-04-02 11:40:04 +05:30
|
|
|
<VersionHistoryPanel documentId={documentId} />
|
2026-04-03 10:42:21 +05:30
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2026-04-02 11:40:04 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 10:42:21 +05:30
|
|
|
export function VersionHistoryDialog({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
documentId,
|
|
|
|
|
}: {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
documentId: number;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className={DIALOG_CLASSES}>
|
|
|
|
|
<DialogTitle className="sr-only">Version History</DialogTitle>
|
|
|
|
|
{open && <VersionHistoryPanel documentId={documentId} />}
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatRelativeTime(dateStr: string): string {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const then = new Date(dateStr).getTime();
|
|
|
|
|
const diffMs = now - then;
|
|
|
|
|
const diffMin = Math.floor(diffMs / 60_000);
|
|
|
|
|
if (diffMin < 1) return "Just now";
|
|
|
|
|
if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? "s" : ""} ago`;
|
|
|
|
|
const diffHr = Math.floor(diffMin / 60);
|
|
|
|
|
if (diffHr < 24) return `${diffHr} hour${diffHr !== 1 ? "s" : ""} ago`;
|
|
|
|
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
|
|
|
|
weekday: "short",
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
year: "numeric",
|
|
|
|
|
hour: "numeric",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:40:04 +05:30
|
|
|
function VersionHistoryPanel({ documentId }: { documentId: number }) {
|
|
|
|
|
const [versions, setVersions] = useState<DocumentVersionSummary[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
|
|
|
|
const [versionContent, setVersionContent] = useState<string>("");
|
|
|
|
|
const [contentLoading, setContentLoading] = useState(false);
|
|
|
|
|
const [restoring, setRestoring] = useState(false);
|
2026-04-03 10:42:21 +05:30
|
|
|
const [copied, setCopied] = useState(false);
|
2026-04-02 11:40:04 +05:30
|
|
|
|
|
|
|
|
const loadVersions = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const data = await documentsApiService.listDocumentVersions(documentId);
|
|
|
|
|
setVersions(data as DocumentVersionSummary[]);
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error("Failed to load version history");
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [documentId]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadVersions();
|
|
|
|
|
}, [loadVersions]);
|
|
|
|
|
|
|
|
|
|
const handleSelectVersion = async (versionNumber: number) => {
|
2026-04-03 10:42:21 +05:30
|
|
|
if (selectedVersion === versionNumber) return;
|
2026-04-02 11:40:04 +05:30
|
|
|
setSelectedVersion(versionNumber);
|
|
|
|
|
setContentLoading(true);
|
|
|
|
|
try {
|
2026-04-03 13:14:40 +05:30
|
|
|
const data = (await documentsApiService.getDocumentVersion(documentId, versionNumber)) as {
|
|
|
|
|
source_markdown: string;
|
|
|
|
|
};
|
2026-04-02 11:40:04 +05:30
|
|
|
setVersionContent(data.source_markdown || "");
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error("Failed to load version content");
|
|
|
|
|
} finally {
|
|
|
|
|
setContentLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRestore = async (versionNumber: number) => {
|
|
|
|
|
setRestoring(true);
|
|
|
|
|
try {
|
|
|
|
|
await documentsApiService.restoreDocumentVersion(documentId, versionNumber);
|
|
|
|
|
toast.success(`Restored version ${versionNumber}`);
|
|
|
|
|
await loadVersions();
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error("Failed to restore version");
|
|
|
|
|
} finally {
|
|
|
|
|
setRestoring(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-03 10:42:21 +05:30
|
|
|
const handleCopy = () => {
|
|
|
|
|
navigator.clipboard.writeText(versionContent);
|
|
|
|
|
setCopied(true);
|
|
|
|
|
setTimeout(() => setCopied(false), 2000);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-02 11:40:04 +05:30
|
|
|
if (loading) {
|
|
|
|
|
return (
|
2026-04-03 10:42:21 +05:30
|
|
|
<div className="flex flex-1 items-center justify-center">
|
2026-04-02 11:40:04 +05:30
|
|
|
<Spinner size="lg" className="text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (versions.length === 0) {
|
|
|
|
|
return (
|
2026-04-03 10:42:21 +05:30
|
|
|
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
|
2026-04-03 10:56:43 +05:30
|
|
|
<p className="text-sm">No version history available yet</p>
|
|
|
|
|
<p className="text-xs mt-1">Versions are created when file content changes</p>
|
2026-04-02 11:40:04 +05:30
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 10:42:21 +05:30
|
|
|
const selectedVersionData = versions.find((v) => v.version_number === selectedVersion);
|
|
|
|
|
|
2026-04-02 11:40:04 +05:30
|
|
|
return (
|
2026-04-03 10:42:21 +05:30
|
|
|
<>
|
|
|
|
|
{/* Left panel — version list */}
|
|
|
|
|
<nav className="w-full md:w-[260px] shrink-0 flex flex-col border-b md:border-b-0 md:border-r border-border">
|
|
|
|
|
<div className="px-4 pr-12 md:pr-4 pt-5 pb-2">
|
|
|
|
|
<h2 className="text-sm font-semibold text-foreground">Version History</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
|
|
|
{versions.map((v) => (
|
|
|
|
|
<button
|
|
|
|
|
key={v.version_number}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => handleSelectVersion(v.version_number)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:outline-none w-full",
|
|
|
|
|
selectedVersion === v.version_number
|
|
|
|
|
? "bg-accent text-accent-foreground"
|
|
|
|
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
2026-04-02 11:40:04 +05:30
|
|
|
)}
|
2026-04-03 10:42:21 +05:30
|
|
|
>
|
|
|
|
|
<div className="flex-1 min-w-0 space-y-0.5">
|
|
|
|
|
<p className="text-sm font-medium truncate">
|
2026-04-03 13:14:40 +05:30
|
|
|
{v.created_at
|
|
|
|
|
? formatRelativeTime(v.created_at)
|
|
|
|
|
: `Version ${v.version_number}`}
|
2026-04-02 11:40:04 +05:30
|
|
|
</p>
|
2026-04-03 13:14:40 +05:30
|
|
|
{v.title && <p className="text-xs text-muted-foreground truncate">{v.title}</p>}
|
2026-04-03 10:42:21 +05:30
|
|
|
</div>
|
|
|
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
{/* Right panel — content preview */}
|
|
|
|
|
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
|
|
|
|
|
{selectedVersion !== null && selectedVersionData ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex items-center justify-between pl-6 pr-14 pt-5 pb-2">
|
|
|
|
|
<h2 className="text-sm font-semibold truncate">
|
|
|
|
|
{selectedVersionData.title || `Version ${selectedVersion}`}
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="gap-1.5 text-xs"
|
|
|
|
|
onClick={handleCopy}
|
|
|
|
|
disabled={contentLoading || copied}
|
|
|
|
|
>
|
2026-04-03 13:14:40 +05:30
|
|
|
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
2026-04-03 10:42:21 +05:30
|
|
|
{copied ? "Copied" : "Copy"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="gap-1.5 text-xs"
|
|
|
|
|
disabled={restoring || contentLoading}
|
|
|
|
|
onClick={() => handleRestore(selectedVersion)}
|
|
|
|
|
>
|
2026-04-03 13:14:40 +05:30
|
|
|
{restoring ? <Spinner size="xs" /> : <RotateCcw className="h-3 w-3" />}
|
2026-04-03 10:42:21 +05:30
|
|
|
Restore
|
|
|
|
|
</Button>
|
2026-04-02 11:40:04 +05:30
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-03 10:42:21 +05:30
|
|
|
<Separator />
|
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
|
|
|
{contentLoading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
<Spinner size="sm" className="text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<pre className="text-sm whitespace-pre-wrap font-mono leading-relaxed text-foreground/90">
|
|
|
|
|
{versionContent || "(empty)"}
|
|
|
|
|
</pre>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
|
|
|
|
<p className="text-sm">Select a version to preview</p>
|
2026-04-02 11:40:04 +05:30
|
|
|
</div>
|
2026-04-03 10:42:21 +05:30
|
|
|
)}
|
2026-04-02 11:40:04 +05:30
|
|
|
</div>
|
2026-04-03 10:42:21 +05:30
|
|
|
</>
|
2026-04-02 11:40:04 +05:30
|
|
|
);
|
|
|
|
|
}
|