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]>
121 lines
4.6 KiB
TypeScript
121 lines
4.6 KiB
TypeScript
"use client";
|
|
// Uploading a product (or a new revision of one): the hub gives an upload token, then the browser sends the file to
|
|
// the catalog itself, so files never pass through the website's server.
|
|
import { useRouter } from "next/navigation";
|
|
import { useState } from "react";
|
|
import { ErrorText } from "@/components/forms";
|
|
import { Button } from "@/components/ui/button";
|
|
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 { startUpload } from "@/app/products/actions";
|
|
|
|
type Option = { id: string; name: string };
|
|
|
|
/** Sends form to url with the token, reporting progress; resolves with the catalog's status and JSON answer. */
|
|
function send(url: string, token: string, form: FormData, progress: (fraction: number) => void) {
|
|
return new Promise<{ status: number; body: unknown }>((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("POST", url);
|
|
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
|
xhr.upload.onprogress = (e) => e.lengthComputable && progress(e.loaded / e.total);
|
|
xhr.onload = () => {
|
|
let body: unknown = null;
|
|
try {
|
|
body = JSON.parse(xhr.responseText);
|
|
} catch {}
|
|
resolve({ status: xhr.status, body });
|
|
};
|
|
xhr.onerror = () => reject(new Error("network"));
|
|
xhr.send(form);
|
|
});
|
|
}
|
|
|
|
export function UploadForm({ catalogs, product }: { catalogs: Option[]; product?: string }) {
|
|
const router = useRouter();
|
|
const [error, setError] = useState("");
|
|
const [progress, setProgress] = useState<number | null>(null);
|
|
|
|
async function submit(form: FormData) {
|
|
setError("");
|
|
setProgress(0);
|
|
const start = await startUpload(String(form.get("catalog")), product);
|
|
if ("error" in start) {
|
|
setError(start.error);
|
|
setProgress(null);
|
|
return;
|
|
}
|
|
const body = new FormData();
|
|
body.set("file", form.get("file") as File);
|
|
body.set("notes", String(form.get("notes") ?? ""));
|
|
try {
|
|
const answer = await send(start.url, start.token, body, setProgress);
|
|
const value = answer.body as { product?: unknown; message?: unknown } | null;
|
|
if (answer.status === 201 && typeof value?.product === "string") {
|
|
// A new product is a draft: on to reviewing its info and publishing it.
|
|
router.push(product ? `/products/${value.product}` : `/products/${value.product}/edit`);
|
|
router.refresh();
|
|
return;
|
|
}
|
|
setError(
|
|
typeof value?.message === "string" && value.message
|
|
? value.message
|
|
: `The catalog refused the upload (${answer.status}).`,
|
|
);
|
|
} catch {
|
|
setError("Couldn't reach the catalog. Try again in a moment.");
|
|
}
|
|
setProgress(null);
|
|
}
|
|
|
|
const busy = progress !== null;
|
|
return (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void submit(new FormData(e.currentTarget));
|
|
}}
|
|
>
|
|
<FieldGroup>
|
|
<Field>
|
|
<FieldLabel htmlFor="catalog">Catalog</FieldLabel>
|
|
<NativeSelect id="catalog" name="catalog" required className="w-full max-w-sm">
|
|
{catalogs.map((c) => (
|
|
<NativeSelectOption key={c.id} value={c.id}>
|
|
{c.name}
|
|
</NativeSelectOption>
|
|
))}
|
|
</NativeSelect>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="file">File</FieldLabel>
|
|
<Input id="file" name="file" type="file" accept=".chkn,.zip" required className="max-w-sm" />
|
|
<FieldDescription>A Create Mode .chkn, or a zip of the product's files.</FieldDescription>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="notes">{product ? "What changed" : "Notes"}</FieldLabel>
|
|
<Textarea id="notes" name="notes" required={!!product} maxLength={4000} rows={3} />
|
|
</Field>
|
|
<div className="flex items-center gap-3">
|
|
<Button type="submit" disabled={busy}>
|
|
{busy ? "Uploading…" : "Upload"}
|
|
</Button>
|
|
{busy && (
|
|
<div
|
|
className="h-1.5 w-40 overflow-hidden rounded-full bg-muted"
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={Math.round(progress * 100)}
|
|
>
|
|
<div className="h-full bg-foreground transition-[width]" style={{ width: `${progress * 100}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{error && <ErrorText>{error}</ErrorText>}
|
|
</FieldGroup>
|
|
</form>
|
|
);
|
|
}
|