This commit is contained in:
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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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} · {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} ·{" "}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user