feat: product drafts, moderator edits, info history, unlisted products, tags and categories (#8)
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:
1 parent
2cbedd26fc
commit
e07a6c6dac
24 files changed
+4248
-95
No files matched your search
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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're editing someone else'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's filed in the shop and inventory: any category T5DE has seen, within its parent'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in new issue
Block a user