CI / test (push) Successful in 1m5s
Rooms made on the website (hosted_room, migration 0005), the REST routes in docs/hosted-rooms.md, and the realtime room.* protocol: joining with the capacity, adult and linked-account rules, seats, numbered messages with forged senders dropped and a rate limit, closing. The Rooms page lists them, with a form to make one and the owner's edit and close. Co-Authored-By: Claude Opus 5.5 <[email protected]>
122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { PageHeader, Tag } from "@/components/status";
|
|
import { buttonVariants } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { api, getMe, unwrap, type HostedRoom } from "@/lib/api";
|
|
|
|
export const metadata: Metadata = { title: "Rooms" };
|
|
|
|
export default async function Rooms() {
|
|
const me = await getMe();
|
|
const [{ rooms }, { rooms: hosted }, mine] = await Promise.all([
|
|
api.rooms.get().then(unwrap),
|
|
api["hosted-rooms"].get({ query: {} }).then(unwrap),
|
|
me ? api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap) : { rooms: [] as HostedRoom[] },
|
|
]);
|
|
const own = new Set(mine.rooms.map((r) => r.n));
|
|
|
|
return (
|
|
<div className="space-y-10">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<PageHeader title="Rooms">Rooms on T5DE, and public IMVU rooms of T5DE users.</PageHeader>
|
|
{me && (
|
|
<Link href="/rooms/new" className={buttonVariants({ className: "px-4" })}>
|
|
New room
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
<section className="space-y-4">
|
|
<h2 className="text-lg font-semibold tracking-tight">On T5DE</h2>
|
|
{hosted.length === 0 ? (
|
|
<Card>
|
|
<CardContent>
|
|
<p className="py-6 text-sm text-muted-foreground">No rooms yet.</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<ul className="grid gap-4 sm:grid-cols-2">
|
|
{hosted.map((r) => (
|
|
<li key={r.n}>
|
|
<HostedCard room={r} mine={own.has(r.n)} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className="space-y-4">
|
|
<h2 className="text-lg font-semibold tracking-tight">On IMVU</h2>
|
|
{rooms.length === 0 ? (
|
|
<Card>
|
|
<CardContent>
|
|
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<ul className="grid gap-4 sm:grid-cols-2">
|
|
{rooms.map((r) => (
|
|
<li key={r.instanceId}>
|
|
<Card className="h-full overflow-hidden py-0">
|
|
{r.imageURL && (
|
|
// IMVU's own image of the room, from IMVU.
|
|
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
|
|
)}
|
|
<CardContent className="space-y-3 pb-6">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
|
|
{r.name}
|
|
</a>
|
|
{r.adult && <Tag tone="adult">AP</Tag>}
|
|
{r.t5de.length > 0 && (
|
|
<Tag tone="online" dot>
|
|
{r.t5de.length} on T5DE
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{r.owner.username} · {r.occupancy}/{r.capacity}
|
|
</p>
|
|
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
|
{r.t5de.length > 0 && (
|
|
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) {
|
|
return (
|
|
<Card className="h-full">
|
|
<CardContent className="space-y-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-medium">{r.name}</span>
|
|
{r.rating === "adult" && <Tag tone="adult">Adult</Tag>}
|
|
{r.occupancy > 0 && (
|
|
<Tag tone="online" dot>
|
|
{r.occupancy} here
|
|
</Tag>
|
|
)}
|
|
{mine && (
|
|
<Link href={`/rooms/${r.n}/edit`} className={buttonVariants({ variant: "outline", size: "sm", className: "ml-auto" })}>
|
|
Edit
|
|
</Link>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{r.ownerName} · {r.occupancy}/{r.maxUsers}
|
|
</p>
|
|
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|