Files
EpicNext-Cms/src/components/admin/studio/nitro-scale32-generator.tsx
T
openhands 4d720ee03c
CI / check (push) Successful in 2m30s
CI / deploy (push) Successful in 1m25s
CI / publish-container (push) Successful in 48s
fix(furni): generate scale 32 from bundles with .png frame keys
resolveNitroFrame now matches spritesheet frame keys that carry a .png
suffix or namespaced naming, and isScale/scaleName preserve that suffix.
Broken source sprites (missing frames or references to icon artwork) are
skipped and reported instead of aborting the whole generation, and the
studio UI surfaces the skipped count.
2026-09-12 12:46:17 +02:00

139 lines
3.6 KiB
TypeScript

"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;
skipped?: 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>
{preview.skipped ? (
<p className="text-sm text-destructive" role="alert">
{preview.skipped} sprite(s) were skipped because the source bundle
has no usable artwork for them.
</p>
) : null}
<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>
);
}