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,26 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { api, ApiError, unwrap } from "@/lib/api";
|
||||
import { field } from "@/lib/actions";
|
||||
|
||||
export type RegisterState = { error?: string; registered?: { id: string; name: string; key: string } } | null;
|
||||
|
||||
/** Registers a catalog; its key is in the answer, the only time it's shown. */
|
||||
export async function registerCatalog(_: RegisterState, form: FormData): Promise<RegisterState> {
|
||||
try {
|
||||
const { catalog, key } = unwrap(
|
||||
await api.catalogs.post({
|
||||
name: field(form, "name"),
|
||||
url: field(form, "url"),
|
||||
contact: field(form, "contact"),
|
||||
visibility: field(form, "visibility"),
|
||||
membership: field(form, "membership"),
|
||||
}),
|
||||
);
|
||||
revalidatePath("/catalogs");
|
||||
return { registered: { id: catalog.id, name: catalog.name, key } };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { error: e.message };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CatalogStatus, membership } from "@/components/catalogs";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, getMe, unwrap, type Catalog } from "@/lib/api";
|
||||
import { RegisterForm } from "./register-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalogs" };
|
||||
|
||||
export default async function Catalogs() {
|
||||
const [me, { catalogs }] = await Promise.all([getMe(), api.catalogs.get().then(unwrap)]);
|
||||
const mine = catalogs.filter((c) => c.mine);
|
||||
const directory = catalogs.filter((c) => !c.mine && c.status === "approved");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Catalogs">Servers that host community products.</PageHeader>
|
||||
|
||||
{mine.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Yours</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CatalogTable catalogs={mine} showStatus />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Directory</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{directory.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No catalogs yet.</p>
|
||||
) : (
|
||||
<CatalogTable catalogs={directory} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{me && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Register a catalog</CardTitle>
|
||||
<CardDescription>Run t5de-catalog, then register its address here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RegisterForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; showStatus?: boolean }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Membership</TableHead>
|
||||
{showStatus && <TableHead>Visibility</TableHead>}
|
||||
{showStatus && <TableHead>Status</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{catalogs.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell>
|
||||
<TableCell>{membership[c.membership] ?? c.membership}</TableCell>
|
||||
{showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>}
|
||||
{showStatus && (
|
||||
<TableCell>
|
||||
<CatalogStatus status={c.status} />
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
import { useActionState } from "react";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { ErrorText, SubmitButton } from "@/components/forms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { registerCatalog } from "./actions";
|
||||
|
||||
export function RegisterForm() {
|
||||
const [state, action] = useActionState(registerCatalog, null);
|
||||
if (state?.registered) {
|
||||
const { id, name, key } = state.registered;
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>{name} is registered</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>Put these in the catalog's settings. The key is shown only once. A moderator approves it next.</p>
|
||||
<KeyLine label="CATALOG_ID" value={id} />
|
||||
<KeyLine label="CATALOG_KEY" value={key} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<form action={action}>
|
||||
<FieldGroup>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||
<Input id="name" name="name" required maxLength={64} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="url">Address</FieldLabel>
|
||||
<Input id="url" name="url" type="url" required maxLength={300} placeholder="https://" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
|
||||
<Input id="contact" name="contact" required maxLength={200} placeholder="Email or URL" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="visibility">Visibility</FieldLabel>
|
||||
<NativeSelect id="visibility" name="visibility" defaultValue="public" className="w-full">
|
||||
<NativeSelectOption value="public">Public</NativeSelectOption>
|
||||
<NativeSelectOption value="private">Private</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="membership">Membership</FieldLabel>
|
||||
<NativeSelect id="membership" name="membership" defaultValue="open" className="w-full">
|
||||
<NativeSelectOption value="open">Open</NativeSelectOption>
|
||||
<NativeSelectOption value="approval">By approval</NativeSelectOption>
|
||||
<NativeSelectOption value="invite">Invite only</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SubmitButton pendingLabel="Registering…">Register</SubmitButton>
|
||||
</div>
|
||||
{state?.error && <ErrorText>{state.error}</ErrorText>}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyLine({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="w-28 font-mono text-xs text-muted-foreground">{label}</span>
|
||||
<code className="min-w-0 rounded-md border bg-muted px-2 py-1 font-mono text-xs break-all text-foreground">
|
||||
{value}
|
||||
</code>
|
||||
<CopyButton text={value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,3 +42,13 @@ export async function setRole(_: ActionState, form: FormData): Promise<ActionSta
|
||||
return `Role set to ${person.role}.`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function decideCatalog(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
const decision = field(form, "decision") === "approve" ? "approve" : "reject";
|
||||
return attempt(async () => {
|
||||
unwrap(await api.console.catalogs({ id })({ decision }).post());
|
||||
revalidatePath("/console/catalogs");
|
||||
revalidatePath("/catalogs");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CatalogStatus, membership } from "@/components/catalogs";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { decideCatalog } from "../actions";
|
||||
import { ConsoleNav, requireModerator } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalogs" };
|
||||
|
||||
export default async function ConsoleCatalogs() {
|
||||
await requireModerator("/console/catalogs");
|
||||
const { catalogs } = unwrap(await api.console.catalogs.get());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Catalogs" />
|
||||
<ConsoleNav />
|
||||
<Card>
|
||||
<CardContent>
|
||||
{catalogs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None registered.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Takedown contact</TableHead>
|
||||
<TableHead>Access</TableHead>
|
||||
<TableHead>Registered</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{catalogs.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.url}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.contact}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={c.createdAt} dateOnly />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CatalogStatus status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status !== "approved" && (
|
||||
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
|
||||
Approve
|
||||
</SubmitButton>
|
||||
)}
|
||||
{c.status !== "rejected" && (
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
name="decision"
|
||||
value="reject"
|
||||
pendingLabel="Rejecting…"
|
||||
confirm={`Reject ${c.name}?`}
|
||||
>
|
||||
Reject
|
||||
</SubmitButton>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function ConsoleNav() {
|
||||
Overview
|
||||
</NavLink>
|
||||
<NavLink href="/console/people">People</NavLink>
|
||||
<NavLink href="/console/catalogs">Catalogs</NavLink>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { UploadCard } from "@/components/upload-card";
|
||||
import { getMe } from "@/lib/api";
|
||||
|
||||
export const metadata: Metadata = { title: "Create" };
|
||||
|
||||
export default async function Create() {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/create");
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Create a product">Upload it to a catalog. You can edit its info after.</PageHeader>
|
||||
<UploadCard title="Upload" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -128,4 +128,37 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Product descriptions: creators' HTML (cleaned by the API), laid out plainly. */
|
||||
@layer components {
|
||||
.product-description {
|
||||
@apply space-y-3 text-sm leading-relaxed break-words;
|
||||
}
|
||||
.product-description :where(h1, h2, h3, h4, h5, h6) {
|
||||
@apply font-semibold;
|
||||
}
|
||||
.product-description :where(h1) {
|
||||
@apply text-xl;
|
||||
}
|
||||
.product-description :where(h2) {
|
||||
@apply text-lg;
|
||||
}
|
||||
.product-description :where(a) {
|
||||
@apply underline underline-offset-4;
|
||||
}
|
||||
.product-description :where(ul) {
|
||||
@apply list-disc pl-5;
|
||||
}
|
||||
.product-description :where(ol) {
|
||||
@apply list-decimal pl-5;
|
||||
}
|
||||
.product-description :where(img) {
|
||||
@apply inline-block h-auto max-w-full;
|
||||
}
|
||||
.product-description :where(td, th) {
|
||||
@apply border px-2 py-1;
|
||||
}
|
||||
.product-description :where(hr) {
|
||||
@apply my-4;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { Empty, ProductCard } from "@/components/products";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { api, getMe, unwrap } from "@/lib/api";
|
||||
import { libraryAction } from "../products/actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Library" };
|
||||
|
||||
export default async function Library() {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/library");
|
||||
const { library } = await api.library.get().then(unwrap);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Your library">Products your T5DE uses, on all your IMVU accounts.</PageHeader>
|
||||
{library.length === 0 ? (
|
||||
<Empty>
|
||||
Nothing yet.{" "}
|
||||
<Link href="/products" className="text-foreground underline underline-offset-4">
|
||||
Browse products
|
||||
</Link>
|
||||
.
|
||||
</Empty>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{library.map(({ product, stayOn }) => (
|
||||
<li key={product.id}>
|
||||
<ProductCard product={product}>
|
||||
{stayOn !== null && <p className="text-xs">Staying on revision {stayOn}</p>}
|
||||
<ActionForm action={libraryAction} fields={{ id: product.id }} className="pt-1">
|
||||
<SubmitButton size="sm" variant="outline" name="intent" value="remove" pendingLabel="Removing…">
|
||||
Remove
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</ProductCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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