feat: product drafts, moderator edits with reasons and locks, info history, unlisted products and tags
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m4s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

New uploads are drafts: only their creator and moderators see them, nobody can get or wear them, and they're
deleted if not published within 30 days (their catalogs get delete changes). The creator reviews the info and
publishes it.

Editing a product's info is a permission check now (product:edit): moderators can edit anyone's with a reason the
creator sees, lock fields (product:lock; locked fields need product:edit-locked) and lower ratings
(product:rate-lower, not below its files'). Every change is kept in the product's info history, with who and why,
and can be undone by the same rules.

Products can be unlisted (out of search and lists, the link still works) and tagged; lists filter by tag and
catalog. The console has a Products section (everyone's for moderators, by status) and a drafts to-do.

A product's own category is left for later: the hub has no list of IMVU's categories to pick from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-05 05:40:43 -08:00
1 parent 2cbedd26fc
commit 8793b5afd8
24 files changed
+4106 -96

No files matched your search

+2 -1
View File
@@ -32,9 +32,10 @@ export async function requireConsole(path: string): Promise<{ me: Person; consol
type Section = ConsoleData["sections"][number];
// Products are managed on their own pages for now; settings are on the overview.
// Settings are on the overview.
const tabs: { section: Section; href: string; label: string }[] = [
{ section: "overview", href: "/console", label: "Overview" },
{ section: "products", href: "/console/products", label: "Products" },
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
{ section: "people", href: "/console/people", label: "People" },
{ section: "audit", href: "/console/audit", label: "Audit" },
+2 -1
View File
@@ -19,6 +19,7 @@ export default async function Console() {
const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
const todo: Stat[] = [
{ label: "Drafts", value: data.todo.drafts, note: "to review and publish", href: "/console/products?status=draft" },
{ label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
];
if (data.moderation) {
@@ -28,7 +29,7 @@ export default async function Console() {
);
}
const mine: Stat[] = [
{ label: "Your Products", value: data.mine.products, note: "you created", href: `/products?creator=${encodeURIComponent(me.username)}` },
{ label: "Your Products", value: data.mine.products, note: "you created", href: "/console/products" },
{ label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" },
{ label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" },
];
+130
View File
@@ -0,0 +1,130 @@
import type { Metadata } from "next";
import Link from "next/link";
import { RatingTag, StateTags } from "@/components/products";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Products" };
const statuses = [
["", "Any status"],
["draft", "Drafts"],
["published", "Published"],
["unlisted", "Unlisted"],
["locked", "Locked"],
["deleted", "Deleted"],
["removed", "Removed"],
] as const;
type Status = Exclude<(typeof statuses)[number][0], "">;
export default async function ConsoleProducts({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
await requireConsole("/console/products");
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.trim().slice(0, 100) : "";
const status = statuses.find(([s]) => s && s === params.status)?.[0] as Status | undefined;
const all = params.all === "1";
const data = unwrap(
await api.console.products.get({
query: { ...(q ? { q } : {}), ...(status ? { status } : {}), ...(all ? { all: "1" as const } : {}) },
}),
);
const filtered = q !== "" || status !== undefined;
return (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Products">
{data.all ? "Everyone's products, drafts and removed ones included." : "Your products, drafts included."}
</PageHeader>
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
New product
</Link>
</div>
<ConsoleNav />
<form action="/console/products" className="mb-6 flex flex-wrap gap-2">
<Input
name="q"
defaultValue={q}
placeholder={data.all ? "Name, ID or creator" : "Name or ID"}
aria-label="Search"
className="max-w-xs"
/>
<NativeSelect name="status" defaultValue={status ?? ""} aria-label="Status" className="w-40">
{statuses.map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
{data.moderator && (
<NativeSelect name="all" defaultValue={all ? "1" : ""} aria-label="Whose" className="w-40">
<NativeSelectOption value="">Yours</NativeSelectOption>
<NativeSelectOption value="1">Everyone&apos;s</NativeSelectOption>
</NativeSelect>
)}
<Button type="submit">Filter</Button>
{(filtered || all) && (
<Link href="/console/products" className={buttonVariants({ variant: "outline" })}>
Clear
</Link>
)}
</form>
<Card>
<CardContent>
{data.products.length === 0 ? (
<p className="text-sm text-muted-foreground">{filtered ? "Nothing matches." : "None yet."}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{data.all && <TableHead>Creator</TableHead>}
<TableHead>State</TableHead>
<TableHead className="text-right">Libraries</TableHead>
<TableHead>Changed</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{data.products.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
{p.name}
</Link>
</TableCell>
{data.all && <TableCell className="text-muted-foreground">{p.creator}</TableCell>}
<TableCell>
<span className="flex flex-wrap gap-1">
<StateTags product={p} />
<RatingTag rating={p.rating} />
</span>
</TableCell>
<TableCell className="text-right tabular-nums">{p.libraries}</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={p.updatedAt} dateOnly />
</TableCell>
<TableCell className="text-right">
{!p.removed && (
<Link href={`/products/${p.id}/edit`} className={buttonVariants({ size: "sm", variant: "outline" })}>
{p.status === "draft" ? "Review" : "Edit"}
</Link>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
);
}
+234 -23
View File
@@ -2,44 +2,95 @@ 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 { StateTags } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, 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 { api, getMe, unwrap, type InfoEntry } from "@/lib/api";
import { publishProduct, revertInfo, saveLocks, saveProduct } from "../../actions";
import { loadProduct } from "../../load";
export const metadata: Metadata = { title: "Edit product" };
const fieldNames: Record<string, string> = {
name: "Name",
description: "Description",
rating: "Rating",
tags: "Tags",
listed: "Listed",
locked: "Locks",
status: "Status",
};
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();
const { product, mine, permissions } = await loadProduct(id);
const can = (p: string) => (permissions as string[]).includes(p);
if (!can("product:edit")) notFound();
if (mine && product.deleted) redirect(`/products/${id}`);
const { entries } = unwrap(await api.products({ id }).history.get());
const editLocked = can("product:edit-locked");
const locked = (f: string) => product.locked.includes(f) && !editLocked;
const draft = product.status === "draft";
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 title={draft ? `Review ${product.name}` : `Edit ${product.name}`}>
<span className="flex flex-wrap items-center gap-2">
<StateTags product={product} />
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
{draft ? "Preview its page" : "Back to the product"}
</Link>
</span>
</PageHeader>
{draft && (
<Card>
<CardHeader>
<CardTitle>Draft</CardTitle>
<CardDescription>
Only you and moderators see it. Check its info below, then publish it so people can get it. Drafts not
published within 30 days are deleted.
</CardDescription>
</CardHeader>
{can("product:publish") && (
<CardContent>
<ActionForm action={publishProduct} fields={{ id }}>
<SubmitButton pendingLabel="Publishing…">Publish</SubmitButton>
</ActionForm>
</CardContent>
)}
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Info</CardTitle>
{!mine && <CardDescription>You&apos;re editing someone else&apos;s product: they see your reason.</CardDescription>}
</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" />
<Input
id="name"
name="name"
defaultValue={product.name}
required
maxLength={64}
disabled={locked("name")}
className="max-w-md"
/>
{locked("name") && <LockedNote />}
</Field>
<Field>
<FieldLabel htmlFor="description">Description</FieldLabel>
@@ -49,22 +100,69 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
defaultValue={product.description}
maxLength={20000}
rows={8}
disabled={locked("description")}
className="font-mono text-xs md:text-xs"
/>
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription>
{locked("description") ? (
<LockedNote />
) : (
<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>
<FieldLabel htmlFor="tags">Tags</FieldLabel>
<Input
id="tags"
name="tags"
defaultValue={product.tags.join(", ")}
maxLength={300}
disabled={locked("tags")}
placeholder="formal, wool, winter"
className="max-w-md"
/>
{locked("tags") ? <LockedNote /> : <FieldDescription>Up to 10, separated by commas.</FieldDescription>}
</Field>
<div className="grid gap-6 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
{locked("rating") || (product.rating === "adult" && !can("product:rate-lower")) ? (
<p className="text-sm capitalize">{product.rating}</p>
) : (
<NativeSelect id="rating" name="rating" defaultValue={product.rating} className="w-48">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult">Adult</NativeSelectOption>
</NativeSelect>
)}
{locked("rating") ? (
<LockedNote />
) : (
<FieldDescription>Raising it to adult is immediate; lowering it needs a moderator.</FieldDescription>
)}
</Field>
<Field>
<FieldLabel htmlFor="listed">Listing</FieldLabel>
{locked("listed") ? (
<p className="text-sm">{product.listed ? "Listed" : "Unlisted"}</p>
) : (
<NativeSelect id="listed" name="listed" defaultValue={product.listed ? "yes" : "no"} className="w-48">
<NativeSelectOption value="yes">Listed</NativeSelectOption>
<NativeSelectOption value="no">Unlisted</NativeSelectOption>
</NativeSelect>
)}
{locked("listed") ? (
<LockedNote />
) : (
<FieldDescription>Unlisted: left out of search and lists; its link still works.</FieldDescription>
)}
</Field>
</div>
{!mine && (
<Field>
<FieldLabel htmlFor="reason">Reason</FieldLabel>
<Input id="reason" name="reason" required maxLength={500} className="max-w-md" />
<FieldDescription>Shown to the creator in the product&apos;s history.</FieldDescription>
</Field>
)}
<div>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div>
@@ -73,7 +171,120 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
</CardContent>
</Card>
<UploadCard title="Upload a new revision" product={id} />
{can("product:lock") && (
<Card>
<CardHeader>
<CardTitle>Locks</CardTitle>
<CardDescription>Locked fields can only be changed by moderators.</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={saveLocks} fields={{ id }}>
<div className="flex flex-wrap gap-4">
{(["name", "description", "rating", "tags", "listed"] as const).map((f) => (
<label key={f} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="locked"
value={f}
defaultChecked={product.locked.includes(f)}
className="size-4 accent-foreground"
/>
{fieldNames[f]}
</label>
))}
</div>
<div className="flex flex-wrap gap-2">
<Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
<SubmitButton variant="outline" pendingLabel="Saving…">
Save locks
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
<CardDescription>Every change to its info, with who made it and why.</CardDescription>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">No changes yet.</p>
) : (
<ul className="divide-y">
{entries.map((e) => (
<HistoryRow key={e.id} id={id} entry={e} mine={mine} canRevert={can("product:edit")} />
))}
</ul>
)}
</CardContent>
</Card>
{mine && <UploadCard title="Upload a new revision" product={id} />}
</div>
);
}
function LockedNote() {
return <FieldDescription>Locked by a moderator.</FieldDescription>;
}
/** A value as the history shows it. */
function shown(field: string, v: unknown): string {
if (field === "listed") return v ? "listed" : "unlisted";
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
if (typeof v === "string") return v || "empty";
return String(v);
}
function HistoryRow({ id, entry: e, mine, canRevert }: { id: string; entry: InfoEntry; mine: boolean; canRevert: boolean }) {
const what =
e.kind === "publish" ? "published it" : e.kind === "lock" ? "changed the locks" : e.kind === "revert" ? "undid a change" : "edited";
return (
<li className="space-y-2 py-4 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{e.by || "Someone"}</span>
{!e.byCreator && <Tag>Moderator</Tag>}
<span className="text-muted-foreground">{what}</span>
<span className="text-muted-foreground">
<DateTime iso={e.at} />
</span>
</div>
{e.reason && <p className="text-sm">Reason: {e.reason}</p>}
{e.kind !== "publish" && (
<ul className="space-y-1 text-sm text-muted-foreground">
{Object.entries(e.changes).map(([field, c]) => (
<li key={field}>
<span className="text-foreground">{fieldNames[field] ?? field}</span>
{field === "description" ? (
<details className="mt-1">
<summary className="cursor-pointer hover:text-foreground">Before and after</summary>
<pre className="mt-1 max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">
{shown(field, c.from)}
</pre>
<pre className="mt-1 max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">
{shown(field, c.to)}
</pre>
</details>
) : (
<>
: {shown(field, c.from)} → {shown(field, c.to)}
</>
)}
</li>
))}
</ul>
)}
{canRevert && (e.kind === "edit" || e.kind === "revert") && (
<ActionForm action={revertInfo} fields={{ id, entry: e.id }} className="flex flex-wrap items-center gap-2 space-y-0">
{!mine && <Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="h-8 max-w-xs" />}
<SubmitButton size="sm" variant="outline" pendingLabel="Undoing…">
Undo
</SubmitButton>
</ActionForm>
)}
</li>
);
}
+42 -7
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { formatSize, ProductImage, RatingTag } from "@/components/products";
import { formatSize, ProductImage, RatingTag, StateTags } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { buttonVariants } from "@/components/ui/button";
@@ -19,8 +19,9 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
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, available, catalogs } = data;
const { product, revisions, inLibrary, stayOn, mine, available, catalogs, permissions } = data;
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
const draft = product.status === "draft";
return (
<div className="space-y-6">
@@ -28,10 +29,30 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
<span className="flex flex-wrap items-center gap-2">
{product.creator && <span>by {product.creator}</span>}
<RatingTag rating={product.rating} />
{product.deleted && <Tag tone="danger">deleted</Tag>}
{/* Its state (draft, unlisted, locks) is for those who manage it; anyone may see that it's deleted. */}
{permissions.includes("product:edit") ? (
<StateTags product={product} />
) : (
product.deleted && <Tag tone="danger">Deleted</Tag>
)}
</span>
</PageHeader>
{draft && (
<p className="text-sm text-muted-foreground">
A draft: only {mine ? "you" : "its creator"} and moderators see it, and nobody can get it until it&apos;s
published.{" "}
{permissions.includes("product:publish") && (
<Link href={`/products/${id}/edit`} className="font-medium text-foreground underline-offset-4 hover:underline">
Review and publish
</Link>
)}
</p>
)}
{!draft && !product.listed && mine && (
<p className="text-sm text-muted-foreground">Unlisted: it&apos;s not in search or lists, but anyone with its link can get it.</p>
)}
{product.deleted && (
<p className="text-sm text-muted-foreground">
{mine ? "You deleted this product." : "Its creator deleted this product."} People who have it keep it.
@@ -51,8 +72,20 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
</div>
)}
{product.tags.length > 0 && (
<ul className="flex flex-wrap gap-1.5" aria-label="Tags">
{product.tags.map((t) => (
<li key={t}>
<Link href={`/products?tag=${encodeURIComponent(t)}`} className="hover:opacity-80">
<Tag>{t}</Tag>
</Link>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-2">
{product.deleted && !inLibrary ? null : me ? (
{draft || (product.deleted && !inLibrary) ? null : me ? (
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{inLibrary ? (
@@ -88,11 +121,13 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
Sign in to get it
</Link>
)}
{permissions.includes("product:edit") && !(mine && product.deleted) && (
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
{mine ? "Edit" : "Edit info"}
</Link>
)}
{mine && !product.deleted && (
<>
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
Edit
</Link>
<ActionForm action={deleteProduct} fields={{ id }}>
<SubmitButton
variant="outline"
+61 -5
View File
@@ -64,24 +64,80 @@ export async function retract(_: ActionState, form: FormData): Promise<ActionSta
});
}
/** Saves a product's info. Fields left out of the form (locked ones are disabled) aren't changed. */
export async function saveProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const has = (name: string) => form.has(name);
const rating = field(form, "rating");
const state = await attempt(async () => {
unwrap(
const listed = field(form, "listed");
return attempt(async () => {
const { fields } = 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 } : {}),
...(has("name") ? { name: field(form, "name") } : {}),
...(has("description") ? { description: String(form.get("description") ?? "") } : {}),
...(rating === "adult" || rating === "general" ? { rating } : {}),
...(has("tags")
? {
tags: field(form, "tags")
.split(",")
.map((t) => t.trim())
.filter(Boolean),
}
: {}),
...(listed === "yes" || listed === "no" ? { listed: listed === "yes" } : {}),
...(field(form, "reason") ? { reason: field(form, "reason") } : {}),
}),
);
done(id);
revalidatePath("/products");
revalidatePath(`/products/${id}/edit`);
return fields.length ? "Saved." : "Nothing changed.";
});
}
export async function publishProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).publish.post());
done(id);
revalidatePath("/products");
revalidatePath("/console/products");
});
if (state?.error) return state;
redirect(`/products/${id}`);
}
/** Moderators lock fields: the checked ones are locked, the rest unlocked. */
export async function saveLocks(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const fields = form.getAll("locked").filter((v): v is "name" | "description" | "rating" | "tags" | "listed" =>
["name", "description", "rating", "tags", "listed"].includes(String(v)),
);
return attempt(async () => {
unwrap(await api.products({ id }).locks.put({ fields, reason: field(form, "reason") }));
done(id);
revalidatePath(`/products/${id}/edit`);
return "Locks saved.";
});
}
/** Puts back what a history entry changed. */
export async function revertInfo(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const reason = field(form, "reason");
unwrap(
await api
.products({ id })
.history({ entry: Number(field(form, "entry")) })
.revert.post(reason ? { reason } : {}),
);
done(id);
revalidatePath(`/products/${id}/edit`);
return "Undone.";
});
}
/** An upload token and the catalog's upload address, for the browser to send the file there itself. */
export async function startUpload(
catalog: string,
+1 -1
View File
@@ -13,7 +13,7 @@ export default async function NewProduct() {
return (
<div className="space-y-6">
<PageHeader title="New product">
Upload it to a catalog. You can edit its info after.{" "}
Upload it to a catalog. Then check its info and publish it.{" "}
<Link href="/products" className="underline-offset-4 hover:underline">
Back to products
</Link>
+10 -4
View File
@@ -11,21 +11,26 @@ export const metadata: Metadata = { title: "Products" };
export default async function Products({
searchParams,
}: {
searchParams: Promise<{ q?: string | string[]; creator?: string | string[] }>;
searchParams: Promise<{ q?: string | string[]; creator?: string | string[]; tag?: string | string[] }>;
}) {
const params = await searchParams;
const one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
const q = one(params.q, 100);
const creator = one(params.creator, 64);
const tag = one(params.tag, 24);
const [me, { products }] = await Promise.all([
getMe(),
api.products.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}) } }).then(unwrap),
api.products
.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}), ...(tag ? { tag } : {}) } })
.then(unwrap),
]);
return (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Products">{creator ? `By ${creator}.` : "Community products for T5DE."}</PageHeader>
<PageHeader title="Products">
{[creator && `By ${creator}.`, tag && `Tagged “${tag}”.`].filter(Boolean).join(" ") || "Community products for T5DE."}
</PageHeader>
{me && (
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
New product
@@ -35,12 +40,13 @@ export default async function Products({
<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" />
{creator && <input type="hidden" name="creator" value={creator} />}
{tag && <input type="hidden" name="tag" value={tag} />}
<Button type="submit" variant="outline">
Search
</Button>
</form>
{products.length === 0 ? (
<Empty>{q ? "Nothing found." : "No products yet."}</Empty>
<Empty>{q || tag || creator ? "Nothing found." : "No products yet."}</Empty>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (