Reapply "Add missing admin action files and navigation links"
This reverts commit 4d515bc400.
This commit is contained in:
1 parent
96ed768f14
commit
5b4228261a
338 files changed
+28143
-5948
No files matched your search
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { updateRoomItem } from '@/actions/rooms'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
|
||||
interface EditItem {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
rot: number
|
||||
wallPos: string
|
||||
extraData: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
roomId: number
|
||||
item: EditItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
|
||||
const { run, isPending } = useServerAction()
|
||||
const [x, setX] = useState('0')
|
||||
const [y, setY] = useState('0')
|
||||
const [z, setZ] = useState('0')
|
||||
const [rot, setRot] = useState('0')
|
||||
const [wallPos, setWallPos] = useState('')
|
||||
const [extraData, setExtraData] = useState('')
|
||||
|
||||
const isWall = item?.type === 'i'
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setX(String(item.x))
|
||||
setY(String(item.y))
|
||||
setZ(String(item.z))
|
||||
setRot(String(item.rot))
|
||||
setWallPos(item.wallPos)
|
||||
setExtraData(item.extraData)
|
||||
}
|
||||
}, [item])
|
||||
|
||||
function handleSave() {
|
||||
if (!item) return
|
||||
const payload = isWall
|
||||
? { roomId, itemId: item.id, wallPos, extraData }
|
||||
: {
|
||||
roomId,
|
||||
itemId: item.id,
|
||||
x: Number(x),
|
||||
y: Number(y),
|
||||
z: Number(z),
|
||||
rot: Number(rot),
|
||||
extraData,
|
||||
}
|
||||
run(() => updateRoomItem(payload), {
|
||||
successMessage: 'Item updated and room reloaded',
|
||||
onSuccess: () => onOpenChange(false),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit furniture</DialogTitle>
|
||||
<DialogDescription>{item ? `${item.name} (#${item.id})` : ''}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isWall ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wallPos">Wall position</Label>
|
||||
<Input
|
||||
id="wallPos"
|
||||
value={wallPos}
|
||||
onChange={(e) => setWallPos(e.target.value)}
|
||||
placeholder=":w=0,0 l=0,0 r"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: <code>:w=X,Y l=X,Y r</code>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="x">X</Label>
|
||||
<Input
|
||||
id="x"
|
||||
type="number"
|
||||
value={x}
|
||||
onChange={(e) => setX(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="y">Y</Label>
|
||||
<Input
|
||||
id="y"
|
||||
type="number"
|
||||
value={y}
|
||||
onChange={(e) => setY(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="z">Z</Label>
|
||||
<Input
|
||||
id="z"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={z}
|
||||
onChange={(e) => setZ(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rot">Rotation (0-7)</Label>
|
||||
<Input
|
||||
id="rot"
|
||||
type="number"
|
||||
min={0}
|
||||
max={7}
|
||||
value={rot}
|
||||
onChange={(e) => setRot(e.target.value)}
|
||||
className="font-mono w-24"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extraData">Extra data</Label>
|
||||
<Textarea
|
||||
id="extraData"
|
||||
value={extraData}
|
||||
onChange={(e) => setExtraData(e.target.value)}
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
placeholder="(empty)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Item state/data (e.g. dice value, sticky note text, color)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isPending}>
|
||||
{isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Armchair,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Frame,
|
||||
Layers,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Users,
|
||||
UserX,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { bulkDeleteRoomItems, deleteRoomItem, roomRconAction } from '@/actions/rooms'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
import { EditItemDialog } from './edit-item-dialog'
|
||||
|
||||
interface FurniRow {
|
||||
id: number
|
||||
baseId: number
|
||||
name: string
|
||||
itemName: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
rot: number
|
||||
wallPos: string
|
||||
userId: number
|
||||
ownerUsername: string | null
|
||||
extraData: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
room: {
|
||||
id: number
|
||||
name: string
|
||||
ownerUsername: string
|
||||
}
|
||||
items?: FurniRow[]
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
const PER_PAGE = 50
|
||||
type SortKey = 'id' | 'name' | 'type' | 'pos' | 'owner'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
type View = 'flat' | 'grouped'
|
||||
|
||||
function iconUrl(itemName: string) {
|
||||
if (!itemName) return null
|
||||
const base = itemName.split('*')[0]
|
||||
return `/swf/dcr/hof_furni/icons/${base}_icon.png`
|
||||
}
|
||||
|
||||
function isWallType(t: string) {
|
||||
return t === 'i'
|
||||
}
|
||||
|
||||
export function FurniClient({ room, items = [], canEdit }: Props) {
|
||||
const { run, isPending } = useServerAction()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [view, setView] = useState<View>('flat')
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'floor' | 'wall'>('all')
|
||||
const [ownerFilter, setOwnerFilter] = useState<string>('all')
|
||||
const [sortKey, setSortKey] = useState<SortKey>('id')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [editingItem, setEditingItem] = useState<FurniRow | null>(null)
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
const floor = items.filter((i) => !isWallType(i.type)).length
|
||||
const wall = items.filter((i) => isWallType(i.type)).length
|
||||
const uniqueBases = new Set(items.map((i) => i.baseId)).size
|
||||
const owners = new Set(items.map((i) => i.userId).filter((u) => u > 0)).size
|
||||
return { total: items.length, floor, wall, uniqueBases, owners }
|
||||
}, [items])
|
||||
|
||||
// ── Owner options for filter ───────────────────────────────
|
||||
const ownerOptions = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const i of items) {
|
||||
const key = i.ownerUsername ?? `#${i.userId}`
|
||||
map.set(key, (map.get(key) ?? 0) + 1)
|
||||
}
|
||||
return Array.from(map.entries()).sort((a, b) => b[1] - a[1])
|
||||
}, [items])
|
||||
|
||||
// ── Filtering ──────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return items.filter((i) => {
|
||||
if (typeFilter === 'floor' && isWallType(i.type)) return false
|
||||
if (typeFilter === 'wall' && !isWallType(i.type)) return false
|
||||
if (ownerFilter !== 'all') {
|
||||
const key = i.ownerUsername ?? `#${i.userId}`
|
||||
if (key !== ownerFilter) return false
|
||||
}
|
||||
if (q) {
|
||||
return (
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
i.itemName.toLowerCase().includes(q) ||
|
||||
String(i.baseId).includes(q) ||
|
||||
String(i.id).includes(q)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [items, search, typeFilter, ownerFilter])
|
||||
|
||||
// ── Sorting ────────────────────────────────────────────────
|
||||
const sorted = useMemo(() => {
|
||||
const arr = [...filtered]
|
||||
arr.sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortKey) {
|
||||
case 'id':
|
||||
cmp = a.id - b.id
|
||||
break
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'type':
|
||||
cmp = a.type.localeCompare(b.type)
|
||||
break
|
||||
case 'pos':
|
||||
cmp = a.x - b.x || a.y - b.y
|
||||
break
|
||||
case 'owner':
|
||||
cmp = (a.ownerUsername ?? '').localeCompare(b.ownerUsername ?? '')
|
||||
break
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return arr
|
||||
}, [filtered, sortKey, sortDir])
|
||||
|
||||
// ── Grouped view ───────────────────────────────────────────
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<number, { base: FurniRow; count: number; ids: number[] }>()
|
||||
for (const i of filtered) {
|
||||
const existing = map.get(i.baseId)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
existing.ids.push(i.id)
|
||||
} else {
|
||||
map.set(i.baseId, { base: i, count: 1, ids: [i.id] })
|
||||
}
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.count - a.count)
|
||||
}, [filtered])
|
||||
|
||||
// ── Pagination (flat view only) ─────────────────────────────
|
||||
const lastPage = Math.max(1, Math.ceil(sorted.length / PER_PAGE))
|
||||
const currentPage = Math.min(page, lastPage)
|
||||
const pageRows = sorted.slice((currentPage - 1) * PER_PAGE, currentPage * PER_PAGE)
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
function SortIcon({ k }: { k: SortKey }) {
|
||||
if (sortKey !== k) return <ArrowUpDown className="w-3 h-3 opacity-40" />
|
||||
return sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />
|
||||
}
|
||||
|
||||
function togglePageSelection(checked: boolean) {
|
||||
const next = new Set(selected)
|
||||
for (const r of pageRows) {
|
||||
if (checked) next.add(r.id)
|
||||
else next.delete(r.id)
|
||||
}
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
function toggleRowSelection(id: number, checked: boolean) {
|
||||
const next = new Set(selected)
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
function handleRcon(action: 'reload' | 'kick') {
|
||||
if (action === 'kick' && !confirm('Kick all users from this room?')) return
|
||||
run(() => roomRconAction({ roomId: room.id, action }), {
|
||||
successMessage: action === 'reload' ? 'Room reloaded' : 'All users kicked',
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete(itemId: number, name: string) {
|
||||
if (!confirm(`Delete "${name}" (#${itemId}) from this room?`)) return
|
||||
run(() => deleteRoomItem({ roomId: room.id, itemId }), { successMessage: 'Item deleted' })
|
||||
}
|
||||
|
||||
function handleBulkDelete() {
|
||||
if (selected.size === 0) return
|
||||
if (!confirm(`Delete ${selected.size} selected item${selected.size > 1 ? 's' : ''}?`)) return
|
||||
run(() => bulkDeleteRoomItems({ roomId: room.id, itemIds: Array.from(selected) }), {
|
||||
successMessage: `${selected.size} item(s) deleted`,
|
||||
onSuccess: () => setSelected(new Set()),
|
||||
})
|
||||
}
|
||||
|
||||
function handleDeleteGroup(_baseId: number, ids: number[], name: string) {
|
||||
if (!confirm(`Delete all ${ids.length} "${name}" from this room?`)) return
|
||||
run(() => bulkDeleteRoomItems({ roomId: room.id, itemIds: ids }), {
|
||||
successMessage: `${ids.length} item(s) deleted`,
|
||||
})
|
||||
}
|
||||
|
||||
const allPageSelected = pageRows.length > 0 && pageRows.every((r) => selected.has(r.id))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Header ─────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/rooms/show/${room.id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Back
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Armchair className="w-6 h-6" />
|
||||
{room.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Room #{room.id} · Owner: {room.ownerUsername}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRcon('reload')}
|
||||
disabled={isPending}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" /> Reload
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleRcon('kick')}
|
||||
disabled={isPending}
|
||||
>
|
||||
<UserX className="w-4 h-4 mr-1" /> Kick All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Stats Cards ────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<StatCard icon={<Armchair className="w-4 h-4" />} label="Total" value={stats.total} />
|
||||
<StatCard icon={<Layers className="w-4 h-4" />} label="Floor" value={stats.floor} />
|
||||
<StatCard icon={<Frame className="w-4 h-4" />} label="Wall" value={stats.wall} />
|
||||
<StatCard
|
||||
icon={<Armchair className="w-4 h-4" />}
|
||||
label="Unique furni"
|
||||
value={stats.uniqueBases}
|
||||
/>
|
||||
<StatCard icon={<Users className="w-4 h-4" />} label="Owners" value={stats.owners} />
|
||||
</div>
|
||||
|
||||
{/* ── Toolbar ────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or ID..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onValueChange={(v: 'all' | 'floor' | 'wall') => {
|
||||
setTypeFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="floor">Floor only</SelectItem>
|
||||
<SelectItem value="wall">Wall only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={ownerFilter}
|
||||
onValueChange={(v) => {
|
||||
setOwnerFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All owners</SelectItem>
|
||||
{ownerOptions.map(([name, count]) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name} ({count})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as View)} className="ml-auto">
|
||||
<TabsList>
|
||||
<TabsTrigger value="flat">Flat</TabsTrigger>
|
||||
<TabsTrigger value="grouped">Grouped</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{view === 'flat' && selected.size > 0 && canEdit && (
|
||||
<div className="flex items-center justify-between bg-muted/50 border rounded-md px-3 py-2">
|
||||
<span className="text-sm">
|
||||
<strong>{selected.size}</strong> selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" /> Delete selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Flat Table ─────────────────────────────────── */}
|
||||
{view === 'flat' ? (
|
||||
<>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{canEdit && (
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allPageSelected}
|
||||
onCheckedChange={(c) => togglePageSelection(c === true)}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="w-16"></TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={() => toggleSort('name')}
|
||||
>
|
||||
Name <SortIcon k="name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-20">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={() => toggleSort('type')}
|
||||
>
|
||||
Type <SortIcon k="type" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-32">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={() => toggleSort('pos')}
|
||||
>
|
||||
Position <SortIcon k="pos" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-16">Rot</TableHead>
|
||||
<TableHead className="w-32">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={() => toggleSort('owner')}
|
||||
>
|
||||
Owner <SortIcon k="owner" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={() => toggleSort('id')}
|
||||
>
|
||||
ID <SortIcon k="id" />
|
||||
</button>
|
||||
</TableHead>
|
||||
{canEdit && <TableHead className="w-24 text-right">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={canEdit ? 9 : 7}
|
||||
className="text-center text-muted-foreground py-12"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Armchair className="w-10 h-10 mx-auto opacity-30" />
|
||||
<p>This room has no furniture.</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>No items match your filters.</p>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pageRows.map((item) => {
|
||||
const icon = iconUrl(item.itemName)
|
||||
const wall = isWallType(item.type)
|
||||
const isSelected = selected.has(item.id)
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={isSelected ? 'bg-muted/40' : 'hover:bg-muted/30'}
|
||||
>
|
||||
{canEdit && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(c) => toggleRowSelection(item.id, c === true)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={icon}
|
||||
alt={item.name}
|
||||
className="w-10 h-10 object-contain"
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLImageElement).style.visibility = 'hidden'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-muted rounded" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{item.itemName || `base #${item.baseId}`}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={wall ? 'outline' : 'secondary'}>
|
||||
{wall ? 'Wall' : item.type === 's' ? 'Floor' : item.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{wall
|
||||
? item.wallPos || '-'
|
||||
: `${item.x},${item.y},${item.z.toFixed(1)}`}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{item.rot}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{item.ownerUsername ? (
|
||||
<Link
|
||||
href={`/admin/users/show/${item.userId}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{item.ownerUsername}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">#{item.userId}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
||||
{canEdit && (
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingItem(item)}
|
||||
disabled={isPending}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(item.id, item.name)}
|
||||
disabled={isPending}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{lastPage > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {(currentPage - 1) * PER_PAGE + 1}-
|
||||
{Math.min(currentPage * PER_PAGE, sorted.length)} of {sorted.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm flex items-center px-2">
|
||||
{currentPage} / {lastPage}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
|
||||
disabled={currentPage === lastPage}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ── Grouped View ──────────────────────────────── */
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16"></TableHead>
|
||||
<TableHead>Furniture</TableHead>
|
||||
<TableHead className="w-24">Type</TableHead>
|
||||
<TableHead className="w-24 text-right">Count</TableHead>
|
||||
{canEdit && <TableHead className="w-32 text-right">Action</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grouped.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={canEdit ? 5 : 4}
|
||||
className="text-center text-muted-foreground py-12"
|
||||
>
|
||||
No items.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
grouped.map((g) => {
|
||||
const icon = iconUrl(g.base.itemName)
|
||||
const wall = isWallType(g.base.type)
|
||||
return (
|
||||
<TableRow key={g.base.baseId} className="hover:bg-muted/30">
|
||||
<TableCell>
|
||||
{icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={icon}
|
||||
alt={g.base.name}
|
||||
className="w-10 h-10 object-contain"
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLImageElement).style.visibility = 'hidden'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-muted rounded" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{g.base.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{g.base.itemName || `base #${g.base.baseId}`}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={wall ? 'outline' : 'secondary'}>
|
||||
{wall ? 'Wall' : g.base.type === 's' ? 'Floor' : g.base.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge>{g.count}</Badge>
|
||||
</TableCell>
|
||||
{canEdit && (
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(g.base.baseId, g.ids, g.base.name)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive mr-1" />
|
||||
Delete all
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<EditItemDialog
|
||||
roomId={room.id}
|
||||
item={editingItem}
|
||||
open={editingItem !== null}
|
||||
onOpenChange={(o) => !o && setEditingItem(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">{icon}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { loadRoom } from '../../_lib/load-room'
|
||||
import { FurniClient } from './furni-client'
|
||||
|
||||
export default async function RoomFurniPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; locale: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ROOMS_VIEW, session.user.rank)) redirect('/admin')
|
||||
|
||||
const room = await loadRoom(id)
|
||||
|
||||
// Load items in this room. items.item_id → items_base.id
|
||||
const items = await prisma.item.findMany({
|
||||
where: { roomId: room.id },
|
||||
orderBy: { id: 'desc' },
|
||||
})
|
||||
|
||||
// Resolve item bases
|
||||
const baseIds = Array.from(new Set(items.map((i) => i.itemId)))
|
||||
const bases = baseIds.length
|
||||
? await prisma.itemsBase.findMany({
|
||||
where: { id: { in: baseIds } },
|
||||
select: { id: true, itemName: true, publicName: true, type: true },
|
||||
})
|
||||
: []
|
||||
const baseMap = new Map(bases.map((b) => [b.id, b]))
|
||||
|
||||
// Resolve owner usernames
|
||||
const userIds = Array.from(new Set(items.map((i) => i.userId).filter((u) => u > 0)))
|
||||
const users = userIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: []
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]))
|
||||
|
||||
const rows = items.map((i) => {
|
||||
const base = baseMap.get(i.itemId)
|
||||
return {
|
||||
id: i.id,
|
||||
baseId: i.itemId,
|
||||
name: base?.publicName || base?.itemName || `#${i.itemId}`,
|
||||
itemName: base?.itemName ?? '',
|
||||
type: base?.type ?? '?',
|
||||
x: i.x,
|
||||
y: i.y,
|
||||
z: i.z,
|
||||
rot: i.rot,
|
||||
wallPos: i.wallPos,
|
||||
userId: i.userId,
|
||||
ownerUsername: userMap.get(i.userId) ?? null,
|
||||
extraData: i.extraData,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AdminBreadcrumb
|
||||
items={[
|
||||
{ label: 'Rooms', href: '/admin/rooms' },
|
||||
{ label: room.name, href: `/admin/rooms/show/${room.id}` },
|
||||
{ label: 'Furniture' },
|
||||
]}
|
||||
/>
|
||||
<FurniClient
|
||||
room={{
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
ownerUsername: room.owner?.username ?? room.owner_name,
|
||||
}}
|
||||
items={rows}
|
||||
canEdit={session.user.rank >= 4}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,83 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { DoorOpen } from "lucide-react";
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminRoomDetail({
|
||||
export default async function RoomLegacyPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
params: Promise<{ locale: string; id: string }>
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.rooms");
|
||||
|
||||
const { id } = await params;
|
||||
const roomId = Number(id);
|
||||
|
||||
if (!Number.isInteger(roomId) || roomId <= 0) notFound();
|
||||
|
||||
let room: Awaited<ReturnType<typeof prisma.rooms.findUnique>> = null;
|
||||
try {
|
||||
room = await prisma.rooms.findUnique({ where: { id: roomId } });
|
||||
} catch {
|
||||
room = null;
|
||||
}
|
||||
if (!room) notFound();
|
||||
|
||||
const fields: Array<[string, string]> = [
|
||||
[t("colId"), String(room.id)],
|
||||
[t("colName"), room.name || t("unnamed")],
|
||||
[t("detail.description"), room.description || "—"],
|
||||
[t("detail.owner"), `${room.ownerName || "—"} (#${room.ownerId})`],
|
||||
[t("detail.state"), room.state],
|
||||
[t("colUsers"), `${room.users} / ${room.usersMax}`],
|
||||
[t("colCategory"), String(room.category)],
|
||||
[t("detail.score"), String(room.score)],
|
||||
[t("detail.model"), room.model],
|
||||
[t("detail.guildId"), room.guildId ? String(room.guildId) : "—"],
|
||||
[t("detail.tags"), room.tags || "—"],
|
||||
[t("detail.password"), room.password ? t("detail.yes") : "—"],
|
||||
[t("detail.public"), room.isPublic === "1" ? t("detail.yes") : t("detail.no")],
|
||||
[t("detail.staffPicked"), room.isStaffPicked === "1" ? t("detail.yes") : t("detail.no")],
|
||||
[t("detail.forSale"), room.isForSale === "1" ? t("detail.yes") : t("detail.no")],
|
||||
[t("detail.tradeMode"), String(room.tradeMode)],
|
||||
];
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||
<Link href="/admin/rooms">← {t("title")}</Link>
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
||||
<DoorOpen size={20} className="text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{room.name || t("unnamed")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("detail.subtitle", { id: room.id, owner: room.ownerName || "—", users: room.users, maxUsers: room.usersMax, state: room.state })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<table>
|
||||
<tbody>
|
||||
{fields.map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<th className="w-[35%] whitespace-nowrap">{label}</th>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{room.ownerId ? (
|
||||
<p className="muted" style={{ marginTop: "1rem" }}>
|
||||
<Link href={`/admin/users/${room.ownerId}`}>{t("detail.viewOwner")} →</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
const { id } = await params
|
||||
redirect(`/admin/rooms/show/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2, Lock, LogOut, RefreshCw, Save, Trash2, Unlock } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { deleteRoom, roomRconAction, updateRoom } from '@/actions/rooms'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useServerAction } from '@/hooks/use-server-action'
|
||||
import { type UpdateRoomInput, updateRoomSchema } from '@/lib/validators/room'
|
||||
|
||||
interface RoomEditFormProps {
|
||||
room: {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
state: string
|
||||
usersMax: number
|
||||
users: number
|
||||
ownerName: string
|
||||
}
|
||||
}
|
||||
|
||||
export function RoomEditForm({ room }: RoomEditFormProps) {
|
||||
const { run, isPending } = useServerAction()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<UpdateRoomInput>({
|
||||
resolver: zodResolver(updateRoomSchema),
|
||||
defaultValues: {
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
state: room.state as 'open' | 'locked' | 'password' | 'invisible',
|
||||
usersMax: room.usersMax,
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: UpdateRoomInput) {
|
||||
run(() => updateRoom({ id: room.id, ...data }), {
|
||||
successMessage: 'Room updated successfully.',
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!confirm('Are you sure you want to delete this room? This cannot be undone.')) return
|
||||
run(() => deleteRoom({ id: room.id }), { redirectTo: '/admin/rooms' })
|
||||
}
|
||||
|
||||
function handleRconAction(action: 'kick' | 'lock' | 'unlock' | 'reload') {
|
||||
run(() => roomRconAction({ roomId: room.id, action }), {
|
||||
successMessage: `Action "${action}" executed successfully.`,
|
||||
errorMessage: `Action "${action}" failed. Is the emulator running?`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Edit Form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Room Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" {...register('name')} />
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" rows={3} {...register('description')} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>State</Label>
|
||||
<Select
|
||||
defaultValue={room.state}
|
||||
onValueChange={(v) =>
|
||||
setValue('state', v as 'open' | 'locked' | 'password' | 'invisible')
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="locked">Locked</SelectItem>
|
||||
<SelectItem value="password">Password</SelectItem>
|
||||
<SelectItem value="invisible">Invisible</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usersMax">Max Users</Label>
|
||||
<Input
|
||||
id="usersMax"
|
||||
type="number"
|
||||
{...register('usersMax', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Room
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* RCON Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Owner</span>
|
||||
<span className="font-medium">{room.ownerName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Current Users</span>
|
||||
<Badge>
|
||||
{room.users} / {room.usersMax}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">State</span>
|
||||
<Badge
|
||||
variant={
|
||||
room.state === 'open'
|
||||
? 'default'
|
||||
: room.state === 'locked'
|
||||
? 'secondary'
|
||||
: 'destructive'
|
||||
}
|
||||
>
|
||||
{room.state}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-sm font-medium">RCON Commands</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRconAction('kick')}
|
||||
disabled={isPending}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Kick All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRconAction('lock')}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Lock
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRconAction('unlock')}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Unlock className="mr-2 h-4 w-4" />
|
||||
Unlock
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRconAction('reload')}
|
||||
disabled={isPending}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function loadRoom(id: string) {
|
||||
const roomId = parseInt(id, 10)
|
||||
if (Number.isNaN(roomId)) notFound()
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { owner: { select: { id: true, username: true, look: true } } },
|
||||
})
|
||||
|
||||
if (!room) notFound()
|
||||
return room
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { DoorOpen, Eye } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { loadRoom } from '../../_lib/load-room'
|
||||
import { RoomEditForm } from '../../[id]/room-edit-form'
|
||||
|
||||
export default async function RoomEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ROOMS_VIEW, session.user.rank) || session.user.rank < 4) {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const room = await loadRoom(id)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AdminBreadcrumb
|
||||
items={[
|
||||
{ label: 'Rooms', href: '/admin/rooms' },
|
||||
{ label: room.name, href: `/admin/rooms/show/${room.id}` },
|
||||
{ label: 'Edit' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<DoorOpen className="h-6 w-6 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
Edit: {room.name}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
#{room.id}
|
||||
</Badge>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Owner: {room.owner.username} · {room.users}/{room.usersMax} users
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/rooms/show/${room.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" /> View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RoomEditForm
|
||||
room={{
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
state: room.state,
|
||||
usersMax: room.usersMax,
|
||||
users: room.users,
|
||||
ownerName: room.owner.username,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function RoomsLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+43
-141
@@ -1,152 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { DoorOpen } from "lucide-react";
|
||||
import { fetchAdminList } from '@/lib/admin-list'
|
||||
import { canAccess, PERMS } from '@/lib/permissions'
|
||||
import { RoomsTable } from './rooms-table'
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PER_PAGE = 25;
|
||||
|
||||
type RoomRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
ownerName: string;
|
||||
users: number;
|
||||
usersMax: number;
|
||||
state: string;
|
||||
category: number;
|
||||
};
|
||||
|
||||
export default async function AdminRooms({
|
||||
export default async function RoomsPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.rooms");
|
||||
|
||||
const sp = await searchParams;
|
||||
const q = (sp.q ?? "").trim();
|
||||
const page = Math.max(1, Number(sp.page ?? "1") || 1);
|
||||
|
||||
const where = q
|
||||
? { OR: [{ name: { contains: q } }, { ownerName: { contains: q } }] }
|
||||
: {};
|
||||
|
||||
let rooms: RoomRow[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
[rooms, total] = await Promise.all([
|
||||
prisma.rooms.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ownerName: true,
|
||||
users: true,
|
||||
usersMax: true,
|
||||
state: true,
|
||||
category: true,
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
skip: (page - 1) * PER_PAGE,
|
||||
take: PER_PAGE,
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.ROOMS_VIEW,
|
||||
model: 'room',
|
||||
searchFields: ['name', 'owner.username'],
|
||||
include: { owner: { select: { username: true } } },
|
||||
mapRow: (r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
ownerName: r.owner.username,
|
||||
ownerId: r.owner?.id ?? 0,
|
||||
state: r.state,
|
||||
users: r.users,
|
||||
usersMax: r.usersMax,
|
||||
description: r.description,
|
||||
}),
|
||||
prisma.rooms.count({ where }),
|
||||
]);
|
||||
} catch {
|
||||
rooms = [];
|
||||
total = 0;
|
||||
}
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
)
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||
const qs = (p: number) =>
|
||||
`/admin/rooms?q=${encodeURIComponent(q)}&page=${p}`;
|
||||
|
||||
const openOnPage = rooms.filter((r) => r.state === "open").length;
|
||||
const usersOnPage = rooms.reduce((sum, r) => sum + r.users, 0);
|
||||
const canEdit = canAccess(result.permissions, PERMS.ROOMS_VIEW, result.rank) && result.rank >= 4
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
||||
<DoorOpen size={20} className="text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{q ? t("matchingQuery", { query: q }) + " · " : ""}{t("totalRooms", { count: total })}</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Rooms</h1>
|
||||
<p className="text-muted-foreground">{result.total.toLocaleString()} rooms total</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={q ? t("matchingRooms") : t("totalRooms", { count: total })} value={total} icon="🏠" />
|
||||
<StatusCard label={t("openThisPage")} value={openOnPage} state="ok" icon="🔓" />
|
||||
<StatusCard label={t("usersInRoom")} value={usersOnPage} icon="👥" />
|
||||
</div>
|
||||
|
||||
<form className="flex gap-2 mb-4">
|
||||
<input name="q" defaultValue={q} placeholder={t("searchPlaceholder")} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("search")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colId")}</th>
|
||||
<th>{t("colName")}</th>
|
||||
<th>{t("colOwner")}</th>
|
||||
<th>{t("colUsers")}</th>
|
||||
<th>{t("colState")}</th>
|
||||
<th>{t("colCategory")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{r.id}</td>
|
||||
<td>
|
||||
<Link href={`/admin/rooms/${r.id}`}>{r.name || t("unnamed")}</Link>
|
||||
</td>
|
||||
<td>{r.ownerName || "—"}</td>
|
||||
<td>
|
||||
{r.users} / {r.usersMax}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${r.state === "open" ? "bg-green-600/20 text-[var(--color-text)]" : "bg-red-500/22 text-[var(--color-text)]"}`}>
|
||||
{r.state}
|
||||
</span>
|
||||
</td>
|
||||
<td>{r.category}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rooms.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ marginTop: "1rem" }}>
|
||||
{t("noRooms")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400" style={{ marginTop: "1rem" }}>
|
||||
{t("pagination", { page, pages, total })}
|
||||
{page > 1 ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href={qs(page - 1)}>← {t("prev")}</Link>
|
||||
</>
|
||||
) : null}
|
||||
{page < pages ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href={qs(page + 1)}>{t("next")} →</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
<RoomsTable
|
||||
data={{
|
||||
rows: result.rows,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { Eye, Pencil, Sofa, Wifi } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { DataTable } from '@/components/admin/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||
|
||||
interface RoomRow {
|
||||
id: number
|
||||
name: string
|
||||
ownerName: string
|
||||
ownerId: number
|
||||
state: string
|
||||
users: number
|
||||
usersMax: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const stateVariants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
open: 'default',
|
||||
locked: 'secondary',
|
||||
password: 'destructive',
|
||||
invisible: 'outline',
|
||||
}
|
||||
|
||||
function getColumns(canEdit: boolean): DataTableColumn<RoomRow>[] {
|
||||
return [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Room',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<Link href={`/admin/rooms/show/${row.id}`} className="hover:opacity-80">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium block">{row.name}</span>
|
||||
{row.description && (
|
||||
<span className="text-xs text-muted-foreground truncate block max-w-[250px]">
|
||||
{row.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ownerName',
|
||||
label: 'Owner',
|
||||
render: (_, row) => (
|
||||
<Link
|
||||
href={`/admin/users/show/${row.ownerId}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{row.ownerName}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
label: 'State',
|
||||
sortable: true,
|
||||
filterKey: 'filter_state',
|
||||
filterOptions: [
|
||||
{ label: 'Open', value: 'open' },
|
||||
{ label: 'Locked', value: 'locked' },
|
||||
{ label: 'Password', value: 'password' },
|
||||
{ label: 'Invisible', value: 'invisible' },
|
||||
],
|
||||
render: (value) => (
|
||||
<Badge variant={stateVariants[String(value)] || 'outline'}>{String(value)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: 'Users',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
{row.users > 0 && <Wifi className="h-3 w-3 text-green-500" />}
|
||||
<span className={row.users > 0 ? 'font-medium' : 'text-muted-foreground'}>
|
||||
{row.users}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/ {row.usersMax}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/rooms/show/${row.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/rooms/${row.id}/furni`}>
|
||||
<Sofa className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/admin/rooms/edit/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
interface RoomsTableProps {
|
||||
data: PaginatedResult<RoomRow>
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
export function RoomsTable({ data, canEdit }: RoomsTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={getColumns(canEdit)}
|
||||
searchPlaceholder="Search by room name or owner..."
|
||||
exportUrl={canEdit ? '/api/admin/export?type=rooms' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { DoorOpen, Eye, Globe, Lock, Pencil, Sofa, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { loadRoom } from '../../_lib/load-room'
|
||||
|
||||
const stateVariants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
open: 'default',
|
||||
locked: 'secondary',
|
||||
password: 'destructive',
|
||||
invisible: 'outline',
|
||||
}
|
||||
|
||||
export default async function RoomShowPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.ROOMS_VIEW, session.user.rank)) redirect('/admin')
|
||||
|
||||
const room = await loadRoom(id)
|
||||
const canEdit = session.user.rank >= 4
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AdminBreadcrumb items={[{ label: 'Rooms', href: '/admin/rooms' }, { label: room.name }]} />
|
||||
|
||||
{/* Hero */}
|
||||
<div className="rounded-xl border bg-gradient-to-r from-card to-muted/30 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<DoorOpen className="h-6 w-6 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold">{room.name}</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
#{room.id}
|
||||
</Badge>
|
||||
<Badge variant={stateVariants[room.state] || 'outline'}>{room.state}</Badge>
|
||||
</div>
|
||||
{room.description && <p className="text-muted-foreground">{room.description}</p>}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{room.users}</span> / {room.usersMax} users
|
||||
</span>
|
||||
<Link
|
||||
href={`/admin/users/show/${room.owner.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Owner: {room.owner.username}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/rooms/${room.id}/furni`}>
|
||||
<Sofa className="mr-2 h-4 w-4" /> Furniture
|
||||
</Link>
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rooms/edit/${room.id}`}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{room.users} / {room.usersMax}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Current Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Lock className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold capitalize">{room.state}</p>
|
||||
<p className="text-xs text-muted-foreground">Access State</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Eye className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{room.score}</p>
|
||||
<p className="text-xs text-muted-foreground">Score</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold truncate">{room.category}</p>
|
||||
<p className="text-xs text-muted-foreground">Category</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user