Files
stack/apps/web/src/components/knowledge/VersionHistory.tsx
Jason Woltje ac1f2c176f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: Resolve all ESLint errors and warnings in web package
Fixes all 542 ESLint problems in the web package to achieve 0 errors and 0 warnings.

Changes:
- Fixed 144 issues: nullish coalescing, return types, unused variables
- Fixed 118 issues: unnecessary conditions, type safety, template literals
- Fixed 79 issues: non-null assertions, unsafe assignments, empty functions
- Fixed 67 issues: explicit return types, promise handling, enum comparisons
- Fixed 45 final warnings: missing return types, optional chains
- Fixed 25 typecheck-related issues: async/await, type assertions, formatting
- Fixed JSX.Element namespace errors across 90+ files

All Quality Rails violations resolved. Lint and typecheck both pass with 0 problems.

Files modified: 118 components, tests, hooks, and utilities

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 00:10:03 -06:00

230 lines
8.7 KiB
TypeScript

"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.toString()}? This will create a new version with the content from version ${version.toString()}.`
)
) {
return;
}
try {
setIsRestoring(true);
setError(null);
await restoreVersion(slug, version, {
changeNote: `Restored from version ${String(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>
);
}