style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

@@ -4,12 +4,12 @@ import { useEffect, useState } from "react";
import { updateRoomItem } from "@/actions/rooms";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -17,163 +17,169 @@ 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);
}
}, [item]);
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),
});
}
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>
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-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>
<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>
);
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
+77 -66
View File
@@ -5,76 +5,87 @@ 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" },
});
// Load items in this room. items.item_id → items_base.id
const items = await prisma.items.findMany({
where: { roomId: room.id },
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 bases = baseIds.length
? await prisma.itemsBase.findMany({
where: { id: { in: baseIds } },
select: { id: true, itemName: true, publicName: true, type: true },
})
: [];
const baseMap = new Map(bases.map((b) => [b.id, b]));
// Resolve 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 prisma.itemsBase.findMany({
where: { id: { in: baseIds } },
select: { id: true, itemName: true, publicName: true, type: true },
})
: [];
const baseMap = new Map(bases.map((b) => [b.id, b]));
// Resolve owner usernames
const userIds = Array.from(new Set(items.map((i) => i.userId).filter((u) => u > 0)));
const users = userIds.length
? await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
})
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
// Resolve owner usernames
const userIds = Array.from(
new Set(items.map((i) => i.userId).filter((u) => u > 0)),
);
const users = userIds.length
? await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
})
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
const rows = items.map((i) => {
const base = 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,
};
});
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={session.user.rank >= 4}
/>
</div>
);
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={session.user.rank >= 4}
/>
</div>
);
}
+4 -4
View File
@@ -1,10 +1,10 @@
import { redirect } from "next/navigation";
export default async function RoomLegacyPage({
params,
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}`);
}
+217 -174
View File
@@ -1,7 +1,15 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Lock, LogOut, RefreshCw, Save, Trash2, Unlock } from "lucide-react";
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";
@@ -9,194 +17,229 @@ 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 {
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;
};
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,
},
});
const { run, isPending } = useServerAction();
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<UpdateRoomInput>({
resolver: zodResolver(updateRoomSchema),
defaultValues: {
name: room.name,
description: room.description,
state: room.state as "open" | "locked" | "password" | "invisible",
usersMax: room.usersMax,
},
});
function onSubmit(data: UpdateRoomInput) {
run(() => updateRoom({ id: room.id, ...data }), {
successMessage: "Room updated successfully.",
});
}
function onSubmit(data: UpdateRoomInput) {
run(() => updateRoom({ id: room.id, ...data }), {
successMessage: "Room updated successfully.",
});
}
function handleDelete() {
if (!confirm("Are you sure you want to delete this room? This cannot be undone.")) return;
run(() => deleteRoom({ id: room.id }), { redirectTo: "/admin/rooms" });
}
function handleDelete() {
if (
!confirm(
"Are you sure you want to delete this room? This cannot be undone.",
)
)
return;
run(() => deleteRoom({ id: room.id }), { redirectTo: "/admin/rooms" });
}
function handleRconAction(action: "kick" | "lock" | "unlock" | "reload") {
run(() => roomRconAction({ roomId: room.id, action }), {
successMessage: `Action "${action}" executed successfully.`,
errorMessage: `Action "${action}" failed. Is the emulator running?`,
});
}
function handleRconAction(action: "kick" | "lock" | "unlock" | "reload") {
run(() => roomRconAction({ roomId: room.id, action }), {
successMessage: `Action "${action}" executed successfully.`,
errorMessage: `Action "${action}" failed. Is the emulator running?`,
});
}
return (
<div className="space-y-4">
<div className="grid gap-6 lg:grid-cols-2">
{/* Edit Form */}
<Card>
<CardHeader>
<CardTitle>Room Details</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" {...register("name")} />
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
</div>
return (
<div className="space-y-4">
<div className="grid gap-6 lg:grid-cols-2">
{/* Edit Form */}
<Card>
<CardHeader>
<CardTitle>Room Details</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" {...register("name")} />
{errors.name && (
<p className="text-sm text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" rows={3} {...register("description")} />
</div>
<div className="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="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>
<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>
{/* 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>
);
<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>
);
}
+14 -14
View File
@@ -2,20 +2,20 @@ 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 },
});
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 };
}
+58 -50
View File
@@ -8,58 +8,66 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { loadRoom } from "../../_lib/load-room";
import { RoomEditForm } from "../../[id]/room-edit-form";
export default async function RoomEditPage({ params }: { params: Promise<{ locale: string; id: string }> }) {
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ROOMS_VIEW, session.user.rank) || session.user.rank < 4) {
redirect("/admin");
}
export default async function RoomEditPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (
!canAccess(permissions, PERMS.ROOMS_VIEW, session.user.rank) ||
session.user.rank < 4
) {
redirect("/admin");
}
const room = await loadRoom(id);
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" },
]}
/>
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>
<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>
);
<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>
);
}
+27 -24
View File
@@ -1,28 +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 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
>
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
);
}
+47 -43
View File
@@ -3,52 +3,56 @@ import { canAccess, PERMS } from "@/lib/permissions";
import { RoomsTable } from "./rooms-table";
export default async function RoomsPage({
params: paramsPromise,
searchParams,
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"],
include: { owner: { select: { username: true } } },
mapRow: (r) => ({
id: r.id,
name: r.name,
ownerName: r.owner.username,
ownerId: r.owner?.id ?? 0,
state: r.state,
users: r.users,
usersMax: r.usersMax,
description: r.description,
}),
},
paramsPromise,
searchParams,
);
const result = await fetchAdminList(
{
permission: PERMS.ROOMS_VIEW,
model: "room",
searchFields: ["name", "owner.username"],
include: { owner: { select: { username: true } } },
mapRow: (r) => ({
id: r.id,
name: r.name,
ownerName: r.owner.username,
ownerId: r.owner?.id ?? 0,
state: r.state,
users: r.users,
usersMax: r.usersMax,
description: r.description,
}),
},
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">
<div>
<h1 className="text-3xl font-bold tracking-tight">Rooms</h1>
<p className="text-muted-foreground">{result.total.toLocaleString()} rooms total</p>
</div>
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Rooms</h1>
<p className="text-muted-foreground">
{result.total.toLocaleString()} rooms total
</p>
</div>
<RoomsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
canEdit={canEdit}
/>
</div>
);
<RoomsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
canEdit={canEdit}
/>
</div>
);
}
+118 -102
View File
@@ -8,117 +8,133 @@ 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: "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>
),
},
];
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;
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}
/>
);
return (
<DataTable
data={data}
columns={getColumns(canEdit)}
searchPlaceholder="Search by room name or owner..."
exportUrl={canEdit ? "/api/admin/export?type=rooms" : undefined}
/>
);
}
+126 -108
View File
@@ -8,118 +8,136 @@ 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 }]} />
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>
{/* 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>
);
{/* 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>
);
}