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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user