web/src/routes/bundles.index.tsx
5.5 KB · sha256:e64af2d5149fe6620ec559a5849ce9c719e2334e8803e3c403370f1414ff7e01
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Boxes, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { api } from "../lib/api";
import { Badge, Button, Card, Input, PageHeader } from "../components/ui";
import { Modal, ModalFooter } from "../components/modal";
import { PageShell } from "../components/layout";
import { DataTable, type Column } from "../components/data-table";
import type { GroupBundleSummary } from "../lib/types";
export const Route = createFileRoute("/bundles/")({
component: BundlesIndex,
});
function BundlesIndex() {
const navigate = useNavigate();
const qc = useQueryClient();
const bundles = useQuery({
queryKey: ["bundles"],
queryFn: api.bundles.list,
});
const [creating, setCreating] = useState(false);
const deleteMany = useMutation({
mutationFn: async (rows: GroupBundleSummary[]) => {
await Promise.all(rows.map((r) => api.bundles.delete(r.name)));
},
meta: { pending: "Deleting bundles…", success: "Bundles deleted" },
onSuccess: () => qc.invalidateQueries({ queryKey: ["bundles"] }),
});
const rows = bundles.data ?? [];
const columns: Column<GroupBundleSummary>[] = [
{
id: "name",
header: "Bundle",
accessor: (b) => b.displayName || b.name,
cell: (b) => (
<div>
<span className="text-fg font-medium">
{b.displayName || b.name}
</span>
{b.displayName && b.displayName !== b.name && (
<span className="text-fg-subtle ml-2 text-xs">({b.name})</span>
)}
</div>
),
},
{
id: "size",
header: "Groups",
accessor: (b) => b.size,
align: "right",
cell: (b) => <span className="tabular-nums">{b.size}</span>,
},
{
id: "preview",
header: "Members",
cell: (b) => (
<div className="flex flex-wrap gap-1">
{b.groups.length === 0 && (
<span className="text-fg-subtle text-xs">(empty)</span>
)}
{b.groups.slice(0, 8).map((g) => (
<Badge key={g} tone="neutral">
{g}
</Badge>
))}
{b.groups.length > 8 && (
<span className="text-fg-subtle text-xs">
+{b.groups.length - 8} more
</span>
)}
</div>
),
},
];
return (
<PageShell>
<PageHeader
title={
<span>
Bundles{" "}
<span className="text-fg-subtle text-base font-normal">
({rows.length})
</span>
</span>
}
description="Named ordered collections of groups. Drop a whole bundle into a track as one shot, or reuse the set across multiple ladders."
action={
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus size={16} /> New bundle
</Button>
}
/>
<DataTable<GroupBundleSummary>
data={rows}
columns={columns}
rowKey={(b) => b.name}
searchPlaceholder="Search bundles…"
pageSize={25}
loading={bundles.isLoading}
onRowClick={(b) =>
navigate({ to: "/bundles/$name", params: { name: b.name } })
}
bulkActions={[
{
id: "delete",
label: "Delete",
icon: Trash2,
variant: "danger",
confirm: "Delete {n} bundles? (Groups inside are NOT removed.)",
run: (rows) => deleteMany.mutateAsync(rows),
},
]}
emptyState={{
title: "No bundles yet",
description:
'Create one to bundle a set of groups together — e.g. "prison-mines" with all your mine ranks.',
}}
/>
<CreateBundleModal
open={creating}
onClose={() => setCreating(false)}
/>
</PageShell>
);
}
function CreateBundleModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState("");
const create = useMutation({
mutationFn: () => api.bundles.create(name.trim()),
meta: {
pending: "Creating bundle…",
success: (b: { name: string }) => `Created bundle ${b.name}`,
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bundles"] });
setName("");
onClose();
},
});
return (
<Modal
open={open}
onClose={onClose}
title="New bundle"
description="Start empty; add groups in the next screen."
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (name.trim()) create.mutate();
}}
>
<label className="block">
<span className="text-fg-muted text-xs font-medium">Bundle name</span>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="prison-mines"
className="mt-1"
/>
</label>
<ModalFooter>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
loading={create.isPending}
disabled={!name.trim()}
>
<Boxes size={14} /> Create bundle
</Button>
</ModalFooter>
</form>
</Modal>
);
}