feat: choose a product's category from the ones T5DE has seen
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m8s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

A product can be filed under another IMVU category than its parent's: any category path the hub has seen on a
revision's parent (and each step of it), within the parent's top-level category, so it stays the same kind of
product (the proxy tells clothing, furniture and rooms apart by category). It's an info field like the others:
lockable, in the history, undone by the same rules. Lists filter by the chosen path, and products show it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-05 06:00:04 -08:00
1 parent 8793b5afd8
commit ceceb69eaf
12 files changed
+165 -22

No files matched your search

+41 -2
View File
@@ -23,6 +23,7 @@ const fieldNames: Record<string, string> = {
rating: "Rating",
tags: "Tags",
listed: "Listed",
category: "Category",
locked: "Locks",
status: "Status",
};
@@ -35,7 +36,11 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
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 [{ 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;
@@ -122,6 +127,38 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
/>
{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="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>
)}
{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>
@@ -180,7 +217,7 @@ export default async function EditProduct({ params }: { params: Promise<{ id: st
<CardContent>
<ActionForm action={saveLocks} fields={{ id }}>
<div className="flex flex-wrap gap-4">
{(["name", "description", "rating", "tags", "listed"] as const).map((f) => (
{(["name", "description", "rating", "tags", "listed", "category"] as const).map((f) => (
<label key={f} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
@@ -234,6 +271,8 @@ function LockedNote() {
/** 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);
+6 -3
View File
@@ -85,6 +85,7 @@ export async function saveProduct(_: ActionState, form: FormData): Promise<Actio
}
: {}),
...(listed === "yes" || listed === "no" ? { listed: listed === "yes" } : {}),
...(has("category") ? { category: Number(field(form, "category")) || null } : {}),
...(field(form, "reason") ? { reason: field(form, "reason") } : {}),
}),
);
@@ -110,9 +111,11 @@ export async function publishProduct(_: ActionState, form: FormData): Promise<Ac
/** 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)),
);
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);