web/src/routes/tracks.index.tsx
5.9 KB · sha256:d293568063dc79b21ab68605fe9142094e79df7f7167b2bc03191da0cce9d30d
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Plus, Trash2, Zap } from "lucide-react";
import { useState } from "react";
import { api } from "../lib/api";
import { Badge, Button, Card, Input, PageHeader } from "../components/ui";
import { PageShell } from "../components/layout";
import { DataTable, type Column } from "../components/data-table";
import type { TrackSummary } from "../lib/types";
export const Route = createFileRoute("/tracks/")({
component: TracksIndex,
});
function TracksIndex() {
const navigate = useNavigate();
const qc = useQueryClient();
const tracks = useQuery({ queryKey: ["tracks"], queryFn: api.tracks.list });
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const create = useMutation({
mutationFn: () => api.tracks.create(name.trim()),
meta: {
pending: "Creating track…",
success: (t: { name: string }) => `Created track ${t.name}`,
},
onSuccess: () => {
setName("");
setCreating(false);
qc.invalidateQueries({ queryKey: ["tracks"] });
},
});
const deleteMany = useMutation({
mutationFn: async (rows: TrackSummary[]) => {
await Promise.all(rows.map((r) => api.tracks.delete(r.name)));
},
meta: { pending: "Deleting tracks…", success: "Tracks deleted" },
onSuccess: () => qc.invalidateQueries({ queryKey: ["tracks"] }),
});
const rows = tracks.data ?? [];
const columns: Column<TrackSummary>[] = [
{
id: "name",
header: "Track",
accessor: (t) => t.name,
cell: (t) => <span className="text-fg font-medium">{t.name}</span>,
},
{
id: "rungs",
header: "Rungs",
accessor: (t) => t.rungCount,
align: "right",
cell: (t) => <span className="tabular-nums">{t.rungCount}</span>,
},
{
id: "auto",
header: "Auto",
accessor: (t) => t.autoRungs,
align: "right",
cell: (t) =>
t.autoRungs > 0 ? (
<Badge tone="brand">
<Zap size={10} /> {t.autoRungs}
</Badge>
) : (
<span className="text-fg-subtle tabular-nums">0</span>
),
},
{
id: "poll",
header: "Poll",
accessor: (t) => t.pollSeconds,
align: "right",
cell: (t) => (
<span className="text-fg-muted tabular-nums">{t.pollSeconds}s</span>
),
},
{
id: "ladder",
header: "Order",
cell: (t) => (
<div className="flex flex-wrap items-center gap-1.5">
{t.rungs.length === 0 && (
<span className="text-fg-subtle text-xs">(empty)</span>
)}
{t.rungs.slice(0, 6).map((r, i, arr) => (
<span key={`${r.group}:${i}`} className="flex items-center gap-1.5">
<Badge tone={r.autoPromote ? "brand" : "neutral"}>
{r.autoPromote && <Zap size={10} />}
{r.group}
</Badge>
{i < arr.length - 1 && (
<ChevronRight size={12} className="text-fg-subtle" />
)}
</span>
))}
{t.rungs.length > 6 && (
<span className="text-fg-subtle inline-flex items-center gap-1.5 text-xs font-medium">
<ChevronRight size={12} />+{t.rungs.length - 6} more
</span>
)}
</div>
),
},
];
return (
<PageShell>
<PageHeader
title={
<span>
Tracks{" "}
<span className="text-fg-subtle text-base font-normal">
({rows.length})
</span>
</span>
}
description="Promotion ladders made of ordered groups. Each rung can carry requirements and costs."
action={
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus size={16} /> New track
</Button>
}
/>
{creating && (
<Card className="mb-4">
<form
className="flex items-center gap-3 p-4"
onSubmit={(e) => {
e.preventDefault();
if (name.trim()) create.mutate();
}}
>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="track name"
/>
<Button
type="submit"
variant="primary"
disabled={!name.trim() || create.isPending}
>
{create.isPending ? "Creating…" : "Create"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setCreating(false);
setName("");
}}
>
Cancel
</Button>
{create.isError && (
<span className="text-red-600 dark:text-red-300 text-xs">
{(create.error as Error).message}
</span>
)}
</form>
</Card>
)}
<DataTable<TrackSummary>
data={rows}
columns={columns}
rowKey={(t) => t.name}
searchPlaceholder="Search tracks…"
pageSize={25}
loading={tracks.isLoading}
onRowClick={(t) =>
navigate({ to: "/tracks/$name", params: { name: t.name } })
}
bulkActions={[
{
id: "delete",
label: "Delete",
icon: Trash2,
variant: "danger",
confirm: "Delete {n} tracks?",
run: (rows) => deleteMany.mutateAsync(rows),
},
]}
emptyState={{
title: "No tracks yet",
description:
'Create one — e.g. "prison" with rungs A → B → C — then add per-rung requirements and costs.',
}}
/>
</PageShell>
);
}