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]>
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>
|
|
);
|
|
}
|