style: format code with prettier
Local Build and Deploy / deploy (push) Successful in 49s

This commit is contained in:
openhands committed 2026-07-12 21:07:34 +02:00
1 parent e85e4d74ea
commit 2e4ed76121
378 files changed
+22404 -20686

No files matched your search

@@ -1,8 +1,8 @@
'use client'
"use client";
import { useEffect, useState } from 'react'
import { updateRoomItem } from '@/actions/rooms'
import { Button } from '@/components/ui/button'
import { useEffect, useState } from "react";
import { updateRoomItem } from "@/actions/rooms";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -10,55 +10,55 @@ import {
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'
} 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
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
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 { 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'
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)
setX(String(item.x));
setY(String(item.y));
setZ(String(item.z));
setRot(String(item.rot));
setWallPos(item.wallPos);
setExtraData(item.extraData);
}
}, [item])
}, [item]);
function handleSave() {
if (!item) return
if (!item) return;
const payload = isWall
? { roomId, itemId: item.id, wallPos, extraData }
: {
@@ -69,11 +69,11 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
z: Number(z),
rot: Number(rot),
extraData,
}
};
run(() => updateRoomItem(payload), {
successMessage: 'Item updated and room reloaded',
successMessage: "Item updated and room reloaded",
onSuccess: () => onOpenChange(false),
})
});
}
return (
@@ -81,7 +81,7 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Edit furniture</DialogTitle>
<DialogDescription>{item ? `${item.name} (#${item.id})` : ''}</DialogDescription>
<DialogDescription>{item ? `${item.name} (#${item.id})` : ""}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -170,10 +170,10 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
Cancel
</Button>
<Button onClick={handleSave} disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
{isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
);
}
+164 -198
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
Armchair,
@@ -16,115 +16,102 @@ import {
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'
} 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
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
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'
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`
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'
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)
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])
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>()
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)
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])
return Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
}, [items]);
// ── Filtering ──────────────────────────────────────────────
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
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 (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 (
@@ -132,119 +119,119 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
i.itemName.toLowerCase().includes(q) ||
String(i.baseId).includes(q) ||
String(i.id).includes(q)
)
);
}
return true
})
}, [items, search, typeFilter, ownerFilter])
return true;
});
}, [items, search, typeFilter, ownerFilter]);
// ── Sorting ────────────────────────────────────────────────
const sorted = useMemo(() => {
const arr = [...filtered]
const arr = [...filtered];
arr.sort((a, b) => {
let cmp = 0
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
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])
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[] }>()
const map = new Map<number, { base: FurniRow; count: number; ids: number[] }>();
for (const i of filtered) {
const existing = map.get(i.baseId)
const existing = map.get(i.baseId);
if (existing) {
existing.count++
existing.ids.push(i.id)
existing.count++;
existing.ids.push(i.id);
} else {
map.set(i.baseId, { base: i, count: 1, ids: [i.id] })
map.set(i.baseId, { base: i, count: 1, ids: [i.id] });
}
}
return Array.from(map.values()).sort((a, b) => b.count - a.count)
}, [filtered])
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)
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'))
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key)
setSortDir('asc')
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" />
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)
const next = new Set(selected);
for (const r of pageRows) {
if (checked) next.add(r.id)
else next.delete(r.id)
if (checked) next.add(r.id);
else next.delete(r.id);
}
setSelected(next)
setSelected(next);
}
function toggleRowSelection(id: number, checked: boolean) {
const next = new Set(selected)
if (checked) next.add(id)
else next.delete(id)
setSelected(next)
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
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',
})
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' })
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
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
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))
const allPageSelected = pageRows.length > 0 && pageRows.every((r) => selected.has(r.id));
return (
<div className="space-y-6">
@@ -268,20 +255,10 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
</div>
{canEdit && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleRcon('reload')}
disabled={isPending}
>
<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}
>
<Button variant="destructive" size="sm" onClick={() => handleRcon("kick")} disabled={isPending}>
<UserX className="w-4 h-4 mr-1" /> Kick All
</Button>
</div>
@@ -293,11 +270,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
<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={<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>
@@ -311,8 +284,8 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
placeholder="Search by name or ID..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
setSearch(e.target.value);
setPage(1);
}}
className="pl-8"
/>
@@ -320,9 +293,9 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
<Select
value={typeFilter}
onValueChange={(v: 'all' | 'floor' | 'wall') => {
setTypeFilter(v)
setPage(1)
onValueChange={(v: "all" | "floor" | "wall") => {
setTypeFilter(v);
setPage(1);
}}
>
<SelectTrigger className="w-[140px]">
@@ -338,8 +311,8 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
<Select
value={ownerFilter}
onValueChange={(v) => {
setOwnerFilter(v)
setPage(1)
setOwnerFilter(v);
setPage(1);
}}
>
<SelectTrigger className="w-[180px]">
@@ -364,7 +337,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
</div>
{/* Bulk action bar */}
{view === 'flat' && selected.size > 0 && canEdit && (
{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
@@ -373,12 +346,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
Clear
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={isPending}
>
<Button variant="destructive" size="sm" onClick={handleBulkDelete} disabled={isPending}>
<Trash2 className="w-4 h-4 mr-1" /> Delete selected
</Button>
</div>
@@ -386,7 +354,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
)}
{/* ── Flat Table ─────────────────────────────────── */}
{view === 'flat' ? (
{view === "flat" ? (
<>
<div className="border rounded-md overflow-hidden">
<Table>
@@ -405,7 +373,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => toggleSort('name')}
onClick={() => toggleSort("name")}
>
Name <SortIcon k="name" />
</button>
@@ -414,7 +382,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => toggleSort('type')}
onClick={() => toggleSort("type")}
>
Type <SortIcon k="type" />
</button>
@@ -423,7 +391,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => toggleSort('pos')}
onClick={() => toggleSort("pos")}
>
Position <SortIcon k="pos" />
</button>
@@ -433,7 +401,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => toggleSort('owner')}
onClick={() => toggleSort("owner")}
>
Owner <SortIcon k="owner" />
</button>
@@ -442,7 +410,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => toggleSort('id')}
onClick={() => toggleSort("id")}
>
ID <SortIcon k="id" />
</button>
@@ -469,13 +437,13 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
</TableRow>
) : (
pageRows.map((item) => {
const icon = iconUrl(item.itemName)
const wall = isWallType(item.type)
const isSelected = selected.has(item.id)
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'}
className={isSelected ? "bg-muted/40" : "hover:bg-muted/30"}
>
{canEdit && (
<TableCell>
@@ -493,7 +461,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
alt={item.name}
className="w-10 h-10 object-contain"
onError={(e) => {
;(e.target as HTMLImageElement).style.visibility = 'hidden'
(e.target as HTMLImageElement).style.visibility = "hidden";
}}
/>
) : (
@@ -507,14 +475,12 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
</div>
</TableCell>
<TableCell>
<Badge variant={wall ? 'outline' : 'secondary'}>
{wall ? 'Wall' : item.type === 's' ? 'Floor' : item.type}
<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)}`}
{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">
@@ -555,7 +521,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
</TableCell>
)}
</TableRow>
)
);
})
)}
</TableBody>
@@ -617,8 +583,8 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
</TableRow>
) : (
grouped.map((g) => {
const icon = iconUrl(g.base.itemName)
const wall = isWallType(g.base.type)
const icon = iconUrl(g.base.itemName);
const wall = isWallType(g.base.type);
return (
<TableRow key={g.base.baseId} className="hover:bg-muted/30">
<TableCell>
@@ -629,7 +595,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
alt={g.base.name}
className="w-10 h-10 object-contain"
onError={(e) => {
;(e.target as HTMLImageElement).style.visibility = 'hidden'
(e.target as HTMLImageElement).style.visibility = "hidden";
}}
/>
) : (
@@ -643,8 +609,8 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
</div>
</TableCell>
<TableCell>
<Badge variant={wall ? 'outline' : 'secondary'}>
{wall ? 'Wall' : g.base.type === 's' ? 'Floor' : g.base.type}
<Badge variant={wall ? "outline" : "secondary"}>
{wall ? "Wall" : g.base.type === "s" ? "Floor" : g.base.type}
</Badge>
</TableCell>
<TableCell className="text-right">
@@ -664,7 +630,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
</TableCell>
)}
</TableRow>
)
);
})
)}
</TableBody>
@@ -681,7 +647,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
onOpenChange={(o) => !o && setEditingItem(null)}
/>
</div>
)
);
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
@@ -697,5 +663,5 @@ function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string
</div>
</CardContent>
</Card>
)
);
}
+27 -31
View File
@@ -1,55 +1,51 @@
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'
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')
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)
const room = await loadRoom(id);
// Load items in this room. items.item_id → items_base.id
const items = await prisma.items.findMany({
where: { roomId: room.id },
orderBy: { id: 'desc' },
})
orderBy: { id: "desc" },
});
// Resolve item bases
const baseIds = Array.from(new Set(items.map((i) => i.itemId).filter((id): id is number => id !== null)))
const baseIds = Array.from(new Set(items.map((i) => i.itemId).filter((id): id is number => id !== null)));
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]))
: [];
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 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 userMap = new Map(users.map((u) => [u.id, u.username]));
const rows = items.map((i) => {
const base = i.itemId === null ? undefined : baseMap.get(i.itemId)
const base = i.itemId === null ? undefined : baseMap.get(i.itemId);
return {
id: i.id,
baseId: i.itemId ?? 0,
name: base?.publicName || base?.itemName || `#${i.itemId}`,
itemName: base?.itemName ?? '',
type: base?.type ?? '?',
itemName: base?.itemName ?? "",
type: base?.type ?? "?",
x: i.x,
y: i.y,
z: i.z,
@@ -58,16 +54,16 @@ export default async function RoomFurniPage({
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: "Rooms", href: "/admin/rooms" },
{ label: room.name, href: `/admin/rooms/show/${room.id}` },
{ label: 'Furniture' },
{ label: "Furniture" },
]}
/>
<FurniClient
@@ -80,5 +76,5 @@ export default async function RoomFurniPage({
canEdit={session.user.rank >= 4}
/>
</div>
)
);
}
+4 -4
View File
@@ -1,10 +1,10 @@
import { redirect } from 'next/navigation'
import { redirect } from "next/navigation";
export default async function RoomLegacyPage({
params,
}: {
params: Promise<{ locale: string; id: string }>
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params
redirect(`/admin/rooms/show/${id}`)
const { id } = await params;
redirect(`/admin/rooms/show/${id}`);
}
+42 -61
View File
@@ -1,39 +1,33 @@
'use client'
"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'
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
}
id: number;
name: string;
description: string;
state: string;
usersMax: number;
users: number;
ownerName: string;
};
}
export function RoomEditForm({ room }: RoomEditFormProps) {
const { run, isPending } = useServerAction()
const { run, isPending } = useServerAction();
const {
register,
handleSubmit,
@@ -44,27 +38,27 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
defaultValues: {
name: room.name,
description: room.description,
state: room.state as 'open' | 'locked' | 'password' | 'invisible',
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.',
})
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' })
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') {
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 (
@@ -79,13 +73,13 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" {...register('name')} />
<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')} />
<Textarea id="description" rows={3} {...register("description")} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
@@ -94,7 +88,7 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
<Select
defaultValue={room.state}
onValueChange={(v) =>
setValue('state', v as 'open' | 'locked' | 'password' | 'invisible')
setValue("state", v as "open" | "locked" | "password" | "invisible")
}
>
<SelectTrigger>
@@ -110,11 +104,7 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="usersMax">Max Users</Label>
<Input
id="usersMax"
type="number"
{...register('usersMax', { valueAsNumber: true })}
/>
<Input id="usersMax" type="number" {...register("usersMax", { valueAsNumber: true })} />
</div>
</div>
@@ -127,12 +117,7 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
)}
Save Changes
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending}
>
<Button type="button" variant="destructive" onClick={handleDelete} disabled={isPending}>
<Trash2 className="mr-2 h-4 w-4" />
Delete Room
</Button>
@@ -161,11 +146,7 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
<span className="text-muted-foreground">State</span>
<Badge
variant={
room.state === 'open'
? 'default'
: room.state === 'locked'
? 'secondary'
: 'destructive'
room.state === "open" ? "default" : room.state === "locked" ? "secondary" : "destructive"
}
>
{room.state}
@@ -178,7 +159,7 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
<Button
variant="outline"
size="sm"
onClick={() => handleRconAction('kick')}
onClick={() => handleRconAction("kick")}
disabled={isPending}
>
<LogOut className="mr-2 h-4 w-4" />
@@ -187,7 +168,7 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
<Button
variant="outline"
size="sm"
onClick={() => handleRconAction('lock')}
onClick={() => handleRconAction("lock")}
disabled={isPending}
>
<Lock className="mr-2 h-4 w-4" />
@@ -196,7 +177,7 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
<Button
variant="outline"
size="sm"
onClick={() => handleRconAction('unlock')}
onClick={() => handleRconAction("unlock")}
disabled={isPending}
>
<Unlock className="mr-2 h-4 w-4" />
@@ -205,7 +186,7 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
<Button
variant="outline"
size="sm"
onClick={() => handleRconAction('reload')}
onClick={() => handleRconAction("reload")}
disabled={isPending}
>
<RefreshCw className="mr-2 h-4 w-4" />
@@ -217,5 +198,5 @@ export function RoomEditForm({ room }: RoomEditFormProps) {
</Card>
</div>
</div>
)
);
}
+14 -13
View File
@@ -1,20 +1,21 @@
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
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 roomId = parseInt(id, 10);
if (Number.isNaN(roomId)) notFound();
const room = await prisma.rooms.findUnique({
where: { id: roomId },
})
});
if (!room) notFound()
const owner = room.ownerId > 0
? await prisma.user.findUnique({
where: { id: room.ownerId },
select: { id: true, username: true, look: true },
})
: null
return { ...room, owner }
if (!room) notFound();
const owner =
room.ownerId > 0
? await prisma.user.findUnique({
where: { id: room.ownerId },
select: { id: true, username: true, look: true },
})
: null;
return { ...room, owner };
}
+17 -21
View File
@@ -1,33 +1,29 @@
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'
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()
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')
redirect("/admin");
}
const room = await loadRoom(id)
const room = await loadRoom(id);
return (
<div className="space-y-6">
<AdminBreadcrumb
items={[
{ label: 'Rooms', href: '/admin/rooms' },
{ label: "Rooms", href: "/admin/rooms" },
{ label: room.name, href: `/admin/rooms/show/${room.id}` },
{ label: 'Edit' },
{ label: "Edit" },
]}
/>
@@ -65,5 +61,5 @@ export default async function RoomEditPage({
}}
/>
</div>
)
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Skeleton } from "@/components/ui/skeleton";
export default function RoomsLoading() {
return (
@@ -24,5 +24,5 @@ export default function RoomsLoading() {
))}
</div>
</div>
)
);
}
+10 -10
View File
@@ -1,19 +1,19 @@
import { fetchAdminList } from '@/lib/admin-list'
import { canAccess, PERMS } from '@/lib/permissions'
import { RoomsTable } from './rooms-table'
import { fetchAdminList } from "@/lib/admin-list";
import { canAccess, PERMS } from "@/lib/permissions";
import { RoomsTable } from "./rooms-table";
export default async function RoomsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>
searchParams: Promise<Record<string, string>>
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.ROOMS_VIEW,
model: 'room',
searchFields: ['name', 'owner.username'],
model: "room",
searchFields: ["name", "owner.username"],
include: { owner: { select: { username: true } } },
mapRow: (r) => ({
id: r.id,
@@ -28,9 +28,9 @@ export default async function RoomsPage({
},
paramsPromise,
searchParams,
)
);
const canEdit = canAccess(result.permissions, PERMS.ROOMS_VIEW, result.rank) && result.rank >= 4
const canEdit = canAccess(result.permissions, PERMS.ROOMS_VIEW, result.rank) && result.rank >= 4;
return (
<div className="space-y-6">
@@ -50,5 +50,5 @@ export default async function RoomsPage({
canEdit={canEdit}
/>
</div>
)
);
}
+45 -52
View File
@@ -1,36 +1,36 @@
'use client'
"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'
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
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',
}
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: "id", label: "ID", sortable: true },
{
key: 'name',
label: 'Room',
key: "name",
label: "Room",
sortable: true,
render: (_, row) => (
<Link href={`/admin/rooms/show/${row.id}`} className="hover:opacity-80">
@@ -46,49 +46,42 @@ function getColumns(canEdit: boolean): DataTableColumn<RoomRow>[] {
),
},
{
key: 'ownerName',
label: 'Owner',
key: "ownerName",
label: "Owner",
render: (_, row) => (
<Link
href={`/admin/users/show/${row.ownerId}`}
className="text-sm text-primary hover:underline"
>
<Link href={`/admin/users/show/${row.ownerId}`} className="text-sm text-primary hover:underline">
{row.ownerName}
</Link>
),
},
{
key: 'state',
label: 'State',
key: "state",
label: "State",
sortable: true,
filterKey: 'filter_state',
filterKey: "filter_state",
filterOptions: [
{ label: 'Open', value: 'open' },
{ label: 'Locked', value: 'locked' },
{ label: 'Password', value: 'password' },
{ label: 'Invisible', value: 'invisible' },
{ 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>
),
render: (value) => <Badge variant={stateVariants[String(value)] || "outline"}>{String(value)}</Badge>,
},
{
key: 'users',
label: 'Users',
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-[var(--admin-success)]" />}
<span className={row.users > 0 ? 'font-medium' : 'text-muted-foreground'}>
{row.users}
</span>
<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: '',
key: "actions",
label: "",
render: (_, row) => (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" asChild>
@@ -111,12 +104,12 @@ function getColumns(canEdit: boolean): DataTableColumn<RoomRow>[] {
</div>
),
},
]
];
}
interface RoomsTableProps {
data: PaginatedResult<RoomRow>
canEdit: boolean
data: PaginatedResult<RoomRow>;
canEdit: boolean;
}
export function RoomsTable({ data, canEdit }: RoomsTableProps) {
@@ -125,7 +118,7 @@ export function RoomsTable({ data, canEdit }: RoomsTableProps) {
data={data}
columns={getColumns(canEdit)}
searchPlaceholder="Search by room name or owner..."
exportUrl={canEdit ? '/api/admin/export?type=rooms' : undefined}
exportUrl={canEdit ? "/api/admin/export?type=rooms" : undefined}
/>
)
);
}
+24 -28
View File
@@ -1,35 +1,31 @@
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'
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',
}
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')
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
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 }]} />
<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">
@@ -41,7 +37,7 @@ export default async function RoomShowPage({
<Badge variant="secondary" className="text-xs">
#{room.id}
</Badge>
<Badge variant={stateVariants[room.state] || 'outline'}>{room.state}</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">
@@ -125,5 +121,5 @@ export default async function RoomShowPage({
</Card>
</div>
</div>
)
);
}