web/src/routes/groups.$name.tsx
10 KB · sha256:af2463b45903945b40d91b7aea6804622141ca557aee8d50748f1b2ef79bfd71
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Star, Trash2 } from "lucide-react";
import { useState } from "react";
import { api } from "../lib/api";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
EmptyState,
Input,
PageHeader,
Select,
} from "../components/ui";
import { PageShell } from "../components/layout";
export const Route = createFileRoute("/groups/$name")({
component: GroupDetailPage,
});
function GroupDetailPage() {
const { name } = Route.useParams();
const qc = useQueryClient();
const group = useQuery({
queryKey: ["group", name],
queryFn: () => api.groups.get(name),
});
const allGroups = useQuery({ queryKey: ["groups"], queryFn: api.groups.list });
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["group", name] });
qc.invalidateQueries({ queryKey: ["groups"] });
};
const setWeight = useMutation({
mutationFn: (w: number) => api.groups.setWeight(name, w),
meta: { pending: "Saving weight…", success: "Weight saved" },
onSuccess: invalidate,
});
const setPrefix = useMutation({
mutationFn: (text: string) => api.groups.setPrefix(name, text),
meta: { pending: "Saving prefix…", success: "Prefix saved" },
onSuccess: invalidate,
});
const setSuffix = useMutation({
mutationFn: (text: string) => api.groups.setSuffix(name, text),
meta: { pending: "Saving suffix…", success: "Suffix saved" },
onSuccess: invalidate,
});
const addParent = useMutation({
mutationFn: (p: string) => api.groups.addParent(name, p),
meta: { success: "Parent added" },
onSuccess: invalidate,
});
const removeParent = useMutation({
mutationFn: (p: string) => api.groups.removeParent(name, p),
meta: { success: "Parent removed" },
onSuccess: invalidate,
});
const grant = useMutation({
mutationFn: (perm: string) => api.groups.grant(name, perm),
meta: { success: "Permission granted" },
onSuccess: invalidate,
});
const revoke = useMutation({
mutationFn: (perm: string) => api.groups.revoke(name, perm),
meta: { success: "Permission revoked" },
onSuccess: invalidate,
});
const setDefault = useMutation({
mutationFn: () => api.groups.setDefault(name),
meta: { success: "Default group updated" },
onSuccess: invalidate,
});
const deleteGroup = useMutation({
mutationFn: () => api.groups.delete(name),
meta: { pending: "Deleting group…", success: "Group deleted" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["groups"] });
history.back();
},
});
if (group.isLoading) {
return (
<PageShell>
<p className="text-fg-muted">Loading…</p>
</PageShell>
);
}
if (!group.data) {
return (
<PageShell>
<EmptyState title="Group not found" description={name} />
</PageShell>
);
}
const g = group.data;
const candidates = (allGroups.data ?? [])
.map((x) => x.name)
.filter((n) => n !== name && !g.parents.includes(n));
return (
<PageShell>
<Link
to="/groups"
className="text-fg-muted hover:text-fg mb-4 inline-flex items-center gap-1 text-sm"
>
<ArrowLeft size={14} /> Groups
</Link>
<PageHeader
title={
<span className="flex items-center gap-3">
{g.displayName || g.name}
{g.isDefault && (
<Badge tone="brand">
<Star size={10} /> default
</Badge>
)}
</span>
}
description={
g.displayName && g.displayName !== g.name ? (
<span className="font-mono text-xs">{g.name}</span>
) : undefined
}
action={
<div className="flex gap-2">
{!g.isDefault && (
<Button variant="secondary" size="sm" onClick={() => setDefault.mutate()}>
Make default
</Button>
)}
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm(`Delete group "${name}"? Members lose this group.`))
deleteGroup.mutate();
}}
>
<Trash2 size={14} /> Delete
</Button>
</div>
}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card>
<CardHeader title="Weight" description="Higher wins on conflict." />
<CardBody>
<WeightField initial={g.weight} onSave={(w) => setWeight.mutate(w)} />
</CardBody>
</Card>
<Card className="lg:col-span-2">
<CardHeader title="Display" description="Prefix + suffix served to chat." />
<CardBody className="space-y-3">
<InlineEditField
label="Prefix"
initial={g.nodes.find((n) => n.type === "prefix")?.key.replace(/^prefix\.\d+\./, "") ?? ""}
onSave={(v) => setPrefix.mutate(v)}
/>
<InlineEditField
label="Suffix"
initial={g.nodes.find((n) => n.type === "suffix")?.key.replace(/^suffix\.\d+\./, "") ?? ""}
onSave={(v) => setSuffix.mutate(v)}
/>
</CardBody>
</Card>
<Card className="lg:col-span-2">
<CardHeader
title="Parents"
description="Groups this one inherits permissions from."
/>
<CardBody>
<div className="flex flex-wrap gap-2">
{g.parents.length === 0 && (
<span className="text-fg-subtle text-sm">No parents.</span>
)}
{g.parents.map((p) => (
<span
key={p}
className="bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200 inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium"
>
{p}
<button
type="button"
onClick={() => removeParent.mutate(p)}
className="hover:text-red-600"
>
×
</button>
</span>
))}
{candidates.length > 0 && (
<Select
className="h-7 w-auto px-2 text-xs"
value=""
onChange={(e) => {
if (e.target.value) addParent.mutate(e.target.value);
}}
>
<option value="">+ add parent</option>
{candidates.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</Select>
)}
</div>
</CardBody>
</Card>
<Card>
<CardHeader title="Grant permission" />
<CardBody>
<AddPermissionField onAdd={(v) => grant.mutate(v)} />
</CardBody>
</Card>
<Card className="lg:col-span-3">
<CardHeader
title="Nodes"
description="All entries directly on this group."
/>
<CardBody>
{g.nodes.length === 0 ? (
<EmptyState title="No nodes" />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{g.nodes.map((n, i) => (
<div
key={`${n.type}:${n.key}:${i}`}
className="border-border bg-surface-2 flex items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex min-w-0 items-center gap-2">
<Badge
tone={
n.value
? n.type === "inheritance"
? "brand"
: "success"
: "danger"
}
>
{n.type}
</Badge>
<span className="text-fg truncate font-mono text-sm">
{n.key}
</span>
</div>
{n.type === "permission" && (
<button
type="button"
onClick={() => revoke.mutate(n.key)}
className="text-fg-subtle hover:text-red-600"
>
<Trash2 size={14} />
</button>
)}
</div>
))}
</div>
)}
</CardBody>
</Card>
</div>
</PageShell>
);
}
function WeightField({
initial,
onSave,
}: {
initial: number;
onSave: (n: number) => void;
}) {
const [v, setV] = useState(String(initial));
return (
<div className="flex gap-2">
<Input
type="number"
value={v}
onChange={(e) => setV(e.target.value)}
/>
<Button
variant="subtle"
disabled={String(initial) === v || !Number.isFinite(parseInt(v, 10))}
onClick={() => onSave(parseInt(v, 10))}
>
Save
</Button>
</div>
);
}
function InlineEditField({
label,
initial,
onSave,
}: {
label: string;
initial: string;
onSave: (v: string) => void;
}) {
const [v, setV] = useState(initial);
return (
<div>
<label className="text-fg-muted text-xs font-medium">{label}</label>
<div className="mt-1 flex gap-2">
<Input
value={v}
onChange={(e) => setV(e.target.value)}
placeholder="MiniMessage…"
/>
<Button variant="subtle" disabled={v === initial} onClick={() => onSave(v)}>
Save
</Button>
</div>
</div>
);
}
function AddPermissionField({ onAdd }: { onAdd: (v: string) => void }) {
const [v, setV] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (v.trim()) {
onAdd(v.trim());
setV("");
}
}}
className="flex gap-2"
>
<Input
value={v}
onChange={(e) => setV(e.target.value)}
placeholder="permission.node"
/>
<Button type="submit" variant="primary">
Grant
</Button>
</form>
);
}