diff --git a/apps/web/src/app/api/history/route.ts b/apps/web/src/app/api/history/route.ts new file mode 100644 index 0000000..30a9db8 --- /dev/null +++ b/apps/web/src/app/api/history/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@codeboard/database"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const repo = searchParams.get("repo"); + + if (!repo) { + return NextResponse.json( + { error: "repo parameter required" }, + { status: 400 } + ); + } + + const generations = await prisma.generation.findMany({ + where: { repoUrl: repo, status: "COMPLETED" }, + select: { + id: true, + repoUrl: true, + repoName: true, + commitHash: true, + createdAt: true, + duration: true, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(generations); +} diff --git a/apps/web/src/app/docs/[id]/page.tsx b/apps/web/src/app/docs/[id]/page.tsx index d768e08..df3c60b 100644 --- a/apps/web/src/app/docs/[id]/page.tsx +++ b/apps/web/src/app/docs/[id]/page.tsx @@ -1,7 +1,7 @@ import { DocViewer } from "@/components/doc-viewer"; import type { GeneratedDocs } from "@codeboard/shared"; import { notFound } from "next/navigation"; -import { Github, ArrowLeft } from "lucide-react"; +import { Github, ArrowLeft, History } from "lucide-react"; import Link from "next/link"; async function fetchDocs(id: string): Promise { @@ -45,15 +45,25 @@ export default async function DocsPage({ Back to Home - - - View on GitHub - +
+ + + Version History + + + + + View on GitHub + +
diff --git a/apps/web/src/app/history/page.tsx b/apps/web/src/app/history/page.tsx new file mode 100644 index 0000000..67fa634 --- /dev/null +++ b/apps/web/src/app/history/page.tsx @@ -0,0 +1,791 @@ +"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 ( +
+ {removed.map((tech) => ( + + {tech} + + ))} + {unchanged.map((tech) => ( + + {tech} + + ))} + {added.map((tech) => ( + + {tech} + + ))} +
+ ); +} + +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 ( +
+
+
+
+
+ +

Version Comparison

+
+ +
+
+
+ +
+
+ {/* Left Panel - Older */} +
+
+
+ + + {leftGen.commitHash.slice(0, 7)} + + + + {formatDate(leftGen.createdAt)} + +
+

Older version

+
+ +
+

+ + Overview +

+

+ {leftOverview.description} +

+ +

+ Tech Stack +

+
+ {leftOverview.techStack.map((tech) => ( + + {tech} + + ))} +
+
+ +
+

+ + Architecture +

+ +
+ +
+
+
+ {leftOverview.keyMetrics.files} +
+
Files
+
+
+
+ {leftOverview.keyMetrics.modules} +
+
Modules
+
+
+
+ {leftOverview.keyMetrics.languages.length} +
+
Languages
+
+
+
+ + {/* Right Panel - Newer */} +
+
+
+ + + {rightGen.commitHash.slice(0, 7)} + + + + {formatDate(rightGen.createdAt)} + +
+

Newer version

+
+ +
+

+ + Overview +

+

+ {rightOverview.description} +

+ +

+ Tech Stack Changes +

+ +
+ +
+

+ + Architecture +

+ +
+ +
+
+
0 + ? "text-green-400" + : filesDiff < 0 + ? "text-red-400" + : "text-white" + }`} + > + {rightOverview.keyMetrics.files} +
+
Files
+ {filesDiff !== 0 && ( +
0 ? "text-green-400" : "text-red-400" + }`} + > + {filesDiff > 0 ? "+" : ""} + {filesDiff} +
+ )} +
+
+
0 + ? "text-green-400" + : modulesDiff < 0 + ? "text-red-400" + : "text-white" + }`} + > + {rightOverview.keyMetrics.modules} +
+
Modules
+ {modulesDiff !== 0 && ( +
0 ? "text-green-400" : "text-red-400" + }`} + > + {modulesDiff > 0 ? "+" : ""} + {modulesDiff} +
+ )} +
+
+
0 + ? "text-green-400" + : languagesDiff < 0 + ? "text-red-400" + : "text-white" + }`} + > + {rightOverview.keyMetrics.languages.length} +
+
Languages
+ {languagesDiff !== 0 && ( +
0 ? "text-green-400" : "text-red-400" + }`} + > + {languagesDiff > 0 ? "+" : ""} + {languagesDiff} +
+ )} +
+
+
+
+ + {/* Module Comparison */} +
+

+ + Module Breakdown Comparison +

+ +
+
+

+ Older Version ({left.sections.modules.length} modules) +

+
+ {left.sections.modules.map((module) => ( +
+ +
+
+ {module.name} +
+
+ {module.path} +
+
+
+ ))} +
+
+ +
+

+ Newer Version ({right.sections.modules.length} modules) +

+
+ {right.sections.modules.map((module) => { + const existedInLeft = left.sections.modules.some( + (m) => m.name === module.name + ); + return ( +
+ +
+
+ {module.name} + {!existedInLeft && ( + + (new) + + )} +
+
+ {module.path} +
+
+
+ ); + })} +
+
+
+
+
+
+ ); +} + +function HistoryContent() { + const searchParams = useSearchParams(); + const repo = searchParams.get("repo"); + + const [generations, setGenerations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [comparing, setComparing] = useState(false); + const [leftDoc, setLeftDoc] = useState(null); + const [rightDoc, setRightDoc] = useState(null); + const [leftGen, setLeftGen] = useState(null); + const [rightGen, setRightGen] = useState(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 ( +
+
+
+ Loading history... +
+
+ ); + } + + if (error) { + return ( +
+
+
Error
+

{error}

+ + + Back to Home + +
+
+ ); + } + + if (!repo) { + return ( +
+
+ +

+ No Repository Specified +

+

+ Please provide a repository URL to view its history. +

+ + + Back to Home + +
+
+ ); + } + + if (generations.length === 0) { + return ( +
+
+ +

No History Found

+

+ No documentation has been generated for this repository yet. +

+ + + Back to Home + +
+
+ ); + } + + if (generations.length === 1) { + return ( +
+
+
+
+ + + Back to Home + +
+
+
+ +
+
+ +

+ Version History +

+

+ {generations[0].repoName} +

+
+ +
+
+ +
+

+ Only One Version Exists +

+

+ Generate docs again after code changes to compare versions and track + how your architecture evolves over time. +

+ +
+
+ + + {generations[0].commitHash.slice(0, 7)} + +
+
+ + + {formatDate(generations[0].createdAt)} + +
+
+ + + Generated in {formatDuration(generations[0].duration)} + +
+
+ + + + View Documentation + +
+
+
+ ); + } + + return ( +
+
+
+
+ + + Back to Home + + + {selectedIds.size} of 2 selected + +
+
+
+ +
+
+ +

Version History

+

{generations[0]?.repoName}

+

+ Select any 2 versions to compare side-by-side +

+
+ +
+ {generations.map((gen) => { + const isSelected = selectedIds.has(gen.id); + const canSelect = selectedIds.size < 2 || isSelected; + + return ( +
+
+ + +
+
+ + + {gen.commitHash.slice(0, 7)} + + + + {formatDate(gen.createdAt)} + + + + {formatDuration(gen.duration)} + +
+
+ + + View + +
+
+ ); + })} +
+ + {selectedIds.size === 2 && ( +
+ +
+ )} +
+ + {leftDoc && rightDoc && leftGen && rightGen && ( + + )} +
+ ); +} + +export default function HistoryPage() { + return ( + +
+
+ Loading... +
+
+ } + > + +
+ ); +}