Website: products, library, catalogs and their console page

/products (search), product pages with revisions, getting and staying on a
revision, retracting; /products/:id/edit; /create and new revisions upload
from the browser straight to the catalog with a token from the hub;
/library; /catalogs (directory, registering with a once-shown key);
/console/catalogs to approve or reject. Every page now sends a nonce-based
CSP (proxy.ts), so product descriptions can't run scripts.

buttonVariants now merges classes, so outline links get their border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 19:06:08 -07:00
1 parent b4f6fce9df
commit 51d5bb5ff3
24 files changed
+1064 -5

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
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, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, 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 { UploadCard } from "@/components/upload-card";
import { getMe } from "@/lib/api";
import { saveProduct } from "../../actions";
import { loadProduct } from "../../load";
export const metadata: Metadata = { title: "Edit product" };
export default async function EditProduct({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const me = await getMe();
if (!me) redirect(`/signin?next=/products/${id}/edit`);
const { product, mine } = await loadProduct(id);
if (!mine) notFound();
return (
<div className="space-y-6">
<PageHeader title={`Edit ${product.name}`}>
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
Back to the product
</Link>
</PageHeader>
<Card>
<CardHeader>
<CardTitle>Info</CardTitle>
</CardHeader>
<CardContent>
<ActionForm action={saveProduct} fields={{ id }}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" defaultValue={product.name} required maxLength={64} className="max-w-md" />
</Field>
<Field>
<FieldLabel htmlFor="description">Description</FieldLabel>
<Textarea
id="description"
name="description"
defaultValue={product.description}
maxLength={20000}
rows={8}
className="font-mono text-xs md:text-xs"
/>
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
{product.rating === "adult" ? (
<p className="text-sm">Adult</p>
) : (
<NativeSelect id="rating" name="rating" defaultValue="general" className="w-48">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult">Adult</NativeSelectOption>
</NativeSelect>
)}
<FieldDescription>Raising it to adult is immediate; lowering it needs review.</FieldDescription>
</Field>
<div>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div>
</FieldGroup>
</ActionForm>
</CardContent>
</Card>
<UploadCard title="Upload a new revision" product={id} />
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { formatSize, RatingTag } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getMe, type Revision } from "@/lib/api";
import { libraryAction, retract } from "../actions";
import { loadProduct } from "../load";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { product } = await loadProduct((await params).id);
return { title: product.name };
}
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [me, data] = await Promise.all([getMe(), loadProduct(id)]);
const { product, revisions, inLibrary, stayOn, mine } = data;
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
return (
<div className="space-y-6">
<PageHeader title={product.name}>
<span className="flex flex-wrap items-center gap-2">
{product.creator && <span>by {product.creator}</span>}
<RatingTag rating={product.rating} />
</span>
</PageHeader>
<div className="flex flex-wrap items-center gap-2">
{me ? (
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{inLibrary ? (
<>
<Tag tone="strong">In your library</Tag>
<SubmitButton variant="outline" name="intent" value="remove" pendingLabel="Removing…">
Remove
</SubmitButton>
</>
) : (
<SubmitButton name="intent" value="get" pendingLabel="Getting…">
Get
</SubmitButton>
)}
</div>
</ActionForm>
) : (
<Link href={`/signin?next=/products/${id}`} className={buttonVariants()}>
Sign in to get it
</Link>
)}
{mine && (
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
Edit
</Link>
)}
</div>
{product.description && (
<Card>
<CardContent>
{/* Cleaned by the API's allowlist when saved; the page's CSP blocks scripts besides. */}
<div className="product-description" dangerouslySetInnerHTML={{ __html: product.description }} />
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Revisions</CardTitle>
</CardHeader>
<CardContent>
<ul className="divide-y">
{revisions.map((r) => (
<RevisionRow
key={r.number}
id={id}
revision={r}
latest={r.number === product.latest}
inLibrary={inLibrary}
stayOn={stayOn}
using={using === r.number}
mine={mine}
/>
))}
</ul>
</CardContent>
</Card>
</div>
);
}
/** The revision T5DE uses (as the API's revisionFor): want, else the nearest earlier one not retracted, else later. */
function inUse(revisions: Revision[], want: number): number | undefined {
const usable = revisions.filter((r) => !r.retracted); // newest first
return (usable.find((r) => r.number === want) ?? usable.find((r) => r.number < want) ?? usable.at(-1))?.number;
}
function RevisionRow({
id,
revision: r,
latest,
inLibrary,
stayOn,
using,
mine,
}: {
id: string;
revision: Revision;
latest: boolean;
inLibrary: boolean;
stayOn: number | null;
using: boolean;
mine: boolean;
}) {
const facts = [
formatSize(r.size),
`${r.files.length} ${r.files.length === 1 ? "file" : "files"}`,
r.catalogs.length > 0 ? r.catalogs.join(", ") : "no catalog",
];
return (
<li className="space-y-2 py-4 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">Revision {r.number}</span>
<span className="text-sm text-muted-foreground">
<DateTime iso={r.createdAt} dateOnly />
</span>
{latest && <Tag>latest</Tag>}
<RatingTag rating={r.rating} />
{r.retracted && <Tag tone="danger">retracted</Tag>}
{using && <Tag tone="strong">{stayOn === r.number ? "staying on this" : "in use"}</Tag>}
</div>
{r.notes && <p className="text-sm whitespace-pre-line">{r.notes}</p>}
<p className="text-xs text-muted-foreground">{facts.join(" · ")}</p>
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Files</summary>
<ul className="mt-2 space-y-1 font-mono text-xs">
{r.files.map((f) => (
<li key={f.name} className="flex gap-3">
<span className="min-w-0 flex-1 truncate">{f.name}</span>
<span className="text-muted-foreground tabular-nums">{formatSize(f.size)}</span>
</li>
))}
</ul>
</details>
{((inLibrary && !r.retracted) || (mine && !r.retracted)) && (
<div className="flex flex-wrap gap-2">
{inLibrary && !r.retracted && (
<ActionForm action={libraryAction} fields={{ id, revision: r.number }}>
{stayOn === r.number ? (
<SubmitButton size="sm" variant="outline" name="intent" value="follow" pendingLabel="Saving…">
Follow latest
</SubmitButton>
) : (
<SubmitButton size="sm" variant="outline" name="intent" value="stay" pendingLabel="Saving…">
Stay on this revision
</SubmitButton>
)}
</ActionForm>
)}
{mine && !r.retracted && (
<ActionForm action={retract} fields={{ id, revision: r.number }}>
<SubmitButton
size="sm"
variant="outline"
pendingLabel="Retracting…"
confirm={`Retract revision ${r.number}? T5DE stops using it.`}
>
Retract
</SubmitButton>
</ActionForm>
)}
</div>
)}
</li>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, ApiError, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const done = (id: string) => {
revalidatePath(`/products/${id}`);
revalidatePath("/library");
};
/** The person's library and a product: get it, remove it, stay on a revision or follow the latest. */
export async function libraryAction(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const lib = api.library({ id });
switch (field(form, "intent")) {
case "get":
unwrap(await lib.put());
break;
case "remove":
unwrap(await lib.delete());
break;
case "stay":
unwrap(await lib.patch({ stayOn: Number(field(form, "revision")) }));
break;
case "follow":
unwrap(await lib.patch({ stayOn: null }));
break;
}
done(id);
});
}
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
unwrap(await api.products({ id }).revisions({ n: Number(field(form, "revision")) }).retract.post());
done(id);
});
}
export async function saveProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const rating = field(form, "rating");
const state = await attempt(async () => {
unwrap(
await api.products({ id }).patch({
name: field(form, "name"),
description: typeof form.get("description") === "string" ? String(form.get("description")) : "",
...(rating === "adult" ? { rating: "adult" as const } : {}),
}),
);
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect(`/products/${id}`);
}
/** An upload token and the catalog's upload address, for the browser to send the file there itself. */
export async function startUpload(
catalog: string,
product?: string,
): Promise<{ token: string; url: string } | { error: string }> {
try {
return unwrap(await api.products.upload.post({ catalog, ...(product ? { product } : {}) }));
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}
+14
View File
@@ -0,0 +1,14 @@
import "server-only";
import { notFound } from "next/navigation";
import { api, ApiError, unwrap, type ProductPage } from "@/lib/api";
/** The product, or not found (also when it's adult and the visitor can't see adult products). */
export async function loadProduct(id: string): Promise<ProductPage> {
if (!/^[a-z2-7]{10}$/.test(id)) notFound();
try {
return unwrap(await api.products({ id }).get());
} catch (e) {
if (e instanceof ApiError && e.status === 404) notFound();
throw e;
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Empty, ProductCard } from "@/components/products";
import { PageHeader } from "@/components/status";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api, unwrap } from "@/lib/api";
export const metadata: Metadata = { title: "Products" };
export default async function Products({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
const raw = (await searchParams).q;
const q = typeof raw === "string" ? raw.trim().slice(0, 100) : "";
const { products } = await api.products.get({ query: q ? { q } : {} }).then(unwrap);
return (
<>
<PageHeader title="Products">Community products for T5DE.</PageHeader>
<form action="/products" className="mb-6 flex w-full max-w-md gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" />
<Button type="submit" variant="outline">
Search
</Button>
</form>
{products.length === 0 ? (
<Empty>{q ? "Nothing found." : "No products yet."}</Empty>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
</ul>
)}
</>
);
}