Files
t5de-hub/apps/web/app/rooms/page.tsx
T
dhkatzandClaude Opus 5.5 2a1fc94df6
CI / test (push) Successful in 1m5s
Hosted rooms: the hub's side
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]>
2026-10-30 03:21:21 -07:00

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>
);
}