web/src/routes/tracks.$name.tsx
30 KB · sha256:7be7481008ae269b92be8b9507309f06d73da0ec54b0865edefc51ee3d3bbc04
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Boxes,
ChevronRight,
Plus,
Trash2,
Zap,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "../lib/api";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
EmptyState,
Input,
PageHeader,
Select,
} from "../components/ui";
import { Modal, ModalFooter } from "../components/modal";
import { useToast } from "../components/toast";
import { PageShell } from "../components/layout";
import { expandMathTemplate } from "../lib/template-expr";
import type { TrackRung } from "../lib/types";
export const Route = createFileRoute("/tracks/$name")({
component: TrackDetailPage,
});
function TrackDetailPage() {
const { name } = Route.useParams();
const qc = useQueryClient();
const track = useQuery({
queryKey: ["track", name],
queryFn: () => api.tracks.get(name),
});
const groups = useQuery({ queryKey: ["groups"], queryFn: api.groups.list });
const handlers = useQuery({
queryKey: ["handlers"],
queryFn: api.handlers,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ["track", name] });
const addRung = useMutation({
mutationFn: (group: string) => api.tracks.addRung(name, group),
meta: { success: "Rung added" },
onSuccess: invalidate,
});
const removeRung = useMutation({
mutationFn: (group: string) => api.tracks.removeRung(name, group),
meta: { success: "Rung removed" },
onSuccess: invalidate,
});
const setAutoPromote = useMutation({
mutationFn: ({ index, on }: { index: number; on: boolean }) =>
api.tracks.setAutoPromote(name, index, on),
meta: { success: "Auto-promote updated" },
onSuccess: invalidate,
});
const setPollSeconds = useMutation({
mutationFn: (seconds: number) => api.tracks.setPollSeconds(name, seconds),
meta: { success: "Poll interval saved" },
onSuccess: invalidate,
});
const addRequirement = useMutation({
mutationFn: ({ index, expression }: { index: number; expression: string }) =>
api.tracks.addRequirement(name, index, expression),
meta: { success: "Requirement added" },
onSuccess: invalidate,
});
const removeRequirement = useMutation({
mutationFn: ({ index, reqIndex }: { index: number; reqIndex: number }) =>
api.tracks.removeRequirement(name, index, reqIndex),
meta: { success: "Requirement removed" },
onSuccess: invalidate,
});
const addCost = useMutation({
mutationFn: ({
index,
handler,
amount,
}: {
index: number;
handler: string;
amount: number;
}) => api.tracks.addCost(name, index, handler, amount),
meta: { success: "Cost added" },
onSuccess: invalidate,
});
const removeCost = useMutation({
mutationFn: ({ index, costIndex }: { index: number; costIndex: number }) =>
api.tracks.removeCost(name, index, costIndex),
meta: { success: "Cost removed" },
onSuccess: invalidate,
});
const deleteTrack = useMutation({
mutationFn: () => api.tracks.delete(name),
meta: { pending: "Deleting track…", success: "Track deleted" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tracks"] });
history.back();
},
});
if (track.isLoading) {
return (
<PageShell>
<p className="text-fg-muted">Loading…</p>
</PageShell>
);
}
if (!track.data) {
return (
<PageShell>
<EmptyState title="Track not found" description={name} />
</PageShell>
);
}
const t = track.data;
const groupNames = (groups.data ?? []).map((g) => g.name);
const handlerIds = handlers.data ?? [];
return (
<PageShell>
<Link
to="/tracks"
className="text-fg-muted hover:text-fg mb-4 inline-flex items-center gap-1 text-sm"
>
<ArrowLeft size={14} /> Tracks
</Link>
<PageHeader
title={t.name}
description={
<span className="flex items-center gap-3">
<span>{t.rungs.length} rungs</span>
<span className="text-fg-subtle">·</span>
<PollField
initial={t.pollSeconds}
onSave={(s) => setPollSeconds.mutate(s)}
/>
</span>
}
action={
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm(`Delete track "${name}"?`)) deleteTrack.mutate();
}}
>
<Trash2 size={14} /> Delete track
</Button>
}
/>
<Card className="mb-4">
<CardHeader
title="Rungs"
description='Players advance from rung 0 to the last rung. Toggle "auto" on a rung to make Ward attempt the promotion every poll tick.'
action={
<div className="flex items-center gap-2">
<AddFromBundleButton trackName={t.name} onDone={invalidate} />
<AppendRungSelector
groups={groupNames}
onAdd={(g) => addRung.mutate(g)}
/>
</div>
}
/>
<CardBody>
{t.rungs.length === 0 ? (
<EmptyState
title="Empty track"
description="Add a group above to create the first rung."
/>
) : (
<div className="space-y-3">
{t.rungs.map((r, i) => (
<RungCard
key={`${r.group}:${i}`}
rung={r}
index={i}
trackName={t.name}
handlerIds={handlerIds}
onRemove={() => removeRung.mutate(r.group)}
onToggleAuto={(on) =>
setAutoPromote.mutate({ index: i, on })
}
onAddReq={(expression) =>
addRequirement.mutate({ index: i, expression })
}
onRemoveReq={(reqIndex) =>
removeRequirement.mutate({ index: i, reqIndex })
}
onAddCost={(handler, amount) =>
addCost.mutate({ index: i, handler, amount })
}
onRemoveCost={(costIndex) =>
removeCost.mutate({ index: i, costIndex })
}
isLast={i === t.rungs.length - 1}
/>
))}
</div>
)}
</CardBody>
</Card>
</PageShell>
);
}
function RungCard({
rung,
index,
handlerIds,
onRemove,
onToggleAuto,
onAddReq,
onRemoveReq,
onAddCost,
onRemoveCost,
isLast,
}: {
rung: TrackRung;
index: number;
trackName: string;
handlerIds: string[];
onRemove: () => void;
onToggleAuto: (on: boolean) => void;
onAddReq: (expression: string) => void;
onRemoveReq: (i: number) => void;
onAddCost: (handler: string, amount: number) => void;
onRemoveCost: (i: number) => void;
isLast: boolean;
}) {
return (
<div className="border-border bg-surface-2 relative rounded-xl border p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<span className="bg-brand-50 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200 flex h-9 w-9 items-center justify-center rounded-lg font-semibold tabular-nums">
{index + 1}
</span>
<div>
<div className="text-fg font-semibold">{rung.group}</div>
<div className="text-fg-subtle text-xs">
{rung.requirements.length} requirement
{rung.requirements.length === 1 ? "" : "s"} · {rung.costs.length}{" "}
cost{rung.costs.length === 1 ? "" : "s"}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onToggleAuto(!rung.autoPromote)}
className={
rung.autoPromote
? "bg-brand-600 inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium text-white"
: "bg-surface text-fg-muted border-border hover:text-fg inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium"
}
>
<Zap size={12} />
auto-promote {rung.autoPromote ? "on" : "off"}
</button>
<button
type="button"
onClick={onRemove}
className="text-fg-subtle hover:text-red-600"
aria-label="Remove rung"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<h4 className="text-fg-muted mb-2 text-xs font-semibold tracking-wide uppercase">
Requirements
</h4>
<div className="space-y-1.5">
{rung.requirements.map((expr, i) => (
<div
key={`${expr}:${i}`}
className="bg-surface border-border flex items-center justify-between gap-2 rounded-md border px-3 py-1.5"
>
<code className="text-fg flex-1 truncate font-mono text-xs">
{expr}
</code>
<button
type="button"
onClick={() => onRemoveReq(i)}
className="text-fg-subtle hover:text-red-600"
>
<Trash2 size={12} />
</button>
</div>
))}
<AddRequirementField onAdd={onAddReq} />
</div>
</div>
<div>
<h4 className="text-fg-muted mb-2 text-xs font-semibold tracking-wide uppercase">
Costs
</h4>
<div className="space-y-1.5">
{rung.costs.map((c, i) => (
<div
key={`${c.handler}:${i}`}
className="bg-surface border-border flex items-center justify-between gap-2 rounded-md border px-3 py-1.5"
>
<div className="flex flex-1 items-center gap-2 truncate">
<Badge tone="brand">{c.handler}</Badge>
{c.amount != null && (
<span className="text-fg text-sm tabular-nums">
{c.amount}
</span>
)}
</div>
<button
type="button"
onClick={() => onRemoveCost(i)}
className="text-fg-subtle hover:text-red-600"
>
<Trash2 size={12} />
</button>
</div>
))}
<AddCostField handlers={handlerIds} onAdd={onAddCost} />
</div>
</div>
</div>
{!isLast && (
<ChevronRight
size={16}
className="text-fg-subtle absolute -bottom-3.5 left-1/2 -translate-x-1/2 rotate-90"
/>
)}
</div>
);
}
function AppendRungSelector({
groups,
onAdd,
}: {
groups: string[];
onAdd: (g: string) => void;
}) {
return (
<Select
className="h-9 w-auto pr-10 text-sm"
value=""
onChange={(e) => {
if (e.target.value) onAdd(e.target.value);
}}
>
<option value="">+ append rung</option>
{groups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
);
}
function AddRequirementField({ onAdd }: { onAdd: (v: string) => void }) {
const [v, setV] = useState("");
const [err, setErr] = useState<string | null>(null);
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (!v.trim()) return;
try {
onAdd(v.trim());
setV("");
setErr(null);
} catch (e) {
setErr((e as Error).message);
}
}}
className="flex gap-2"
>
<Input
value={v}
onChange={(e) => {
setV(e.target.value);
setErr(null);
}}
placeholder="{vault_eco_balance} >= 500"
className="h-9 font-mono text-xs"
/>
<Button type="submit" variant="subtle" size="sm" disabled={!v.trim()}>
<Plus size={12} />
</Button>
{err && (
<span className="text-red-600 dark:text-red-300 text-xs">{err}</span>
)}
</form>
);
}
function AddCostField({
handlers,
onAdd,
}: {
handlers: string[];
onAdd: (handler: string, amount: number) => void;
}) {
const [handler, setHandler] = useState(handlers[0] ?? "");
const [amount, setAmount] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
const n = Number(amount);
if (handler && Number.isFinite(n) && n >= 0) {
onAdd(handler, n);
setAmount("");
}
}}
className="flex gap-2"
>
<Select
value={handler}
onChange={(e) => setHandler(e.target.value)}
className="h-9 w-40 text-xs"
>
{handlers.length === 0 && <option value="">(no handlers)</option>}
{handlers.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</Select>
<Input
type="number"
min={0}
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="amount"
className="h-9 w-28 text-xs"
/>
<Button
type="submit"
variant="subtle"
size="sm"
disabled={!handler || !amount.trim()}
>
<Plus size={12} />
</Button>
</form>
);
}
function PollField({
initial,
onSave,
}: {
initial: number;
onSave: (n: number) => void;
}) {
const [v, setV] = useState(String(initial));
return (
<span className="inline-flex items-center gap-1.5 text-sm">
poll
<input
type="number"
value={v}
min={5}
onChange={(e) => setV(e.target.value)}
onBlur={() => {
const n = parseInt(v, 10);
if (Number.isFinite(n) && n !== initial && n >= 5) onSave(n);
}}
className="bg-surface border-border text-fg w-14 rounded border px-1.5 py-0.5 text-sm tabular-nums focus:outline-none"
/>
s
</span>
);
}
// ---------------------------------------------------------------------------
// Add From Bundle
//
// One click drops every group in a saved bundle onto the track as rungs.
// Ordering options: keep the bundle's saved order, or re-sort the bundle
// by group weight (asc/desc) or alphabetically before appending.
// ---------------------------------------------------------------------------
type BundleOrder = "bundle" | "weight-desc" | "weight-asc" | "alpha";
function AddFromBundleButton({
trackName,
onDone,
}: {
trackName: string;
onDone: () => void;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setOpen(true)}
>
<Boxes size={14} /> From bundle
</Button>
{open && (
<AddFromBundleModal
trackName={trackName}
onClose={() => setOpen(false)}
onDone={onDone}
/>
)}
</>
);
}
interface CostTemplate {
handler: string;
amount: string;
}
interface VarRow {
key: string;
value: string;
}
function AddFromBundleModal({
trackName,
onClose,
onDone,
}: {
trackName: string;
onClose: () => void;
onDone: () => void;
}) {
const bundles = useQuery({ queryKey: ["bundles"], queryFn: api.bundles.list });
const allGroups = useQuery({ queryKey: ["groups"], queryFn: api.groups.list });
const handlers = useQuery({ queryKey: ["handlers"], queryFn: api.handlers });
const toast = useToast();
const [pick, setPick] = useState<string>("");
const [order, setOrder] = useState<BundleOrder>("bundle");
const [autoPromote, setAutoPromote] = useState(false);
const [reqText, setReqText] = useState("");
const [costs, setCosts] = useState<CostTemplate[]>([]);
const [vars, setVars] = useState<VarRow[]>([{ key: "base", value: "500" }]);
const [submitting, setSubmitting] = useState(false);
const selected = bundles.data?.find((b) => b.name === pick) ?? null;
const ordered = useMemo(() => {
if (!selected) return [] as string[];
const groups = selected.groups.slice();
if (order === "bundle") return groups;
if (order === "alpha") return groups.sort((a, b) => a.localeCompare(b));
const w = (n: string) =>
allGroups.data?.find((g) => g.name === n)?.weight ?? 0;
return groups.sort((a, b) =>
order === "weight-desc" ? w(b) - w(a) : w(a) - w(b),
);
}, [selected, order, allGroups.data]);
const reqTemplates = useMemo(
() =>
reqText
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean),
[reqText],
);
const varMap = useMemo(() => {
const out: Record<string, number> = {};
for (const v of vars) {
const k = v.key.trim();
const n = Number(v.value);
if (k && Number.isFinite(n)) out[k] = n;
}
return out;
}, [vars]);
// Per-row template expansion preview. Shows up to 4 sample rungs so
// admins can see exactly what gets baked in before they hit submit.
const preview = useMemo(() => {
if (ordered.length === 0) return null;
const samplesIdx = sampleIndexes(ordered.length, 4);
const rows: { i: number; group: string; reqs: string[]; costs: { handler: string; amount: string }[]; error?: string }[] = [];
for (const i of samplesIdx) {
const env = { vars: { ...varMap, i, n: ordered.length, idx: i + 1 } };
try {
const reqs = reqTemplates.map((t) => expandMathTemplate(t, env));
const cs = costs.map((c) => ({
handler: c.handler,
amount: c.amount ? expandMathTemplate(c.amount, env) : "0",
}));
rows.push({ i, group: ordered[i], reqs, costs: cs });
} catch (e) {
rows.push({
i,
group: ordered[i],
reqs: [],
costs: [],
error: (e as Error).message,
});
}
}
return rows;
}, [ordered, reqTemplates, costs, varMap]);
const submit = useMutation({
mutationFn: () =>
api.tracks.bulkAppendRungs(trackName, {
groups: ordered,
requirements: reqTemplates.length > 0 ? reqTemplates : undefined,
costs:
costs.length > 0
? costs.map((c) => ({ handler: c.handler, amount: c.amount }))
: undefined,
vars: Object.keys(varMap).length > 0 ? varMap : undefined,
autoPromote: autoPromote || undefined,
}),
meta: {
pending: "Appending rungs…",
success: (r: { created: number }) =>
`Appended ${r.created} rung${r.created === 1 ? "" : "s"}`,
},
onSuccess: () => {
onDone();
onClose();
},
});
const hasPreviewError = preview?.some((p) => p.error);
return (
<Modal
open
onClose={onClose}
size="xl"
title="Append from bundle"
description="Drop every group in a bundle onto this track. Each rung's requirements + costs can use math templates evaluated per-row (vars: i, n, idx + your own)."
>
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
if (!selected || ordered.length === 0) return;
if (hasPreviewError) {
toast.error("Fix template errors before appending");
return;
}
setSubmitting(true);
submit.mutate(undefined, {
onSettled: () => setSubmitting(false),
});
}}
>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="block">
<span className="text-fg-muted text-xs font-medium">Bundle</span>
<Select
autoFocus
value={pick}
onChange={(e) => setPick(e.target.value)}
className="mt-1"
>
<option value="">— pick a bundle —</option>
{(bundles.data ?? []).map((b) => (
<option key={b.name} value={b.name}>
{b.displayName || b.name} ({b.size})
</option>
))}
</Select>
</label>
<fieldset>
<legend className="text-fg-muted mb-1 text-xs font-medium">
Ordering
</legend>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
{(
[
["bundle", "Bundle"],
["weight-desc", "Weight ↓"],
["weight-asc", "Weight ↑"],
["alpha", "Alpha"],
] as const
).map(([k, label]) => (
<button
key={k}
type="button"
onClick={() => setOrder(k)}
className={
order === k
? "bg-brand-50 text-brand-700 border-brand-300 dark:bg-brand-900/30 dark:text-brand-200 dark:border-brand-600/40 rounded-lg border px-3 py-2 text-xs font-medium"
: "bg-surface text-fg-muted border-border hover:text-fg rounded-lg border px-3 py-2 text-xs font-medium"
}
>
{label}
</button>
))}
</div>
</fieldset>
</div>
<ModalSection
title="Variables"
description={
<>
Bound inside <code>{"{}"}</code> math expressions. Per-rung
extras: <code>i</code> (0-indexed), <code>n</code> (total),{" "}
<code>idx</code> (1-indexed). Constants:{" "}
<code>pi</code>, <code>e</code>, <code>tau</code>.
</>
}
>
<VarsEditor vars={vars} onChange={setVars} />
</ModalSection>
<ModalSection
title="Requirements (templates)"
description={
<>
One per line. PAPI placeholders like{" "}
<code>{"{vault_eco_balance}"}</code> stay literal. Math
like <code>{"{base * (1.1 ^ i)}"}</code> evaluates per-rung.
</>
}
>
<textarea
value={reqText}
onChange={(e) => setReqText(e.target.value)}
rows={3}
spellCheck={false}
className="bg-surface text-fg placeholder:text-fg-subtle border-border focus:border-brand-500 focus:ring-brand-500/20 w-full rounded-lg border px-3 py-2 font-mono text-xs transition outline-none focus:ring-4"
placeholder={`{vault_eco_balance} >= {base * (1.1 ^ i)}\n{playtime_hours} >= {idx * 2}`}
/>
</ModalSection>
<ModalSection
title="Costs (templates)"
description="Cost amount can be a math expression too -- it's evaluated per-rung and stored as a number on each rung."
>
<CostsEditor
costs={costs}
onChange={setCosts}
handlers={handlers.data ?? []}
/>
</ModalSection>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoPromote}
onChange={(e) => setAutoPromote(e.target.checked)}
className="accent-brand-600 border-border h-4 w-4 cursor-pointer rounded"
/>
<span className="text-fg">
Mark every new rung as <span className="font-medium">auto-promote</span>
</span>
</label>
<Preview rows={preview} totalRungs={ordered.length} />
<ModalFooter>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
loading={submitting || submit.isPending}
disabled={!selected || ordered.length === 0 || hasPreviewError}
>
Append {ordered.length} rung{ordered.length === 1 ? "" : "s"}
</Button>
</ModalFooter>
</form>
</Modal>
);
}
function sampleIndexes(n: number, count: number): number[] {
if (n <= count) return Array.from({ length: n }, (_, i) => i);
const out = new Set<number>();
out.add(0);
out.add(n - 1);
// One or two middle samples for an exponential feel.
if (count >= 3) out.add(Math.floor(n / 3));
if (count >= 4) out.add(Math.floor((n * 2) / 3));
return [...out].sort((a, b) => a - b);
}
function ModalSection({
title,
description,
children,
}: {
title: string;
description?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="border-border rounded-xl border p-4">
<div className="mb-2">
<h4 className="text-fg text-sm font-semibold">{title}</h4>
{description && (
<p className="text-fg-subtle mt-0.5 text-xs leading-snug">
{description}
</p>
)}
</div>
{children}
</div>
);
}
function VarsEditor({
vars,
onChange,
}: {
vars: VarRow[];
onChange: (v: VarRow[]) => void;
}) {
return (
<div className="space-y-1.5">
{vars.map((v, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={v.key}
onChange={(e) => {
const next = vars.slice();
next[i] = { ...next[i], key: e.target.value };
onChange(next);
}}
placeholder="name"
className="h-8 max-w-xs font-mono text-xs"
/>
<span className="text-fg-subtle">=</span>
<Input
type="number"
value={v.value}
onChange={(e) => {
const next = vars.slice();
next[i] = { ...next[i], value: e.target.value };
onChange(next);
}}
placeholder="0"
className="h-8 max-w-xs font-mono text-xs"
/>
<button
type="button"
onClick={() => onChange(vars.filter((_, j) => j !== i))}
className="text-fg-subtle hover:text-red-600"
aria-label="Remove variable"
>
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={() => onChange([...vars, { key: "", value: "" }])}
className="text-brand-600 dark:text-brand-300 inline-flex items-center gap-1 text-xs font-medium hover:underline"
>
<Plus size={12} /> Add variable
</button>
</div>
);
}
function CostsEditor({
costs,
onChange,
handlers,
}: {
costs: CostTemplate[];
onChange: (c: CostTemplate[]) => void;
handlers: string[];
}) {
return (
<div className="space-y-1.5">
{costs.length === 0 && (
<p className="text-fg-subtle text-xs">No costs configured.</p>
)}
{costs.map((c, i) => (
<div key={i} className="flex items-center gap-2">
<Select
value={c.handler}
onChange={(e) => {
const next = costs.slice();
next[i] = { ...next[i], handler: e.target.value };
onChange(next);
}}
className="h-8 max-w-[180px] text-xs"
>
<option value="">— handler —</option>
{handlers.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</Select>
<Input
value={c.amount}
onChange={(e) => {
const next = costs.slice();
next[i] = { ...next[i], amount: e.target.value };
onChange(next);
}}
placeholder="{base * (1.1 ^ i)}"
className="h-8 font-mono text-xs"
/>
<button
type="button"
onClick={() => onChange(costs.filter((_, j) => j !== i))}
className="text-fg-subtle hover:text-red-600"
aria-label="Remove cost"
>
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={() =>
onChange([...costs, { handler: handlers[0] ?? "", amount: "" }])
}
className="text-brand-600 dark:text-brand-300 inline-flex items-center gap-1 text-xs font-medium hover:underline"
>
<Plus size={12} /> Add cost
</button>
</div>
);
}
function Preview({
rows,
totalRungs,
}: {
rows:
| {
i: number;
group: string;
reqs: string[];
costs: { handler: string; amount: string }[];
error?: string;
}[]
| null;
totalRungs: number;
}) {
if (!rows || rows.length === 0) return null;
return (
<div className="border-border bg-surface-2/40 rounded-xl border p-3">
<div className="text-fg-muted mb-2 text-xs font-medium">
Preview ({rows.length} of {totalRungs} sampled)
</div>
<div className="space-y-2">
{rows.map((r) => (
<div key={r.i} className="bg-surface border-border rounded-md border p-2 text-xs">
<div className="flex items-center gap-2">
<span className="bg-brand-50 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200 inline-flex h-5 min-w-[20px] items-center justify-center rounded px-1.5 text-[10px] font-semibold tabular-nums">
{r.i + 1}
</span>
<Badge tone="neutral">{r.group}</Badge>
</div>
{r.error ? (
<div className="text-red-600 dark:text-red-300 mt-1 font-mono">
{r.error}
</div>
) : (
<>
{r.reqs.length > 0 && (
<div className="mt-1 space-y-0.5">
{r.reqs.map((req, j) => (
<div key={j} className="text-fg font-mono">
<span className="text-fg-subtle">req:</span> {req}
</div>
))}
</div>
)}
{r.costs.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1.5">
{r.costs.map((c, j) => (
<span
key={j}
className="bg-surface-3 text-fg inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono"
>
<span className="text-fg-subtle">{c.handler}</span>
{c.amount}
</span>
))}
</div>
)}
</>
)}
</div>
))}
</div>
</div>
);
}