Files
EpicNext-Cms/src/app/admin/permissions/[id]/rank-edit-client.tsx
T
openhands 7a41775c7e fix: disable route prefetching app-wide to avoid spurious requests
Wrap next/link in a shared Link component that ships prefetch=false by
default, so no route is ever prefetched (viewport or hover) anymore, and
drop the DNS prefetch hint. Removes hidden background requests that were
the source of intermittent issues.
2026-08-29 19:27:13 +02:00

804 lines
23 KiB
TypeScript

"use client";
import {
ArrowLeft,
ChevronDown,
Crown,
Loader2,
Save,
Search,
Settings,
Shield,
ShieldCheck,
Terminal,
UserCheck,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
saveRank as saveRankAction,
setCmsPermissions,
} from "@/actions/permissions";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useServerAction } from "@/hooks/use-server-action";
import { getAvatarUrl } from "@/lib/imager";
import { cn } from "@/lib/utils";
/* ─── Types ──────────────────────────────────────────────── */
interface RankData {
id: number;
rank_name: string;
badge: string;
level: number;
prefix: string;
prefix_color: string;
hidden_rank: string;
log_commands: string;
room_effect: number;
auto_credits_amount: number;
auto_pixels_amount: number;
auto_gotw_amount: number;
auto_points_amount: number;
permissions: Record<string, string>;
permissionMaxValues: Record<string, number>;
}
interface RankUser {
id: number;
username: string;
look: string;
}
interface CmsRoleData {
id: number;
slug: string;
title: string;
permissionSlugs: string[];
}
interface CmsPermission {
id: number;
slug: string;
title: string;
}
interface Props {
rank: RankData;
users: RankUser[];
totalUsers: number;
cmsRole: CmsRoleData | null;
allCmsPermissions: CmsPermission[];
}
/* ─── Helpers ────────────────────────────────────────────── */
function categorizePerms(keys: string[]) {
const commands: string[] = [];
const account: string[] = [];
const other: string[] = [];
for (const key of keys.sort()) {
if (key.startsWith("cmd_") || key === "kiss_cmd") commands.push(key);
else if (key.startsWith("acc_")) account.push(key);
else other.push(key);
}
return { commands, account, other };
}
/* ─── Tri-state Toggle ───────────────────────────────────── */
function PermToggle({
value,
maxValue,
onChange,
}: {
value: string;
maxValue: number;
onChange: (v: string) => void;
}) {
const values = Array.from({ length: Math.max(1, maxValue) + 1 }, (_, i) =>
String(i),
);
return (
<div className="flex gap-0.5 shrink-0">
{values.map((v) => (
<button
key={v}
type="button"
onClick={() => onChange(v)}
className={cn(
"w-7 h-6 text-[11px] font-mono rounded transition-all font-medium",
v === value
? v === "0"
? "bg-[var(--admin-text-muted)] text-[var(--color-surface-text-readable)] shadow-sm"
: v === "1"
? "bg-[var(--admin-success)] text-[var(--admin-success-foreground)] shadow-sm"
: "bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] shadow-sm"
: "bg-muted/60 text-muted-foreground/40 hover:bg-muted hover:text-muted-foreground",
)}
>
{v}
</button>
))}
</div>
);
}
/* ─── Main Component ─────────────────────────────────────── */
export function RankEditClient({
rank: initialRank,
users,
totalUsers,
cmsRole: initialCmsRole,
allCmsPermissions,
}: Props) {
const { run, isPending } = useServerAction();
const [general, setGeneral] = useState({
rank_name: initialRank.rank_name,
badge: initialRank.badge,
prefix: initialRank.prefix,
prefix_color: initialRank.prefix_color,
hidden_rank: initialRank.hidden_rank,
log_commands: initialRank.log_commands,
room_effect: initialRank.room_effect,
auto_credits_amount: initialRank.auto_credits_amount,
auto_pixels_amount: initialRank.auto_pixels_amount,
auto_gotw_amount: initialRank.auto_gotw_amount,
auto_points_amount: initialRank.auto_points_amount,
});
const [perms, setPerms] = useState<Record<string, string>>(
initialRank.permissions,
);
const [cmsPermSlugs, setCmsPermSlugs] = useState<string[]>(
initialCmsRole?.permissionSlugs ?? [],
);
const [permSearch, setPermSearch] = useState("");
const [cmsSearch, setCmsSearch] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const cats = useMemo(
() => categorizePerms(Object.keys(initialRank.permissions)),
[initialRank.permissions],
);
const enabledCount = Object.values(perms).filter((v) => v !== "0").length;
const totalCount = Object.keys(perms).length;
function setNum(field: string, val: string) {
setGeneral((p) => ({ ...p, [field]: parseInt(val, 10) || 0 }));
}
function saveRank() {
const fields = Object.fromEntries(
Object.entries({ ...general, ...perms }).map(([key, value]) => [
key,
String(value),
]),
);
run(() => saveRankAction({ id: initialRank.id, fields }), {
successMessage: "Rank saved",
});
}
async function toggleCms(slug: string) {
if (!initialCmsRole) return;
const prev = cmsPermSlugs;
const next = prev.includes(slug)
? prev.filter((s) => s !== slug)
: [...prev, slug];
setCmsPermSlugs(next);
try {
const r = await setCmsPermissions({
roleId: initialCmsRole.id,
permissionSlugs: next,
});
if (!r.ok) {
setCmsPermSlugs(prev);
toast.error("Failed");
}
} catch {
setCmsPermSlugs(prev);
toast.error("Failed");
}
}
function toggle(key: string) {
setCollapsed((p) => {
const n = new Set(p);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
}
/* ── Collapsible perm section ──────────────────────────── */
function setSectionAll(keys: string[], value: string) {
setPerms((p) => {
const next = { ...p };
for (const k of keys) next[k] = value;
return next;
});
}
function setAllPerms(value: string) {
setPerms((p) => {
const next = { ...p };
for (const k of Object.keys(next)) next[k] = value;
return next;
});
}
function PermSection({
title,
keys,
sectionKey,
}: {
title: string;
keys: string[];
sectionKey: string;
}) {
const filtered = permSearch
? keys.filter((k) => k.toLowerCase().includes(permSearch.toLowerCase()))
: keys;
if (permSearch && filtered.length === 0) return null;
const on = filtered.filter((k) => (perms[k] ?? "0") !== "0").length;
const _allOn = on === filtered.length;
void _allOn;
const open = !collapsed.has(sectionKey);
return (
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center bg-muted/30">
<button
type="button"
onClick={() => toggle(sectionKey)}
className="flex flex-1 items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
>
<span className="text-sm font-medium">{title}</span>
<div className="flex items-center gap-2">
<Badge
variant={on > 0 ? "default" : "secondary"}
className="text-[10px]"
>
{on}/{filtered.length}
</Badge>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
!open && "-rotate-90",
)}
/>
</div>
</button>
{open && (
<div className="flex gap-1 pr-3 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={(e) => {
e.stopPropagation();
setSectionAll(filtered, "1");
}}
>
All On
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={(e) => {
e.stopPropagation();
setSectionAll(filtered, "0");
}}
>
All Off
</Button>
</div>
)}
</div>
{open && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border">
{filtered.map((key) => (
<div
key={key}
className={cn(
"flex items-center justify-between px-3 py-2 bg-card",
(perms[key] ?? "0") !== "0" &&
"bg-[var(--admin-success-subtle)]",
)}
>
<span className="font-mono text-xs truncate mr-3">{key}</span>
<PermToggle
value={perms[key] ?? "0"}
maxValue={initialRank.permissionMaxValues[key] ?? 1}
onChange={(v) => setPerms((p) => ({ ...p, [key]: v }))}
/>
</div>
))}
</div>
)}
</div>
);
}
const filteredCms = cmsSearch
? allCmsPermissions.filter(
(p) =>
p.slug.toLowerCase().includes(cmsSearch.toLowerCase()) ||
p.title?.toLowerCase().includes(cmsSearch.toLowerCase()),
)
: allCmsPermissions;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/admin/permissions">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
{initialRank.id >= 7 ? (
<Crown className="h-6 w-6 text-[var(--admin-warning)]" />
) : initialRank.id >= 5 ? (
<ShieldCheck className="h-6 w-6 text-[var(--admin-info)]" />
) : (
<Shield className="h-6 w-6 text-muted-foreground" />
)}
<div>
<h1 className="text-2xl font-bold">
{general.rank_name || `Rank ${initialRank.id}`}
</h1>
<p className="text-sm text-muted-foreground">
Rank #{initialRank.id} &middot; Level {initialRank.level} &middot;{" "}
{totalUsers} users
</p>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">
<Settings className="w-3.5 h-3.5 mr-1" /> General
</TabsTrigger>
<TabsTrigger value="emulator">
<Terminal className="w-3.5 h-3.5 mr-1" /> Emulator ({enabledCount})
</TabsTrigger>
<TabsTrigger value="cms">
<UserCheck className="w-3.5 h-3.5 mr-1" /> CMS (
{cmsPermSlugs.length})
</TabsTrigger>
<TabsTrigger value="users">Users ({totalUsers})</TabsTrigger>
</TabsList>
{/* ═══ General ═══ */}
<TabsContent value="general" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Rank Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Rank Name</Label>
<Input
value={general.rank_name}
onChange={(e) =>
setGeneral({ ...general, rank_name: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Badge</Label>
<Input
value={general.badge}
onChange={(e) =>
setGeneral({ ...general, badge: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Prefix</Label>
<Input
value={general.prefix}
onChange={(e) =>
setGeneral({ ...general, prefix: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Prefix Color</Label>
<div className="flex gap-2">
<Input
value={general.prefix_color}
onChange={(e) =>
setGeneral({ ...general, prefix_color: e.target.value })
}
/>
{general.prefix_color && (
<div
className="w-9 h-9 rounded border shrink-0"
style={{
backgroundColor: general.prefix_color.startsWith("#")
? general.prefix_color
: `#${general.prefix_color}`,
}}
/>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm">Hidden Rank</Label>
<p className="text-xs text-muted-foreground">
Hide from public
</p>
</div>
<Switch
checked={general.hidden_rank === "1"}
onCheckedChange={(v) =>
setGeneral({ ...general, hidden_rank: v ? "1" : "0" })
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm">Log Commands</Label>
<p className="text-xs text-muted-foreground">
Record to audit log
</p>
</div>
<Switch
checked={general.log_commands === "1"}
onCheckedChange={(v) =>
setGeneral({ ...general, log_commands: v ? "1" : "0" })
}
/>
</div>
<div className="space-y-1.5 rounded-lg border p-3">
<Label className="text-xs">Room Effect ID</Label>
<Input
type="number"
value={general.room_effect}
onChange={(e) => setNum("room_effect", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4">
<div className="space-y-1.5">
<Label className="text-xs">Auto Credits</Label>
<Input
type="number"
value={general.auto_credits_amount}
onChange={(e) =>
setNum("auto_credits_amount", e.target.value)
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Auto Pixels</Label>
<Input
type="number"
value={general.auto_pixels_amount}
onChange={(e) =>
setNum("auto_pixels_amount", e.target.value)
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Auto GOTW</Label>
<Input
type="number"
value={general.auto_gotw_amount}
onChange={(e) => setNum("auto_gotw_amount", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Auto Points</Label>
<Input
type="number"
value={general.auto_points_amount}
onChange={(e) =>
setNum("auto_points_amount", e.target.value)
}
/>
</div>
</div>
<div className="flex justify-end mt-4">
<Button onClick={saveRank} disabled={isPending} size="sm">
{isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1" />
)}
Save
</Button>
</div>
</CardContent>
</Card>
{/* Users preview */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Users with this rank
{totalUsers > users.length && (
<span className="text-muted-foreground font-normal ml-1 text-sm">
(showing {users.length} of {totalUsers})
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{users.length === 0 ? (
<p className="text-sm text-muted-foreground">
No users with this rank.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
{users.map((u) => (
<Link
key={u.id}
href={`/admin/users/show/${u.id}`}
className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors"
>
<img
src={getAvatarUrl(u.look, {
size: "s",
headDirection: 2,
})}
alt=""
className="w-8 h-12 object-cover"
/>
<span className="text-xs font-medium truncate">
{u.username}
</span>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ═══ Emulator Permissions ═══ */}
<TabsContent value="emulator" className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-sm text-muted-foreground">
{enabledCount} of {totalCount} permissions enabled
</p>
<div className="flex items-center gap-3">
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAllPerms("1")}
>
Enable All
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAllPerms("0")}
>
Disable All
</Button>
</div>
<div className="flex gap-2 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded bg-[var(--admin-text-muted)] inline-block" />{" "}
Off
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded bg-[var(--admin-success-subtle)] inline-block" />{" "}
On
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded bg-[var(--admin-warning-subtle)] inline-block" />{" "}
Owner
</span>
</div>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search permissions..."
value={permSearch}
onChange={(e) => setPermSearch(e.target.value)}
className="pl-9"
/>
</div>
<PermSection title="Commands" keys={cats.commands} sectionKey="cmd" />
<PermSection title="Account" keys={cats.account} sectionKey="acc" />
{cats.other.length > 0 && (
<PermSection title="Other" keys={cats.other} sectionKey="other" />
)}
<div className="flex justify-end">
<Button onClick={saveRank} disabled={isPending}>
{isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1" />
)}
Save Rank
</Button>
</div>
</TabsContent>
{/* ═══ CMS Permissions ═══ */}
<TabsContent value="cms" className="space-y-4">
{initialRank.id < 4 ? (
<div className="rounded-lg border bg-card p-6 text-center">
<p className="text-muted-foreground">
CMS permissions are available from rank 4 and above.
</p>
</div>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{cmsPermSlugs.length} of {allCmsPermissions.length} assigned
</p>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={async () => {
if (!initialCmsRole) return;
const all = allCmsPermissions.map((p) => p.slug);
setCmsPermSlugs(all);
const r = await setCmsPermissions({
roleId: initialCmsRole.id,
permissionSlugs: all,
});
if (!r.ok) {
setCmsPermSlugs(initialCmsRole.permissionSlugs);
toast.error("Failed");
}
}}
>
Select All
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={async () => {
if (!initialCmsRole) return;
setCmsPermSlugs([]);
const r = await setCmsPermissions({
roleId: initialCmsRole.id,
permissionSlugs: [],
});
if (!r.ok) {
setCmsPermSlugs(initialCmsRole.permissionSlugs);
toast.error("Failed");
}
}}
>
Deselect All
</Button>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search CMS permissions..."
value={cmsSearch}
onChange={(e) => setCmsSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="space-y-1">
{filteredCms.map((perm) => {
const on = cmsPermSlugs.includes(perm.slug);
return (
<div
key={perm.id}
className={cn(
"flex items-center gap-3 rounded-lg border px-4 py-3 transition-all",
on
? "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]"
: "hover:bg-muted/50",
)}
>
<Checkbox
checked={on}
onCheckedChange={() => toggleCms(perm.slug)}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-mono">{perm.slug}</span>
{perm.title && (
<span className="text-xs text-muted-foreground ml-2">
{perm.title}
</span>
)}
</div>
{on && (
<Badge className="bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-[var(--admin-success-border)] text-[10px]">
Active
</Badge>
)}
</div>
);
})}
</div>
{filteredCms.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No permissions match your search.
</p>
)}
</>
)}
</TabsContent>
{/* ═══ Users ═══ */}
<TabsContent value="users">
<Card>
<CardHeader>
<CardTitle className="text-base">
Users with this rank
{totalUsers > users.length && (
<span className="text-muted-foreground font-normal ml-1 text-sm">
(showing {users.length} of {totalUsers})
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{users.length === 0 ? (
<p className="text-sm text-muted-foreground">
No users with this rank.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
{users.map((u) => (
<Link
key={u.id}
href={`/admin/users/show/${u.id}`}
className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors"
>
<img
src={getAvatarUrl(u.look, {
size: "s",
headDirection: 2,
})}
alt=""
className="w-8 h-12 object-cover"
/>
<span className="text-xs font-medium truncate">
{u.username}
</span>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}