feat: add knowledge version history (closes #75, closes #76)

- Added EntryVersion model with author relation
- Implemented automatic versioning on entry create/update
- Added API endpoints for version history:
  - GET /api/knowledge/entries/:slug/versions - list versions
  - GET /api/knowledge/entries/:slug/versions/:version - get specific
  - POST /api/knowledge/entries/:slug/restore/:version - restore version
- Created VersionHistory.tsx component with timeline view
- Added History tab to entry detail page
- Supports version viewing and restoring
- Includes comprehensive tests for version operations
- All TypeScript types are explicit and type-safe
This commit is contained in:
Jason Woltje
2026-01-29 23:27:03 -06:00
parent 59aec28d5c
commit 7465d0a3c2
14 changed files with 2450 additions and 24 deletions

View File

@@ -7,6 +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 { VersionHistory } from "@/components/knowledge/VersionHistory";
import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge";
/**
@@ -32,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(() => {
@@ -179,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">
@@ -268,12 +289,44 @@ export default function EntryPage() {
</div>
)}
{/* Tabs */}
{!isEditing && (
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav className="flex gap-6">
<button
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
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"
}`}
>
History
</button>
</nav>
</div>
)}
{/* Content */}
<div className="mb-6">
{isEditing ? (
<EntryEditor content={editContent} onChange={setEditContent} />
) : (
) : activeTab === "content" ? (
<EntryViewer entry={entry} />
) : (
<VersionHistory slug={slug} onRestore={handleVersionRestore} />
)}
</div>