feat(studio): localize catalog studio UI and polish responsive layout
CI / check (push) Failing after 43s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped

Route all Catalog Studio strings through the admin.studio namespace
(workspace, filters, review, inspector, quality, sync, scale32, organize
imports and page titles/subtitles) with English fallback, and add shared
StudioPage chrome plus a loading skeleton. Tighten mobile padding on the
header and sub-bars.
This commit is contained in:
openhands committed 2026-10-13 20:31:26 +02:00
1 parent d7d6382b60
commit 56676effc3
28 files changed
+1293 -634

No files matched your search

@@ -1,4 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
@@ -15,6 +16,7 @@ function kb(bytes: number): string {
}
function CompressionPanel({ report }: { report: CompressionReport }) {
const t = useTranslations("admin.studio.quality");
const { texture, json } = report,
// Differences that a renderer can actually see.
visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences,
@@ -26,20 +28,20 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
return (
<section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">
Compression · {texture.format.toUpperCase()}
{t("compressionTitle", { format: texture.format.toUpperCase() })}
{texture.format === "webp"
? texture.lossless
? " Lossless (VP8L)"
: " Lossy (VP8)"
? ` ${t("losslessVp8l")}`
: ` ${t("lossyVp8")}`
: ""}
</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Bundle</dt>
<dt className="text-muted-foreground">{t("bundle")}</dt>
<dd className="font-medium">{kb(report.bundleBytes)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Texture</dt>
<dt className="text-muted-foreground">{t("texture")}</dt>
<dd className="font-medium">
{kb(texture.compressedBytes)}
<span className="text-muted-foreground">
@@ -49,75 +51,85 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
</dd>
</div>
<div>
<dt className="text-muted-foreground">Metadata</dt>
<dt className="text-muted-foreground">{t("metadata")}</dt>
<dd className="font-medium">
{kb(json.compressedBytes)}
<span className="text-muted-foreground"> · ×{json.ratio}</span>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Container</dt>
<dt className="text-muted-foreground">{t("container")}</dt>
<dd className="font-medium">{kb(report.overheadBytes)}</dd>
</div>
</dl>
<p className="text-xs text-muted-foreground">
Texture {kb(texture.rawBytes)} decoded · deflate ×{texture.ratio} ·
sheet {texture.width}×{texture.height} · {texture.spriteCount} frames
{t("textureDetail", {
raw: kb(texture.rawBytes),
ratio: texture.ratio,
width: texture.width,
height: texture.height,
frames: texture.spriteCount,
})}
</p>
{texture.format === "png" ? (
<p className="text-xs">
Legacy PNG texture. Re-importing as WebP Lossless would store{" "}
<strong>{kb(texture.webpEstimate.bytes)}</strong>
{texture.webpEstimate.savingBytes > 0 ? (
<>
, saving <strong>{kb(texture.webpEstimate.savingBytes)}</strong> (
{texture.webpEstimate.bytes > 0
? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) * 100,
)
: 0}
% smaller)
</>
) : (
" (no gain on this bundle)"
)}
{texture.webpEstimate.savingBytes > 0
? t.rich("legacyPngSaving", {
size: kb(texture.webpEstimate.bytes),
saving: kb(texture.webpEstimate.savingBytes),
percent:
texture.webpEstimate.bytes > 0
? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) *
100,
)
: 0,
strong: (chunks) => <strong>{chunks}</strong>,
})
: t.rich("legacyPngNoGain", {
size: kb(texture.webpEstimate.bytes),
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
) : (
<p className="text-xs">
WebP Lossless stores {kb(texture.rawBytes)} against{" "}
{kb(texture.pngEstimate.bytes)} for the same artwork as PNG
{pngSaving > 0 ? (
<>
, saving <strong>{kb(pngSaving)}</strong> (
{texture.pngEstimate.bytes > 0
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
: 0}
% smaller)
</>
) : (
" (PNG is not larger on this bundle)"
)}
{pngSaving > 0
? t.rich("webpSaving", {
raw: kb(texture.rawBytes),
png: kb(texture.pngEstimate.bytes),
saving: kb(pngSaving),
percent:
texture.pngEstimate.bytes > 0
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
: 0,
strong: (chunks) => <strong>{chunks}</strong>,
})
: t("webpNoGain", {
raw: kb(texture.rawBytes),
png: kb(texture.pngEstimate.bytes),
})}
</p>
)}
<p className="text-xs">
{visibleDiffs === 0 ? (
<>
<span className="font-medium text-foreground">Pixel-exact</span> —
every visible texel round-trips unchanged.
{t.rich("pixelExact", {
strong: (chunks) => (
<span className="font-medium text-foreground">{chunks}</span>
),
})}
{texture.invisibleDifferences > 0 && (
<span className="text-muted-foreground">
{" "}
{texture.invisibleDifferences.toLocaleString()} fully
transparent texels carry no RGB, which VP8L discards and no
renderer can see.
{t("invisibleDiffs", {
count: texture.invisibleDifferences,
})}
</span>
)}
</>
) : (
<strong className="text-destructive">
{visibleDiffs.toLocaleString()} texel
{visibleDiffs === 1 ? "" : "s"} differ after a lossless round-trip —
the furniture may not render identically.
{t("texelsDiffer", { count: visibleDiffs })}
</strong>
)}
</p>
@@ -136,32 +148,34 @@ function ScalePanel({
}) {
const [asset, setAsset] = useState(report.assets[0] ?? ""),
[failed, setFailed] = useState(false);
const t = useTranslations("admin.studio.quality");
const stateLabel =
report.state === "missing"
? t("stateMissing")
: report.state === "issues"
? t("stateIssues")
: report.derived
? t("stateDerived")
: t("statePresent");
return (
<section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">
Scale {report.size} ·{" "}
{report.state === "missing"
? "Not included"
: report.state === "issues"
? "Needs review"
: report.derived
? "Derived from 64"
: "Present"}
{t("scaleTitle", { size: report.size, state: stateLabel })}
</h3>
<p className="text-xs text-muted-foreground">
{report.assets.length} sprites · {report.animations.length} animation
definitions · directions {report.directions.join(", ") || "—"}
{t("spritesLine", {
sprites: report.assets.length,
animations: report.animations.length,
directions: report.directions.join(", ") || "—",
})}
</p>
{report.state === "missing" && (
<p className="text-sm">
This bundle contains no sprites at this scale. Missing scale 32 does
not by itself mean the furniture is unusable.
</p>
<p className="text-sm">{t("missingScale")}</p>
)}
{report.assets.length > 0 && (
<>
<label className="block text-sm">
Sprite
{t("sprite")}
<select
className="mt-1 block w-full rounded border bg-background p-2"
value={asset}
@@ -179,7 +193,7 @@ function ScalePanel({
</label>
<div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30">
{failed ? (
<p role="alert">This sprite could not be rendered.</p>
<p role="alert">{t("renderFailed")}</p>
) : (
<img
key={`${asset}-${revision}`}
@@ -210,6 +224,7 @@ function ScalePanel({
);
}
export function NitroQualityPanel({ classname }: { classname: string }) {
const t = useTranslations("admin.studio.quality");
const [report, setReport] = useState<NitroQuality | null>(null),
[error, setError] = useState(""),
[revision, setRevision] = useState(0);
@@ -224,35 +239,32 @@ export function NitroQualityPanel({ classname }: { classname: string }) {
{ signal: controller.signal },
);
const data = await response.json();
if (!response.ok) throw Error(data.error || "Inspection failed");
if (!response.ok) throw Error(data.error || t("inspectionFailed"));
if (!controller.signal.aborted) setReport(data.report);
} catch (error) {
if (!controller.signal.aborted)
setError(
error instanceof Error ? error.message : "Inspection failed",
error instanceof Error ? error.message : t("inspectionFailed"),
);
}
})();
return () => controller.abort();
}, [classname, revision]);
}, [classname, revision, t]);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm">
Inspect the saved bundle at both scales. Previews show individual
sprites, not the assembled furniture or animation playback.
</p>
<p className="text-sm">{t("intro")}</p>
<Button
variant="outline"
onClick={() => setRevision((value) => value + 1)}
>
Recheck
{t("recheck")}
</Button>
</div>
{error ? (
<p role="alert">{error}</p>
) : !report ? (
<p role="status">Checking scales and texture references…</p>
<p role="status">{t("checking")}</p>
) : (
<div className="space-y-3">
{report.compression && (