Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
CI / test (push) Successful in 1m10s
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]>
This commit is contained in:
1 parent
43183ec7ee
commit
76c8e0342e
16 files changed
+3851
-73
No files matched your search
@@ -6,7 +6,9 @@ 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 { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { closeRoom, removeImage, saveImage, saveRoom } from "../../actions";
|
||||
import { RoomFields } from "../../room-fields";
|
||||
|
||||
export const metadata: Metadata = { title: "Edit room" };
|
||||
@@ -40,7 +42,7 @@ export default async function EditRoom({ params }: { params: Promise<{ n: string
|
||||
<CardContent>
|
||||
<ActionForm action={saveRoom} fields={{ n: room.n }}>
|
||||
<FieldGroup>
|
||||
<RoomFields room={room} canAdult={links.some((l) => l.adult)} />
|
||||
<RoomFields room={room} canAdult={links.some((l) => l.adult)} moderators />
|
||||
<div>
|
||||
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
|
||||
</div>
|
||||
@@ -49,6 +51,36 @@ export default async function EditRoom({ params }: { params: Promise<{ n: string
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Image</CardTitle>
|
||||
<CardDescription>Shown in room lists.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{room.image && (
|
||||
// The room's image, from the hub.
|
||||
<img src={room.image} alt="" className="aspect-[332/281] w-full max-w-xs rounded-md bg-muted object-cover" />
|
||||
)}
|
||||
<ActionForm action={saveImage} fields={{ n: room.n }}>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="image">{room.image ? "Replace" : "Upload"}</FieldLabel>
|
||||
<Input id="image" name="image" type="file" accept="image/png,image/jpeg" required className="max-w-md" />
|
||||
<FieldDescription>PNG or JPEG, up to 512 KB.</FieldDescription>
|
||||
</Field>
|
||||
<div>
|
||||
<SubmitButton pendingLabel="Uploading…">Save image</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
{room.image && (
|
||||
<ActionForm action={removeImage} fields={{ n: room.n }}>
|
||||
<SubmitButton variant="outline" pendingLabel="Removing…">
|
||||
Remove image
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Close room</CardTitle>
|
||||
|
||||
@@ -5,6 +5,12 @@ 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));
|
||||
const privacy = (form: FormData) => (field(form, "privacy") === "private" ? ("private" as const) : ("public" as const));
|
||||
const moderators = (form: FormData) =>
|
||||
field(form, "moderators")
|
||||
.split(/[,\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** Makes a hosted room, then goes back to the list. */
|
||||
export async function createRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
@@ -17,6 +23,7 @@ export async function createRoom(_: ActionState, form: FormData): Promise<Action
|
||||
rating: rating(form),
|
||||
maxUsers: Number(field(form, "maxUsers")),
|
||||
imvuId: Number(field(form, "imvuId")),
|
||||
privacy: privacy(form),
|
||||
}),
|
||||
);
|
||||
revalidatePath("/rooms");
|
||||
@@ -36,6 +43,8 @@ export async function saveRoom(_: ActionState, form: FormData): Promise<ActionSt
|
||||
roomPid: Number(field(form, "roomPid")),
|
||||
rating: rating(form),
|
||||
maxUsers: Number(field(form, "maxUsers")),
|
||||
privacy: privacy(form),
|
||||
moderators: moderators(form),
|
||||
}),
|
||||
);
|
||||
revalidatePath("/rooms");
|
||||
@@ -44,6 +53,47 @@ export async function saveRoom(_: ActionState, form: FormData): Promise<ActionSt
|
||||
redirect("/rooms");
|
||||
}
|
||||
|
||||
/** The room's image: a PNG or JPEG, up to 512 KB. */
|
||||
export async function saveImage(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
const image = form.get("image");
|
||||
if (!(image instanceof File) || image.size === 0) return { error: "Choose an image." };
|
||||
if (image.size > 512 * 1024) return { error: "An image is up to 512 KB." };
|
||||
return attempt(async () => {
|
||||
unwrap(await api["hosted-rooms"]({ n }).image.put({ image }));
|
||||
revalidatePath("/rooms");
|
||||
revalidatePath(`/rooms/${n}/edit`);
|
||||
return "Image saved.";
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeImage(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
return attempt(async () => {
|
||||
unwrap(await api["hosted-rooms"]({ n }).image.delete());
|
||||
revalidatePath("/rooms");
|
||||
revalidatePath(`/rooms/${n}/edit`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Stars a room, or (favorite=0) unstars it. */
|
||||
export async function setFavorite(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
return attempt(async () => {
|
||||
const room = api["hosted-rooms"]({ n }).favorite;
|
||||
unwrap(await (field(form, "favorite") === "1" ? room.put() : room.delete()));
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
}
|
||||
|
||||
export async function rateRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
return attempt(async () => {
|
||||
unwrap(await api["hosted-rooms"]({ n }).rating.put({ score: Number(field(form, "score")) }));
|
||||
revalidatePath("/rooms");
|
||||
});
|
||||
}
|
||||
|
||||
/** Closes a room: everyone in it is sent out. */
|
||||
export async function closeRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const n = field(form, "n");
|
||||
|
||||
@@ -4,6 +4,7 @@ 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" };
|
||||
|
||||
@@ -39,7 +40,7 @@ export default async function Rooms() {
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{hosted.map((r) => (
|
||||
<li key={r.n}>
|
||||
<HostedCard room={r} mine={own.has(r.n)} />
|
||||
<HostedCard room={r} mine={own.has(r.n)} signedIn={!!me} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -93,13 +94,18 @@ export default async function Rooms() {
|
||||
);
|
||||
}
|
||||
|
||||
function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) {
|
||||
function HostedCard({ room: r, mine, signedIn }: { room: HostedRoom; mine: boolean; signedIn: boolean }) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardContent className="space-y-3">
|
||||
<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
|
||||
@@ -113,8 +119,10 @@ function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) {
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
// A hosted room card's favorite star and rating buttons.
|
||||
import { Star } from "lucide-react";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { rateRoom, setFavorite } from "./actions";
|
||||
|
||||
export function RoomActions({ n, favorite, canRate }: { n: number; favorite: boolean; canRate: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActionForm action={setFavorite} fields={{ n, favorite: favorite ? "0" : "1" }} className="space-y-0">
|
||||
<SubmitButton variant="ghost" size="icon" aria-label={favorite ? "Unstar" : "Star"} title={favorite ? "Unstar" : "Star"}>
|
||||
<Star className={cn("size-4", favorite && "fill-current text-amber-500")} />
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
{canRate && (
|
||||
<ActionForm action={rateRoom} fields={{ n }} className="flex items-center gap-0.5 space-y-0">
|
||||
<span className="mr-1 text-xs text-muted-foreground">Rate</span>
|
||||
{[1, 2, 3, 4, 5].map((k) => (
|
||||
<SubmitButton key={k} name="score" value={String(k)} variant="ghost" size="xs" aria-label={`${k} of 5`}>
|
||||
{k}
|
||||
</SubmitButton>
|
||||
))}
|
||||
</ActionForm>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,10 @@ 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 }) {
|
||||
/** The moderators as the edit field shows them: names where known, else IDs. */
|
||||
const moderatorList = (room: HostedRoom) => room.moderators.map((id, i) => room.moderatorNames[i] || String(id)).join(", ");
|
||||
|
||||
export function RoomFields({ room, canAdult, moderators }: { room?: HostedRoom; canAdult: boolean; moderators?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
@@ -55,6 +58,21 @@ export function RoomFields({ room, canAdult }: { room?: HostedRoom; canAdult: bo
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="privacy">Privacy</FieldLabel>
|
||||
<NativeSelect id="privacy" name="privacy" defaultValue={room?.privacy ?? "public"} className="w-64">
|
||||
<NativeSelectOption value="public">Public</NativeSelectOption>
|
||||
<NativeSelectOption value="private">Private</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<FieldDescription>Private: unlisted; only you, moderators and people invited get in.</FieldDescription>
|
||||
</Field>
|
||||
{moderators && room && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="moderators">Moderators</FieldLabel>
|
||||
<Input id="moderators" name="moderators" defaultValue={moderatorList(room)} placeholder="IMVU names or IDs" />
|
||||
<FieldDescription>Up to 20, comma-separated. They can boot and ban.</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user