Files
codeboard/apps/web/src/app/history/page.tsx
Vectry 72de50dffa feat: add version history page with side-by-side comparison
New /history page shows all past generations for a repo and allows
selecting two to compare side-by-side. Displays tech stack diffs,
architecture diagrams, key metrics changes, and module breakdowns.
Added Version History link to doc viewer header.
2026-02-09 20:48:53 +00:00

792 lines
27 KiB
TypeScript

"use client";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import type { GeneratedDocs } from "@codeboard/shared";
import { MermaidDiagram } from "@/components/mermaid-diagram";
import {
ArrowLeft,
Clock,
GitCommit,
History,
CheckSquare,
Square,
GitCompare,
X,
BookOpen,
Layers,
Folder,
FileCode,
} from "lucide-react";
interface Generation {
id: string;
repoUrl: string;
repoName: string;
commitHash: string;
createdAt: string;
duration: number | null;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatDuration(seconds: number | null): string {
if (!seconds) return "Unknown";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins > 0) {
return `${mins}m ${secs}s`;
}
return `${secs}s`;
}
function TechStackDiff({
leftStack,
rightStack,
}: {
leftStack: string[];
rightStack: string[];
}) {
const leftSet = new Set(leftStack.map((s) => s.toLowerCase()));
const rightSet = new Set(rightStack.map((s) => s.toLowerCase()));
const added = rightStack.filter((s) => !leftSet.has(s.toLowerCase()));
const removed = leftStack.filter((s) => !rightSet.has(s.toLowerCase()));
const unchanged = leftStack.filter((s) => rightSet.has(s.toLowerCase()));
return (
<div className="flex flex-wrap gap-2">
{removed.map((tech) => (
<span
key={`removed-${tech}`}
className="px-3 py-1 text-sm bg-red-500/10 border border-red-500/30 rounded-full text-red-300 line-through"
title="Removed"
>
{tech}
</span>
))}
{unchanged.map((tech) => (
<span
key={tech}
className="px-3 py-1 text-sm bg-white/5 border border-white/10 rounded-full text-zinc-400"
>
{tech}
</span>
))}
{added.map((tech) => (
<span
key={`added-${tech}`}
className="px-3 py-1 text-sm bg-green-500/10 border border-green-500/30 rounded-full text-green-300"
title="Added"
>
{tech}
</span>
))}
</div>
);
}
function ComparisonView({
left,
right,
leftGen,
rightGen,
onClose,
}: {
left: GeneratedDocs;
right: GeneratedDocs;
leftGen: Generation;
rightGen: Generation;
onClose: () => void;
}) {
const leftOverview = left.sections.overview;
const rightOverview = right.sections.overview;
const filesDiff = rightOverview.keyMetrics.files - leftOverview.keyMetrics.files;
const modulesDiff =
rightOverview.keyMetrics.modules - leftOverview.keyMetrics.modules;
const languagesDiff =
rightOverview.keyMetrics.languages.length -
leftOverview.keyMetrics.languages.length;
return (
<div className="fixed inset-0 z-50 bg-[#0a0a0f] overflow-auto">
<div className="sticky top-0 z-10 border-b border-white/10 bg-black/50 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<GitCompare className="w-6 h-6 text-blue-400" />
<h2 className="text-xl font-bold text-white">Version Comparison</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Panel - Older */}
<div className="space-y-6">
<div className="glass rounded-xl p-4 border-l-4 border-l-zinc-500">
<div className="flex items-center gap-3 mb-2">
<GitCommit className="w-4 h-4 text-zinc-400" />
<code className="text-sm text-zinc-300">
{leftGen.commitHash.slice(0, 7)}
</code>
<span className="text-xs text-zinc-500"></span>
<span className="text-sm text-zinc-400">
{formatDate(leftGen.createdAt)}
</span>
</div>
<p className="text-xs text-zinc-500">Older version</p>
</div>
<div className="glass rounded-xl p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<BookOpen className="w-5 h-5 text-blue-400" />
Overview
</h3>
<p className="text-zinc-300 text-sm leading-relaxed mb-4">
{leftOverview.description}
</p>
<h4 className="text-sm font-medium text-zinc-400 mb-3">
Tech Stack
</h4>
<div className="flex flex-wrap gap-2">
{leftOverview.techStack.map((tech) => (
<span
key={tech}
className="px-3 py-1 text-sm bg-white/5 border border-white/10 rounded-full text-zinc-300"
>
{tech}
</span>
))}
</div>
</div>
<div className="glass rounded-xl p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Layers className="w-5 h-5 text-blue-400" />
Architecture
</h3>
<MermaidDiagram chart={leftOverview.architectureDiagram} />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="p-4 glass rounded-lg text-center">
<div className="text-2xl font-bold text-white">
{leftOverview.keyMetrics.files}
</div>
<div className="text-sm text-zinc-500">Files</div>
</div>
<div className="p-4 glass rounded-lg text-center">
<div className="text-2xl font-bold text-white">
{leftOverview.keyMetrics.modules}
</div>
<div className="text-sm text-zinc-500">Modules</div>
</div>
<div className="p-4 glass rounded-lg text-center">
<div className="text-2xl font-bold text-white">
{leftOverview.keyMetrics.languages.length}
</div>
<div className="text-sm text-zinc-500">Languages</div>
</div>
</div>
</div>
{/* Right Panel - Newer */}
<div className="space-y-6">
<div className="glass rounded-xl p-4 border-l-4 border-l-green-500">
<div className="flex items-center gap-3 mb-2">
<GitCommit className="w-4 h-4 text-zinc-400" />
<code className="text-sm text-zinc-300">
{rightGen.commitHash.slice(0, 7)}
</code>
<span className="text-xs text-zinc-500"></span>
<span className="text-sm text-zinc-400">
{formatDate(rightGen.createdAt)}
</span>
</div>
<p className="text-xs text-green-400">Newer version</p>
</div>
<div className="glass rounded-xl p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<BookOpen className="w-5 h-5 text-blue-400" />
Overview
</h3>
<p className="text-zinc-300 text-sm leading-relaxed mb-4">
{rightOverview.description}
</p>
<h4 className="text-sm font-medium text-zinc-400 mb-3">
Tech Stack Changes
</h4>
<TechStackDiff
leftStack={leftOverview.techStack}
rightStack={rightOverview.techStack}
/>
</div>
<div className="glass rounded-xl p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Layers className="w-5 h-5 text-blue-400" />
Architecture
</h3>
<MermaidDiagram chart={rightOverview.architectureDiagram} />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="p-4 glass rounded-lg text-center relative overflow-hidden">
<div
className={`text-2xl font-bold ${
filesDiff > 0
? "text-green-400"
: filesDiff < 0
? "text-red-400"
: "text-white"
}`}
>
{rightOverview.keyMetrics.files}
</div>
<div className="text-sm text-zinc-500">Files</div>
{filesDiff !== 0 && (
<div
className={`absolute top-1 right-2 text-xs ${
filesDiff > 0 ? "text-green-400" : "text-red-400"
}`}
>
{filesDiff > 0 ? "+" : ""}
{filesDiff}
</div>
)}
</div>
<div className="p-4 glass rounded-lg text-center relative overflow-hidden">
<div
className={`text-2xl font-bold ${
modulesDiff > 0
? "text-green-400"
: modulesDiff < 0
? "text-red-400"
: "text-white"
}`}
>
{rightOverview.keyMetrics.modules}
</div>
<div className="text-sm text-zinc-500">Modules</div>
{modulesDiff !== 0 && (
<div
className={`absolute top-1 right-2 text-xs ${
modulesDiff > 0 ? "text-green-400" : "text-red-400"
}`}
>
{modulesDiff > 0 ? "+" : ""}
{modulesDiff}
</div>
)}
</div>
<div className="p-4 glass rounded-lg text-center relative overflow-hidden">
<div
className={`text-2xl font-bold ${
languagesDiff > 0
? "text-green-400"
: languagesDiff < 0
? "text-red-400"
: "text-white"
}`}
>
{rightOverview.keyMetrics.languages.length}
</div>
<div className="text-sm text-zinc-500">Languages</div>
{languagesDiff !== 0 && (
<div
className={`absolute top-1 right-2 text-xs ${
languagesDiff > 0 ? "text-green-400" : "text-red-400"
}`}
>
{languagesDiff > 0 ? "+" : ""}
{languagesDiff}
</div>
)}
</div>
</div>
</div>
</div>
{/* Module Comparison */}
<div className="mt-8">
<h3 className="text-2xl font-bold text-white mb-6 flex items-center gap-3">
<Folder className="w-6 h-6 text-blue-400" />
Module Breakdown Comparison
</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="glass rounded-xl p-6">
<h4 className="font-semibold text-white mb-4 text-zinc-400">
Older Version ({left.sections.modules.length} modules)
</h4>
<div className="space-y-2 max-h-96 overflow-auto">
{left.sections.modules.map((module) => (
<div
key={module.name}
className="flex items-center gap-3 p-3 rounded-lg bg-white/5"
>
<FileCode className="w-4 h-4 text-zinc-500" />
<div className="flex-1 min-w-0">
<div className="text-sm text-zinc-300 truncate">
{module.name}
</div>
<div className="text-xs text-zinc-500 truncate">
{module.path}
</div>
</div>
</div>
))}
</div>
</div>
<div className="glass rounded-xl p-6">
<h4 className="font-semibold text-white mb-4 text-green-400">
Newer Version ({right.sections.modules.length} modules)
</h4>
<div className="space-y-2 max-h-96 overflow-auto">
{right.sections.modules.map((module) => {
const existedInLeft = left.sections.modules.some(
(m) => m.name === module.name
);
return (
<div
key={module.name}
className={`flex items-center gap-3 p-3 rounded-lg ${
existedInLeft ? "bg-white/5" : "bg-green-500/10 border border-green-500/20"
}`}
>
<FileCode
className={`w-4 h-4 ${
existedInLeft ? "text-zinc-500" : "text-green-400"
}`}
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-zinc-300 truncate">
{module.name}
{!existedInLeft && (
<span className="ml-2 text-xs text-green-400">
(new)
</span>
)}
</div>
<div className="text-xs text-zinc-500 truncate">
{module.path}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function HistoryContent() {
const searchParams = useSearchParams();
const repo = searchParams.get("repo");
const [generations, setGenerations] = useState<Generation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [comparing, setComparing] = useState(false);
const [leftDoc, setLeftDoc] = useState<GeneratedDocs | null>(null);
const [rightDoc, setRightDoc] = useState<GeneratedDocs | null>(null);
const [leftGen, setLeftGen] = useState<Generation | null>(null);
const [rightGen, setRightGen] = useState<Generation | null>(null);
useEffect(() => {
if (!repo) {
setLoading(false);
setError("No repository URL provided");
return;
}
fetch(`/api/history?repo=${encodeURIComponent(repo)}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch history");
return res.json();
})
.then((data: Generation[]) => {
setGenerations(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, [repo]);
const toggleSelection = (id: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else if (newSelected.size < 2) {
newSelected.add(id);
}
setSelectedIds(newSelected);
};
const handleCompare = async () => {
if (selectedIds.size !== 2) return;
const ids = Array.from(selectedIds);
const gen1 = generations.find((g) => g.id === ids[0])!;
const gen2 = generations.find((g) => g.id === ids[1])!;
// Sort by date - older first
const [olderGen, newerGen] =
new Date(gen1.createdAt) < new Date(gen2.createdAt)
? [gen1, gen2]
: [gen2, gen1];
setComparing(true);
try {
const [olderDocRes, newerDocRes] = await Promise.all([
fetch(`/api/docs/${olderGen.id}`),
fetch(`/api/docs/${newerGen.id}`),
]);
if (!olderDocRes.ok || !newerDocRes.ok) {
throw new Error("Failed to fetch documentation");
}
const [olderDoc, newerDoc] = await Promise.all([
olderDocRes.json(),
newerDocRes.json(),
]);
setLeftDoc(olderDoc);
setRightDoc(newerDoc);
setLeftGen(olderGen);
setRightGen(newerGen);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to compare");
setComparing(false);
}
};
const closeComparison = () => {
setLeftDoc(null);
setRightDoc(null);
setLeftGen(null);
setRightGen(null);
setComparing(false);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
<span className="text-zinc-400">Loading history...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="glass rounded-xl p-8 text-center max-w-md">
<div className="text-red-400 mb-2">Error</div>
<p className="text-zinc-400">{error}</p>
<Link
href="/"
className="inline-flex items-center gap-2 mt-4 text-blue-400 hover:text-blue-300"
>
<ArrowLeft className="w-4 h-4" />
Back to Home
</Link>
</div>
</div>
);
}
if (!repo) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="glass rounded-xl p-8 text-center max-w-md">
<History className="w-12 h-12 text-zinc-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">
No Repository Specified
</h2>
<p className="text-zinc-400 mb-4">
Please provide a repository URL to view its history.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 btn-primary"
>
<ArrowLeft className="w-4 h-4" />
Back to Home
</Link>
</div>
</div>
);
}
if (generations.length === 0) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="glass rounded-xl p-8 text-center max-w-md">
<History className="w-12 h-12 text-zinc-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">No History Found</h2>
<p className="text-zinc-400 mb-4">
No documentation has been generated for this repository yet.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 btn-primary"
>
<ArrowLeft className="w-4 h-4" />
Back to Home
</Link>
</div>
</div>
);
}
if (generations.length === 1) {
return (
<div className="min-h-screen">
<div className="border-b border-white/10 bg-black/20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Home
</Link>
</div>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<History className="w-12 h-12 text-zinc-500 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-white mb-2">
Version History
</h1>
<p className="text-zinc-400">
{generations[0].repoName}
</p>
</div>
<div className="glass rounded-xl p-8 text-center">
<div className="w-16 h-16 rounded-full bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mx-auto mb-4">
<GitCommit className="w-8 h-8 text-blue-400" />
</div>
<h2 className="text-xl font-bold text-white mb-2">
Only One Version Exists
</h2>
<p className="text-zinc-400 max-w-md mx-auto">
Generate docs again after code changes to compare versions and track
how your architecture evolves over time.
</p>
<div className="mt-8 p-4 rounded-lg bg-white/5 max-w-md mx-auto text-left">
<div className="flex items-center gap-3 mb-2">
<GitCommit className="w-4 h-4 text-zinc-400" />
<code className="text-sm text-zinc-300">
{generations[0].commitHash.slice(0, 7)}
</code>
</div>
<div className="flex items-center gap-3 mb-2">
<Clock className="w-4 h-4 text-zinc-400" />
<span className="text-sm text-zinc-300">
{formatDate(generations[0].createdAt)}
</span>
</div>
<div className="flex items-center gap-3">
<Clock className="w-4 h-4 text-zinc-400" />
<span className="text-sm text-zinc-300">
Generated in {formatDuration(generations[0].duration)}
</span>
</div>
</div>
<Link
href={`/docs/${generations[0].id}`}
className="inline-flex items-center gap-2 btn-primary mt-8"
>
<BookOpen className="w-4 h-4" />
View Documentation
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen">
<div className="border-b border-white/10 bg-black/20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Home
</Link>
<span className="text-sm text-zinc-500">
{selectedIds.size} of 2 selected
</span>
</div>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12">
<History className="w-12 h-12 text-zinc-500 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-white mb-2">Version History</h1>
<p className="text-zinc-400">{generations[0]?.repoName}</p>
<p className="text-sm text-zinc-500 mt-2">
Select any 2 versions to compare side-by-side
</p>
</div>
<div className="space-y-3">
{generations.map((gen) => {
const isSelected = selectedIds.has(gen.id);
const canSelect = selectedIds.size < 2 || isSelected;
return (
<div
key={gen.id}
className={`glass rounded-xl p-4 transition-all ${
isSelected
? "border-blue-500/50 bg-blue-500/5"
: "border-white/10"
} ${!canSelect ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-4">
<button
onClick={() => canSelect && toggleSelection(gen.id)}
className={`flex-shrink-0 p-2 rounded-lg transition-colors ${
isSelected
? "text-blue-400 hover:bg-blue-500/10"
: "text-zinc-500 hover:text-zinc-300 hover:bg-white/5"
} ${!canSelect ? "cursor-not-allowed" : ""}`}
disabled={!canSelect}
>
{isSelected ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<GitCommit className="w-4 h-4 text-zinc-500" />
<code className="text-sm text-zinc-300">
{gen.commitHash.slice(0, 7)}
</code>
<span className="text-zinc-600"></span>
<span className="text-sm text-zinc-400">
{formatDate(gen.createdAt)}
</span>
<span className="text-zinc-600"></span>
<span className="text-sm text-zinc-500">
{formatDuration(gen.duration)}
</span>
</div>
</div>
<Link
href={`/docs/${gen.id}`}
className="flex-shrink-0 px-3 py-1.5 text-sm text-zinc-400 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
View
</Link>
</div>
</div>
);
})}
</div>
{selectedIds.size === 2 && (
<div className="mt-8 flex justify-center animate-slide-up">
<button
onClick={handleCompare}
disabled={comparing}
className="inline-flex items-center gap-2 btn-primary"
>
{comparing ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Loading...
</>
) : (
<>
<GitCompare className="w-4 h-4" />
Compare Selected Versions
</>
)}
</button>
</div>
)}
</div>
{leftDoc && rightDoc && leftGen && rightGen && (
<ComparisonView
left={leftDoc}
right={rightDoc}
leftGen={leftGen}
rightGen={rightGen}
onClose={closeComparison}
/>
)}
</div>
);
}
export default function HistoryPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
<span className="text-zinc-400">Loading...</span>
</div>
</div>
}
>
<HistoryContent />
</Suspense>
);
}