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.
804 lines
23 KiB
TypeScript
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} · Level {initialRank.level} ·{" "}
|
|
{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>
|
|
);
|
|
}
|