- 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
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { useRouter, useParams } from "next/navigation";
|
|
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
|
|
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";
|
|
|
|
/**
|
|
* Knowledge Entry Detail/Editor Page
|
|
* View and edit mode for a single knowledge entry
|
|
*/
|
|
export default function EntryPage() {
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const slug = params.slug as string;
|
|
|
|
const [entry, setEntry] = useState<KnowledgeEntryWithTags | null>(null);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Edit state
|
|
const [editTitle, setEditTitle] = useState("");
|
|
const [editContent, setEditContent] = useState("");
|
|
const [editStatus, setEditStatus] = useState<EntryStatus>(EntryStatus.DRAFT);
|
|
const [editVisibility, setEditVisibility] = useState<Visibility>(Visibility.WORKSPACE);
|
|
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(() => {
|
|
async function loadEntry(): Promise<void> {
|
|
try {
|
|
setIsLoading(true);
|
|
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));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to load entry");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
void loadEntry();
|
|
}, [slug]);
|
|
|
|
// Load available tags
|
|
useEffect(() => {
|
|
async function loadTags(): Promise<void> {
|
|
try {
|
|
const tags = await fetchTags();
|
|
setAvailableTags(tags);
|
|
} catch (err) {
|
|
console.error("Failed to load tags:", err);
|
|
}
|
|
}
|
|
void loadTags();
|
|
}, []);
|
|
|
|
// Track unsaved changes
|
|
useEffect(() => {
|
|
if (!entry || !isEditing) {
|
|
setHasUnsavedChanges(false);
|
|
return;
|
|
}
|
|
|
|
const changed =
|
|
editTitle !== entry.title ||
|
|
editContent !== entry.content ||
|
|
editStatus !== entry.status ||
|
|
editVisibility !== entry.visibility ||
|
|
JSON.stringify(editTags.sort()) !==
|
|
JSON.stringify(entry.tags.map((t) => t.id).sort());
|
|
|
|
setHasUnsavedChanges(changed);
|
|
}, [entry, isEditing, editTitle, editContent, editStatus, editVisibility, editTags]);
|
|
|
|
// Warn before leaving with unsaved changes
|
|
useEffect(() => {
|
|
const handleBeforeUnload = (e: BeforeUnloadEvent): string => {
|
|
if (hasUnsavedChanges) {
|
|
e.preventDefault();
|
|
return "You have unsaved changes. Are you sure you want to leave?";
|
|
}
|
|
return "";
|
|
};
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
}, [hasUnsavedChanges]);
|
|
|
|
// Save changes
|
|
const handleSave = useCallback(async (): Promise<void> => {
|
|
if (!entry || isSaving || !editTitle.trim() || !editContent.trim()) {
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const updated = await updateEntry(slug, {
|
|
title: editTitle.trim(),
|
|
content: editContent.trim(),
|
|
status: editStatus,
|
|
visibility: editVisibility,
|
|
tags: editTags,
|
|
});
|
|
|
|
setEntry(updated);
|
|
setHasUnsavedChanges(false);
|
|
setIsEditing(false);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to save changes");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [entry, slug, editTitle, editContent, editStatus, editVisibility, editTags, isSaving]);
|
|
|
|
// Cmd+S / Ctrl+S to save
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "s" && isEditing) {
|
|
e.preventDefault();
|
|
void handleSave();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [handleSave, isEditing]);
|
|
|
|
const handleEdit = (): void => {
|
|
if (!entry) return;
|
|
setIsEditing(true);
|
|
};
|
|
|
|
const handleCancel = (): void => {
|
|
if (!entry) return;
|
|
|
|
if (
|
|
!hasUnsavedChanges ||
|
|
confirm("You have unsaved changes. Are you sure you want to cancel?")
|
|
) {
|
|
setEditTitle(entry.title);
|
|
setEditContent(entry.content);
|
|
setEditStatus(entry.status);
|
|
setEditVisibility(entry.visibility);
|
|
setEditTags(entry.tags.map((tag) => tag.id));
|
|
setIsEditing(false);
|
|
setHasUnsavedChanges(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (): Promise<void> => {
|
|
if (
|
|
!confirm(
|
|
"Are you sure you want to delete this entry? It will be archived and can be restored later."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await deleteEntry(slug);
|
|
router.push("/knowledge");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to delete entry");
|
|
}
|
|
};
|
|
|
|
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">
|
|
<div className="animate-pulse space-y-4">
|
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
|
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !entry) {
|
|
return (
|
|
<div className="max-w-4xl mx-auto p-6">
|
|
<div className="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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto p-6">
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
{isEditing ? (
|
|
<div className="space-y-4">
|
|
<EntryMetadata
|
|
title={editTitle}
|
|
status={editStatus}
|
|
visibility={editVisibility}
|
|
selectedTags={editTags}
|
|
availableTags={availableTags}
|
|
onTitleChange={setEditTitle}
|
|
onStatusChange={setEditStatus}
|
|
onVisibilityChange={setEditVisibility}
|
|
onTagsChange={setEditTags}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
|
{entry.title}
|
|
</h1>
|
|
<div className="mt-3 flex items-center gap-4 flex-wrap">
|
|
{/* Status Badge */}
|
|
<span
|
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
|
entry.status === EntryStatus.PUBLISHED
|
|
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
|
|
: entry.status === EntryStatus.DRAFT
|
|
? "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
|
|
: "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
|
|
}`}
|
|
>
|
|
{entry.status}
|
|
</span>
|
|
|
|
{/* Visibility Badge */}
|
|
<span className="px-3 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
|
|
{entry.visibility}
|
|
</span>
|
|
|
|
{/* Tags */}
|
|
{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"
|
|
style={tag.color ? { backgroundColor: tag.color } : undefined}
|
|
>
|
|
{tag.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-6 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>
|
|
)}
|
|
|
|
{/* 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>
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<div>
|
|
{isEditing && (
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete}
|
|
disabled={isSaving}
|
|
className="px-4 py-2 text-sm font-medium text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 disabled:opacity-50"
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
{isEditing ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={handleCancel}
|
|
disabled={isSaving}
|
|
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"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={isSaving || !editTitle.trim() || !editContent.trim()}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
>
|
|
{isSaving ? "Saving..." : "Save Changes"}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handleEdit}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isEditing && (
|
|
<p className="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
|
|
Press <kbd className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded">Cmd+S</kbd>{" "}
|
|
or <kbd className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded">Ctrl+S</kbd> to
|
|
save
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|