CI / test (push) Successful in 1m10s
Moderators (up to 20 IMVU accounts, set by ID or name) and the owner account boot and ban over realtime (room.boot); bans are listed and lifted over REST. Private rooms are unlisted and joined by the owner, moderators and holders of an accepted invite (room.invite / room.invited / room.invite_reply / room.invite_answer). Room images (PNG/JPEG by magic bytes, up to 512 KB) are stored in the database and served with cache headers. Per-person favorites, ratings and recent rooms. room.watch pushes room.occupancy; participants carry their IMVU name, and GET .../participants lists them. Contract in docs/hosted-rooms.md, "Phase 2". Co-Authored-By: Claude Opus 5.5 <[email protected]>
130 lines
5.1 KiB
TypeScript
130 lines
5.1 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";
|
|
import { RoomActions } from "./room-actions";
|
|
|
|
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)} signedIn={!!me} />
|
|
</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, signedIn }: { room: HostedRoom; mine: boolean; signedIn: boolean }) {
|
|
return (
|
|
<Card className="h-full overflow-hidden py-0">
|
|
{r.image && (
|
|
// The room's image, from the hub.
|
|
<img src={r.image} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
|
|
)}
|
|
<CardContent className="space-y-3 py-6">
|
|
<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.privacy === "private" && <Tag>Private</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}
|
|
{r.ratings > 0 && ` · ★ ${r.score.toFixed(1)} (${r.ratings})`}
|
|
</p>
|
|
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
|
{signedIn && <RoomActions n={r.n} favorite={r.favorite} canRate={!mine} />}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|