Merge: Knowledge version history - API and UI (closes #75, #76)

This commit is contained in:
Jason Woltje
2026-01-29 23:39:49 -06:00
14 changed files with 1222 additions and 140 deletions

View 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>
);
}

View File

@@ -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";