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]>
135 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|