feat: product drafts, moderator edits with reasons and locks, info history, unlisted products and tags
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:
1 parent
2cbedd26fc
commit
8793b5afd8
24 files changed
+4106
-96
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user