223
apps/web/src/components/knowledge/VersionHistory.tsx
Normal file
223
apps/web/src/components/knowledge/VersionHistory.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import type { KnowledgeEntryVersionWithAuthor } from "@mosaic/shared";
|
||||
import { fetchVersions, fetchVersion, restoreVersion } from "@/lib/api/knowledge";
|
||||
|
||||
interface VersionHistoryProps {
|
||||
slug: string;
|
||||
onRestore?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version History Component
|
||||
* Displays version history timeline for a knowledge entry
|
||||
* Allows viewing and restoring previous versions
|
||||
*/
|
||||
export function VersionHistory({ slug, onRestore }: VersionHistoryProps): React.JSX.Element {
|
||||
const [versions, setVersions] = useState<KnowledgeEntryVersionWithAuthor[]>([]);
|
||||
const [selectedVersion, setSelectedVersion] = useState<KnowledgeEntryVersionWithAuthor | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Load versions
|
||||
useEffect(() => {
|
||||
async function loadVersions(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await fetchVersions(slug, page, 20);
|
||||
setVersions([...response.data]);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load version history");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
void loadVersions();
|
||||
}, [slug, page]);
|
||||
|
||||
// Load specific version for preview
|
||||
const handleViewVersion = async (version: number): Promise<void> => {
|
||||
try {
|
||||
setError(null);
|
||||
const versionData = await fetchVersion(slug, version);
|
||||
setSelectedVersion(versionData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load version");
|
||||
}
|
||||
};
|
||||
|
||||
// Restore a version
|
||||
const handleRestore = async (version: number): Promise<void> => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to restore version ${version}? This will create a new version with the content from version ${version}.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsRestoring(true);
|
||||
setError(null);
|
||||
await restoreVersion(slug, version, {
|
||||
changeNote: `Restored from version ${version}`,
|
||||
});
|
||||
setSelectedVersion(null);
|
||||
setPage(1); // Reload first page to see new version
|
||||
if (onRestore) {
|
||||
onRestore();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to restore version");
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return new Date(date).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading && versions.length === 0) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{versions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>No version history available</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Version Timeline */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{versions.map((version, index) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className={`border rounded-lg p-4 transition-colors ${
|
||||
selectedVersion?.id === version.id
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
|
||||
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Version {version.version}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
{version.author.name} ({version.author.email})
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
{formatDate(version.createdAt)}
|
||||
</p>
|
||||
{version.changeNote && (
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300 italic">
|
||||
"{version.changeNote}"
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{version.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewVersion(version.version)}
|
||||
className="px-3 py-1 text-sm font-medium text-blue-700 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300"
|
||||
>
|
||||
{selectedVersion?.id === version.id ? "Hide" : "View"}
|
||||
</button>
|
||||
{index !== 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRestore(version.version)}
|
||||
disabled={isRestoring}
|
||||
className="px-3 py-1 text-sm font-medium text-green-700 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 disabled:opacity-50"
|
||||
>
|
||||
{isRestoring ? "Restoring..." : "Restore"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Preview */}
|
||||
{selectedVersion?.id === version.id && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="font-semibold mb-2 text-gray-900 dark:text-gray-100">
|
||||
Content Preview
|
||||
</h4>
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 max-h-96 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 font-mono">
|
||||
{selectedVersion.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1 || isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages || isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export { EntryCard } from "./EntryCard";
|
||||
export { EntryEditor } from "./EntryEditor";
|
||||
export { EntryFilters } from "./EntryFilters";
|
||||
export { EntryList } from "./EntryList";
|
||||
export { EntryMetadata } from "./EntryMetadata";
|
||||
/**
|
||||
* Knowledge module components
|
||||
*/
|
||||
|
||||
export { EntryViewer } from "./EntryViewer";
|
||||
export { StatsDashboard } from "./StatsDashboard";
|
||||
export { EntryGraphViewer } from "./EntryGraphViewer";
|
||||
export { EntryEditor } from "./EntryEditor";
|
||||
export { EntryMetadata } from "./EntryMetadata";
|
||||
export { VersionHistory } from "./VersionHistory";
|
||||
|
||||
Reference in New Issue
Block a user