feat: product drafts, moderator edits, info history, unlisted products, tags and categories (#8)
Release / release (push) Successful in 13s
CI / test (push) Successful in 1m5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 4s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 16s

Phase 2 of the plan in `docs/design.md` ("Next"): product info.

**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.

**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.

**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.

**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).

**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.

**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.

**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.

**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.

**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 2 of the plan in `docs/design.md` ("Next"): product info.

**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.

**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.

**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.

**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).

**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.

**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.

**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.

**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.

**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
This commit was merged in pull request #8.
This commit is contained in:
dhkatz authored and dhkatz committed 2026-11-05 14:23:26 +00:00
1 parent 2cbedd26fc
commit e07a6c6dac
24 files changed
+4248 -95

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>
</>
);
}
+270 -20
View File
@@ -2,44 +2,100 @@ 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",
category: "Category",
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 }, { choices, parent }] = await Promise.all([
api.products({ id }).history.get().then(unwrap),
api.products({ id }).categories.get().then(unwrap),
]);
const path = (c: { name: string }[]) => c.map((x) => x.name).join(" › ");
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 +105,101 @@ 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>
<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>
<Field>
<FieldLabel htmlFor="category">Category</FieldLabel>
{locked("category") || choices.length === 0 ? (
<p className="text-sm">{product.categories.length ? path(product.categories) : "None known"}</p>
) : (
<NativeSelect id="rating" name="rating" defaultValue="general" className="w-48">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult">Adult</NativeSelectOption>
<NativeSelect
id="category"
name="category"
defaultValue={String(product.category?.at(-1)?.id ?? "")}
className="w-full max-w-md"
>
<NativeSelectOption value="">
{parent.length ? `Its parent's: ${path(parent)}` : "None"}
</NativeSelectOption>
{choices
.filter((c) => c.at(-1)!.id !== parent.at(-1)?.id)
.map((c) => (
<NativeSelectOption key={c.at(-1)!.id} value={String(c.at(-1)!.id)}>
{path(c)}
</NativeSelectOption>
))}
</NativeSelect>
)}
<FieldDescription>Raising it to adult is immediate; lowering it needs review.</FieldDescription>
{locked("category") ? (
<LockedNote />
) : (
<FieldDescription>
Where it&apos;s filed in the shop and inventory: any category T5DE has seen, within its parent&apos;s
kind of product.
</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 +208,122 @@ 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", "category"] 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 (field === "category")
return Array.isArray(v) ? v.map((c: { name?: string }) => c.name ?? "").join(" › ") : "its parent's";
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"
+64 -5
View File
@@ -64,24 +64,83 @@ 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" } : {}),
...(has("category") ? { category: Number(field(form, "category")) || null } : {}),
...(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" | "category" =>
["name", "description", "rating", "tags", "listed", "category"].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) => (