Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
simoleo89
2026-08-30 21:31:34 +02:00
parent 488b6e57c4
commit b1ddda66ff
802 changed files with 61370 additions and 76659 deletions
@@ -0,0 +1,185 @@
"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,831 @@
"use client";
import {
Armchair,
ArrowDown,
ArrowLeft,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Frame,
Layers,
Pencil,
RefreshCw,
Search,
Trash2,
Users,
UserX,
} from "lucide-react";
import { useMemo, useState } from "react";
import {
bulkDeleteRoomItems,
deleteRoomItem,
roomRconAction,
} from "@/actions/rooms";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import Link from "@/components/link";
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 { confirm, dialog: confirmDialog } = useConfirmDialog();
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);
}
async function handleRcon(action: "reload" | "kick") {
if (action === "kick") {
const ok = await confirm({
title: "Kick Users",
description: "Kick all users from this room?",
confirmLabel: "Kick",
variant: "danger",
});
if (!ok) return;
}
run(() => roomRconAction({ roomId: room.id, action }), {
successMessage:
action === "reload" ? "Room reloaded" : "All users kicked",
});
}
async function handleDelete(itemId: number, name: string) {
const ok = await confirm({
title: "Delete Item",
description: `Delete "${name}" (#${itemId}) from this room?`,
confirmLabel: "Delete",
variant: "danger",
});
if (!ok) return;
run(() => deleteRoomItem({ roomId: room.id, itemId }), {
successMessage: "Item deleted",
});
}
async function handleBulkDelete() {
if (selected.size === 0) return;
const ok = await confirm({
title: "Delete Items",
description: `Delete ${selected.size} selected item${selected.size > 1 ? "s" : ""}?`,
confirmLabel: "Delete",
variant: "danger",
});
if (!ok) return;
run(
() =>
bulkDeleteRoomItems({ roomId: room.id, itemIds: Array.from(selected) }),
{
successMessage: `${selected.size} item(s) deleted`,
onSuccess: () => setSelected(new Set()),
},
);
}
async function handleDeleteGroup(
_baseId: number,
ids: number[],
name: string,
) {
const ok = await confirm({
title: "Delete Items",
description: `Delete all ${ids.length} "${name}" from this room?`,
confirmLabel: "Delete",
variant: "danger",
});
if (!ok) 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">
{confirmDialog}
{/* ── 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) => {
setTypeFilter(v as "all" | "floor" | "wall");
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>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { desc, eq, inArray } from "drizzle-orm";
import { redirect } from "next/navigation";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { db, Items, ItemsBase, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
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 db
.select()
.from(Items)
.where(eq(Items.roomId, room.id))
.orderBy(desc(Items.id));
// Resolve item bases
const baseIds = Array.from(
new Set(
items.map((i) => i.itemId).filter((id): id is number => id !== null),
),
);
const bases = baseIds.length
? await db
.select({
id: ItemsBase.id,
itemName: ItemsBase.itemName,
publicName: ItemsBase.publicName,
type: ItemsBase.type,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds))
: [];
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 db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, userIds))
: [];
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);
return {
id: i.id,
baseId: i.itemId ?? 0,
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.ownerName,
}}
items={rows}
canEdit={canAccess(permissions, PERMS.ROOMS_EDIT, session.user.rank)}
/>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
export default async function RoomLegacyPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params;
redirect(`/admin/rooms/show/${id}`);
}
+251
View File
@@ -0,0 +1,251 @@
"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 { useConfirmDialog } from "@/components/admin/confirm-dialog";
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.",
});
}
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete() {
const ok = await confirm({
title: "Delete Room",
description:
"Are you sure you want to delete this room? This cannot be undone.",
confirmLabel: "Delete",
variant: "danger",
});
if (!ok) 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">
{confirmDialog}
<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>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { db, Rooms, User } from "@/lib/db";
export async function loadRoom(id: string) {
const roomId = parseInt(id, 10);
if (Number.isNaN(roomId)) notFound();
const [room] = await db
.select({
id: Rooms.id,
ownerId: Rooms.ownerId,
ownerName: Rooms.ownerName,
name: Rooms.name,
description: Rooms.description,
state: Rooms.state,
users: Rooms.users,
usersMax: Rooms.usersMax,
category: Rooms.category,
score: Rooms.score,
})
.from(Rooms)
.where(eq(Rooms.id, roomId))
.limit(1);
if (!room) notFound();
const owner =
room.ownerId > 0
? ((
await db
.select({
id: User.id,
username: User.username,
look: User.look,
})
.from(User)
.where(eq(User.id, room.ownerId))
.limit(1)
)[0] ?? null)
: null;
return { ...room, owner };
}
+70
View File
@@ -0,0 +1,70 @@
import { DoorOpen, Eye } from "lucide-react";
import { redirect } from "next/navigation";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import Link from "@/components/link";
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_EDIT, session.user.rank)) {
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.ownerName} &middot;{" "}
{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 ?? room.ownerName,
}}
/>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
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 }, (_, i) => i).map((n) => (
<div
key={`skeleton-row-${n}`}
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>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { and, count, desc, eq, like, or } from "drizzle-orm";
import { DoorOpen } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { fetchAdminList } from "@/lib/admin-list";
import { db, Rooms } from "@/lib/db";
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>>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.ROOMS_VIEW,
fetch: async ({ search, page, perPage, rawParams }) => {
const conditions = [];
if (search) {
const ors = [
like(Rooms.name, `%${search}%`),
like(Rooms.ownerName, `%${search}%`),
];
const asId = Number(search);
if (Number.isFinite(asId) && asId > 0) {
ors.push(eq(Rooms.id, asId));
}
conditions.push(or(...ors));
}
if (rawParams.filter_state) {
conditions.push(eq(Rooms.state, rawParams.filter_state));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, totals] = await Promise.all([
db
.select({
id: Rooms.id,
name: Rooms.name,
ownerId: Rooms.ownerId,
ownerName: Rooms.ownerName,
state: Rooms.state,
users: Rooms.users,
usersMax: Rooms.usersMax,
description: Rooms.description,
})
.from(Rooms)
.where(where)
.orderBy(desc(Rooms.id))
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(Rooms).where(where),
]);
return {
rows: rows.map((r) => ({
id: r.id,
name: r.name,
ownerName: r.ownerName || "—",
ownerId: r.ownerId ?? 0,
state: r.state,
users: r.users,
usersMax: r.usersMax,
description: r.description || "",
})),
total: totals[0]?.total ?? 0,
};
},
},
paramsPromise as Promise<{ locale: string }>,
searchParams,
);
const t = await getTranslations("pages.admin.rooms");
const canEdit = canAccess(result.permissions, PERMS.ROOMS_EDIT, result.rank);
return (
<AdminPageShell icon={DoorOpen} title={t("title")} subtitle={t("subtitle")}>
<p className="text-muted-foreground m-0">
{result.total.toLocaleString()} rooms total
</p>
<RoomsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
canEdit={canEdit}
/>
</AdminPageShell>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { Eye, Pencil, Sofa, Wifi } from "lucide-react";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
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-[var(--admin-success)]" />
)}
<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}
/>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { DoorOpen, Eye, Globe, Lock, Pencil, Sofa, Users } from "lucide-react";
import { redirect } from "next/navigation";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import Link from "@/components/link";
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 = canAccess(permissions, PERMS.ROOMS_EDIT, session.user.rank);
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>
{room.owner ? (
<Link
href={`/admin/users/show/${room.owner.id}`}
className="text-primary hover:underline"
>
Owner: {room.owner.username}
</Link>
) : (
<span>Owner: {room.ownerName}</span>
)}
</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>
);
}