159 lines
4.8 KiB
TypeScript
159 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type TimelineStepStatus = "completed" | "active" | "pending" | "error" | "skipped";
|
|
|
|
export interface TimelineStepData {
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
timestamp?: string;
|
|
status: TimelineStepStatus;
|
|
metadata?: React.ReactNode;
|
|
}
|
|
|
|
export interface TimelineStepProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
step: TimelineStepData;
|
|
isLast?: boolean;
|
|
}
|
|
|
|
export interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
steps: TimelineStepData[];
|
|
}
|
|
|
|
const stepIconConfig: Record<TimelineStepStatus, { iconBg: string; iconColor: string; connectorClass: string }> = {
|
|
completed: {
|
|
iconBg: "bg-primary",
|
|
iconColor: "text-primary-foreground",
|
|
connectorClass: "bg-primary",
|
|
},
|
|
active: {
|
|
iconBg: "bg-white border-2 border-primary",
|
|
iconColor: "text-primary",
|
|
connectorClass: "bg-border",
|
|
},
|
|
pending: {
|
|
iconBg: "bg-white border-2 border-border",
|
|
iconColor: "text-muted-foreground",
|
|
connectorClass: "bg-border",
|
|
},
|
|
error: {
|
|
iconBg: "bg-destructive",
|
|
iconColor: "text-destructive-foreground",
|
|
connectorClass: "bg-destructive/30",
|
|
},
|
|
skipped: {
|
|
iconBg: "bg-muted border-2 border-border",
|
|
iconColor: "text-muted-foreground",
|
|
connectorClass: "bg-border",
|
|
},
|
|
};
|
|
|
|
const StepIcon = ({ status }: { status: TimelineStepStatus }) => {
|
|
switch (status) {
|
|
case "completed":
|
|
return (
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none">
|
|
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
);
|
|
case "active":
|
|
return <div className="h-2 w-2 rounded-full bg-primary" />;
|
|
case "error":
|
|
return (
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none">
|
|
<path d="M4 4L10 10M10 4L4 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
case "skipped":
|
|
return (
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none">
|
|
<path d="M5 4L9 7L5 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
<path d="M9 4V10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
default:
|
|
return <div className="h-2 w-2 rounded-full bg-border" />;
|
|
}
|
|
};
|
|
|
|
const TimelineStep = React.forwardRef<HTMLDivElement, TimelineStepProps>(
|
|
({ className, step, isLast = false, ...props }, ref) => {
|
|
const config = stepIconConfig[step.status];
|
|
|
|
return (
|
|
<div ref={ref} className={cn("flex gap-4", className)} {...props}>
|
|
{/* Icon + connector column */}
|
|
<div className="flex flex-col items-center">
|
|
<div
|
|
className={cn(
|
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full shadow-sm",
|
|
config.iconBg,
|
|
config.iconColor
|
|
)}
|
|
>
|
|
<StepIcon status={step.status} />
|
|
</div>
|
|
{!isLast && (
|
|
<div
|
|
className={cn("w-0.5 flex-1 my-1.5", config.connectorClass)}
|
|
style={{ minHeight: "1.5rem" }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className={cn("flex-1 pb-6", isLast && "pb-0")}>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<p
|
|
className={cn(
|
|
"text-sm font-semibold leading-7",
|
|
step.status === "pending" || step.status === "skipped"
|
|
? "text-muted-foreground"
|
|
: "text-foreground"
|
|
)}
|
|
>
|
|
{step.title}
|
|
</p>
|
|
{step.description && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
|
|
{step.description}
|
|
</p>
|
|
)}
|
|
{step.metadata && (
|
|
<div className="mt-2">{step.metadata}</div>
|
|
)}
|
|
</div>
|
|
{step.timestamp && (
|
|
<span className="shrink-0 text-xs text-muted-foreground pt-1">{step.timestamp}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
TimelineStep.displayName = "TimelineStep";
|
|
|
|
const Timeline = React.forwardRef<HTMLDivElement, TimelineProps>(
|
|
({ className, steps, ...props }, ref) => {
|
|
return (
|
|
<div ref={ref} className={cn("flex flex-col", className)} {...props}>
|
|
{steps.map((step, idx) => (
|
|
<TimelineStep
|
|
key={step.id}
|
|
step={step}
|
|
isLast={idx === steps.length - 1}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
Timeline.displayName = "Timeline";
|
|
|
|
export { TimelineStep, Timeline };
|