feat: choose a product's category from the ones T5DE has seen
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:
1 parent
8793b5afd8
commit
ceceb69eaf
12 files changed
+165
-22
No files matched your search
@@ -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'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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user