Add interactive Timeline component for roadmap
cgen-7a519870809441c183e33083911dec7f
This commit is contained in:
parent
7105cb58ec
commit
24fc10542a
1 changed files with 111 additions and 0 deletions
111
client/components/roadmap/Timeline.tsx
Normal file
111
client/components/roadmap/Timeline.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { useMemo, useRef } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircle2, Circle, Rocket, Target, Flame, Sparkles } from "lucide-react";
|
||||
|
||||
export type RoadmapPhase = "now" | "month1" | "month2" | "month3";
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
phase: RoadmapPhase;
|
||||
xp: number;
|
||||
claimed?: boolean;
|
||||
}
|
||||
|
||||
export default function Timeline({
|
||||
events,
|
||||
onSelectPhase,
|
||||
onToggleClaim,
|
||||
}: {
|
||||
events: TimelineEvent[];
|
||||
onSelectPhase?: (p: RoadmapPhase) => void;
|
||||
onToggleClaim?: (id: string) => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const phases: RoadmapPhase[] = ["now", "month1", "month2", "month3"];
|
||||
const iconFor: Record<RoadmapPhase, any> = { now: Target, month1: Flame, month2: Rocket, month3: Sparkles };
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<RoadmapPhase, TimelineEvent[]> = { now: [], month1: [], month2: [], month3: [] };
|
||||
for (const e of events) map[e.phase].push(e);
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const scrollToPhase = (p: RoadmapPhase) => {
|
||||
const el = containerRef.current?.querySelector<HTMLDivElement>(`[data-phase="${p}"]`);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
|
||||
onSelectPhase?.(p);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/40 bg-background/60 p-4 backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{phases.map((p) => {
|
||||
const Icon = iconFor[p];
|
||||
const label = p === "now" ? "Now" : p === "month1" ? "Month 1" : p === "month2" ? "Month 2" : "Month 3";
|
||||
return (
|
||||
<Button key={p} size="sm" variant="outline" onClick={() => scrollToPhase(p)} className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-aethex-300" /> {label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Badge variant="outline">Interactive timeline</Badge>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="mt-4 overflow-x-auto">
|
||||
<div className="min-w-[720px] w-[1200px] lg:w-full relative">
|
||||
<div className="absolute left-0 right-0 top-8 h-0.5 bg-border/50" />
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{phases.map((p, idx) => (
|
||||
<div key={p} data-phase={p} className="col-span-3">
|
||||
{/* Phase header */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{(() => { const Icon = iconFor[p]; return <Icon className="h-4 w-4 text-aethex-300" />; })()}
|
||||
<span className="text-sm font-medium">
|
||||
{p === "now" ? "Now" : p === "month1" ? "Month 1" : p === "month2" ? "Month 2" : "Month 3"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Events for phase */}
|
||||
<div className="space-y-3">
|
||||
{grouped[p].map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border border-border/40 bg-background/70 p-3 text-left transition hover:border-aethex-400/50",
|
||||
e.claimed && "ring-1 ring-emerald-400/30",
|
||||
)}
|
||||
onClick={() => onToggleClaim?.(e.id)}
|
||||
title={e.title}
|
||||
>
|
||||
<div className="absolute left-[-10px] top-1/2 -translate-y-1/2">
|
||||
{e.claimed ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium leading-tight">{e.title}</div>
|
||||
<div className="text-xs text-muted-foreground">Tap to {e.claimed ? "unclaim" : "claim"} • {e.xp} XP</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">{e.xp} XP</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{grouped[p].length === 0 && (
|
||||
<div className="rounded border border-border/40 p-3 text-xs text-muted-foreground">No quests yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue