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]>
This commit is contained in:
1 parent
0c67232425
commit
2a1fc94df6
18 files changed
+3138
-52
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { api, getMe, unwrap } from "@/lib/api";
|
||||
import { closeRoom, saveRoom } from "../../actions";
|
||||
import { RoomFields } from "../../room-fields";
|
||||
|
||||
export const metadata: Metadata = { title: "Edit room" };
|
||||
|
||||
export default async function EditRoom({ params }: { params: Promise<{ n: string }> }) {
|
||||
const { n } = await params;
|
||||
if (!/^[0-9]{1,9}$/.test(n)) notFound();
|
||||
const me = await getMe();
|
||||
if (!me) redirect(`/signin?next=/rooms/${n}/edit`);
|
||||
const [{ rooms }, { links }] = await Promise.all([
|
||||
api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap),
|
||||
api.account.get().then(unwrap),
|
||||
]);
|
||||
const room = rooms.find((r) => String(r.n) === n);
|
||||
if (!room) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={`Edit ${room.name}`}>
|
||||
<Link href="/rooms" className="underline-offset-4 hover:underline">
|
||||
Back to rooms
|
||||
</Link>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Owned by {room.ownerName} · {room.occupancy} here now
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={saveRoom} fields={{ n: room.n }}>
|
||||
<FieldGroup>
|
||||
<RoomFields room={room} canAdult={links.some((l) => l.adult)} />
|
||||
<div>
|
||||
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Close room</CardTitle>
|
||||
<CardDescription>Everyone in it is sent out. It can't be opened again.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={closeRoom} fields={{ n: room.n }}>
|
||||
<SubmitButton variant="destructive" pendingLabel="Closing…" confirm={`Close ${room.name}?`}>
|
||||
Close room
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
const rating = (form: FormData) => (field(form, "rating") === "adult" ? ("adult" as const) : ("general" as const));
|
||||
|
||||
/** Makes a hosted room, then goes back to the list. */
|
||||
export async function createRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const state = await attempt(async () => {
|
||||
unwrap(
|
||||
await api["hosted-rooms"].post({
|
||||
name: field(form, "name"),
|
||||
description: field(form, "description"),
|
||||
roomPid: Number(field(form, "roomPid")),
|
||||
rating: rating(form),
|
||||
maxUsers: Number(field(form, "maxUsers")),
|
||||
imvuId: Number(field(form, "imvuId")),
|
||||
}),
|
||||
);
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect("/rooms");
|
||||
}
|
||||
|
||||
/** The owner's changes to a room. */
|
||||
export async function saveRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
const state = await attempt(async () => {
|
||||
unwrap(
|
||||
await api["hosted-rooms"]({ n }).patch({
|
||||
name: field(form, "name"),
|
||||
description: field(form, "description"),
|
||||
roomPid: Number(field(form, "roomPid")),
|
||||
rating: rating(form),
|
||||
maxUsers: Number(field(form, "maxUsers")),
|
||||
}),
|
||||
);
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect("/rooms");
|
||||
}
|
||||
|
||||
/** Closes a room: everyone in it is sent out. */
|
||||
export async function closeRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
const state = await attempt(async () => {
|
||||
unwrap(await api["hosted-rooms"]({ n }).delete());
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
if (state?.error) return state;
|
||||
redirect("/rooms");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { api, getMe, unwrap } from "@/lib/api";
|
||||
import { createRoom } from "../actions";
|
||||
import { RoomFields } from "../room-fields";
|
||||
|
||||
export const metadata: Metadata = { title: "New room" };
|
||||
|
||||
export default async function NewRoom() {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/rooms/new");
|
||||
const { links } = await api.account.get().then(unwrap);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="New room">
|
||||
<Link href="/rooms" className="underline-offset-4 hover:underline">
|
||||
Back to rooms
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{links.length === 0 ? (
|
||||
<p className="py-2 text-sm text-muted-foreground">
|
||||
<Link href="/account" className="text-foreground underline underline-offset-4">
|
||||
Link an IMVU account
|
||||
</Link>{" "}
|
||||
first: it's shown as the room's owner.
|
||||
</p>
|
||||
) : (
|
||||
<ActionForm action={createRoom}>
|
||||
<FieldGroup>
|
||||
<RoomFields canAdult={links.some((l) => l.adult)} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor="imvuId">Owner</FieldLabel>
|
||||
<NativeSelect id="imvuId" name="imvuId" defaultValue={String(links[0]!.imvuId)} className="w-64">
|
||||
{links.map((l) => (
|
||||
<NativeSelectOption key={l.imvuId} value={String(l.imvuId)}>
|
||||
{l.username}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<FieldDescription>The IMVU account shown as owner. It can decorate the room.</FieldDescription>
|
||||
</Field>
|
||||
<div>
|
||||
<SubmitButton pendingLabel="Making…">Make room</SubmitButton>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</ActionForm>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+110
-44
@@ -1,55 +1,121 @@
|
||||
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, unwrap } from "@/lib/api";
|
||||
import { api, getMe, unwrap, type HostedRoom } from "@/lib/api";
|
||||
|
||||
export const metadata: Metadata = { title: "Rooms" };
|
||||
|
||||
export default async function Rooms() {
|
||||
const { rooms } = await api.rooms.get().then(unwrap);
|
||||
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 (
|
||||
<>
|
||||
<PageHeader title="Rooms">Public IMVU rooms of T5DE users.</PageHeader>
|
||||
{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>
|
||||
<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>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// The fields of a hosted room, for making one and for editing it.
|
||||
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { HostedRoom } from "@/lib/api";
|
||||
|
||||
export function RoomFields({ room, canAdult }: { room?: HostedRoom; canAdult: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||
<Input id="name" name="name" defaultValue={room?.name} required maxLength={64} className="max-w-md" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="description">Description</FieldLabel>
|
||||
<Textarea id="description" name="description" defaultValue={room?.description} maxLength={512} rows={3} />
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="roomPid">Room shell</FieldLabel>
|
||||
<Input
|
||||
id="roomPid"
|
||||
name="roomPid"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
required
|
||||
defaultValue={room?.roomPid}
|
||||
placeholder="Product ID"
|
||||
/>
|
||||
<FieldDescription>An IMVU room product's ID, from its shop page.</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rating">Rating</FieldLabel>
|
||||
<NativeSelect id="rating" name="rating" defaultValue={room?.rating ?? "general"} className="w-full">
|
||||
<NativeSelectOption value="general">General</NativeSelectOption>
|
||||
<NativeSelectOption value="adult" disabled={!canAdult && room?.rating !== "adult"}>
|
||||
Adult
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
{!canAdult && <FieldDescription>Adult needs an age-verified account.</FieldDescription>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="maxUsers">Max people</FieldLabel>
|
||||
<Input
|
||||
id="maxUsers"
|
||||
name="maxUsers"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={2}
|
||||
max={30}
|
||||
required
|
||||
defaultValue={room?.maxUsers ?? 10}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
|
||||
export type Revision = ProductPage["revisions"][number];
|
||||
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
|
||||
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
|
||||
export type HostedRoom = Data<V1["hosted-rooms"]["get"]>["rooms"][number];
|
||||
|
||||
/** An error the API answered: message is written to be shown as is. */
|
||||
export class ApiError extends Error {
|
||||
|
||||
Reference in new issue
Block a user