- Add 11 missing server action files: badges, bulk-users, catalog, catalog-bc, catalog-items, import-badges, import-furni, multi-account-detect, permissions, rooms, soundtracks - Add missing admin navigation links: tickets, sounds, translations, import, radio sub-pages - Add translation keys for all new navigation items
729 lines
26 KiB
TypeScript
729 lines
26 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
ArrowLeft,
|
|
ChevronDown,
|
|
Crown,
|
|
Loader2,
|
|
Save,
|
|
Search,
|
|
Settings,
|
|
Shield,
|
|
ShieldCheck,
|
|
Terminal,
|
|
UserCheck,
|
|
} from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useMemo, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { saveRank as saveRankAction, setCmsPermissions } from '@/actions/permissions'
|
|
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-zinc-500 text-white shadow-sm'
|
|
: v === '1'
|
|
? 'bg-emerald-500 text-white shadow-sm'
|
|
: 'bg-amber-500 text-white 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() {
|
|
run(() => saveRankAction({ id: initialRank.id, fields: { ...general, ...perms } }), {
|
|
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
|
|
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-emerald-500/5',
|
|
)}
|
|
>
|
|
<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-yellow-500" />
|
|
) : initialRank.id >= 5 ? (
|
|
<ShieldCheck className="h-6 w-6 text-blue-500" />
|
|
) : (
|
|
<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-zinc-500 inline-block" /> Off
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded bg-emerald-500 inline-block" /> On
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded bg-amber-500 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-emerald-500/40 bg-emerald-500/5' : '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-emerald-500/15 text-emerald-600 border-emerald-500/30 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>
|
|
)
|
|
}
|