mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 16:56:22 +02:00
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:
parent
19dfd9f907
commit
4ca0787ab7
5 changed files with 214 additions and 85 deletions
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue