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:
1 parent
b4f6fce9df
commit
51d5bb5ff3
24 files changed
+1064
-5
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user