feat: add progress tracker component
This commit is contained in:
211
apps/web/src/components/progress-tracker.tsx
Normal file
211
apps/web/src/components/progress-tracker.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { GenerationStatus } from "@codeboard/shared";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
AlertCircle,
|
||||||
|
FileText,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface ProgressData {
|
||||||
|
status: GenerationStatus;
|
||||||
|
progress: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressTrackerProps {
|
||||||
|
generationId: string;
|
||||||
|
repoUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: { status: GenerationStatus; label: string }[] = [
|
||||||
|
{ status: "QUEUED", label: "Queued" },
|
||||||
|
{ status: "CLONING", label: "Cloning Repository" },
|
||||||
|
{ status: "PARSING", label: "Analyzing Code" },
|
||||||
|
{ status: "GENERATING", label: "Generating Docs" },
|
||||||
|
{ status: "RENDERING", label: "Finalizing" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProgressTracker({
|
||||||
|
generationId,
|
||||||
|
repoUrl,
|
||||||
|
}: ProgressTrackerProps) {
|
||||||
|
const [data, setData] = useState<ProgressData>({
|
||||||
|
status: "QUEUED",
|
||||||
|
progress: 0,
|
||||||
|
message: "Waiting in queue...",
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource(`/api/status/${generationId}`);
|
||||||
|
|
||||||
|
eventSource.addEventListener("progress", (event) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data);
|
||||||
|
setData(parsed);
|
||||||
|
|
||||||
|
if (parsed.status === "COMPLETED" || parsed.status === "FAILED") {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to parse progress data");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("timeout", () => {
|
||||||
|
setError("Connection timed out. Please refresh the page.");
|
||||||
|
eventSource.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setError("Connection error. Please refresh the page.");
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [generationId]);
|
||||||
|
|
||||||
|
const getStepIndex = (status: GenerationStatus) => {
|
||||||
|
if (status === "COMPLETED") return STEPS.length;
|
||||||
|
if (status === "FAILED") return -1;
|
||||||
|
return STEPS.findIndex((s) => s.status === status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStepIndex = getStepIndex(data.status);
|
||||||
|
const isCompleted = data.status === "COMPLETED";
|
||||||
|
const isFailed = data.status === "FAILED";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="relative h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${data.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-zinc-400">{data.message}</span>
|
||||||
|
<span className="text-zinc-500 font-mono">{data.progress}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{STEPS.map((step, index) => {
|
||||||
|
const isActive = index === currentStepIndex;
|
||||||
|
const isDone = index < currentStepIndex || isCompleted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.status}
|
||||||
|
className={`flex items-center gap-4 p-4 rounded-xl border transition-all duration-300 ${
|
||||||
|
isActive
|
||||||
|
? "bg-blue-500/10 border-blue-500/30"
|
||||||
|
: isDone
|
||||||
|
? "bg-zinc-900/50 border-zinc-800"
|
||||||
|
: "bg-transparent border-zinc-800/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
isActive
|
||||||
|
? "bg-blue-500/20 text-blue-400"
|
||||||
|
: isDone
|
||||||
|
? "bg-green-500/20 text-green-400"
|
||||||
|
: "bg-zinc-800 text-zinc-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : isDone ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex-1 ${
|
||||||
|
isActive
|
||||||
|
? "text-white"
|
||||||
|
: isDone
|
||||||
|
? "text-green-400"
|
||||||
|
: "text-zinc-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFailed && (
|
||||||
|
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-red-400 font-medium">Generation Failed</p>
|
||||||
|
<p className="text-red-400/70 text-sm mt-1">
|
||||||
|
Something went wrong. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 flex items-center gap-2 px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="p-6 rounded-xl bg-green-500/10 border border-green-500/20 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
|
Documentation Ready!
|
||||||
|
</h3>
|
||||||
|
<p className="text-zinc-400 text-sm mb-6">
|
||||||
|
Your interactive documentation has been generated successfully.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/docs/${generationId}`}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-medium rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
View Documentation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !isFailed && (
|
||||||
|
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-red-400 font-medium">Connection Error</p>
|
||||||
|
<p className="text-red-400/70 text-sm mt-1">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user