web/src/routes/players.$uuid.tsx
13 KB · sha256:d9e45c9a907dc4e8eaaa0008b7ac4c06603642bfb5b3b8df3cbe5e19cb19ea9d
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Trash2 } from "lucide-react";
import { useState } from "react";
import { api } from "../lib/api";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
EmptyState,
Input,
PageHeader,
PlayerAvatar,
Select,
} from "../components/ui";
import { PageShell } from "../components/layout";
export const Route = createFileRoute("/players/$uuid")({
component: PlayerDetailPage,
});
function PlayerDetailPage() {
const { uuid } = Route.useParams();
const qc = useQueryClient();
const player = useQuery({
queryKey: ["players", uuid],
queryFn: () => api.players.get(uuid),
});
const groups = useQuery({
queryKey: ["groups"],
queryFn: api.groups.list,
});
const tracks = useQuery({
queryKey: ["tracks"],
queryFn: api.tracks.list,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["players", uuid] });
qc.invalidateQueries({ queryKey: ["players"] });
};
const setPrimary = useMutation({
mutationFn: (group: string) => api.players.setPrimaryGroup(uuid, group),
meta: { success: "Primary group updated" },
onSuccess: invalidate,
});
const addGroup = useMutation({
mutationFn: (group: string) => api.players.addGroup(uuid, group),
meta: { success: "Group added" },
onSuccess: invalidate,
});
const removeGroup = useMutation({
mutationFn: (group: string) => api.players.removeGroup(uuid, group),
meta: { success: "Group removed" },
onSuccess: invalidate,
});
const setPrefix = useMutation({
mutationFn: (text: string) => api.players.setPrefix(uuid, text),
meta: { pending: "Saving prefix…", success: "Prefix saved" },
onSuccess: invalidate,
});
const setSuffix = useMutation({
mutationFn: (text: string) => api.players.setSuffix(uuid, text),
meta: { pending: "Saving suffix…", success: "Suffix saved" },
onSuccess: invalidate,
});
const grant = useMutation({
mutationFn: (permission: string) => api.players.grant(uuid, permission),
meta: { success: "Permission granted" },
onSuccess: invalidate,
});
const revoke = useMutation({
mutationFn: (perm: string) => api.players.revoke(uuid, perm),
meta: { success: "Permission revoked" },
onSuccess: invalidate,
});
// promote / demote handle their own feedback (the inline "from → to"
// message + reason on failure). Suppress the global error toast so the
// user sees the rich message instead of a generic one.
const promote = useMutation({
mutationFn: (track: string) => api.players.promote(uuid, track),
meta: { toastErrors: false },
onSuccess: invalidate,
});
const demote = useMutation({
mutationFn: (track: string) => api.players.demote(uuid, track),
meta: { toastErrors: false },
onSuccess: invalidate,
});
const clear = useMutation({
mutationFn: () => api.players.clear(uuid),
meta: { pending: "Clearing nodes…", success: "All nodes cleared" },
onSuccess: invalidate,
});
if (player.isLoading) {
return (
<PageShell>
<p className="text-fg-muted">Loading…</p>
</PageShell>
);
}
if (!player.data) {
return (
<PageShell>
<EmptyState
title="Player not found"
description={`No Ward record for ${uuid}.`}
action={
<Link
to="/players"
className="text-brand-600 dark:text-brand-300 text-sm font-medium hover:underline"
>
← Back to players
</Link>
}
/>
</PageShell>
);
}
const p = player.data;
const allGroups = groups.data ?? [];
const allTracks = tracks.data ?? [];
return (
<PageShell>
<Link
to="/players"
className="text-fg-muted hover:text-fg mb-4 inline-flex items-center gap-1 text-sm"
>
<ArrowLeft size={14} /> Players
</Link>
<PageHeader
title={
<span className="flex items-center gap-3">
<PlayerAvatar uuid={p.uuid} name={p.username} size={40} />
{p.username}
{p.online && <Badge tone="success">Online</Badge>}
</span>
}
description={
<span className="font-mono text-xs">{p.uuid}</span>
}
action={
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm(`Clear ALL nodes for ${p.username}?`)) clear.mutate();
}}
>
<Trash2 size={14} /> Clear all nodes
</Button>
}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader title="Groups" description="Membership + primary group." />
<CardBody className="space-y-4">
<Row label="Primary group">
<Select
value={p.primaryGroup}
onChange={(e) => setPrimary.mutate(e.target.value)}
>
<option value="">(none)</option>
{allGroups.map((g) => (
<option key={g.name} value={g.name}>
{g.name}
</option>
))}
</Select>
</Row>
<Row label="Inherited groups">
<div className="flex flex-wrap gap-2">
{p.groups.length === 0 && (
<span className="text-fg-subtle text-sm">(none)</span>
)}
{p.groups.map((g) => (
<span
key={g}
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"
>
{g}
<button
type="button"
onClick={() => removeGroup.mutate(g)}
className="hover:text-red-600"
aria-label={`Remove ${g}`}
>
×
</button>
</span>
))}
<AddGroupChip
groups={allGroups.map((g) => g.name)}
exclude={p.groups}
onAdd={(g) => addGroup.mutate(g)}
/>
</div>
</Row>
</CardBody>
</Card>
<Card>
<CardHeader title="Display" description="Prefix + suffix override." />
<CardBody className="space-y-3">
<InlineEditField
label="Prefix"
initial={p.prefix ?? ""}
onSave={(v) => setPrefix.mutate(v)}
/>
<InlineEditField
label="Suffix"
initial={p.suffix ?? ""}
onSave={(v) => setSuffix.mutate(v)}
/>
</CardBody>
</Card>
{allTracks.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader
title="Track movement"
description={
p.online
? "Promote / demote applies requirements + costs."
: "Player offline; cost-based promotion is disabled."
}
/>
<CardBody>
<div className="space-y-2">
{allTracks.map((t) => (
<div
key={t.name}
className="border-border flex items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="min-w-0">
<div className="text-fg font-medium">{t.name}</div>
<div className="text-fg-subtle text-xs">
{t.rungCount} rung{t.rungCount === 1 ? "" : "s"} ·{" "}
{t.autoRungs} auto
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="subtle"
disabled={!p.online}
onClick={() => promote.mutate(t.name)}
>
Promote
</Button>
<Button
size="sm"
variant="ghost"
disabled={!p.online}
onClick={() => demote.mutate(t.name)}
>
Demote
</Button>
</div>
</div>
))}
{(promote.data && !promote.data.ok) ||
(demote.data && !demote.data.ok) ? (
<p className="text-red-600 dark:text-red-300 text-sm">
{promote.data && !promote.data.ok ? promote.data.reason : ""}
{demote.data && !demote.data.ok ? demote.data.reason : ""}
</p>
) : null}
</div>
</CardBody>
</Card>
)}
<Card className="lg:col-span-3">
<CardHeader
title="Direct nodes"
description="Permissions, inheritance, and meta attached directly to this player."
action={
<AddPermissionField
placeholder="permission.node"
onAdd={(v) => grant.mutate(v)}
/>
}
/>
<CardBody>
{p.nodes.length === 0 ? (
<EmptyState
title="No direct nodes"
description="This player has no direct overrides; their access comes purely from group inheritance."
/>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{p.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"
aria-label="Remove"
>
<Trash2 size={14} />
</button>
)}
</div>
))}
</div>
)}
</CardBody>
</Card>
</div>
</PageShell>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 items-center gap-2 sm:grid-cols-[160px_1fr]">
<span className="text-fg-muted text-sm font-medium">{label}</span>
<div>{children}</div>
</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"
size="md"
disabled={v === initial}
onClick={() => onSave(v)}
>
Save
</Button>
</div>
</div>
);
}
function AddGroupChip({
groups,
exclude,
onAdd,
}: {
groups: string[];
exclude: string[];
onAdd: (g: string) => void;
}) {
const remaining = groups.filter((g) => !exclude.includes(g));
if (remaining.length === 0) return null;
return (
<Select
className="h-7 w-auto px-2 text-xs"
value=""
onChange={(e) => {
if (e.target.value) onAdd(e.target.value);
}}
>
<option value="">+ add group</option>
{remaining.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
);
}
function AddPermissionField({
placeholder,
onAdd,
}: {
placeholder: string;
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={placeholder}
className="h-9 w-56"
/>
<Button variant="subtle" size="sm" type="submit">
Grant
</Button>
</form>
);
}