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

+26
View File
@@ -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;
}
}
+88
View File
@@ -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>
);
}
+79
View File
@@ -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&apos;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>
);
}
+10
View File
@@ -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");
});
}
+86
View File
@@ -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>
</>
);
}
+1
View File
@@ -17,6 +17,7 @@ export function ConsoleNav() {
Overview
</NavLink>
<NavLink href="/console/people">People</NavLink>
<NavLink href="/console/catalogs">Catalogs</NavLink>
</nav>
);
}
+18
View File
@@ -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" />
</>
);
}
+34 -1
View File
@@ -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;
}
}
+46
View File
@@ -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>
)}
</>
);
}
+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>
)}
</>
);
}