Files
EpicNext-Cms/src/app/admin/rooms/[id]/furni/furni-client.tsx
T
2026-07-11 20:52:56 +02:00

702 lines
27 KiB
TypeScript

'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>
)
}