feat: add version history functionality to document management, enabling users to view and manage document versions through UI updates and backend integration

This commit is contained in:
Anish Sarkar 2026-04-03 10:42:21 +05:30
parent 19dfd9f907
commit 4ca0787ab7
5 changed files with 214 additions and 85 deletions

View file

@ -1,16 +1,17 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Clock, RotateCcw } from "lucide-react";
import { Check, ChevronRight, Clock, Copy, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { toast } from "sonner";
@ -26,28 +27,72 @@ interface VersionHistoryProps {
documentType: string;
}
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)]";
export function VersionHistoryButton({ documentId, documentType }: VersionHistoryProps) {
const showVersionHistory = documentType === "LOCAL_FOLDER_FILE" || documentType === "OBSIDIAN_CONNECTOR";
if (!showVersionHistory) return null;
if (!isVersionableType(documentType)) return null;
return (
<Sheet>
<SheetTrigger asChild>
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1.5 text-xs">
<Clock className="h-3.5 w-3.5" />
Versions
</Button>
</SheetTrigger>
<SheetContent className="w-[400px] sm:w-[540px]">
<SheetHeader>
<SheetTitle>Version History</SheetTitle>
</SheetHeader>
</DialogTrigger>
<DialogContent className={DIALOG_CLASSES}>
<DialogTitle className="sr-only">Version History</DialogTitle>
<VersionHistoryPanel documentId={documentId} />
</SheetContent>
</Sheet>
</DialogContent>
</Dialog>
);
}
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",
});
}
function VersionHistoryPanel({ documentId }: { documentId: number }) {
const [versions, setVersions] = useState<DocumentVersionSummary[]>([]);
const [loading, setLoading] = useState(true);
@ -55,6 +100,7 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
const [versionContent, setVersionContent] = useState<string>("");
const [contentLoading, setContentLoading] = useState(false);
const [restoring, setRestoring] = useState(false);
const [copied, setCopied] = useState(false);
const loadVersions = useCallback(async () => {
setLoading(true);
@ -73,6 +119,7 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
}, [loadVersions]);
const handleSelectVersion = async (versionNumber: number) => {
if (selectedVersion === versionNumber) return;
setSelectedVersion(versionNumber);
setContentLoading(true);
try {
@ -101,9 +148,15 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
}
};
const handleCopy = () => {
navigator.clipboard.writeText(versionContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="flex flex-1 items-center justify-center">
<Spinner size="lg" className="text-muted-foreground" />
</div>
);
@ -111,75 +164,111 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
if (versions.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Clock className="h-8 w-8 mb-2 opacity-50" />
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
<p className="text-sm">No version history available yet.</p>
<p className="text-xs mt-1">Versions are created when file content changes.</p>
</div>
);
}
return (
<div className="flex flex-col gap-4 pt-4 h-full">
<div className="flex-1 overflow-y-auto space-y-2">
{versions.map((v) => (
<div
key={v.version_number}
className={`rounded-lg border p-3 cursor-pointer transition-colors ${
selectedVersion === v.version_number
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
onClick={() => handleSelectVersion(v.version_number)}
>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Version {v.version_number}</p>
{v.created_at && (
<p className="text-xs text-muted-foreground">
{new Date(v.created_at).toLocaleString()}
</p>
)}
{v.title && (
<p className="text-xs text-muted-foreground truncate max-w-[200px]">
{v.title}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1"
disabled={restoring}
onClick={(e) => {
e.stopPropagation();
handleRestore(v.version_number);
}}
>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
</div>
</div>
))}
</div>
const selectedVersionData = versions.find((v) => v.version_number === selectedVersion);
{selectedVersion !== null && (
<div className="border-t pt-4 max-h-[40vh] overflow-y-auto">
<h4 className="text-sm font-medium mb-2">
Preview Version {selectedVersion}
</h4>
{contentLoading ? (
<div className="flex items-center justify-center py-6">
<Spinner size="sm" />
</div>
) : (
<pre className="text-xs whitespace-pre-wrap font-mono bg-muted/50 rounded-lg p-3 max-h-[30vh] overflow-y-auto">
{versionContent || "(empty)"}
</pre>
)}
return (
<>
{/* 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>
<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"
)}
>
<div className="flex-1 min-w-0 space-y-0.5">
<p className="text-sm font-medium truncate">
{v.created_at ? formatRelativeTime(v.created_at) : `Version ${v.version_number}`}
</p>
{v.title && (
<p className="text-xs text-muted-foreground truncate">
{v.title}
</p>
)}
</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}
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Copied" : "Copy"}
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
disabled={restoring || contentLoading}
onClick={() => handleRestore(selectedVersion)}
>
{restoring ? (
<Spinner size="xs" />
) : (
<RotateCcw className="h-3 w-3" />
)}
Restore
</Button>
</div>
</div>
<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>
</div>
)}
</div>
</>
);
}