web/src/routes/groups.index.tsx
26 KB · sha256:50f6059397c4440b3f4f0ac0d32899fb9e20af782e01ecd5c90a4b34086f8f84
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Copy,
LayoutList,
Plus,
Star,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "../lib/api";
import {
Badge,
Button,
Card,
Input,
PageHeader,
Select,
} from "../components/ui";
import { Modal, ModalFooter } from "../components/modal";
import { PageShell } from "../components/layout";
import { DataTable, type Column } from "../components/data-table";
import { useToast } from "../components/toast";
import type { GroupSummary } from "../lib/types";
export const Route = createFileRoute("/groups/")({
component: GroupsIndex,
});
function GroupsIndex() {
const navigate = useNavigate();
const qc = useQueryClient();
const groups = useQuery({ queryKey: ["groups"], queryFn: api.groups.list });
const bundles = useQuery({ queryKey: ["bundles"], queryFn: api.bundles.list });
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [bundleFilter, setBundleFilter] = useState<string>("");
const [cloning, setCloning] = useState<GroupSummary | null>(null);
const [bulkOpen, setBulkOpen] = useState(false);
const create = useMutation({
mutationFn: () => api.groups.create(name.trim()),
meta: {
pending: "Creating group…",
success: (g: { name: string }) => `Created group ${g.name}`,
},
onSuccess: () => {
setName("");
setCreating(false);
qc.invalidateQueries({ queryKey: ["groups"] });
},
});
const setDefault = useMutation({
mutationFn: (n: string) => api.groups.setDefault(n),
meta: { success: "Default group updated" },
onSuccess: () => qc.invalidateQueries({ queryKey: ["groups"] }),
});
const deleteMany = useMutation({
mutationFn: async (rows: GroupSummary[]) => {
await Promise.all(rows.map((r) => api.groups.delete(r.name)));
},
meta: { pending: "Deleting groups…", success: "Groups deleted" },
onSuccess: () => qc.invalidateQueries({ queryKey: ["groups"] }),
});
const allRows = groups.data ?? [];
const activeBundle = bundleFilter
? bundles.data?.find((b) => b.name === bundleFilter)
: null;
const rows = useMemo(() => {
if (!activeBundle) return allRows;
const allowed = new Set(activeBundle.groups);
return allRows.filter((g) => allowed.has(g.name));
}, [allRows, activeBundle]);
const columns: Column<GroupSummary>[] = [
{
id: "name",
header: "Name",
accessor: (g) => g.displayName || g.name,
cell: (g) => (
<div>
<span className="text-fg font-medium">
{g.displayName || g.name}
</span>
{g.displayName && g.displayName !== g.name && (
<span className="text-fg-subtle ml-2 text-xs">({g.name})</span>
)}
</div>
),
},
{
id: "weight",
header: "Weight",
accessor: (g) => g.weight,
align: "right",
cell: (g) => <span className="tabular-nums">{g.weight}</span>,
},
{
id: "nodes",
header: "Nodes",
accessor: (g) => g.nodeCount,
align: "right",
cell: (g) => <span className="tabular-nums">{g.nodeCount}</span>,
},
{
id: "default",
header: "Default",
accessor: (g) => (g.isDefault ? 1 : 0),
cell: (g) =>
g.isDefault ? (
<Badge tone="brand">
<Star size={10} /> default
</Badge>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDefault.mutate(g.name);
}}
className="text-fg-muted hover:text-brand-700 dark:hover:text-brand-300 text-xs hover:underline"
>
Make default
</button>
),
},
{
id: "actions",
header: "",
sortable: false,
align: "right",
cell: (g) => (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setCloning(g);
}}
className="text-fg-subtle hover:text-brand-700 dark:hover:text-brand-300 inline-flex items-center gap-1 text-xs font-medium hover:underline"
aria-label={`Clone ${g.name}`}
>
<Copy size={12} /> Clone
</button>
),
},
];
return (
<PageShell>
<PageHeader
title={
<span>
Groups{" "}
<span className="text-fg-subtle text-base font-normal">
({rows.length})
</span>
</span>
}
description="Permission groups players inherit from."
action={
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setBulkOpen(true)}>
<LayoutList size={16} /> Bulk create
</Button>
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus size={16} /> New group
</Button>
</div>
}
/>
{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="group name (lowercase, no spaces)"
/>
<Button
type="submit"
variant="primary"
loading={create.isPending}
disabled={!name.trim()}
>
Create
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setCreating(false);
setName("");
}}
>
Cancel
</Button>
</form>
</Card>
)}
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-fg-muted text-xs font-medium">Bundle filter</span>
<Select
value={bundleFilter}
onChange={(e) => setBundleFilter(e.target.value)}
className="h-8 max-w-xs text-xs"
>
<option value="">All groups ({allRows.length})</option>
{(bundles.data ?? []).map((b) => (
<option key={b.name} value={b.name}>
{b.displayName || b.name} ({b.size})
</option>
))}
</Select>
{bundleFilter && (
<button
type="button"
onClick={() => setBundleFilter("")}
className="text-fg-subtle hover:text-fg text-xs hover:underline"
>
clear
</button>
)}
{activeBundle && (
<span className="text-fg-subtle text-xs">
Showing {rows.length} of {allRows.length} (members of{" "}
<span className="text-fg font-medium">
{activeBundle.displayName || activeBundle.name}
</span>
)
</span>
)}
</div>
<DataTable<GroupSummary>
data={rows}
columns={columns}
rowKey={(g) => g.name}
searchPlaceholder="Search groups…"
pageSize={25}
loading={groups.isLoading}
onRowClick={(g) =>
navigate({ to: "/groups/$name", params: { name: g.name } })
}
bulkActions={[
{
id: "delete",
label: "Delete",
icon: Trash2,
variant: "danger",
confirm: "Delete {n} groups? Members lose this group.",
run: (rows) => deleteMany.mutateAsync(rows),
},
]}
emptyState={{
title: "No groups yet",
description: "Create one to start assigning permissions and prefixes.",
}}
/>
<CloneGroupModal
source={cloning}
onClose={() => setCloning(null)}
/>
<BulkCreateModal
open={bulkOpen}
onClose={() => setBulkOpen(false)}
existingGroups={rows.map((g) => g.name)}
/>
</PageShell>
);
}
// ---- Clone modal -----------------------------------------------------
function CloneGroupModal({
source,
onClose,
}: {
source: GroupSummary | null;
onClose: () => void;
}) {
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [weight, setWeight] = useState<string>("");
const clone = useMutation({
mutationFn: () =>
api.groups.clone(source!.name, newName.trim(), {
weight: weight.trim() ? Number(weight) : undefined,
}),
meta: {
pending: "Cloning…",
success: (g: { name: string }) => `Cloned to ${g.name}`,
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["groups"] });
setNewName("");
setWeight("");
onClose();
},
});
// Reset state when the dialog opens for a different source.
if (!source) return null;
return (
<Modal
open={!!source}
onClose={onClose}
title={`Clone "${source.displayName || source.name}"`}
description={`Copies all ${source.nodeCount} node${source.nodeCount === 1 ? "" : "s"} (permissions, parents, prefix/suffix) into a new group.`}
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) clone.mutate();
}}
>
<Field
label="New group name"
hint="Lowercase, no spaces. Must be unique."
>
<Input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={`${source.name}-copy`}
/>
</Field>
<Field
label="Weight override"
hint={`Leave blank to inherit (${source.weight}).`}
>
<Input
type="number"
value={weight}
onChange={(e) => setWeight(e.target.value)}
placeholder={String(source.weight)}
/>
</Field>
<ModalFooter>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
loading={clone.isPending}
disabled={!newName.trim()}
>
<Copy size={14} /> Clone group
</Button>
</ModalFooter>
</form>
</Modal>
);
}
// ---- Bulk create modal ----------------------------------------------
interface BulkResult {
created: string[];
failed: { name: string; reason: string }[];
}
type BulkMode = "pattern" | "list";
function toAlpha(n: number): string {
if (n < 0) return "";
let out = "";
let i = n;
while (i >= 0) {
out = String.fromCharCode(97 + (i % 26)) + out;
i = Math.floor(i / 26) - 1;
}
return out;
}
function expandTemplate(template: string, i: number, alphaIdx: number): string {
return template
.replace(/\{i\}/g, String(i))
.replace(/\{i:02\}/g, String(i).padStart(2, "0"))
.replace(/\{i:03\}/g, String(i).padStart(3, "0"))
.replace(/\{a\}/g, toAlpha(alphaIdx))
.replace(/\{A\}/g, toAlpha(alphaIdx).toUpperCase());
}
type BundleAssign = "none" | "existing" | "new";
function BulkCreateModal({
open,
onClose,
existingGroups,
}: {
open: boolean;
onClose: () => void;
existingGroups: string[];
}) {
const qc = useQueryClient();
const toast = useToast();
const bundles = useQuery({
queryKey: ["bundles"],
queryFn: api.bundles.list,
enabled: open,
});
const [mode, setMode] = useState<BulkMode>("pattern");
// Pattern mode
const [template, setTemplate] = useState("rank-{i}");
const [start, setStart] = useState("1");
const [end, setEnd] = useState("10");
// List mode
const [listText, setListText] = useState("");
// Shared
const [baseWeight, setBaseWeight] = useState("0");
const [weightIncrement, setWeightIncrement] = useState("10");
const [cloneFrom, setCloneFrom] = useState("");
// Inheritance
const [chainParents, setChainParents] = useState(false);
const [inheritFrom, setInheritFrom] = useState("");
// Bundle assignment
const [bundleAssign, setBundleAssign] = useState<BundleAssign>("none");
const [bundleExisting, setBundleExisting] = useState("");
const [bundleNewName, setBundleNewName] = useState("");
// Build preview
const previewNames: string[] = useMemo(() => {
if (mode === "list") {
return listText
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
}
const s = parseInt(start, 10);
const e = parseInt(end, 10);
if (!Number.isFinite(s) || !Number.isFinite(e) || e < s) return [];
if (e - s > 200) return [];
const out: string[] = [];
for (let i = s; i <= e; i++) {
out.push(expandTemplate(template, i, i - s));
}
return out;
}, [mode, template, start, end, listText]);
const baseW = Number(baseWeight) || 0;
const incW = Number(weightIncrement) || 0;
const submit = useMutation({
mutationFn: () => {
const bundle =
bundleAssign === "existing" && bundleExisting
? { name: bundleExisting, create: false }
: bundleAssign === "new" && bundleNewName.trim()
? { name: bundleNewName.trim(), create: true }
: undefined;
const common = {
baseWeight: baseW,
weightIncrement: incW,
cloneFrom: cloneFrom || undefined,
chainParents: chainParents || undefined,
inheritFrom: inheritFrom || undefined,
bundle,
};
return api.groups.bulkCreate(
mode === "pattern"
? {
template,
start: parseInt(start, 10),
end: parseInt(end, 10),
...common,
}
: { names: previewNames, ...common },
);
},
onSuccess: (res: BulkResult) => {
qc.invalidateQueries({ queryKey: ["groups"] });
qc.invalidateQueries({ queryKey: ["bundles"] });
if (res.failed.length === 0) {
toast.success(`Created ${res.created.length} groups`);
} else if (res.created.length === 0) {
toast.error(
`Failed all ${res.failed.length}: ${res.failed[0].reason}`,
);
} else {
toast.success(
`Created ${res.created.length}, skipped ${res.failed.length}`,
{ description: res.failed.map((f) => `${f.name}: ${f.reason}`).join(", ") },
);
}
onClose();
},
});
const count = previewNames.length;
const tooMany = count > 200;
const noNames = count === 0;
const bundleNewExists = !!bundles.data?.some(
(b) => b.name.toLowerCase() === bundleNewName.trim().toLowerCase(),
);
return (
<Modal
open={open}
onClose={onClose}
size="lg"
title="Bulk create groups"
description="Spin up many groups at once with optional templating, weight stepping, and node-cloning from a base group."
>
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
if (!noNames && !tooMany) submit.mutate();
}}
>
<ModeTabs mode={mode} onChange={setMode} />
{mode === "pattern" ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[2fr_1fr_1fr]">
<Field
label="Name template"
hint={
<>
<code>{"{i}"}</code> = index, <code>{"{i:02}"}</code> /{" "}
<code>{"{i:03}"}</code> = zero-padded,{" "}
<code>{"{a}"}</code> / <code>{"{A}"}</code> = alphabetical
(a..z, then aa..zz, then aaa..).
</>
}
>
<Input
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder="mine-{a}"
/>
</Field>
<Field label="Start">
<Input
type="number"
value={start}
onChange={(e) => setStart(e.target.value)}
/>
</Field>
<Field label="End">
<Input
type="number"
value={end}
onChange={(e) => setEnd(e.target.value)}
/>
</Field>
</div>
) : (
<Field
label="Group names"
hint="One per line. Whitespace and blank lines are stripped."
>
<textarea
value={listText}
onChange={(e) => setListText(e.target.value)}
rows={6}
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-sm transition outline-none focus:ring-4"
placeholder={"member\nvip\nvip+\nmod"}
/>
</Field>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Field label="Base weight" hint="First group gets this.">
<Input
type="number"
value={baseWeight}
onChange={(e) => setBaseWeight(e.target.value)}
/>
</Field>
<Field label="Weight step" hint="Added per subsequent group.">
<Input
type="number"
value={weightIncrement}
onChange={(e) => setWeightIncrement(e.target.value)}
/>
</Field>
<Field
label="Clone from"
hint="Copy nodes from this group into each new one."
>
<Select
value={cloneFrom}
onChange={(e) => setCloneFrom(e.target.value)}
>
<option value="">— none (empty groups) —</option>
{existingGroups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
</Field>
</div>
<Section
title="Inheritance"
description="Auto-parent each new group. Chain forms a → b → c → … so each rank inherits everything below it; the fixed parent is added to every new group on top of that."
>
<label className="text-fg flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={chainParents}
onChange={(e) => setChainParents(e.target.checked)}
className="accent-brand-600 border-border h-4 w-4 cursor-pointer rounded"
/>
<span>
<span className="font-medium">Chain parents</span>
<span className="text-fg-subtle ml-2 text-xs">
each group inherits from the previous one in the batch
</span>
</span>
</label>
<Field
label="Fixed parent"
hint="Add this group as a parent of every newly-created one (e.g. all minions inherit default)."
>
<Select
value={inheritFrom}
onChange={(e) => setInheritFrom(e.target.value)}
>
<option value="">— none —</option>
{existingGroups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
</Field>
</Section>
<Section
title="Bundle"
description="Optionally drop every created group into a bundle so you can re-use the set on tracks later."
>
<div className="bg-surface-3 inline-flex w-fit rounded-lg p-0.5 text-sm">
{(
[
["none", "Don't assign"],
["existing", "Add to existing"],
["new", "Create new bundle"],
] as const
).map(([k, label]) => (
<button
key={k}
type="button"
onClick={() => setBundleAssign(k)}
className={
bundleAssign === k
? "bg-surface text-fg rounded-md px-3 py-1.5 font-medium shadow-sm"
: "text-fg-muted hover:text-fg rounded-md px-3 py-1.5 font-medium"
}
>
{label}
</button>
))}
</div>
{bundleAssign === "existing" && (
<Field label="Bundle">
<Select
value={bundleExisting}
onChange={(e) => setBundleExisting(e.target.value)}
>
<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>
</Field>
)}
{bundleAssign === "new" && (
<Field
label="New bundle name"
hint={
bundleNewExists ? (
<span className="text-red-600 dark:text-red-300">
A bundle with this name already exists — pick "Add to
existing" instead.
</span>
) : (
"Created on submit, populated with the new groups in creation order."
)
}
>
<Input
value={bundleNewName}
onChange={(e) => setBundleNewName(e.target.value)}
placeholder="prison-mines"
/>
</Field>
)}
</Section>
<Preview names={previewNames} baseW={baseW} incW={incW} />
{tooMany && (
<p className="text-red-600 dark:text-red-300 text-sm">
That's {count} groups — server caps each batch at 200. Tighten the
range.
</p>
)}
<ModalFooter>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
loading={submit.isPending}
disabled={
noNames ||
tooMany ||
(bundleAssign === "new" && (!bundleNewName.trim() || bundleNewExists)) ||
(bundleAssign === "existing" && !bundleExisting)
}
>
Create {count > 0 ? `${count} group${count === 1 ? "" : "s"}` : "…"}
</Button>
</ModalFooter>
</form>
</Modal>
);
}
function ModeTabs({
mode,
onChange,
}: {
mode: BulkMode;
onChange: (m: BulkMode) => void;
}) {
return (
<div className="bg-surface-3 inline-flex w-fit rounded-lg p-0.5 text-sm">
{(["pattern", "list"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => onChange(m)}
className={
mode === m
? "bg-surface text-fg rounded-md px-3 py-1.5 font-medium shadow-sm"
: "text-fg-muted hover:text-fg rounded-md px-3 py-1.5 font-medium"
}
>
{m === "pattern" ? "Pattern" : "List"}
</button>
))}
</div>
);
}
function Preview({
names,
baseW,
incW,
}: {
names: string[];
baseW: number;
incW: number;
}) {
const [showAll, setShowAll] = useState(false);
if (names.length === 0) return null;
const COLLAPSED = 12;
const overflow = names.length - COLLAPSED;
const visible = showAll ? names : names.slice(0, COLLAPSED);
return (
<div className="border-border bg-surface-2/40 rounded-lg border p-3">
<div className="text-fg-muted mb-2 flex items-center justify-between text-xs">
<span className="font-medium">
Preview ({names.length} group{names.length === 1 ? "" : "s"})
</span>
{overflow > 0 && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="text-brand-600 dark:text-brand-300 font-medium hover:underline"
>
{showAll ? "Show first 12 only" : `Show all ${names.length}`}
</button>
)}
</div>
<div
className={
showAll
? "max-h-64 overflow-y-auto pr-1"
: undefined
}
>
<div className="flex flex-wrap gap-1.5">
{visible.map((n, i) => (
<span
key={`${n}:${i}`}
className="bg-surface border-border text-fg inline-flex items-center gap-2 rounded-md border px-2 py-1 font-mono text-xs"
>
{n}
<span className="text-fg-subtle tabular-nums">
{baseW + i * incW}
</span>
</span>
))}
</div>
</div>
</div>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<label className="block">
<span className="text-fg-muted text-xs font-medium">{label}</span>
<div className="mt-1">{children}</div>
{hint && (
<p className="text-fg-subtle mt-1 text-xs leading-snug">{hint}</p>
)}
</label>
);
}
function Section({
title,
description,
children,
}: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<div className="border-border rounded-xl border p-4">
<div className="mb-3">
<h4 className="text-fg text-sm font-semibold">{title}</h4>
{description && (
<p className="text-fg-subtle mt-0.5 text-xs">{description}</p>
)}
</div>
<div className="space-y-3">{children}</div>
</div>
);
}