Files
t5de-hub/apps/web/app/console/page.tsx
T
dhkatzandClaude Opus 5.5 8793b5afd8
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m4s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
feat: product drafts, moderator edits with reasons and locks, info history, unlisted products and tags
New uploads are drafts: only their creator and moderators see them, nobody can get or wear them, and they're
deleted if not published within 30 days (their catalogs get delete changes). The creator reviews the info and
publishes it.

Editing a product's info is a permission check now (product:edit): moderators can edit anyone's with a reason the
creator sees, lock fields (product:lock; locked fields need product:edit-locked) and lower ratings
(product:rate-lower, not below its files'). Every change is kept in the product's info history, with who and why,
and can be undone by the same rules.

Products can be unlisted (out of search and lists, the link still works) and tagged; lists filter by tag and
catalog. The console has a Products section (everyone's for moderators, by status) and a drafts to-do.

A product's own category is left for later: the hub has no list of IMVU's categories to pick from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:40:43 -08:00

135 lines
5.3 KiB
TypeScript

import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { api, unwrap } from "@/lib/api";
import { setRegistration } from "./actions";
import { AuditTable } from "./audit-table";
import { ConsoleNav, requireConsole } from "./console-nav";
export const metadata: Metadata = { title: "Console" };
type Stat = { label: string; value: number; note: string; href?: string };
export default async function Console() {
const { me, console: data } = await requireConsole("/console");
const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
const todo: Stat[] = [
{ label: "Drafts", value: data.todo.drafts, note: "to review and publish", href: "/console/products?status=draft" },
{ label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
];
if (data.moderation) {
todo.push(
{ label: "Catalogs", value: data.todo.pendingCatalogs, note: "waiting for approval", href: "/console/catalogs" },
{ label: "Accounts", value: data.todo.waitingPeople, note: "waiting for approval", href: "/console/people?waiting=true" },
);
}
const mine: Stat[] = [
{ label: "Your Products", value: data.mine.products, note: "you created", href: "/console/products" },
{ label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" },
{ label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" },
];
const hub: Stat[] = data.moderation
? [
{ label: "People", value: data.moderation.counts.people, note: "accounts" },
{ label: "IMVU Accounts", value: data.moderation.counts.links, note: "linked" },
{ label: "Computers", value: data.moderation.counts.computers, note: "signed in" },
]
: [];
return (
<>
<PageHeader title="Console">What you manage on T5DE.</PageHeader>
<ConsoleNav />
<div className="space-y-6">
<StatGrid stats={todo.filter((s) => s.value > 0 || data.moderation)} />
<StatGrid stats={mine.filter((s) => s.value > 0)} />
{hub.length > 0 && <StatGrid stats={hub} />}
{data.moderation && (
<Card>
<CardHeader>
<CardTitle>Online Now</CardTitle>
<CardDescription>
{data.moderation.connections} {data.moderation.connections === 1 ? "connection" : "connections"} when
the page loaded
</CardDescription>
</CardHeader>
<CardContent>
<OnlineList initial={data.moderation.online} moderator />
</CardContent>
</Card>
)}
{settings && (
<Card id="settings">
<CardHeader>
<CardTitle>Registration</CardTitle>
<CardDescription>
{settings.registration === "closed"
? "Closed: new accounts can sign in, but wait for a moderator's approval."
: "Open: new accounts can use the hub at once."}
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={setRegistration}>
<div className="flex gap-2">
<NativeSelect name="registration" defaultValue={settings.registration} aria-label="Registration">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="closed">Closed</NativeSelectOption>
</NativeSelect>
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Recent Changes</CardTitle>
<CardDescription>
{data.moderation ? "Across the hub." : "To what you manage, and by you."}{" "}
<Link href="/console/audit" className="underline-offset-4 hover:underline">
Full audit log
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<AuditTable entries={data.audit} />
</CardContent>
</Card>
</div>
</>
);
}
function StatGrid({ stats }: { stats: Stat[] }) {
if (stats.length === 0) return null;
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Card key={s.label}>
<CardHeader>
<CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{s.href ? (
<Link href={s.href} className="underline-offset-4 hover:underline">
{s.note}
</Link>
) : (
s.note
)}
</CardContent>
</Card>
))}
</div>
);
}