feat(catalog): generate derived scale 32 with preview and backup restore
CI / check (push) Successful in 1m32s
CI / deploy (push) Successful in 1m12s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 17:36:22 +02:00
1 parent 08b0b5021e
commit cd75361b9f
9 files changed
+749 -11

No files matched your search

@@ -23,7 +23,9 @@ function ScalePanel({
? "Not included"
: report.state === "issues"
? "Needs review"
: "Present"}
: report.derived
? "Derived from 64"
: "Present"}
</h3>
<p className="text-xs text-muted-foreground">
{report.assets.length} sprites · {report.animations.length} animation
@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
export function NitroScale32Generator({
classname,
disabled,
onChanged,
}: {
classname: string;
disabled: boolean;
onChanged: (metadata: Record<string, unknown>) => void;
}) {
const [preview, setPreview] = useState<{
hash: string;
before: string;
after: string;
count: number;
} | null>(null),
[undo, setUndo] = useState<{ hash: string; backupHash: string } | null>(
null,
),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
async function run(action: "preview" | "apply" | "restore") {
setBusy(true);
setError("");
try {
const response = await adminFetch("/api/admin/studio/nitro-scale32", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classname,
action,
expectedHash: action === "restore" ? undo?.hash : preview?.hash,
backupHash: undo?.backupHash,
}),
});
const data = await response.json();
if (!response.ok) throw Error(data.error || "Generation failed");
if (action === "preview") setPreview(data);
else {
setPreview(null);
setUndo(
action === "apply"
? { hash: data.hash, backupHash: data.backupHash }
: null,
);
onChanged(data.metadata);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Generation failed");
} finally {
setBusy(false);
}
}
return (
<section className="space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">Generate scale 32 from 64</h3>
<p className="text-sm text-muted-foreground">
Adds derived artwork using nearest-neighbor reduction. Existing scale 32
is preserved. Preview uses the saved bundle and shows one sprite at its
original pixel size.
</p>
{disabled && (
<p className="text-sm">Save or reset your pending edits first.</p>
)}
<Button
variant="outline"
disabled={busy || disabled}
onClick={() => run("preview")}
>
{busy ? "Working…" : "Preview generation"}
</Button>
{preview && (
<>
<p className="text-sm">
{preview.count} sprite entries will be added. Check positioning and
animations in the client after saving.
</p>
<div className="grid grid-cols-2 gap-3">
{[
["Original 64", preview.before],
["Derived 32", preview.after],
].map(([label, src]) => (
<figure key={label} className="min-w-0">
<figcaption>{label}</figcaption>
<div className="overflow-auto p-3">
<img
src={src}
alt={label}
style={{ imageRendering: "pixelated", maxWidth: "none" }}
/>
</div>
</figure>
))}
</div>
<Button disabled={busy || disabled} onClick={() => run("apply")}>
Save with backup
</Button>
<Button
variant="ghost"
disabled={busy}
onClick={() => setPreview(null)}
>
Discard preview
</Button>
</>
)}
{undo && (
<div>
<p className="text-sm">
Saved. A copy of the original bundle is preserved.
</p>
<Button
variant="outline"
disabled={busy || disabled}
onClick={() => run("restore")}
>
Restore original bundle
</Button>
</div>
)}
{error && (
<p role="alert" className="text-sm">
{error}
</p>
)}
</section>
);
}