feat(studio): localize catalog studio UI and polish responsive layout
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:
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 && (
|
||||
|
||||
Reference in new issue
Block a user