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

@@ -7,7 +7,7 @@ import { EntryStatus, Visibility } from "@mosaic/shared";
import { EntryViewer } from "@/components/knowledge/EntryViewer";
import { EntryEditor } from "@/components/knowledge/EntryEditor";
import { EntryMetadata } from "@/components/knowledge/EntryMetadata";
import { EntryGraphViewer } from "@/components/knowledge/EntryGraphViewer";
import { VersionHistory } from "@/components/knowledge/VersionHistory";
import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge";
/**
@@ -21,7 +21,6 @@ export default function EntryPage() {
const [entry, setEntry] = useState<KnowledgeEntryWithTags | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [showGraph, setShowGraph] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -34,6 +33,7 @@ export default function EntryPage() {
const [editTags, setEditTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<KnowledgeTag[]>([]);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [activeTab, setActiveTab] = useState<"content" | "history">("content");
// Load entry data
useEffect(() => {
@@ -46,7 +46,7 @@ export default function EntryPage() {
setEditContent(data.content);
setEditStatus(data.status);
setEditVisibility(data.visibility);
setEditTags(data.tags.map((tag: { id: string }) => tag.id));
setEditTags(data.tags.map((tag) => tag.id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load entry");
} finally {
@@ -82,7 +82,7 @@ export default function EntryPage() {
editStatus !== entry.status ||
editVisibility !== entry.visibility ||
JSON.stringify(editTags.sort()) !==
JSON.stringify(entry.tags.map((t: { id: string }) => t.id).sort());
JSON.stringify(entry.tags.map((t) => t.id).sort());
setHasUnsavedChanges(changed);
}, [entry, isEditing, editTitle, editContent, editStatus, editVisibility, editTags]);
@@ -158,7 +158,7 @@ export default function EntryPage() {
setEditContent(entry.content);
setEditStatus(entry.status);
setEditVisibility(entry.visibility);
setEditTags(entry.tags.map((tag: { id: string }) => tag.id));
setEditTags(entry.tags.map((tag) => tag.id));
setIsEditing(false);
setHasUnsavedChanges(false);
}
@@ -181,6 +181,25 @@ export default function EntryPage() {
}
};
const handleVersionRestore = (): void => {
// Reload entry after version restore
async function reload(): Promise<void> {
try {
const data = await fetchEntry(slug);
setEntry(data);
setEditTitle(data.title);
setEditContent(data.content);
setEditStatus(data.status);
setEditVisibility(data.visibility);
setEditTags(data.tags.map((tag) => tag.id));
setActiveTab("content");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to reload entry");
}
}
void reload();
};
if (isLoading) {
return (
<div className="max-w-4xl mx-auto p-6">
@@ -250,7 +269,7 @@ export default function EntryPage() {
</span>
{/* Tags */}
{entry.tags.map((tag: { id: string; name: string; color: string | null }) => (
{entry.tags.map((tag) => (
<span
key={tag.id}
className="px-3 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
@@ -270,31 +289,33 @@ export default function EntryPage() {
</div>
)}
{/* View Tabs */}
{/* Tabs */}
{!isEditing && (
<div className="mb-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex gap-4">
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav className="flex gap-6">
<button
onClick={() => setShowGraph(false)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
!showGraph
? "border-blue-500 text-blue-600 dark:text-blue-400"
type="button"
onClick={() => setActiveTab("content")}
className={`pb-3 border-b-2 font-medium text-sm transition-colors ${
activeTab === "content"
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Content
</button>
<button
onClick={() => setShowGraph(true)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
showGraph
? "border-blue-500 text-blue-600 dark:text-blue-400"
type="button"
onClick={() => setActiveTab("history")}
className={`pb-3 border-b-2 font-medium text-sm transition-colors ${
activeTab === "history"
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Graph View
History
</button>
</div>
</nav>
</div>
)}
@@ -302,12 +323,10 @@ export default function EntryPage() {
<div className="mb-6">
{isEditing ? (
<EntryEditor content={editContent} onChange={setEditContent} />
) : showGraph ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden" style={{ height: '600px' }}>
<EntryGraphViewer slug={slug} initialDepth={1} />
</div>
) : (
) : activeTab === "content" ? (
<EntryViewer entry={entry} />
) : (
<VersionHistory slug={slug} onRestore={handleVersionRestore} />
)}
</div>

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

View File

@@ -3,7 +3,12 @@
* Handles knowledge entry-related API requests
*/
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
import type {
KnowledgeEntryWithTags,
KnowledgeTag,
KnowledgeEntryVersionWithAuthor,
PaginatedResponse,
} from "@mosaic/shared";
import { EntryStatus, Visibility } from "@mosaic/shared";
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
@@ -44,6 +49,11 @@ export interface UpdateEntryData {
status?: EntryStatus;
visibility?: Visibility;
tags?: string[];
changeNote?: string;
}
export interface RestoreVersionData {
changeNote?: string;
}
/**
@@ -129,19 +139,46 @@ export async function fetchTags(): Promise<KnowledgeTag[]> {
}
/**
* Fetch entry-centered graph view
* Fetch version history for an entry
*/
export async function fetchEntryGraph(slug: string, depth: number = 1) {
export async function fetchVersions(
slug: string,
page: number = 1,
limit: number = 20
): Promise<PaginatedResponse<KnowledgeEntryVersionWithAuthor>> {
const params = new URLSearchParams();
params.append("depth", depth.toString());
return apiGet(`/api/knowledge/entries/${slug}/graph?${params.toString()}`);
params.append("page", page.toString());
params.append("limit", limit.toString());
return apiGet<PaginatedResponse<KnowledgeEntryVersionWithAuthor>>(
`/api/knowledge/entries/${slug}/versions?${params.toString()}`
);
}
/**
* Fetch knowledge base statistics
* Fetch a specific version of an entry
*/
export async function fetchKnowledgeStats() {
return apiGet("/api/knowledge/stats");
export async function fetchVersion(
slug: string,
version: number
): Promise<KnowledgeEntryVersionWithAuthor> {
return apiGet<KnowledgeEntryVersionWithAuthor>(
`/api/knowledge/entries/${slug}/versions/${version}`
);
}
/**
* Restore a previous version of an entry
*/
export async function restoreVersion(
slug: string,
version: number,
data?: RestoreVersionData
): Promise<KnowledgeEntryWithTags> {
return apiPost<KnowledgeEntryWithTags>(
`/api/knowledge/entries/${slug}/restore/${version}`,
data || {}
);
}
/**