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
@@ -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'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 +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'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>
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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