Website: products, library, catalogs and their console page
/products (search), product pages with revisions, getting and staying on a revision, retracting; /products/:id/edit; /create and new revisions upload from the browser straight to the catalog with a token from the hub; /library; /catalogs (directory, registering with a once-shown key); /console/catalogs to approve or reject. Every page now sends a nonce-based CSP (proxy.ts), so product descriptions can't run scripts. buttonVariants now merges classes, so outline links get their border. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b4f6fce9df
commit
51d5bb5ff3
24 files changed
+1064
-5
No files matched your search
@@ -0,0 +1,119 @@
|
||||
"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") {
|
||||
router.push(`/products/${value.product}`);
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user