1 Commits
Author SHA1 Message Date
remco 1fe2d7860e Add renovate.json
CI / check (pull_request) Failing after 24s
CI / tests-unit (pull_request) Skipped
CI / tests-integration (pull_request) Skipped
CI / tests-ui (pull_request) Skipped
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-10-13 14:00:09 +00:00
86 changed files with 768 additions and 17230 deletions

No files matched your search

-7
View File
@@ -89,13 +89,6 @@ DB_BACKUP_KEEP=7
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
HEALTH_ALERT_COOLDOWN_MIN=15
# --- FURNITURE ASSETS ---
# Extension the local Nitro client requests for furniture bundles. The CMS
# writes new/imported/repaired bundles with this extension and prefers it when
# resolving existing ones (the other stays readable as a legacy fallback).
# ".hab" (default) or ".nitro".
FURNI_BUNDLE_EXT=.hab
# --- RCON (Low Latency Loop) ---
RCON_HOST=127.0.0.1
RCON_PORT=3003
-29
View File
@@ -116,35 +116,6 @@ jobs:
test-results/ui/
retention-days: 14
# Cross-browser imaging: the deploy gate only runs chromium, so this verifies
# a real render (decode + APNG animation + visual baseline) across every
# bundled engine against the public site. Blocking on purpose: this is the
# signal that avatar imaging works on every system, so a failure here must
# stop the pipeline. IMAGING_EDGE marks the target as edge-fronted, enabling
# the public-routing smoke and skipping the CMS-only /imaging assertions.
tests-imaging-browsers:
needs: check
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install cross-browser engines
run: pnpm exec playwright install --with-deps chromium firefox webkit
- name: Cross-browser imaging checks
env:
PLAYWRIGHT_BASE_URL: https://epicnabbo.nl
IMAGING_EDGE: "1"
run: pnpm test:imaging
preflight:
needs: [tests-unit, tests-integration, tests-ui]
if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/'))
-2
View File
@@ -15,8 +15,6 @@ public/cache/
prod.log
*.log
# Imager health-alert state (last-known up/down + timestamp)
logs/imager-health.state
# Database backups
db_backup_*.sql
+3 -6
View File
@@ -191,9 +191,7 @@ it does not fetch changes to this Git-built CMS. Use the updater for releases.
A clone does **not** install an automatic scheduler. If desired, explicitly add a
cron entry with the absolute path of **your** checkout; keep logs in its `logs`
directory. Do not configure both CI and Compose updates for the same instance.
On a CI-managed host (either slot container running, or the nginx upstream
pointing at a blue/green slot) the updater skips with exit 0, so a cron run
that legitimately has nothing to do does not look like a failed deployment.
The updater refuses an active `epicnext-cms-app` CI-managed container.
Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check
failure automatically recreates the CMS with its previous image and verifies its
@@ -596,7 +594,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
### Requirements (host)
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab` by default, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk; choose the written extension with `FURNI_BUNDLE_EXT`) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab`, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
### Configuration — `/docker/Polaris-imager/.env`
@@ -900,8 +898,7 @@ that:
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
`epicnext-cms-app`, but after a cutover to the green slot that container is
stopped and deleted, so the guard stopped firing while the host stayed
CI-managed. It now checks both slot containers and the nginx upstream, and
reports the skip with exit 0 instead of failing the cron.
CI-managed. It now checks both slot containers and the nginx upstream.
- `ci-deploy.sh` retires a compose replica of *this* checkout
(`com.docker.compose.project.config_files`) from the candidate port before
starting the candidate — but never a slot container, and never the port nginx
-25
View File
@@ -1,25 +0,0 @@
CREATE TABLE `website_daily_reward_claims` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`user_id` int NOT NULL,
`claim_date` date NOT NULL,
`streak` int unsigned NOT NULL DEFAULT 1,
`reward_day` int unsigned NOT NULL DEFAULT 1,
`currency` varchar(20) NOT NULL DEFAULT 'credits',
`amount` int NOT NULL DEFAULT 0,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `website_daily_reward_claims_id` PRIMARY KEY(`id`),
CONSTRAINT `daily_claim_user_date_uk` UNIQUE(`user_id`,`claim_date`)
);
--> statement-breakpoint
CREATE TABLE `website_daily_rewards` (
`id` int unsigned AUTO_INCREMENT NOT NULL,
`day` int unsigned NOT NULL DEFAULT 1,
`currency` varchar(20) NOT NULL DEFAULT 'credits',
`amount` int NOT NULL DEFAULT 0,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `website_daily_rewards_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `admin_audit_log` MODIFY COLUMN `before` mediumtext;--> statement-breakpoint
ALTER TABLE `admin_audit_log` MODIFY COLUMN `after` mediumtext;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -8,13 +8,6 @@
"when": 1788791201804,
"tag": "0000_premium_spirit",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1791828901011,
"tag": "0001_breezy_maestro",
"breakpoints": true
}
]
}
-192
View File
@@ -1,192 +0,0 @@
import { expect, test } from "@playwright/test";
import { avatarPath, countBytes, looksLikeImage } from "./imaging-target";
// Endpoints exercised here are identical whether the CMS is reached directly
// or through the edge proxy, so this spec runs unchanged in the deploy gate and
// in the cross-browser job. Topology-specific checks live in
// `cms-imaging.spec.ts` (direct only) and `public-routing.spec.ts` (edge only).
const IMAGE_SOURCES = new Set([
"primary",
"fallback",
"cache",
"cache-stale",
"unavailable",
]);
test("renders an animated APNG for a figure with an effect", async ({
request,
}) => {
const response = await request.get(
avatarPath({ img_format: "apng", effect: "14" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
// `unavailable` means the placeholder was served, i.e. no real render.
expect(response.headers()["x-imager-source"]).not.toBe("unavailable");
const body = await response.body();
expect(body.length).toBeGreaterThan(1000);
// APNG signature chunks: `acTL` announces the animation, `fcTL` marks frames.
expect(body.includes(Buffer.from("acTL"))).toBe(true);
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
});
test("serves a static PNG when no animation is requested", async ({
request,
}) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.subarray(1, 4).toString("ascii")).toBe("PNG");
});
test("advertises a cacheable response and a known render source", async ({
request,
}) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
const source = response.headers()["x-imager-source"];
expect(source, "x-imager-source header").toBeTruthy();
expect(IMAGE_SOURCES.has(source as string)).toBe(true);
const cacheControl = response.headers()["cache-control"];
expect(cacheControl, "cache-control header").toContain("public");
expect(cacheControl).toMatch(/max-age=\d+/);
// Render sources other than the graceful placeholder are worth caching.
if (source !== "unavailable") {
expect(cacheControl).not.toContain("no-cache");
}
});
test("answers conditional requests with 304", async ({ request }) => {
const first = await request.get(avatarPath({ img_format: "png" }));
expect(first.status()).toBe(200);
const etag = first.headers()["etag"];
expect(etag).toBeTruthy();
const second = await request.get(avatarPath({ img_format: "png" }), {
headers: { "if-none-match": etag as string },
});
expect(second.status()).toBe(304);
expect(second.headers()["etag"]).toBe(etag);
});
test("serves the rendered avatar behind permissive CORS", async ({
request,
}) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
expect(response.headers()["access-control-allow-origin"]).toBe("*");
});
test("preflight requests advertise GET and revalidation", async ({
request,
}) => {
const response = await request.fetch(avatarPath(), { method: "OPTIONS" });
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-origin"]).toBe("*");
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
expect(response.headers()["access-control-allow-headers"]).toContain(
"If-None-Match",
);
});
test.describe("rejects malformed figures", () => {
const cases: Array<{ name: string; query: string }> = [
{ name: "missing figure", query: "" },
{ name: "empty figure", query: "?figure=" },
{ name: "path traversal", query: "?figure=../bad" },
{ name: "separator injection", query: "?figure=hd-180-1/../x" },
{
name: "over-long figure",
query: `?figure=hd-180-${"x".repeat(600)}`,
},
{
name: "too many parts",
query: `?figure=hd-180${".a-1".repeat(30)}`,
},
];
for (const { name, query } of cases) {
test(name, async ({ request }) => {
const response = await request.get(`/api/imaging/avatar${query}`);
expect(response.status()).toBe(400);
});
}
});
test("collapses a burst of concurrent requests for one figure", async ({
request,
}) => {
// Vary the key so this can hit a cold (or at least distinct) render while a
// page-load style burst is in flight. Every response must still be a real
// image — single-flight must not turn contention into 5xx/placeholders.
const path = avatarPath({
img_format: "png",
direction: "6",
head_direction: "1",
});
const responses = await Promise.all(
Array.from({ length: 6 }, () => request.get(path)),
);
for (const response of responses) {
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
}
});
test("the badge endpoint serves an image and revalidates", async ({
request,
}) => {
const first = await request.get("/api/imaging/badge?code=ADM");
expect(first.status()).toBe(200);
expect(first.headers()["content-type"]).toContain("image/");
expect(first.headers()["access-control-allow-origin"]).toBe("*");
const etag = first.headers()["etag"];
expect(etag).toBeTruthy();
const second = await request.get("/api/imaging/badge?code=ADM", {
headers: { "if-none-match": etag as string },
});
expect(second.status()).toBe(304);
});
test("the badge endpoint can resolve from a local source", async ({
request,
}) => {
const response = await request.get(
"/api/imaging/badge?code=ADM&source=local",
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(response.headers()["x-imager-source"]).toBeTruthy();
});
test.describe("rejects malformed badge codes", () => {
const cases = [
{ name: "missing code", query: "" },
{ name: "empty code", query: "?code=" },
{ name: "path traversal", query: "?code=../x" },
{ name: "too long", query: `?code=${"A".repeat(51)}` },
];
for (const { name, query } of cases) {
test(name, async ({ request }) => {
const response = await request.get(`/api/imaging/badge${query}`);
expect(response.status()).toBe(400);
});
}
});
test("the badge preflight advertises GET", async ({ request }) => {
const response = await request.fetch("/api/imaging/badge?code=ADM", {
method: "OPTIONS",
});
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
});
-57
View File
@@ -1,57 +0,0 @@
import { expect, test } from "@playwright/test";
import { avatarPath } from "./imaging-target";
/**
* Cross-browser check that a real browser (chromium/firefox/webkit) can decode
* the avatar the imager produces and that it actually animates. The endpoint
* spec covers the HTTP contract; this one covers the client-side decode, where
* an unsupported image format would surface as a blank/broken avatar.
*
* Navigates straight to the image URL (a top-level navigation, which CSP
* `upgrade-insecure-requests` does not rewrite) rather than embedding it in a
* page: over a plain-HTTP host the upgrade directive would otherwise send the
* subresource request to https and break the load, without exercising anything
* about the image itself.
*
* Run across engines via `pnpm test:imaging` (playwright.imaging.config.ts).
*/
const ANIMATED = avatarPath({ img_format: "apng", effect: "14" });
const STATIC = avatarPath({ img_format: "png" });
test("the avatar decodes and animates in the browser", async ({ page }) => {
const response = await page.goto(ANIMATED);
expect(response?.status()).toBe(200);
const image = page.locator("img");
await expect(image).toBeVisible();
const decoded = await image.evaluate((element: HTMLImageElement) => ({
width: element.naturalWidth,
height: element.naturalHeight,
}));
expect(decoded.width).toBeGreaterThan(0);
expect(decoded.height).toBeGreaterThan(0);
// An animated APNG keeps producing new frames; a static/undecodable one
// yields the same pixels every time. Sampling screenshots over a second is
// enough to tell them apart without depending on exact frame timing.
const frames = new Set<string>();
for (let attempt = 0; attempt < 8; attempt += 1) {
frames.add((await image.screenshot()).toString("base64"));
await page.waitForTimeout(180);
}
expect(frames.size).toBeGreaterThan(1);
});
test("the static render matches its committed visual baseline", async ({
page,
}) => {
// Pins the exact pixels of a non-animated render so a renderer/asset drift
// (wrong palette, missing part, changed sizing) is caught even when the
// image still decodes. Baselines are per engine and committed under
// e2e/imaging/__screenshots__/.
const response = await page.goto(STATIC);
expect(response?.status()).toBe(200);
await expect(page.locator("img")).toHaveScreenshot("static-avatar.png", {
animations: "disabled",
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

-78
View File
@@ -1,78 +0,0 @@
import { expect, test } from "@playwright/test";
import { countBytes, FIGURE, IS_EDGE, looksLikeImage } from "./imaging-target";
// These assertions only hold when the CMS itself answers `/imaging`. In
// production the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
// imager container, so the CMS route is shadowed; set `IMAGING_EDGE=1` to skip.
test.skip(
IS_EDGE,
"The CMS /imaging route is shadowed by the edge proxy in production",
);
const imagerPath = (params: Record<string, string> = {}) =>
`/imaging?${new URLSearchParams({ figure: FIGURE, img_format: "png", ...params })}`;
const PNG_MAGIC = Buffer.from("89504e470d0a1a0a", "hex");
test("serves the default render as a PNG", async ({ request }) => {
const response = await request.get(imagerPath());
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.subarray(0, 8).equals(PNG_MAGIC)).toBe(true);
expect(looksLikeImage(body)).toBe(true);
});
test("transcodes to WebP on request", async ({ request }) => {
const response = await request.get(imagerPath({ format: "webp" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/webp");
const body = await response.body();
expect(body.subarray(0, 4).toString("ascii")).toBe("RIFF");
expect(body.subarray(8, 12).toString("ascii")).toBe("WEBP");
});
test("transcodes to AVIF on request", async ({ request }) => {
const response = await request.get(imagerPath({ format: "avif" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/avif");
const body = await response.body();
expect(body.subarray(4, 12).toString("ascii")).toBe("ftypavif");
});
test("leaves animated APNG renders untranscoded", async ({ request }) => {
// The renderer emits effected avatars as APNG; libvips here reads only the
// first frame, so the animated original must be served as-is even when a
// transcode is requested — otherwise the avatar would stop animating.
const response = await request.get(
imagerPath({ img_format: "apng", effect: "14", format: "webp" }),
);
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/png");
const body = await response.body();
expect(body.includes(Buffer.from("acTL"))).toBe(true);
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
});
test("advertises long-lived edge caching", async ({ request }) => {
const response = await request.get(imagerPath());
expect(response.status()).toBe(200);
const cacheControl = response.headers()["cache-control"] ?? "";
expect(cacheControl).toContain("public");
expect(cacheControl).toContain("s-maxage");
expect(cacheControl).toContain("stale-while-revalidate");
});
test("rejects a malformed figure with 400", async ({ request }) => {
const response = await request.get(
`/imaging?figure=${encodeURIComponent("../bad")}`,
);
expect(response.status()).toBe(400);
});
test("the imager preflight advertises GET", async ({ request }) => {
const response = await request.fetch("/imaging", { method: "OPTIONS" });
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
});
-51
View File
@@ -1,51 +0,0 @@
/**
* Where the imaging specs are pointed.
*
* - Deploy gate (`playwright.config.ts`): hits the CMS directly, so the CMS's
* own `/imaging` route answers and its transcoding variants are reachable.
* - Cross-browser job (`playwright.imaging.config.ts` against the public
* origin): the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
* imager container, so the CMS route is shadowed. Only the endpoints that are
* identical in both topologies may be asserted unconditionally.
*
* Set `IMAGING_EDGE=1` when the base URL is behind the edge proxy.
*/
export const IS_EDGE = process.env.IMAGING_EDGE === "1";
// The exact figure that regressed in production: the game client appends a
// trailing `.<n>` segment that the renderer ignores, but the previous
// validation regex rejected with a 400.
export const FIGURE =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
export const avatarPath = (params: Record<string, string> = {}) =>
`/api/imaging/avatar?${new URLSearchParams({ figure: FIGURE, ...params })}`;
/** Public imager endpoint served through the edge proxy (imager container). */
export const IMAGER_PATH = (params: Record<string, string> = {}) =>
`/imaging/avatarimage?${new URLSearchParams({ figure: FIGURE, ...params })}`;
/** Counts non-overlapping occurrences of `needle` inside `haystack`. */
export function countBytes(haystack: Buffer, needle: Buffer): number {
let count = 0;
for (
let index = haystack.indexOf(needle);
index !== -1;
index = haystack.indexOf(needle, index + needle.length)
) {
count += 1;
}
return count;
}
/** PNG/JPEG/GIF/WebP/AVIF signatures, so a test can prove a body is an image. */
export function looksLikeImage(body: Buffer): boolean {
if (body.length < 12) return false;
if (body.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")))
return true; // PNG / APNG
if (body.subarray(0, 3).toString("ascii") === "GIF") return true;
if (body.subarray(0, 2).toString("hex") === "ffd8") return true; // JPEG
if (body.subarray(0, 4).toString("ascii") === "RIFF") return true; // WebP
if (body.subarray(4, 12).toString("ascii") === "ftypavif") return true; // AVIF
return false;
}
-39
View File
@@ -1,39 +0,0 @@
import { expect, test } from "@playwright/test";
import { avatarPath } from "./imaging-target";
// Lightweight size/latency budget for the avatar path. The endpoint spec checks
// correctness; this one catches a regression that keeps the image correct but
// makes it oversized or slow — e.g. losing the disk/edge cache and re-encoding
// (or re-rendering) on every hit. Budgets are deliberately loose: they should
// flag a real regression, not a cold cache or a slow CI network.
const STATIC_MAX_BYTES = 64 * 1024;
const ANIMATED_MAX_BYTES = 512 * 1024;
const WARM_MAX_MS = 3000;
test("the static avatar stays inside its byte budget", async ({ request }) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
expect((await response.body()).length).toBeLessThanOrEqual(STATIC_MAX_BYTES);
});
test("the animated avatar stays inside its byte budget", async ({
request,
}) => {
const response = await request.get(
avatarPath({ img_format: "apng", effect: "14" }),
);
expect(response.status()).toBe(200);
expect((await response.body()).length).toBeLessThanOrEqual(
ANIMATED_MAX_BYTES,
);
});
test("a warm avatar request is served quickly", async ({ request }) => {
const path = avatarPath({ img_format: "png" });
await request.get(path); // warm the disk and edge caches
const started = Date.now();
const response = await request.get(path);
const elapsed = Date.now() - started;
expect([200, 304]).toContain(response.status());
expect(elapsed).toBeLessThan(WARM_MAX_MS);
});
-39
View File
@@ -1,39 +0,0 @@
import { expect, test } from "@playwright/test";
import {
avatarPath,
IMAGER_PATH,
IS_EDGE,
looksLikeImage,
} from "./imaging-target";
// Regression guard for the public topology: the edge proxy must front the
// imager container at `/imaging/*` while `/api/imaging/*` reaches the CMS.
// Mis-routing here blanks avatars for every visitor, and no unit test sees it.
// Only meaningful against the public origin, so it is skipped when the CMS is
// reached directly (set `IMAGING_EDGE=1` for the public origin).
test.skip(
!IS_EDGE,
"Public routing is only asserted against the edge-fronted origin",
);
test("the edge serves the imager endpoint as an image", async ({ request }) => {
const response = await request.get(IMAGER_PATH({ img_format: "png" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
});
test("the CMS avatar endpoint is reachable through the edge", async ({
request,
}) => {
const response = await request.get(avatarPath({ img_format: "png" }));
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
expect(looksLikeImage(await response.body())).toBe(true);
});
test("the edge does not shadow the CMS badge endpoint", async ({ request }) => {
const response = await request.get("/api/imaging/badge?code=ADM");
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image/");
});
-2
View File
@@ -40,8 +40,6 @@
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
"test:imaging": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts",
"test:imaging:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts --update-snapshots",
"performance:report": "node scripts/performance-report.mjs",
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
},
-7
View File
@@ -7,13 +7,6 @@ export default defineConfig({
retries: 1,
timeout: 30000,
reporter: [["list"], ["html", { open: "never" }]],
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
trace: "retain-on-failure",
-41
View File
@@ -1,41 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
// Cross-browser imaging checks. Unlike the main e2e config (chromium only,
// used as the deploy gate), this runs the same specs across every bundled
// engine so an avatar format that only one browser can decode is caught.
//
// Point it at a running CMS with PLAYWRIGHT_BASE_URL (defaults to the local
// host port). Requires all three engines: `pnpm exec playwright install
// chromium firefox webkit`.
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
export default defineConfig({
testDir: "./e2e/imaging",
fullyParallel: false,
workers: 1,
retries: 1,
timeout: 60000,
expect: {
timeout: 15000,
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
reporter: [
["list"],
["html", { outputFolder: "playwright-report/imaging", open: "never" }],
],
outputDir: "test-results/imaging",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
});
+4 -5
View File
@@ -167,7 +167,6 @@ overrides:
'@esbuild-kit/core-utils': npm:tsx@^4.23.15
'@esbuild-kit/esm-loader': npm:tsx@^4.23.15
source-map-js: 1.2.2
postcss-selector-parser: ^7.1.6
importers:
@@ -3256,8 +3255,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==}
postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
[email protected]:
@@ -4962,7 +4961,7 @@ snapshots:
'@tailwindcss/[email protected]([email protected])':
dependencies:
postcss-selector-parser: 7.1.6
postcss-selector-parser: 6.0.10
tailwindcss: 4.3.3
'@tanstack/[email protected]': {}
@@ -6111,7 +6110,7 @@ snapshots:
[email protected]: {}
postcss-selector-parser@7.1.6:
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
-5
View File
@@ -17,8 +17,3 @@ overrides:
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
source-map-js: '1.2.2'
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
# exactly and has no upstream bump. The API subset it uses is unchanged in
# v7, so force the patched major.
postcss-selector-parser: '^7.1.6'
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+4 -12
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
# Create/update the Cloudflare Cache Rule that stores the CMS public API
# allowlist, the client-facing gamedata tree and the avatar/badge imaging
# endpoints at the edge (the routes nginx tags with `Cache-Tag: cms-public`
# respectievelijk `cms-gamedata`, en `/api/imaging/` dat zijn eigen s-maxage
# stuurt).
# allowlist plus the client-facing gamedata tree at the edge (the routes nginx
# tags with `Cache-Tag: cms-public` respectievelijk `cms-gamedata`).
#
# Why a rule is required: Cloudflare only caches a handful of file extensions
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
@@ -37,18 +35,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env"
BASE="https://api.cloudflare.com/client/v4"
PHASE="http_request_cache_settings"
DESCRIPTION="EpicNabbo CMS public API + gamedata + imaging edge cache (cms-public, cms-gamedata, cms-imaging)"
DESCRIPTION="EpicNabbo CMS public API + gamedata edge cache (cms-public, cms-gamedata)"
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
# `starts_with()`.
#
# `/api/imaging/` (avatars, badges) staat erbij omdat de CMS-route zelf al
# `s-maxage=86400` + `stale-while-revalidate` stuurt: de edge kan een avatar een
# dag vasthouden, zodat herhaalde requests de lokale renderer nooit raken. De
# volledige URL (inclusief figure/code) is de cache-key, dus geen vervuiling
# tussen gebruikers.
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/api/imaging/") or starts_with(http.request.uri.path, "/gamedata/"))'
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/gamedata/"))'
load_env() {
local name="$1"
+1 -21
View File
@@ -410,10 +410,7 @@ if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
fi
pnpm install --frozen-lockfile
# All three engines are needed: the chromium gate below plus the cross-browser
# imaging gate just before cutover. Idempotent — already-installed engines in
# PLAYWRIGHT_BROWSERS_PATH are detected and skipped.
pnpm exec playwright install chromium firefox webkit
pnpm exec playwright install chromium
echo "Building $image"
# No --network=host: BuildKit only grants it via --allow=network.host, and the
@@ -539,18 +536,6 @@ if [ "$blue_green" -eq 1 ]; then
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
# 4b. Cross-browser imaging gate. The chromium suite above proves the
# candidate serves the right bytes; this proves every bundled engine can
# actually decode the render. The webkit path is the one that historically
# broke (CSP upgrade-insecure-requests + animated PNG), and a regression
# there must never reach live. Set DEPLOY_SKIP_CROSS_BROWSER=1 only to get
# a release out in an emergency.
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
else
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:imaging
fi
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
cutover_started=1
switch_upstream "$new_port"
@@ -584,11 +569,6 @@ else
healthy 3002
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
else
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:imaging
fi
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
fi
docker tag "$verified_image" "epicnext-cms:verified-$sha"
+2 -5
View File
@@ -11,9 +11,6 @@ LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
mkdir -p "$(dirname "$LOG_FILE")"
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*"; exit 1; }
# A run that legitimately has nothing to do (the host is owned by CI) must not
# look like a failure to cron. `die` stays reserved for real errors.
skip() { log "SKIP: $*"; exit 0; }
migration_image=""
remote_migration_image=""
previous_image=""
@@ -75,11 +72,11 @@ trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were dele
# the only thing that still marks the host as blue/green when a slot is idle.
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
skip "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
fi
for slot_container in epicnext-cms-app epicnext-cms-green; do
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
skip "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
fi
done
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
# Alert when the local avatar imager stops rendering, and again when it recovers.
#
# Intended to run from cron every few minutes (see `crontab -l`). Steady-state is
# silent; a state change posts to DISCORD_WEBHOOK_URL (from the environment or
# .env). While the imager stays down it re-alerts at most once per
# HEALTH_ALERT_COOLDOWN_MIN. The script always exits 0 so an outage never turns
# into a noisy cron failure; the alert itself (and this log) is the signal.
set -Eeuo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_FILE="${IMAGER_HEALTH_LOG:-$DIR/logs/imager-health.log}"
STATE_FILE="${IMAGER_HEALTH_STATE:-$DIR/logs/imager-health.state}"
HEALTH_URL="${IMAGER_HEALTH_URL:-http://127.0.0.1:8082/health}"
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STATE_FILE")"
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"; }
load_env() {
local name="$1"
if [ -n "${!name:-}" ]; then return 0; fi
if [ -f "$DIR/.env" ]; then
local line
line="$(grep -m1 "^$name=" "$DIR/.env" | cut -d= -f2- | tr -d "'\"")" || true
if [ -n "$line" ]; then printf -v "$name" '%s' "$line"; fi
fi
}
load_env DISCORD_WEBHOOK_URL || true
COOLDOWN_MIN="${HEALTH_ALERT_COOLDOWN_MIN:-30}"
notify() {
local msg="$1"
log "ALERT: $msg"
if [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then
log "no DISCORD_WEBHOOK_URL configured; alert written to log only"
return 0
fi
local payload
payload="$(python3 -c 'import json,sys;print(json.dumps({"content":sys.argv[1]}))' "$msg")"
curl -sS -m 10 -H "Content-Type: application/json" -d "$payload" \
"$DISCORD_WEBHOOK_URL" >/dev/null 2>&1 || log "failed to post alert to Discord"
}
body="$(curl -sS -m 5 "$HEALTH_URL" 2>/dev/null || true)"
ready=0
if printf '%s' "$body" | grep -q '"ready":true'; then ready=1; fi
now="$(date +%s)"
prev_state="up"
prev_at=0
if [ -f "$STATE_FILE" ]; then read -r prev_state prev_at < "$STATE_FILE" || true; fi
[ -n "${prev_state:-}" ] || prev_state=up
[ -n "${prev_at:-}" ] || prev_at=0
if [ "$ready" = 1 ]; then
if [ "$prev_state" != "up" ]; then
notify "Avatar imager RECOVERED ($HEALTH_URL reports ready)"
fi
printf 'up %s\n' "$now" > "$STATE_FILE"
exit 0
fi
if [ "$prev_state" != "down" ]; then
notify "Avatar imager DOWN ($HEALTH_URL not ready: ${body:-unreachable})"
printf 'down %s\n' "$now" > "$STATE_FILE"
exit 0
fi
if [ "$(( now - prev_at ))" -ge "$(( COOLDOWN_MIN * 60 ))" ]; then
notify "Avatar imager STILL DOWN after ${COOLDOWN_MIN}m ($HEALTH_URL)"
printf 'down %s\n' "$now" > "$STATE_FILE"
fi
exit 0
-1
View File
@@ -99,7 +99,6 @@ async function NewsPage({
fill
sizes="(max-width: 1024px) 50vw, 300px"
style={{ objectFit: "cover" }}
unoptimized
/>
</div>
) : (
+23 -34
View File
@@ -170,9 +170,12 @@ function GlassPanel({
}
async function getHotelData() {
const [hotelName, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
]);
const [
hotelName,
logo,
online,
users,
rooms,
@@ -182,8 +185,6 @@ async function getHotelData() {
recentUsers,
recentPhotos,
] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
cachedOnlineCount().catch(publicReadFailure("home.online")),
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
publicReadFailure("home.users"),
@@ -242,34 +243,22 @@ export default async function Home() {
const session = await auth();
if (session?.user?.id) redirect("/me");
const [
tp,
te,
th,
tn,
{
hotelName,
online,
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers: rawRecentUsers,
recentPhotos,
logo,
},
captcha,
nonceHeaders,
] = await Promise.all([
getTranslations("pages.home"),
getTranslations("publicData"),
getTranslations("header"),
getTranslations("nav"),
getHotelData(),
captchaConfig(),
headers(),
]);
const tp = await getTranslations("pages.home");
const te = await getTranslations("publicData");
const th = await getTranslations("header");
const tn = await getTranslations("nav");
const {
hotelName,
online,
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers: rawRecentUsers,
recentPhotos,
logo,
} = await getHotelData();
// Users who hide their online state must not surface in the "who is online"
// rails on the homepage either.
@@ -282,7 +271,8 @@ export default async function Home() {
: rawRecentUsers.filter(
(u) => recentUserPrivacy.get(u.id)?.online !== false,
);
const nonce = nonceHeaders.get("x-nonce") ?? undefined;
const captcha = await captchaConfig();
const nonce = (await headers()).get("x-nonce") ?? undefined;
const features = [
{
@@ -325,7 +315,6 @@ export default async function Home() {
fill
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
unoptimized
/>
<div
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
+9 -10
View File
@@ -1,8 +1,5 @@
import { ClipboardCheck } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuditClient } from "@/app/admin/import/audit/audit-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioAuditPage(_props: {
@@ -12,14 +9,16 @@ export default async function StudioAuditPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={ClipboardCheck}
title={t("nav.catalogAudit")}
subtitle={t("pages.auditSubtitle")}
>
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1>
<p className="mt-1 text-muted-foreground">
Scan your entire furniture catalog for missing entries, broken
references, missing files, and items available from clone sources.
</p>
</div>
<AuditClient />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { BadgeCheck } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioBadgesPage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioBadgesPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={BadgeCheck}
title={t("nav.badges")}
subtitle={t("pages.badgesSubtitle")}
>
<div className="p-4">
<ImportBadgesClient />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { Copy } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClonePage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioClonePage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Copy}
title={t("nav.importFromSource")}
subtitle={t("pages.cloneSubtitle")}
>
<div className="p-4">
<ImportCloneClient />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { Shirt } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClothingPage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioClothingPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Shirt}
title={t("nav.clothing")}
subtitle={t("pages.clothingSubtitle")}
>
<div className="p-4">
<ImportClothingClient />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { Dumbbell } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioEffectsPage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioEffectsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Dumbbell}
title={t("nav.effects")}
subtitle={t("pages.effectsSubtitle")}
>
<div className="p-4">
<ImportEffectsClient />
</StudioPage>
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state for any Catalog Studio sub-page. Mirrors the StudioPage
* header (icon tile, title, subtitle) and a body block so navigation between
* studio tools shows a stable skeleton instead of a blank pause.
*/
export default function StudioLoading() {
return (
<div className="p-4">
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<Skeleton className="h-10 w-10 shrink-0 rounded-xl bg-[var(--admin-accent)]/10" />
<div className="min-w-0">
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-72 max-w-full" />
</div>
</div>
</div>
<div className="space-y-3 rounded-xl border border-[var(--admin-border)] p-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-9 w-32" />
</div>
</div>
</div>
);
}
+14 -23
View File
@@ -1,9 +1,7 @@
import { Wrench } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioCatalogMaintenancePage() {
@@ -18,27 +16,20 @@ export default async function StudioCatalogMaintenancePage() {
session.user.rank,
);
const t = await getTranslations("pages.admin.catalog.integrity");
const studioT = await getTranslations("admin.studio");
return (
<StudioPage
icon={Wrench}
title={studioT("nav.catalogMaintenance")}
subtitle={studioT("pages.maintenanceSubtitle")}
>
<div className="min-h-0 space-y-6">
<CatalogIntegrityPanel canRepair={canRepair} />
{canRepair && (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer font-medium">
{t("legacyTitle")}
</summary>
<p className="mt-3 text-sm text-muted-foreground">
{t("legacyHint")}
</p>
<CatalogMaintenancePanel />
</details>
)}
</div>
</StudioPage>
<div className="min-h-0 space-y-6">
<CatalogIntegrityPanel canRepair={canRepair} />
{canRepair && (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer font-medium">
{t("legacyTitle")}
</summary>
<p className="mt-3 text-sm text-muted-foreground">
{t("legacyHint")}
</p>
<CatalogMaintenancePanel />
</details>
)}
</div>
);
}
+9 -10
View File
@@ -1,8 +1,5 @@
import { FileX2 } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioNitroCleanupPage() {
@@ -10,14 +7,16 @@ export default async function StudioNitroCleanupPage() {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={FileX2}
title={t("nav.nitroCleanup")}
subtitle={t("pages.nitroCleanupSubtitle")}
>
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Nitro cleanup</h1>
<p className="mt-1 text-muted-foreground">
Scan the nitro directories for fake bundles (no matching furniture)
and broken bundles (unparseable) and remove them.
</p>
</div>
<NitroCleanupPanel />
</StudioPage>
</div>
);
}
+2 -10
View File
@@ -1,8 +1,5 @@
import { Cat } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioPetsPage(_props: {
@@ -12,14 +9,9 @@ export default async function StudioPetsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Cat}
title={t("nav.pets")}
subtitle={t("pages.petsSubtitle")}
>
<div className="p-4">
<ImportPetsClient />
</StudioPage>
</div>
);
}
+12 -10
View File
@@ -1,8 +1,5 @@
import { Image as ImageIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioRepairIconsPage(_props: {
@@ -12,14 +9,19 @@ export default async function StudioRepairIconsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={ImageIcon}
title={t("pages.repairIconsTitle")}
subtitle={t("pages.repairIconsSubtitle")}
>
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Repair Missing Icons
</h1>
<p className="mt-1 text-muted-foreground">
Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured
clone sources.
</p>
</div>
<RepairIconsClient />
</StudioPage>
</div>
);
}
+28 -35
View File
@@ -17,7 +17,6 @@ import {
Wrench,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper";
import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher";
@@ -29,38 +28,34 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
type StudioLink = { href: string; labelKey: string; icon: LucideIcon };
type StudioLink = { href: string; label: string; icon: LucideIcon };
const assets: StudioLink[] = [
{ href: "/admin/studio/furni", labelKey: "furniture", icon: Puzzle },
{ href: "/admin/studio/badges", labelKey: "badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", labelKey: "clothing", icon: Shirt },
{ href: "/admin/studio/effects", labelKey: "effects", icon: Dumbbell },
{ href: "/admin/studio/pets", labelKey: "pets", icon: Cat },
{ href: "/admin/studio/furni", label: "Furniture", icon: Puzzle },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat },
];
const imports: StudioLink[] = [
{ href: "/admin/studio/upload", labelKey: "uploadFiles", icon: Upload },
{ href: "/admin/studio/clone", labelKey: "importFromSource", icon: Copy },
{ href: "/admin/studio/sync", labelKey: "syncRepository", icon: RefreshCw },
{ href: "/admin/studio/upload", label: "Upload files", icon: Upload },
{ href: "/admin/studio/clone", label: "Import from a source", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw },
];
const maintenance: StudioLink[] = [
{
href: "/admin/studio/audit",
labelKey: "catalogAudit",
icon: ClipboardCheck,
},
{ href: "/admin/studio/audit", label: "Catalog audit", icon: ClipboardCheck },
{
href: "/admin/studio/repair-icons",
labelKey: "repairIcons",
label: "Repair icons",
icon: ImageIcon,
},
{
href: "/admin/studio/maintenance",
labelKey: "catalogMaintenance",
label: "Catalog maintenance",
icon: Wrench,
},
{
href: "/admin/studio/nitro-cleanup",
labelKey: "nitroCleanup",
label: "Nitro cleanup",
icon: FileX2,
},
];
@@ -76,7 +71,6 @@ function StudioMenu({
links: StudioLink[];
pathname: string;
}) {
const t = useTranslations("admin.studio");
const [open, setOpen] = useState(false);
const active = links.find((link) => activePath(pathname, link.href));
return (
@@ -96,7 +90,7 @@ function StudioMenu({
align="end"
className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]"
>
{links.map(({ href, labelKey, icon: Icon }) => (
{links.map(({ href, label: title, icon: Icon }) => (
<Link
key={href}
href={href}
@@ -109,8 +103,8 @@ function StudioMenu({
aria-current={activePath(pathname, href) ? "page" : undefined}
className="flex min-h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-[var(--admin-accent)]/10 aria-[current=page]:bg-[var(--admin-accent)]/15"
>
<Icon size={16} aria-hidden />
{t(`nav.${labelKey}`)}
<Icon size={16} />
{title}
</Link>
))}
</PopoverContent>
@@ -126,15 +120,14 @@ export function StudioLayoutClient({
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
const t = useTranslations("admin.studio");
return (
<div className="flex h-[calc(100dvh-7rem)] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] sm:min-h-[600px]">
<div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]">
<nav
aria-label={t("nav.ariaLabel")}
className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
aria-label="Catalog Studio"
className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
>
<div className="flex shrink-0 gap-1">
{assets.map(({ href, labelKey, icon: Icon }) => (
<div className="flex flex-wrap gap-1">
{assets.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
@@ -145,35 +138,35 @@ export function StudioLayoutClient({
}}
aria-current={activePath(pathname, href) ? "page" : undefined}
className={cn(
"flex min-h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-sm font-medium transition-colors",
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
activePath(pathname, href)
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<Icon size={16} aria-hidden />
{t(`nav.${labelKey}`)}
<Icon size={16} />
{label}
</Link>
))}
</div>
<div className="ml-auto flex shrink-0 gap-1 pl-1">
<div className="flex flex-wrap gap-1">
{canEditCatalog && (
<>
<CatalogManagerDialog
catalogType="normal"
canEdit
triggerLabel={t("nav.catalogManager")}
triggerLabel="Catalog manager"
/>
<OrganizeImportsLauncher />
</>
)}
<StudioMenu
label={t("nav.importSync")}
label="Import & sync"
links={imports}
pathname={pathname}
/>
<StudioMenu
label={t("nav.maintenance")}
label="Maintenance"
links={maintenance}
pathname={pathname}
/>
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -14,7 +13,6 @@ interface Configuration {
hasToken: boolean;
}
export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
const t = useTranslations("admin.studio.sync");
const [config, setConfig] = useState<Configuration | null>(null),
[token, setToken] = useState(""),
[busy, setBusy] = useState(false),
@@ -28,7 +26,10 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
signal: abort.signal,
cache: "no-store",
});
if (!res.ok) throw new Error(t("loadFailed"));
if (!res.ok)
throw new Error(
"Unable to read Git settings. Settings edit permission is required.",
);
const data = await res.json();
if (!abort.signal.aborted) setConfig(data);
} catch (error) {
@@ -36,7 +37,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
}
})();
return () => abort.abort();
}, [t]);
}, []);
async function submit(action: "save" | "test") {
if (!config || busy) return;
setBusy(true);
@@ -49,11 +50,12 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
body: JSON.stringify({ ...config, token, action }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || t("requestFailed"));
if (!res.ok)
throw new Error(data.error || "Git configuration request failed");
if (action === "save") {
setConfig(data);
setToken("");
setMessage(t("saved"));
setMessage("Gitea configuration saved.");
onSaved();
} else setMessage(data.message);
} catch (error) {
@@ -68,18 +70,19 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
open
>
<summary className="cursor-pointer font-semibold">
{t("gitTitle")}
Gitea connection settings
</summary>
<p className="my-3 text-sm text-[var(--admin-text-muted)]">
{t("gitIntro")}
Connect your self-hosted Gitea repository over HTTPS. The CMS manages
its own checkout; no Git-specific .env settings are needed.
</p>
{config && (
<fieldset disabled={busy} className="space-y-3">
<p className="text-sm">
{t("providerLabel")} <strong>{t("providerValue")}</strong>
Provider: <strong>Gitea self-hosted</strong>
</p>
<label htmlFor="catalog-git-1" className="block text-sm">
{t("repositoryUrl")}
Repository URL
<Input
id="catalog-git-1"
type="url"
@@ -91,7 +94,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label htmlFor="catalog-git-2" className="block text-sm">
{t("branch")}
Branch
<Input
id="catalog-git-2"
value={config.branch}
@@ -101,7 +104,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
/>
</label>
<label htmlFor="catalog-git-3" className="block text-sm">
{t("username")}
Gitea username
<Input
id="catalog-git-3"
autoComplete="off"
@@ -113,18 +116,23 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label>
</div>
<label htmlFor="catalog-git-4" className="block text-sm">
{t("accessToken")}
Gitea access token
<Input
id="catalog-git-4"
type="password"
autoComplete="new-password"
value={token}
placeholder={config.hasToken ? t("tokenSaved") : t("tokenEnter")}
placeholder={
config.hasToken
? "Token saved — leave blank to keep it"
: "Enter a token with repository write access"
}
onChange={(event) => setToken(event.target.value)}
/>
</label>
<p className="text-xs text-[var(--admin-text-muted)]">
{t("tokenNote")}
The token is stored encrypted and is never returned to the browser.
Changing repository or username requires entering the token again.
</p>
<label className="flex items-center gap-2 text-sm">
<input
@@ -134,14 +142,14 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
setConfig({ ...config, enabled: event.target.checked })
}
/>
{t("autoExport")}
Automatically export catalog changes
</label>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void submit("test")}>
{t("testConnection")}
Test connection
</Button>
<Button onClick={() => void submit("save")}>
{busy ? t("working") : t("saveSettings")}
{busy ? "Working…" : "Save Gitea settings"}
</Button>
</div>
</fieldset>
+29 -30
View File
@@ -1,9 +1,6 @@
import { RefreshCw } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client";
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { CatalogExportClient } from "./catalog-export-client";
@@ -15,42 +12,44 @@ export default async function StudioSyncPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={RefreshCw}
title={t("nav.syncRepository")}
subtitle={t("sync.pageSubtitle")}
>
<div className="space-y-6">
<CatalogExportClient
canConfigure={canAccess(
permissions,
PERMS.SETTINGS_EDIT,
session.user.rank,
)}
/>
<div className="space-y-8">
<CatalogExportClient
canConfigure={canAccess(
permissions,
PERMS.SETTINGS_EDIT,
session.user.rank,
)}
/>
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
<p className="text-muted-foreground mt-2">
Scan configured sources and automatically import any furniture that is
not yet present in your hotel.
</p>
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold">
{t("sync.cloneSourcesTitle")}
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-muted-foreground mb-2">
Clone Sources
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{t("sync.cloneSourcesBody")}
<p className="text-sm text-muted-foreground">
Scan all configured clone sources and automatically import any
furniture that is not yet present in your hotel.
</p>
<SyncAllClient />
</section>
</div>
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold">
{t("sync.officialFurnidataTitle")}
<div>
<h2 className="text-lg font-semibold text-muted-foreground mb-2">
Official Furnidata
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{t("sync.officialFurnidataBody")}
<p className="text-sm text-muted-foreground">
Scan the official Habbo furnidata and automatically import any
furniture that your hotel doesn't have yet.
</p>
<OfficialSyncClient />
</section>
</div>
</div>
</StudioPage>
</div>
);
}
+12 -10
View File
@@ -1,8 +1,5 @@
import { Upload } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioUploadPage(_props: {
@@ -12,14 +9,19 @@ export default async function StudioUploadPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<StudioPage
icon={Upload}
title={t("pages.uploadTitle")}
subtitle={t("pages.uploadSubtitle")}
>
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Upload .hab Furniture
</h1>
<p className="mt-1 text-muted-foreground">
Upload a .hab bundle and optional icon to import furniture into the
hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically.
</p>
</div>
<UploadFurniClient />
</StudioPage>
</div>
);
}
+4 -37
View File
@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from "next/server";
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import {
fetchAvatarImage,
ImagerUnavailableError,
@@ -21,16 +20,12 @@ function cacheControlFor(source: string): string {
switch (source) {
case "fallback":
case "cache-stale":
// Reuse the render briefly but revalidate the upstream soon. The
// shared `s-maxage` lets the edge (Cloudflare) hold it for an hour
// without pinning a degraded fallback in the browser.
return "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600";
// Reuse the render briefly but revalidate the upstream soon.
return "public, max-age=300";
case "unavailable":
return "no-cache, max-age=0";
default:
// s-maxage=86400 lets the edge cache a day off the origin, matching
// the sibling /imaging route; the URL already keys per figure.
return "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
return "public, max-age=3600";
}
}
@@ -48,52 +43,24 @@ export async function GET(request: NextRequest) {
parsed.params,
);
const etag = strongEtag(body);
const cacheControl = cacheControlFor(source);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control": cacheControl,
ETag: etag,
"Cache-Control": cacheControlFor(source),
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
if (error instanceof ImagerUnavailableError) {
const etag = strongEtag(PLACEHOLDER_SVG_BYTES);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControlFor("unavailable"),
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": cacheControlFor("unavailable"),
ETag: etag,
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
+21 -90
View File
@@ -7,7 +7,6 @@ import {
readImagingCache,
writeImagingCache,
} from "@/lib/imager-cache";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { resolveBadgeBase } from "@/lib/runtime-asset-config";
import { siteSettings } from "@/lib/services/site-settings";
@@ -34,64 +33,6 @@ const BADGE_PLACEHOLDER_SVG =
type BadgeSource = "upstream" | "local" | "cache" | "unavailable";
interface ResolvedBadge {
body: Uint8Array;
contentType: string;
source: BadgeSource;
}
/**
* Concurrent cold-cache requests for the same badge would otherwise each walk
* the whole candidate list, and when the first candidate is a slow/unreachable
* CDN that means every one of them waits out the fetch timeout before falling
* back. Sharing a single resolution keeps a cold cache from stacking identical
* upstream work. Held on globalThis because Next can evaluate this module more
* than once per process.
*/
const globalForBadgeFetches = globalThis as typeof globalThis & {
imagingBadgeFetches?: Map<string, Promise<ResolvedBadge>>;
};
const inFlight =
globalForBadgeFetches.imagingBadgeFetches ??
new Map<string, Promise<ResolvedBadge>>();
globalForBadgeFetches.imagingBadgeFetches = inFlight;
async function resolveBadge(
cacheKey: string,
code: string,
candidates: string[],
): Promise<ResolvedBadge> {
const pending = inFlight.get(cacheKey);
if (pending) return pending;
const work = (async (): Promise<ResolvedBadge> => {
for (const base of candidates) {
const result = await fetchCandidate(base, code);
if (result) {
await writeImagingCache(
badgeCacheDir(),
cacheKey,
result.body,
result.contentType,
);
return result;
}
}
return {
body: new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
contentType: "image/svg+xml",
source: "unavailable",
};
})();
inFlight.set(cacheKey, work);
try {
return await work;
} finally {
if (inFlight.get(cacheKey) === work) inFlight.delete(cacheKey);
}
}
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code")?.trim();
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
@@ -115,20 +56,25 @@ export async function GET(request: NextRequest) {
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
const cached = await readImagingCache(badgeCacheDir(), cacheKey);
if (cached)
return badgeResponse(
cached.body,
cached.contentType,
"cache",
request.headers.get("if-none-match"),
);
if (cached) return badgeResponse(cached.body, cached.contentType, "cache");
for (const base of candidates) {
const result = await fetchCandidate(base, code);
if (result) {
await writeImagingCache(
badgeCacheDir(),
cacheKey,
result.body,
result.contentType,
);
return badgeResponse(result.body, result.contentType, result.source);
}
}
const resolved = await resolveBadge(cacheKey, code, candidates);
return badgeResponse(
resolved.body,
resolved.contentType,
resolved.source,
request.headers.get("if-none-match"),
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
"image/svg+xml",
"unavailable",
);
}
@@ -194,31 +140,16 @@ function badgeResponse(
body: Uint8Array,
contentType: string,
source: BadgeSource,
ifNoneMatch: string | null = null,
) {
const etag = strongEtag(body);
const cacheControl =
source === "unavailable" ? "no-cache, max-age=0" : "public, max-age=3600";
// Same bytes the client already has: skip the body. Validators are hashed
// from the body, so a fallback→upstream swap still forces a fresh 200.
if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control": cacheControl,
ETag: etag,
"Cache-Control":
source === "unavailable"
? "no-cache, max-age=0"
: "public, max-age=3600",
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
+28 -186
View File
@@ -1,47 +1,8 @@
import { type NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import {
avatarCacheDir,
imagingCacheKey,
readImagingCache,
writeImagingCache,
} from "@/lib/imager-cache";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { isAnimatedPng } from "@/lib/imager-png";
import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "@/lib/imager-upstream";
import { resolveImagerBase } from "@/lib/runtime-asset-config";
// Site-wide default imager endpoint. NOTE ON TOPOLOGY: in the current
// production deployment the edge proxy (Traefik) fronts bare `/imaging` and
// `/imaging/*` directly to the local pixinode renderer container, so this CMS
// route is shadowed publicly and only answers when the CMS is reached directly
// (local/dev, self-hosted installs without that proxy rule, or the deploy gate).
// That is intentional: it keeps `habbo_imaging_url=/imaging` working without the
// CMS in the hot path, and this route remains the correct fallback for any
// install where no separate imager is proxied. Its behaviour is covered by
// `src/lib/imaging-route.test.ts` and the CMS-only e2e (`e2e/imaging/cms-imaging.spec.ts`).
//
// It differs from the sibling `/api/imaging/avatar` route in that it also
// transcodes PNG renders to WebP/AVIF (`?format=`) and is cached on disk per
// (figure, variant).
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
type AvatarFormat = "png" | "webp" | "avif";
type TranscodeFormat = Exclude<AvatarFormat, "png">;
// Same neutral silhouette the sibling /api/imaging/avatar route serves, so a
// figure no upstream can render still shows a graceful image instead of the
// browser's broken-image glyph. This is the site-wide default imager endpoint,
// so a 502 here would blank avatars across every server-rendered page.
const PLACEHOLDER_SVG_BYTES = new TextEncoder().encode(
decodeURIComponent(
AVATAR_FALLBACK_SRC.slice("data:image/svg+xml;utf8,".length),
),
);
function resolveFormat(raw: string | null): AvatarFormat {
switch (raw?.toLowerCase()) {
@@ -54,89 +15,6 @@ function resolveFormat(raw: string | null): AvatarFormat {
}
}
/**
* Concurrent cold-cache requests for the same figure all arrive with the same
* decoded PNG and the same target format, so they would each run the same
* sharp transcode — the one operation on this route that is CPU-bound. Sharing
* a single transcode across those requests keeps a cold cache from stacking
* identical encoder work. Held on globalThis because Next can evaluate this
* module more than once per process; separate copies would each start their own
* transcode and defeat the point.
*/
const globalForTranscodes = globalThis as typeof globalThis & {
imagingTranscodes?: Map<string, Promise<TranscodeResult | null>>;
};
const inFlight =
globalForTranscodes.imagingTranscodes ??
new Map<string, Promise<TranscodeResult | null>>();
globalForTranscodes.imagingTranscodes = inFlight;
interface TranscodeResult {
body: Uint8Array;
contentType: string;
}
/**
* Serve a WebP/AVIF variant of the decoded render, cached on disk so sharp only
* runs once per (figure, variant) rather than on every avatar request. This
* endpoint is the default imager, so re-encoding on each hit would burn CPU for
* an image that never changes.
*
* Returns `null` when a conversion cannot be produced (e.g. sharp cannot decode
* the input) so the caller falls back to serving the original render unchanged.
*/
async function transcodeToVariant(
body: Uint8Array,
format: TranscodeFormat,
variantKey: string,
): Promise<TranscodeResult | null> {
if (isAnimatedPng(body)) return null;
const cached = await readImagingCache(avatarCacheDir(), variantKey);
if (cached) return cached;
const pending = inFlight.get(variantKey);
if (pending) return pending;
const work = runTranscode(body, format, variantKey);
inFlight.set(variantKey, work);
try {
return await work;
} finally {
if (inFlight.get(variantKey) === work) inFlight.delete(variantKey);
}
}
async function runTranscode(
body: Uint8Array,
format: TranscodeFormat,
variantKey: string,
): Promise<TranscodeResult | null> {
try {
// `animated: true` lets sharp carry animation through for inputs it can
// decode frame-by-frame (animated GIF/WebP). APNG is handled before this
// point: libvips here reads only its first frame, so those are left as-is.
const pipeline = sharp(Buffer.from(body), { animated: true });
const out =
format === "webp"
? await pipeline.webp().toBuffer()
: await pipeline.avif().toBuffer();
const contentType = format === "webp" ? "image/webp" : "image/avif";
const result = { body: new Uint8Array(out), contentType };
// Best-effort: a failed write must never turn a good render into an
// error; the next request simply re-runs the transcode.
await writeImagingCache(
avatarCacheDir(),
variantKey,
result.body,
contentType,
);
return result;
} catch {
return null;
}
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
@@ -153,84 +31,48 @@ export async function GET(request: NextRequest) {
parsed.params,
);
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
// Falls back to the original PNG if conversion fails.
let respBody: Uint8Array = body;
let respContentType = contentType;
if (format !== "png") {
// The variant is keyed off the exact render that produced it (the
// resolved upstream + the normalized query) plus the target format,
// mirroring how the underlying render itself is cached.
const variantKey = imagingCacheKey(
`${resolveImagerBase(new URL(request.url).origin)}\n${parsed.params.toString()}\n${format}`,
);
const converted = await transcodeToVariant(body, format, variantKey);
if (converted) {
respBody = converted.body;
respContentType = converted.contentType;
try {
if (format === "webp") {
respBody = new Uint8Array(
await sharp(Buffer.from(body)).webp().toBuffer(),
);
respContentType = "image/webp";
} else {
respBody = new Uint8Array(
await sharp(Buffer.from(body)).avif().toBuffer(),
);
respContentType = "image/avif";
}
} catch {
respBody = body;
respContentType = contentType;
}
}
const etag = strongEtag(respBody);
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
// stale-while-revalidate keeps them fresh without blocking requests.
// The ?format= param is part of the URL, so variants cache separately.
// Fallback renders use a shorter TTL so the configured imager is
// retried soon instead of being masked for hours.
const cacheControl =
source === "fallback"
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(respBody as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": respContentType,
"Content-Length": String(respBody.length),
"Cache-Control": cacheControl,
ETag: etag,
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
// stale-while-revalidate keeps them fresh without blocking requests.
// The ?format= param is part of the URL, so variants cache separately.
// Fallback renders use a shorter TTL so the configured imager is
// retried soon instead of being masked for hours.
"Cache-Control":
source === "fallback"
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
if (error instanceof ImagerUnavailableError) {
// Every upstream is down and nothing was cached: serve the graceful
// silhouette as a 200 so <img> tags never break, and keep it
// uncached so a real render is picked up as soon as one recovers.
const etag = strongEtag(PLACEHOLDER_SVG_BYTES);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": "no-cache, max-age=0",
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": "no-cache, max-age=0",
ETag: etag,
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
} catch {
return NextResponse.json(
{ error: "Failed to fetch avatar from upstream" },
{ status: 502 },
-4
View File
@@ -2,10 +2,6 @@
`studio-client.tsx` composes the workspace, selection and actions. Keep reusable data handling outside its render tree.
## Shared chrome
- `studio-page.tsx`: consistent header (icon tile, title, subtitle, actions) and padding for every studio sub-page; wraps `AdminPageShell`.
## Client responsibilities
- `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array.
+32 -37
View File
@@ -9,20 +9,19 @@ import {
RefreshCw,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
const STATUS_KEYS: Record<BatchItemStatus["status"], string> = {
pending: "status.pending",
started: "status.started",
downloading: "status.downloading",
converting: "status.converting",
writing_db: "status.writingDb",
done: "status.done",
failed: "status.failed",
const statusLabels: Record<BatchItemStatus["status"], string> = {
pending: "Waiting",
started: "Starting",
downloading: "Downloading assets",
converting: "Preparing assets",
writing_db: "Saving",
done: "Imported",
failed: "Failed",
};
function formatDuration(ms: number): string {
@@ -66,7 +65,6 @@ export const BatchProgress = memo(function BatchProgress({
onDismiss?: () => void;
onRetry?: (classnames: string[]) => void;
}) {
const t = useTranslations("admin.studio.batch");
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
@@ -101,20 +99,20 @@ export const BatchProgress = memo(function BatchProgress({
const rate = formatRate(completed, elapsed);
const heading = !done
? cancelling
? t("headingStopping")
: t("headingImporting")
? "Stopping import…"
: "Importing furniture…"
: unfinished
? t("headingStopped")
? "Import stopped"
: errors
? t("headingCompletedErrors")
: t("headingCompleted");
? "Import completed with errors"
: "Import completed";
const issues = verification
? verification.furniDataMissing + verification.furniDataConflicts
: 0;
return (
<section
aria-label={t("ariaProgress")}
aria-label="Import progress"
className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -130,7 +128,7 @@ export const BatchProgress = memo(function BatchProgress({
<span className="font-semibold text-[var(--admin-success)]">
{imported}
</span>{" "}
{t("imported")}
imported
</span>
<span className="text-[var(--admin-border)]">·</span>
<span>
@@ -142,13 +140,13 @@ export const BatchProgress = memo(function BatchProgress({
>
{errors}
</span>{" "}
{t("failed")}
failed
</span>
{unfinished > 0 && (
<>
<span className="text-[var(--admin-border)]">·</span>
<span>
{unfinished} {done ? t("notCompleted") : t("remaining")}
{unfinished} {done ? "not completed" : "remaining"}
</span>
</>
)}
@@ -175,12 +173,12 @@ export const BatchProgress = memo(function BatchProgress({
onClick={() => onRetry(failedClassnames)}
>
<RefreshCw size={14} />
{t("retryFailed", { count: failedClassnames.length })}
Retry failed ({failedClassnames.length})
</Button>
)}
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
{t("closeSummary")}
Close summary
</Button>
</>
) : (
@@ -195,14 +193,14 @@ export const BatchProgress = memo(function BatchProgress({
) : (
<Ban size={14} />
)}
{t("stopImport")}
Stop import
</Button>
)}
</div>
</div>
<div
role="progressbar"
aria-label={t("ariaProcessed")}
aria-label="Furniture processed"
aria-valuemin={0}
aria-valuemax={total || 1}
aria-valuenow={completed}
@@ -217,42 +215,39 @@ export const BatchProgress = memo(function BatchProgress({
<div className="mb-3 flex flex-wrap items-center gap-1.5">
<p className="mr-1 text-sm text-[var(--admin-text-muted)]">
{issues
? t("verificationIssues", { count: issues })
: t("verificationDone")}
? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.`
: "Catalog data verification completed."}
</p>
<NodeChip
count={verification.furniDataFixedIds}
label={t("chipFurnidataIds")}
label="furnidata ids fixed"
/>
<NodeChip
count={verification.offerIdsFixed}
label={t("chipOfferIds")}
label="offer ids reconciled"
/>
<NodeChip
count={verification.ownershipFixed.length}
label={t("chipOwnerships")}
label="ownerships fixed"
/>
<NodeChip
count={verification.nitrosSynced.length}
label={t("chipNitros")}
label="nitros synced"
/>
<NodeChip
count={verification.iconsSynced.length}
label={t("chipIcons")}
label="icons synced"
/>
</div>
)}
<details>
<summary className="cursor-pointer text-xs font-medium">
{t("viewItemDetails", { count: total })}
View item details ({total})
</summary>
<div className="mt-2 max-h-44 space-y-2 overflow-auto">
{entries.length > VISIBLE_ITEMS && (
<p className="px-1 text-[0.65rem] text-[var(--admin-text-muted)]">
{t("showingItems", {
shown: VISIBLE_ITEMS,
total: entries.length,
})}
Showing {VISIBLE_ITEMS} of {entries.length} items
</p>
)}
{entries.slice(0, VISIBLE_ITEMS).map((item) => (
@@ -285,8 +280,8 @@ export const BatchProgress = memo(function BatchProgress({
</span>
<span className="shrink-0 text-[var(--admin-text-muted)]">
{done && item.status !== "done" && item.status !== "failed"
? t("statusNotCompleted")
: t(STATUS_KEYS[item.status])}
? "Not completed"
: statusLabels[item.status]}
</span>
</span>
{item.message && (
+11 -18
View File
@@ -10,7 +10,6 @@ import {
Search,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useMemo, useState, useTransition } from "react";
import {
refreshCatalogTree,
@@ -26,7 +25,6 @@ import type { TreeNode } from "@/types/catalog";
* member has no other signal that the import they just ran is not on screen.
*/
function LiveCatalogNotice() {
const t = useTranslations("admin.studio");
const { refreshError, refreshing } = useLiveCatalog();
const [retrying, startRetry] = useTransition();
if (!refreshError && !refreshing) return null;
@@ -43,7 +41,7 @@ function LiveCatalogNotice() {
{refreshError ? (
<>
<AlertTriangle size={14} className="shrink-0" />
<span className="min-w-0 flex-1">{t("rail.refreshFailed")}</span>
<span className="min-w-0 flex-1">Catalog could not refresh.</span>
<button
type="button"
disabled={retrying}
@@ -55,13 +53,13 @@ function LiveCatalogNotice() {
className="flex items-center gap-1 rounded px-1.5 py-0.5 font-medium underline disabled:opacity-50"
>
<RefreshCw size={12} className={cn(retrying && "animate-spin")} />
{t("rail.retry")}
Retry
</button>
</>
) : (
<>
<Loader2 size={14} className="shrink-0 animate-spin" />
<span className="min-w-0 flex-1">{t("rail.updating")}</span>
<span className="min-w-0 flex-1">Updating catalog…</span>
</>
)}
</p>
@@ -79,7 +77,6 @@ export const CatalogRail = memo(function CatalogRail({
onSelect: (id: number | null) => void;
onClose?: () => void;
}) {
const t = useTranslations("admin.studio");
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [search, setSearch] = useState("");
const visible = useMemo(() => {
@@ -103,7 +100,7 @@ export const CatalogRail = memo(function CatalogRail({
}
return (
<aside
aria-label={t("aria.catalogCategories")}
aria-label="Catalog categories"
className="absolute inset-y-0 left-0 z-30 flex w-64 max-w-[85%] flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:z-auto lg:shrink-0 lg:shadow-none"
>
<div className="flex items-center gap-2 px-3 pt-3">
@@ -112,12 +109,12 @@ export const CatalogRail = memo(function CatalogRail({
style={{ fontSize: 14, margin: 0 }}
className="flex-1 text-sm font-semibold"
>
{t("rail.title")}
Categories
</h2>
<button
type="button"
onClick={onClose}
aria-label={t("aria.closeCategories")}
aria-label="Close categories"
className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
>
<X size={16} />
@@ -128,8 +125,8 @@ export const CatalogRail = memo(function CatalogRail({
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("aria.findCategory")}
aria-label={t("aria.findCategory")}
placeholder="Find a category…"
aria-label="Find a category"
className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none"
/>
</label>
@@ -139,13 +136,13 @@ export const CatalogRail = memo(function CatalogRail({
aria-pressed={selectedId === null}
className="mx-2 mb-2 rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--admin-accent)]/10 aria-pressed:bg-[var(--admin-accent)]/15"
>
{t("rail.all")}
All categories
</button>
<LiveCatalogNotice />
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{visible.length === 0 && (
<p className="p-3 text-sm text-[var(--admin-text-muted)]">
{t("rail.noMatches")}
No matching categories.
</p>
)}
{visible.map((node) => (
@@ -162,11 +159,7 @@ export const CatalogRail = memo(function CatalogRail({
{node.childCount > 0 && !search.trim() ? (
<button
type="button"
aria-label={
collapsed.has(node.id)
? t("aria.expandCategory", { name: node.caption })
: t("aria.collapseCategory", { name: node.caption })
}
aria-label={`${collapsed.has(node.id) ? "Expand" : "Collapse"} ${node.caption}`}
aria-expanded={!collapsed.has(node.id)}
onClick={() => toggle(node.id)}
className="grid h-10 w-8 shrink-0 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
@@ -15,7 +15,11 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { FurnitureInspector } from "./furniture-inspector";
import { getFurniImageUrl, handleImgError } from "./studio-furni-icons";
import {
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import type { FurniItem } from "./studio-types";
interface FurnitureDetailDrawerProps {
@@ -45,10 +49,6 @@ export function FurnitureDetailDrawer({
onRepair,
}: FurnitureDetailDrawerProps) {
const t = useTranslations("admin.studio.completeness");
const studioT = useTranslations("admin.studio");
const drawerT = useTranslations("admin.studio.drawer");
const typeLabel =
detail.type === "wallitem" ? studioT("types.wall") : studioT("types.floor");
const detailPreview = previewAutoCatalog(
detail.classname,
detail.type === "wallitem" ? "i" : "s",
@@ -57,11 +57,11 @@ export function FurnitureDetailDrawer({
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
{drawerT("title")}
Furni detail
</span>
<button
type="button"
aria-label={studioT("aria.closeDetails")}
aria-label="Close furniture details"
onClick={onClose}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
@@ -101,17 +101,17 @@ export function FurnitureDetailDrawer({
) : null}
<div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">{typeLabel}</Badge>
<Badge variant="secondary">
{drawerT("revision", { revision: detail.revision })}
{TYPE_LABELS[detail.type] ?? detail.type}
</Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
</Badge>
) : (
<Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> {drawerT("autoImportReady")}
<Sparkles size={10} /> Auto-import ready
</Badge>
)}
</div>
@@ -127,30 +127,22 @@ export function FurnitureDetailDrawer({
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<WandSparkles size={12} className="text-[var(--admin-accent)]" />
{drawerT("automaticPlacement")}
Automatic placement
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
{drawerT("category")}
</span>
<span className="text-[var(--admin-text-muted)]">Category</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
{drawerT("price")}
</span>
<span className="text-[var(--admin-text-muted)]">Price</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0
? drawerT("priceCreditsPoints", {
credits: detailPreview.credits,
points: detailPreview.points,
})
: drawerT("priceCredits", {
count: detailPreview.credits,
})}
? ` + ${detailPreview.points} points`
: ""}
</span>
</div>
</div>
@@ -171,14 +163,14 @@ export function FurnitureDetailDrawer({
) : (
<CloudDownload size={14} />
)}
{drawerT("importAdd")}
Import & add to catalog
</Button>
) : (
<div className="space-y-2">
{!detail.nitroExists && (
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
{drawerT("bundleMissing")}
This furni is in the DB but its bundle file is missing.
</p>
<Button
variant="outline"
@@ -191,7 +183,7 @@ export function FurnitureDetailDrawer({
) : (
<RefreshCw size={14} />
)}
{drawerT("regenerate")}
Regenerate .nitro
</Button>
</>
)}
@@ -202,7 +194,7 @@ export function FurnitureDetailDrawer({
disabled={!detail.nitroExists}
>
<Pencil size={14} />
{drawerT("editNitro")}
Edit nitro
</Button>
</div>
)}
@@ -214,7 +206,7 @@ export function FurnitureDetailDrawer({
onClick={onDelete}
>
<Trash2 size={14} />
{drawerT("deleteImported")}
Delete imported furni
</Button>
)}
</div>
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
compareFurniture,
@@ -10,21 +9,6 @@ import { FurnitureCompleteness } from "./furniture-completeness";
import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection";
const HEALTH_KEYS = {
complete: "healthComplete",
incomplete: "healthIncomplete",
new: "healthNew",
conflict: "healthConflict",
unknown: "healthUnknown",
} as const;
const FIELD_KEYS = {
Name: "fieldName",
Description: "fieldDescription",
Revision: "fieldRevision",
Type: "fieldType",
} as const;
export function FurnitureComparison({
item,
local,
@@ -32,81 +16,69 @@ export function FurnitureComparison({
item: FurniItem;
local: FurnitureInspection;
}) {
const t = useTranslations("admin.studio.inspector");
const comparison = compareFurniture(item, local);
return (
<div className="space-y-3 text-sm">
<FurnitureCompleteness local={local} />
{!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]">
{t("furnidataUnreadable")}
Local furnidata could not be read. Its values are not verified.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr>
<th className="p-2 text-left">{t("field")}</th>
<th className="p-2 text-left">{t("localNow")}</th>
<th className="p-2 text-left">{t("source")}</th>
<th className="p-2 text-left">Field</th>
<th className="p-2 text-left">Local now</th>
<th className="p-2 text-left">Source</th>
</tr>
</thead>
<tbody>
{comparison.fields.map((field) => {
const labelKey =
FIELD_KEYS[field.label as keyof typeof FIELD_KEYS];
return (
<tr
key={field.label}
className={
field.changed ? "bg-[var(--admin-warning)]/10" : ""
}
>
<th className="p-2 text-left">
{labelKey ? t(labelKey) : field.label}
{field.changed ? ` · ${t("changed")}` : ""}
</th>
<td className="max-w-48 break-words p-2">
{field.before ?? "—"}
</td>
<td className="max-w-48 break-words p-2">
{field.after ?? t("notSupplied")}
</td>
</tr>
);
})}
{comparison.fields.map((field) => (
<tr
key={field.label}
className={field.changed ? "bg-[var(--admin-warning)]/10" : ""}
>
<th className="p-2 text-left">
{field.label}
{field.changed ? " · Changed" : ""}
</th>
<td className="max-w-48 break-words p-2">
{field.before ?? "—"}
</td>
<td className="max-w-48 break-words p-2">
{field.after ?? "Not supplied"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-[var(--admin-text-muted)]">
{t("compareNote")}
Source values are compared with local data. Import may remap IDs and
translate names. Existing furniture keeps its local IDs and SQL
settings; new furniture receives a free local ID when the source ID is
occupied.
</p>
<details>
<summary className="cursor-pointer font-medium">
{t("sqlRecords")}
SQL records and catalog offers
</summary>
<div className="mt-2 space-y-2 text-xs">
{local.sql.length ? (
local.sql.map((row) => (
<p key={row.id}>
{t("itemRow", {
id: row.id,
sprite: row.spriteId,
name: row.name,
})}
Item #{row.id} · Sprite #{row.spriteId} · {row.name}
</p>
))
) : (
<p>{t("noSqlItem")}</p>
<p>No local SQL item.</p>
)}
{local.catalog.map((row) => (
<p key={row.id}>
{t("offerRow", {
id: row.id,
page: row.pageId,
credits: row.credits,
points: row.points,
})}
Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "}
{row.points} points
</p>
))}
</div>
@@ -123,12 +95,11 @@ export function FurnitureInspector({
onRepair?: () => Promise<unknown>;
busy?: boolean;
}) {
const t = useTranslations("admin.studio.inspector");
const inspection = useFurnitureInspection([item.classname]);
if (inspection.loading)
return (
<p role="status" className="py-3 text-sm">
{t("checking")}
Checking local data and assets…
</p>
);
if (inspection.error)
@@ -136,7 +107,7 @@ export function FurnitureInspector({
<div role="alert">
<p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}>
{t("retry")}
Retry inspection
</Button>
</div>
);
@@ -147,35 +118,51 @@ export function FurnitureInspector({
return local && health ? (
<div className="space-y-3">
<section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2">
<strong>{t(HEALTH_KEYS[health.state])}</strong>
<strong>
{
{
complete: "Complete",
incomplete: "Needs repair",
new: "Not imported",
conflict: "Local conflict",
unknown: "Not verified",
}[health.state]
}
</strong>
{health.missing.length > 0 && (
<p className="text-sm">
{t("missing", { parts: health.missing.join(", ") })}
</p>
<p className="text-sm">Missing: {health.missing.join(", ")}</p>
)}
{health.state === "conflict" && (
<p className="text-sm">{t("conflictNote")}</p>
<p className="text-sm">
Duplicate records or mismatched IDs/types require review before
repair.
</p>
)}
{health.state === "unknown" && (
<p className="text-sm">{t("unknownNote")}</p>
<p className="text-sm">
Some local data could not be checked. Retry verification before
repair.
</p>
)}
{health.state === "incomplete" && onRepair && (
<>
<p className="text-sm text-[var(--admin-text-muted)]">
{t("repairNote")}
Repair adds missing parts and preserves existing IDs, settings,
furnidata and catalog offers. Missing offers use the automatic
category and price shown below.
</p>
<Button disabled={busy} onClick={() => void onRepair()}>
{busy ? t("repairQueued") : t("repair")}
{busy ? "Repair queued or running…" : "Repair furni"}
</Button>
</>
)}
<Button variant="outline" disabled={busy} onClick={inspection.retry}>
{t("recheck")}
Recheck local state
</Button>
</section>
<FurnitureComparison item={item} local={local} />
</div>
) : (
<p>{t("unavailable")}</p>
<p>Local inspection unavailable.</p>
);
}
+51 -49
View File
@@ -41,7 +41,6 @@ export function ImportReview({
onConfirm: (items: FurniItem[]) => void;
}) {
const preflightT = useTranslations("admin.studio.preflight");
const reviewT = useTranslations("admin.studio.review");
const [reviewFilter, setReviewFilter] = useState<
"all" | "complete" | "conflict" | "verify"
>("all");
@@ -60,16 +59,14 @@ export function ImportReview({
body: form,
});
const data = await response.json();
if (!response.ok) throw Error(data.error || reviewT("uploadFailed"));
if (!response.ok) throw Error(data.error || "Upload failed");
setAttachments((current) => ({
...current,
[item.classname]: data.attachmentId,
}));
toast.success(diagnosticsT("attached"));
} catch (error) {
toast.error(
error instanceof Error ? error.message : reviewT("uploadFailed"),
);
toast.error(error instanceof Error ? error.message : "Upload failed");
} finally {
setUploading(null);
}
@@ -137,44 +134,46 @@ export function ImportReview({
<DialogHeader>
<DialogTitle>
{step === "review"
? reviewT("titleReview")
: reviewT("titleConfirm")}
? "Review furniture import"
: "Confirm furniture import"}
</DialogTitle>
<DialogDescription>{reviewT("steps")}</DialogDescription>
<DialogDescription>
1. Select → 2. Review → 3. Import
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
<div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm">
<p>
<strong>{reviewT("source")}</strong> {source}
<strong>Source:</strong> {source}
</p>
<p>
<strong>{reviewT("destination")}</strong>{" "}
{reviewT("destinationValue")}
<strong>Destination:</strong> local catalog, furnidata, bundle and
icons
</p>
<p>
<strong>{reviewT("translation")}</strong> {translation}
<strong>Translation:</strong> {translation}
</p>
<p>
<strong>{reviewT("selection")}</strong>{" "}
{reviewT("selectionCount", { count: items.length })}
<strong>Selection:</strong> {items.length} furniture items
</p>
</div>
{assets.loading && !inspection.loading && (
<p role="status">
{reviewT("checkingSource", { count: assets.items.length })}
Checking source files… {assets.items.length} checked
</p>
)}
{assets.error && (
<div role="alert">
<p>{assets.error}</p>
<Button variant="outline" onClick={assets.retry}>
{reviewT("retrySource")}
Retry source check
</Button>
</div>
)}
{assets.items.some((item) => item.state === "unknown") && (
<p className="text-sm text-[var(--admin-warning)]">
{reviewT("unknownSources")}
Some sources could not be verified. The import will still try to
download their files.
</p>
)}
{unavailable.map((check) => (
@@ -183,7 +182,7 @@ export function ImportReview({
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
>
<p className="text-sm font-medium">
{reviewT("sourceNotFound", { name: check.classname })}
Source files not found by preliminary check: {check.classname}
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
{progressT("recoveryHint")}
@@ -191,25 +190,24 @@ export function ImportReview({
</section>
))}
{inspection.loading ? (
<p role="status">{reviewT("checkingLocal")}</p>
<p role="status">Checking local data and assets…</p>
) : inspection.error ? (
<div role="alert">
<p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}>
{reviewT("retryInspection")}
Retry inspection
</Button>
</div>
) : !complete ? (
<p role="alert">{reviewT("incomplete")}</p>
<p role="alert">
Inspection is incomplete. Close and retry before importing.
</p>
) : (
<>
<p role="status" className="text-sm">
{reviewT("summary", {
ready: ready.length,
existing,
blocked,
warnings: unavailable.length,
})}
{ready.length} ready to import · {existing} already in SQL
(complete missing data) · {blocked} conflicts ·{" "}
{unavailable.length} source file warnings
</p>
{blocked > 0 && (
<div
@@ -234,7 +232,8 @@ export function ImportReview({
item.nitro.exists === null,
) && (
<p className="text-sm text-[var(--admin-warning)]">
{reviewT("localUnverified")}
Some local files could not be verified. Review the item
details before continuing.
</p>
)}
{step === "review" ? (
@@ -330,14 +329,14 @@ export function ImportReview({
</span>
<span>
{comparison.state === "new"
? reviewT("statusNew")
? "New"
: comparison.state === "changed"
? reviewT("statusChanged")
? "Differences"
: comparison.state === "conflict"
? reviewT("statusConflict")
? "Conflict"
: comparison.state === "unknown"
? reviewT("statusUnknown")
: reviewT("statusPresent")}
? "Not fully verified"
: "Present"}
</span>
</button>
{expanded === item.classname && (
@@ -359,9 +358,7 @@ export function ImportReview({
}}
/>
{uploading === item.classname && (
<span role="status">
{reviewT("validatingFile")}
</span>
<span role="status">Validating file…</span>
)}
</label>
)}
@@ -384,10 +381,16 @@ export function ImportReview({
) : (
<>
<p className="text-sm">
{reviewT("confirmIntro", { count: ready.length })}
Only the {ready.length} eligible items below will be
submitted. Existing furniture keeps its local IDs, SQL
settings and catalog offers. Missing assets and catalog
entries are completed; furnidata is refreshed. Conflicts are
excluded.
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
{reviewT("confirmEstimates")}
Automatic categories and prices below are estimates. Source
rules may override them; final results appear in the import
summary.
</p>
<ul className="space-y-2">
{pageItems.map((item) => {
@@ -402,11 +405,8 @@ export function ImportReview({
>
<strong>{item.name}</strong>
<p>
{reviewT("placement", {
category: placement.categoryLabel,
credits: placement.credits,
points: placement.points,
})}
{placement.categoryLabel} · {placement.credits}{" "}
credits + {placement.points} points
</p>
</li>
);
@@ -424,15 +424,17 @@ export function ImportReview({
disabled={page === 0}
onClick={() => setPage((value) => value - 1)}
>
{reviewT("previous")}
Previous items
</Button>
<span>{reviewT("page", { page: page + 1, total: pageCount })}</span>
<span>
Page {page + 1} / {pageCount}
</span>
<Button
variant="outline"
disabled={page + 1 === pageCount}
onClick={() => setPage((value) => value + 1)}
>
{reviewT("next")}
Next items
</Button>
</div>
)}
@@ -449,7 +451,7 @@ export function ImportReview({
: onCancel
}
>
{step === "confirm" ? reviewT("backToReview") : reviewT("cancel")}
{step === "confirm" ? "Back to review" : "Cancel"}
</Button>
<Button
style={{
@@ -478,8 +480,8 @@ export function ImportReview({
}}
>
{step === "review"
? reviewT("continue")
: reviewT("startImport", { count: ready.length })}
? "Continue to confirmation"
: `Start import (${ready.length})`}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
@@ -16,7 +15,6 @@ 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,
@@ -28,20 +26,20 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
return (
<section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">
{t("compressionTitle", { format: texture.format.toUpperCase() })}
Compression · {texture.format.toUpperCase()}
{texture.format === "webp"
? texture.lossless
? ` ${t("losslessVp8l")}`
: ` ${t("lossyVp8")}`
? " Lossless (VP8L)"
: " Lossy (VP8)"
: ""}
</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">{t("bundle")}</dt>
<dt className="text-muted-foreground">Bundle</dt>
<dd className="font-medium">{kb(report.bundleBytes)}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("texture")}</dt>
<dt className="text-muted-foreground">Texture</dt>
<dd className="font-medium">
{kb(texture.compressedBytes)}
<span className="text-muted-foreground">
@@ -51,85 +49,75 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("metadata")}</dt>
<dt className="text-muted-foreground">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">{t("container")}</dt>
<dt className="text-muted-foreground">Container</dt>
<dd className="font-medium">{kb(report.overheadBytes)}</dd>
</div>
</dl>
<p className="text-xs text-muted-foreground">
{t("textureDetail", {
raw: kb(texture.rawBytes),
ratio: texture.ratio,
width: texture.width,
height: texture.height,
frames: texture.spriteCount,
})}
Texture {kb(texture.rawBytes)} decoded · deflate ×{texture.ratio} ·
sheet {texture.width}×{texture.height} · {texture.spriteCount} frames
</p>
{texture.format === "png" ? (
<p className="text-xs">
{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>,
})}
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)"
)}
</p>
) : (
<p className="text-xs">
{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),
})}
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)"
)}
</p>
)}
<p className="text-xs">
{visibleDiffs === 0 ? (
<>
{t.rich("pixelExact", {
strong: (chunks) => (
<span className="font-medium text-foreground">{chunks}</span>
),
})}
<span className="font-medium text-foreground">Pixel-exact</span> —
every visible texel round-trips unchanged.
{texture.invisibleDifferences > 0 && (
<span className="text-muted-foreground">
{" "}
{t("invisibleDiffs", {
count: texture.invisibleDifferences,
})}
{texture.invisibleDifferences.toLocaleString()} fully
transparent texels carry no RGB, which VP8L discards and no
renderer can see.
</span>
)}
</>
) : (
<strong className="text-destructive">
{t("texelsDiffer", { count: visibleDiffs })}
{visibleDiffs.toLocaleString()} texel
{visibleDiffs === 1 ? "" : "s"} differ after a lossless round-trip —
the furniture may not render identically.
</strong>
)}
</p>
@@ -148,34 +136,32 @@ 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">
{t("scaleTitle", { size: report.size, state: stateLabel })}
Scale {report.size} ·{" "}
{report.state === "missing"
? "Not included"
: report.state === "issues"
? "Needs review"
: report.derived
? "Derived from 64"
: "Present"}
</h3>
<p className="text-xs text-muted-foreground">
{t("spritesLine", {
sprites: report.assets.length,
animations: report.animations.length,
directions: report.directions.join(", ") || "—",
})}
{report.assets.length} sprites · {report.animations.length} animation
definitions · directions {report.directions.join(", ") || "—"}
</p>
{report.state === "missing" && (
<p className="text-sm">{t("missingScale")}</p>
<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>
)}
{report.assets.length > 0 && (
<>
<label className="block text-sm">
{t("sprite")}
Sprite
<select
className="mt-1 block w-full rounded border bg-background p-2"
value={asset}
@@ -193,7 +179,7 @@ function ScalePanel({
</label>
<div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30">
{failed ? (
<p role="alert">{t("renderFailed")}</p>
<p role="alert">This sprite could not be rendered.</p>
) : (
<img
key={`${asset}-${revision}`}
@@ -224,7 +210,6 @@ 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);
@@ -239,32 +224,35 @@ export function NitroQualityPanel({ classname }: { classname: string }) {
{ signal: controller.signal },
);
const data = await response.json();
if (!response.ok) throw Error(data.error || t("inspectionFailed"));
if (!response.ok) throw Error(data.error || "Inspection failed");
if (!controller.signal.aborted) setReport(data.report);
} catch (error) {
if (!controller.signal.aborted)
setError(
error instanceof Error ? error.message : t("inspectionFailed"),
error instanceof Error ? error.message : "Inspection failed",
);
}
})();
return () => controller.abort();
}, [classname, revision, t]);
}, [classname, revision]);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm">{t("intro")}</p>
<p className="text-sm">
Inspect the saved bundle at both scales. Previews show individual
sprites, not the assembled furniture or animation playback.
</p>
<Button
variant="outline"
onClick={() => setRevision((value) => value + 1)}
>
{t("recheck")}
Recheck
</Button>
</div>
{error ? (
<p role="alert">{error}</p>
) : !report ? (
<p role="status">{t("checking")}</p>
<p role="status">Checking scales and texture references…</p>
) : (
<div className="space-y-3">
{report.compression && (
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
@@ -12,7 +11,6 @@ export function NitroScale32Generator({
disabled: boolean;
onChanged: (metadata: Record<string, unknown>) => void;
}) {
const t = useTranslations("admin.studio.scale32");
const [preview, setPreview] = useState<{
hash: string;
before: string;
@@ -40,7 +38,7 @@ export function NitroScale32Generator({
}),
});
const data = await response.json();
if (!response.ok) throw Error(data.error || t("generationFailed"));
if (!response.ok) throw Error(data.error || "Generation failed");
if (action === "preview") setPreview(data);
else {
setPreview(null);
@@ -52,37 +50,45 @@ export function NitroScale32Generator({
onChanged(data.metadata);
}
} catch (e) {
setError(e instanceof Error ? e.message : t("generationFailed"));
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">{t("title")}</h3>
<p className="text-sm text-muted-foreground">{t("intro")}</p>
{disabled && <p className="text-sm">{t("saveFirst")}</p>}
<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 ? t("working") : t("preview")}
{busy ? "Working…" : "Preview generation"}
</Button>
{preview && (
<>
<p className="text-sm">
{t("previewCount", { count: preview.count })}
{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">
{t("skipped", { count: preview.skipped })}
{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">
{[
[t("original64"), preview.before],
[t("derived32"), preview.after],
["Original 64", preview.before],
["Derived 32", preview.after],
].map(([label, src]) => (
<figure key={label} className="min-w-0">
<figcaption>{label}</figcaption>
@@ -97,26 +103,28 @@ export function NitroScale32Generator({
))}
</div>
<Button disabled={busy || disabled} onClick={() => run("apply")}>
{t("saveWithBackup")}
Save with backup
</Button>
<Button
variant="ghost"
disabled={busy}
onClick={() => setPreview(null)}
>
{t("discard")}
Discard preview
</Button>
</>
)}
{undo && (
<div>
<p className="text-sm">{t("saved")}</p>
<p className="text-sm">
Saved. A copy of the original bundle is preserved.
</p>
<Button
variant="outline"
disabled={busy || disabled}
onClick={() => run("restore")}
>
{t("restore")}
Restore original bundle
</Button>
</div>
)}
@@ -851,10 +851,10 @@ export function OrganizeImportsDialog({
</SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-xs">
{t("pointsTypeDuckets")}
Duckets
</SelectItem>
<SelectItem value="5" className="text-xs">
{t("pointsTypeDiamonds")}
Diamonds
</SelectItem>
</SelectContent>
</Select>
@@ -1571,7 +1571,7 @@ export function OrganizeImportsDialog({
</span>
</div>
<Badge variant="outline" className="text-[10px]">
{previewGroup.items.length} {t("itemsUnit")}
{previewGroup.items.length} items
</Badge>
</div>
{previewGroup.layout &&
@@ -1,7 +1,6 @@
"use client";
import { FolderTree } from "lucide-react";
import { useTranslations } from "next-intl";
import { lazy, Suspense, useState } from "react";
import { StudioPanelLoading } from "./studio-panel-loading";
@@ -12,7 +11,6 @@ const OrganizeImportsDialog = lazy(() =>
);
export function OrganizeImportsLauncher() {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const [open, setOpen] = useState(false);
const [requested, setRequested] = useState(false);
return (
@@ -26,7 +24,7 @@ export function OrganizeImportsLauncher() {
className="flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10"
>
<FolderTree size={16} />
{t("title")}
Organize imports
</button>
{requested && (
<Suspense
File diff suppressed because it is too large. Load diff
@@ -1,37 +0,0 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
interface StudioPageProps {
icon?: LucideIcon;
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}
/**
* Consistent chrome for Catalog Studio sub-pages: the shared admin page
* header (icon tile, title, subtitle, optional actions) with the studio's
* standard padding, so every tool page lines up the same way.
*/
export function StudioPage({
icon,
title,
subtitle,
actions,
children,
}: StudioPageProps) {
return (
<div className="p-3 sm:p-4">
<AdminPageShell
icon={icon}
title={title}
subtitle={subtitle}
actions={actions}
>
{children}
</AdminPageShell>
</div>
);
}
@@ -1,5 +1,4 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { setFurnidataTranslateEnabled } from "@/actions/import-furni";
@@ -25,7 +24,6 @@ const TRANSLATE_LANGS = [
] as const;
export function useStudioTranslations(defaultTranslate: boolean) {
const t = useTranslations("admin.studio");
// Translation controls (per-import toggle + language selection, plus a global
// default that persists to the site setting and seeds the per-import toggle).
const ALL_LANGS = TRANSLATE_LANGS as readonly string[];
@@ -54,16 +52,13 @@ export function useStudioTranslations(defaultTranslate: boolean) {
const langs = data.languages ?? [];
const okCount = langs.filter((l) => l.ok).length;
toast.success(
t("toasts.languagesTranslated", {
ok: okCount,
total: langs.length,
}),
`Languages translated (${okCount}/${langs.length} completed)`,
);
} else {
toast.error(data.error ?? t("toasts.translationFailed"));
toast.error(data.error ?? "Translation failed");
}
} catch {
toast.error(t("toasts.networkTranslating"));
toast.error("Network error while translating languages");
} finally {
setBuildingLanguages(false);
}
@@ -80,7 +75,7 @@ export function useStudioTranslations(defaultTranslate: boolean) {
try {
await setFurnidataTranslateEnabled({ enabled: next });
} catch {
toast.error(t("toasts.saveDefaultFailed"));
toast.error("Could not save the default translation setting");
}
};
-3
View File
@@ -75,9 +75,6 @@ const schema = z
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset.
BADGE_UPLOAD_DIR: z.string().optional(),
// Extension local furniture bundles are written with and the client
// requests: ".hab" (default) or ".nitro". Accepts the bare form too.
FURNI_BUNDLE_EXT: z.string().optional(),
// Emulator JAR backup job (jobs-worker, host-side); no-op unless both set.
EMULATOR_JAR_PATH: z.string().optional(),
EMULATOR_BACKUP_DIR: z.string().optional(),
-107
View File
@@ -1,107 +0,0 @@
// @ts-nocheck
import { rm } from "node:fs/promises";
import { NextRequest } from "next/server";
import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
import { GET } from "../app/api/imaging/badge/route";
const SCRATCH_ROOT = `${process.cwd()}/storage/imaging/test-badge-route-${process.pid}`;
beforeEach(async () => {
// Cold disk cache for every test so the candidate walk actually runs.
await rm(SCRATCH_ROOT, { recursive: true, force: true });
vi.stubEnv("IMAGING_CACHE_ROOT", SCRATCH_ROOT);
// A configured CDN short-circuits the database lookup for badge_base_url.
vi.stubEnv("BADGE_URL", "https://badges.test/album1584");
globalThis.imagingBadgeFetches?.clear?.();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
afterAll(async () => {
await rm(SCRATCH_ROOT, { recursive: true, force: true });
});
function gifResponse() {
return new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "image/gif" },
});
}
it("shares a single upstream fetch across concurrent cold-cache requests", async () => {
let calls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
calls += 1;
await new Promise((resolve) => setTimeout(resolve, 20));
return gifResponse();
}),
);
const request = () =>
new NextRequest("https://hotel.test/api/imaging/badge?code=ADM");
const [a, b] = await Promise.all([GET(request()), GET(request())]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(a.headers.get("content-type")).toBe("image/gif");
expect(calls).toBe(1);
});
it("serves a previously fetched badge from disk without re-fetching", async () => {
const fetcher = vi.fn(async () => gifResponse());
vi.stubGlobal("fetch", fetcher);
const request = new NextRequest(
"https://hotel.test/api/imaging/badge?code=HABBO",
);
const first = await GET(request);
expect(first.status).toBe(200);
const callsAfterFirst = fetcher.mock.calls.length;
expect(callsAfterFirst).toBeGreaterThan(0);
const second = await GET(request);
expect(second.status).toBe(200);
expect(second.headers.get("x-imager-source")).toBe("cache");
expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
});
it("revalidates a cached badge with a 304 when the client already has it", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => gifResponse()),
);
const url = "https://hotel.test/api/imaging/badge?code=ETAG";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
});
it("serves a placeholder when every candidate is unavailable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 404 })),
);
const result = await GET(
new NextRequest("https://hotel.test/api/imaging/badge?code=GONE"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/svg+xml");
expect(result.headers.get("x-imager-source")).toBe("unavailable");
});
it("rejects malformed badge codes without contacting any candidate", async () => {
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
const result = await GET(
new NextRequest("https://hotel.test/api/imaging/badge?code=../bad"),
);
expect(result.status).toBe(400);
expect(fetcher).not.toHaveBeenCalled();
});
+1 -17
View File
@@ -1,5 +1,5 @@
// @ts-nocheck
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { buildContentSecurityPolicy, createCspNonce } from "./csp";
describe("csp", () => {
@@ -19,20 +19,4 @@ describe("csp", () => {
expect(csp).toContain("https://challenges.cloudflare.com");
expect(csp).not.toContain("https://cdn.jsdelivr.net");
});
it("blocks framing and upgrades insecure requests outside development", () => {
vi.stubEnv("NODE_ENV", "production");
const csp = buildContentSecurityPolicy("n");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("upgrade-insecure-requests");
vi.unstubAllEnvs();
});
it("omits upgrade-insecure-requests in development", () => {
vi.stubEnv("NODE_ENV", "development");
const csp = buildContentSecurityPolicy("n");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).not.toContain("upgrade-insecure-requests");
vi.unstubAllEnvs();
});
});
-6
View File
@@ -28,17 +28,11 @@ export function buildContentSecurityPolicy(nonce: string): string {
"style-src-attr 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
// Clickjacking defence that also covers the `frame-ancestors`-aware
// browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is
// production-only: over plain-HTTP localhost it would rewrite same-origin
// asset URLs to https and break the dev server.
"frame-ancestors 'none'",
"connect-src 'self' https: wss:",
"font-src 'self' data: https://fonts.gstatic.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
...(isDev ? [] : ["upgrade-insecure-requests"]),
].join("; ");
}
-4
View File
@@ -14,10 +14,6 @@ describe("classifyDdos", () => {
["/api/auth/callback/credentials", "auth"],
["/api/articles/hello", "api"],
["/api/radio/stream", "api"],
// Imaging is deliberately in the `api` bucket: it is high-volume and
// cacheable, so it gets the generous api budget, not the page budget.
["/api/imaging/avatar", "api"],
["/api/imaging/badge", "api"],
])(`classifies %s as %s`, (pathname, expected) => {
expect(classifyDdos(pathname)).toBe(expected);
});
-8
View File
@@ -1,13 +1,5 @@
export type DdosCategory = "pages" | "api" | "auth";
/**
* Bucket a request path for rate limiting. Everything under `/api/` that is not
* an auth route counts as `api` — including `/api/imaging/*` (avatars, badges).
* That is intentional: imaging is a deliberate high-volume, cacheable surface,
* so it gets the wider api budget rather than the page budget. The bare
* `/imaging` renderer path never reaches this gate (it is excluded by the proxy
* matcher and, in production, fronted by the edge proxy directly).
*/
export function classifyDdos(pathname: string): DdosCategory {
if (
pathname === "/api/auth" ||
+16 -14
View File
@@ -188,27 +188,29 @@ describe("Docker clone updates", () => {
expect(r.calls).not.toContain("docker volume prune");
expect(r.calls).not.toContain("docker system prune");
});
it.each(["dirty", "pull-failure", "build-failure", "migration-failure"])(
"does not replace the container after %s",
(scenario) => {
const r = simulate(scenario);
expect(r.status, r.output).not.toBe(0);
expect(r.calls).not.toContain("compose up");
},
);
it.each([
"dirty",
"ci-active",
"ci-green",
"ci-upstream",
"pull-failure",
"build-failure",
"migration-failure",
])("does not replace the container after %s", (scenario) => {
const r = simulate(scenario);
expect(r.status, r.output).not.toBe(0);
expect(r.calls).not.toContain("compose up");
});
// Regressie: na een cutover naar het groene slot bestaat epicnext-cms-app
// niet meer en zag deze controle alleen een vrijgekomen blauwe container. De
// dagelijkse `docker-update.sh` startte toen een compose-replica op poort
// 3002 en blokkeerde elke volgende release. De blauwe container alleen
// controleren is dus geen bewijs dat de host niet door CI beheerd wordt.
//
// A host owned by CI is a legitimate no-op, not an error: the cron run must
// exit 0 so it does not look like a failed deployment.
it.each(["ci-active", "ci-green", "ci-upstream"])(
"skips the compose deployment while CI owns the host (%s)",
it.each(["ci-green", "ci-upstream"])(
"refuses a compose deployment while CI owns the host (%s)",
(scenario) => {
const r = simulate(scenario);
expect(r.status, r.output).toBe(0);
expect(r.status, r.output).not.toBe(0);
expect(r.output).toContain("This host is managed by CI");
expect(r.calls).not.toContain("compose up");
},
+2 -3
View File
@@ -5,9 +5,8 @@ import { LOCAL_BUNDLE_EXT, LOCAL_BUNDLE_EXTS } from "./classname";
/**
* Local furniture-bundle lookups.
*
* New bundles are written with the configured primary extension
* (`FURNI_BUNDLE_EXT`, `.hab` by default), but a large body of pre-existing
* assets may be `.nitro`, so every existence check has to consider both. These
* Bundles are written as `<classname>.hab`, but a large body of pre-existing
* assets is still `.nitro`, so every existence check has to consider both. These
* helpers are the single place that knows that, which keeps "is this item's
* asset present?" from silently flipping to `false` for legacy files and the
* nitro-cleanup scan from classifying them as fake leftovers.
+1 -40
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
getBaseClassname,
getBundleFileName,
@@ -11,7 +11,6 @@ import {
getSwfFileName,
isValidClassname,
normalizeClassname,
resolveLocalBundleExt,
SOURCE_BUNDLE_EXT,
} from "./classname";
@@ -62,44 +61,6 @@ describe("normalizeClassname", () => {
});
});
describe("resolveLocalBundleExt", () => {
it("defaults to .hab", () => {
expect(resolveLocalBundleExt(undefined)).toBe(".hab");
expect(resolveLocalBundleExt("")).toBe(".hab");
});
it("accepts .nitro and the bare form", () => {
expect(resolveLocalBundleExt(".nitro")).toBe(".nitro");
expect(resolveLocalBundleExt("nitro")).toBe(".nitro");
expect(resolveLocalBundleExt("NITRO")).toBe(".nitro");
});
it("accepts .hab and the bare form", () => {
expect(resolveLocalBundleExt(".hab")).toBe(".hab");
expect(resolveLocalBundleExt("HAB")).toBe(".hab");
});
it("falls back to .hab for unknown values", () => {
expect(resolveLocalBundleExt("swf")).toBe(".hab");
});
});
describe("FURNI_BUNDLE_EXT wiring", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it("prefers the configured extension and keeps the other as fallback", async () => {
vi.stubEnv("FURNI_BUNDLE_EXT", ".nitro");
vi.resetModules();
const mod = await import("./classname");
expect(mod.LOCAL_BUNDLE_EXT).toBe(".nitro");
expect(mod.getNitroFileName("sofa_blue")).toBe("sofa_blue.nitro");
expect(mod.getLocalBundleFileNames("sofa_blue")).toEqual([
"sofa_blue.nitro",
"sofa_blue.hab",
]);
});
});
describe("isValidClassname", () => {
it("accepts lowercase letters, digits and allowed symbols", () => {
expect(isValidClassname("chair_basic")).toBe(true);
+6 -44
View File
@@ -17,61 +17,23 @@ export function getSwfFileName(classname: string): string {
return `${getBaseClassname(classname)}.swf`;
}
/** The two extensions a furniture bundle may carry. */
export const BUNDLE_EXT_CHOICES = [".hab", ".nitro"] as const;
export type FurnitureBundleExt = (typeof BUNDLE_EXT_CHOICES)[number];
/**
* Read an env value via a computed key so Next does not inline it at build time
* — changing it should take effect on restart, not require a rebuild (same
* pattern as `runtime-asset-config.ts`).
*/
function readBundleExtEnv(): string | undefined {
const name = "FURNI_BUNDLE_EXT";
return process.env[name]?.trim() || undefined;
}
/**
* Resolve the local furniture-bundle extension this deployment's client asks
* for. Set `FURNI_BUNDLE_EXT` in `.env` to `hab`/`.hab` or `nitro`/`.nitro`;
* anything missing or unrecognised falls back to `.hab`.
*/
export function resolveLocalBundleExt(
raw: string | undefined = readBundleExtEnv(),
): FurnitureBundleExt {
const value = raw?.trim().toLowerCase();
if (!value) return ".hab";
const normalized = value.startsWith(".") ? value : `.${value}`;
return (BUNDLE_EXT_CHOICES as readonly string[]).includes(normalized)
? (normalized as FurnitureBundleExt)
: ".hab";
}
/**
* Extension locally stored furniture bundles are written with. This deployment's
* Nitro client requests `<classname>.hab` by default, but a stock Nitro client
* asks for `.nitro` — choose with `FURNI_BUNDLE_EXT`. New imports and repairs
* produce this extension.
* Nitro client requests `<classname>.hab`, so that is what new imports produce.
*/
export const LOCAL_BUNDLE_EXT: FurnitureBundleExt = resolveLocalBundleExt();
export const LOCAL_BUNDLE_EXT = ".hab";
/**
* Extension remote sources serve bundles with. Upstream (Habbo's CDN and every
* configured clone source) keeps shipping `.nitro`, so download URLs are built
* with this and only re-written to `LOCAL_BUNDLE_EXT` once they land on disk.
*/
export const SOURCE_BUNDLE_EXT = ".nitro" as const;
export const SOURCE_BUNDLE_EXT = ".nitro";
/**
* Every extension a local bundle may be stored under, the configured one first
* so it always wins over a same-stem legacy file.
*/
export const LOCAL_BUNDLE_EXTS: readonly FurnitureBundleExt[] = [
LOCAL_BUNDLE_EXT,
...BUNDLE_EXT_CHOICES.filter((ext) => ext !== LOCAL_BUNDLE_EXT),
];
/** Both extensions a local bundle may carry, newest convention first. */
export const LOCAL_BUNDLE_EXTS = [LOCAL_BUNDLE_EXT, SOURCE_BUNDLE_EXT] as const;
/** Primary local bundle name — the configured extension wins over the legacy one. */
/** Primary local bundle name — `.hab` wins over a legacy `.nitro`. */
export function getNitroFileName(classname: string): string {
return `${getBaseClassname(classname)}${LOCAL_BUNDLE_EXT}`;
}
-39
View File
@@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import { ifNoneMatchSatisfied, strongEtag } from "./imager-http";
describe("strongEtag", () => {
it("is deterministic and quote-wrapped", () => {
const body = new Uint8Array([1, 2, 3]);
expect(strongEtag(body)).toBe(strongEtag(new Uint8Array([1, 2, 3])));
expect(strongEtag(body)).toMatch(/^"[A-Za-z0-9_-]+"$/);
});
it("changes when the body changes", () => {
expect(strongEtag(new Uint8Array([1, 2, 3]))).not.toBe(
strongEtag(new Uint8Array([1, 2, 4])),
);
});
});
describe("ifNoneMatchSatisfied", () => {
const etag = strongEtag(new Uint8Array([9]));
it("returns false without a header", () => {
expect(ifNoneMatchSatisfied(null, etag)).toBe(false);
expect(ifNoneMatchSatisfied("", etag)).toBe(false);
});
it("matches an exact, weak or wildcard validator", () => {
expect(ifNoneMatchSatisfied(etag, etag)).toBe(true);
expect(ifNoneMatchSatisfied(`W/${etag}`, etag)).toBe(true);
expect(ifNoneMatchSatisfied("*", etag)).toBe(true);
});
it("matches any entry in a comma-separated list", () => {
expect(ifNoneMatchSatisfied(`"other", ${etag}`, etag)).toBe(true);
});
it("does not match a different validator", () => {
expect(ifNoneMatchSatisfied('"other"', etag)).toBe(false);
});
});
-33
View File
@@ -1,33 +0,0 @@
import "server-only";
import { createHash } from "node:crypto";
/**
* Strong HTTP validator for an imaging response body.
*
* Derived from the bytes rather than the request URL on purpose: the same URL
* can legitimately return different bytes over its lifetime — a degraded
* fallback render before the configured imager recovers, or a different
* transcode variant — and a URL-based validator would let a browser keep the
* worse image via a stale `304`. Hashing the body means the ETag changes
* exactly when the bytes do.
*/
export function strongEtag(body: Uint8Array): string {
return `"${createHash("sha1").update(body).digest("base64url")}"`;
}
/**
* Whether an `If-None-Match` request header matches `etag`. Handles the
* comma-separated list form, weak validators (`W/"…"`) and the `*` wildcard, so
* a revalidating browser gets a `304` whenever its cached copy still matches.
*/
export function ifNoneMatchSatisfied(
header: string | null,
etag: string,
): boolean {
if (!header) return false;
return header.split(",").some((raw) => {
const tag = raw.trim();
return tag === "*" || tag === etag || tag === `W/${etag}`;
});
}
-62
View File
@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { isAnimatedPng } from "./imager-png";
function png(chunks: Array<{ type: string; data?: number[] }>): Uint8Array {
const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (const chunk of chunks) {
const data = chunk.data ?? [];
const len = data.length;
bytes.push(
(len >>> 24) & 0xff,
(len >>> 16) & 0xff,
(len >>> 8) & 0xff,
len & 0xff,
...chunk.type.split("").map((c) => c.charCodeAt(0)),
...data,
0,
0,
0,
0,
);
}
return new Uint8Array(bytes);
}
describe("isAnimatedPng", () => {
it("returns false for non-PNG input", () => {
expect(isAnimatedPng(new Uint8Array([1, 2, 3]))).toBe(false);
expect(isAnimatedPng(new Uint8Array())).toBe(false);
});
it("returns false for a plain PNG", () => {
const plain = png([{ type: "IHDR" }, { type: "IDAT" }, { type: "IEND" }]);
expect(isAnimatedPng(plain)).toBe(false);
});
it("returns true when an acTL chunk precedes IDAT", () => {
const anim = png([
{ type: "IHDR" },
{ type: "acTL", data: [0, 16, 0, 0] },
{ type: "IDAT" },
{ type: "IEND" },
]);
expect(isAnimatedPng(anim)).toBe(true);
});
it("ignores an acTL chunk that appears after IDAT", () => {
const late = png([
{ type: "IHDR" },
{ type: "IDAT" },
{ type: "acTL", data: [0, 16, 0, 0] },
]);
expect(isAnimatedPng(late)).toBe(false);
});
it("does not loop forever on a corrupt chunk length", () => {
const corrupt = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xff, 0xff, 0xff,
0x49, 0x48, 0x44, 0x52,
]);
expect(isAnimatedPng(corrupt)).toBe(false);
});
});
-43
View File
@@ -1,43 +0,0 @@
import "server-only";
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
/**
* Whether a buffer is an animated PNG (APNG).
*
* APNG is a PNG carrying an `acTL` control chunk before `IDAT`; a plain PNG has
* none. `sharp`/libvips in this build cannot decode APNG *animation* — it reads
* only the first frame and re-encodes that — so a WebP/AVIF transcode of an
* APNG silently turns a moving avatar into a still one. Callers that care about
* quality use this to leave animated renders untouched and only re-encode the
* static ones. Chunk walking stops at `IDAT`/`IEND` because the spec requires
* `acTL` to precede them.
*/
export function isAnimatedPng(bytes: Uint8Array): boolean {
if (bytes.length < 8) return false;
for (let i = 0; i < 8; i++) {
if (bytes[i] !== PNG_SIGNATURE[i]) return false;
}
let offset = 8;
while (offset + 8 <= bytes.length) {
const length =
((bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3]) >>>
0;
const type = String.fromCharCode(
bytes[offset + 4],
bytes[offset + 5],
bytes[offset + 6],
bytes[offset + 7],
);
if (type === "acTL") return true;
if (type === "IDAT" || type === "IEND") return false;
// A corrupt length would loop forever; bail instead.
if (length > bytes.length) return false;
offset += 12 + length;
}
return false;
}
+1 -21
View File
@@ -15,11 +15,7 @@ vi.mock("@/lib/runtime-asset-config", () => ({
resolveImagerBase: (origin: string) => `${origin}/imager`,
}));
import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "./imager-upstream";
import { fetchAvatarImage, ImagerUnavailableError } from "./imager-upstream";
const params = new URLSearchParams({ figure: "hr-1", direction: "2" });
@@ -140,19 +136,3 @@ it("does not queue a new caller behind a render that is already too old", async
expect([...shared[0].body]).toEqual([9]);
expect(shared[1].body).toBe(shared[0].body);
});
it("accepts figures the renderer understands even with a trailing segment", () => {
// The client appends extra segments the imager still draws (e.g. the
// trailing `.0` here). Rejecting them turned valid avatars into 400s.
const figure =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
const parsed = parseAvatarQuery(new URLSearchParams({ figure }));
expect(parsed.ok).toBe(true);
if (parsed.ok) expect(parsed.params.get("figure")).toBe(figure);
});
it("still rejects a figure that is not a figure", () => {
for (const figure of ["../bad", "hd-180-1/../x", "", "0"]) {
expect(parseAvatarQuery(new URLSearchParams({ figure })).ok).toBe(false);
}
});
+1 -6
View File
@@ -8,12 +8,7 @@ import {
} from "@/lib/imager-cache";
import { resolveImagerBase } from "@/lib/runtime-asset-config";
// The client's figure strings are passed straight through to the imager, which
// is the authority on what it can draw. Only the first segment is required to
// look like a real part (`hd-180`); the rest is deliberately loose because some
// clients append extra segments (e.g. a trailing `.0`) that the renderer still
// accepts. Over-strict validation here turned valid avatars into 400s.
export const FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i;
export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i;
export const FIGURE_MAX_LEN = 512;
export const FIGURE_MAX_PARTS = 24;
-171
View File
@@ -1,171 +0,0 @@
// @ts-nocheck
import { rm } from "node:fs/promises";
import { NextRequest } from "next/server";
import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
import { GET } from "../app/imaging/route";
// sharp is the one CPU-bound operation on this default-imager route, so the
// tests instrument it directly to prove transcodes are cached rather than
// re-run on every request.
const { sharpMock, counters } = vi.hoisted(() => {
const counters = {
calls: 0,
options: [] as unknown[],
behavior: "ok" as "ok" | "throw",
};
const sharpMock = (_input: unknown, options?: unknown) => {
counters.calls += 1;
counters.options.push(options);
if (counters.behavior === "throw") throw new Error("decode failed");
return {
webp: () => ({ toBuffer: async () => Buffer.from([1, 2, 3]) }),
avif: () => ({ toBuffer: async () => Buffer.from([4, 5, 6]) }),
};
};
return { sharpMock, counters };
});
vi.mock("sharp", () => ({ default: sharpMock }));
// One scratch root for the whole file, removed afterwards, so the disk cache
// never leaks state between tests or into the runtime cache tree.
const SCRATCH_ROOT = `${process.cwd()}/storage/imaging/test-imaging-route-${process.pid}`;
beforeEach(async () => {
// Cold disk cache for every test: a warm variant would short-circuit the
// transcode being asserted.
await rm(SCRATCH_ROOT, { recursive: true, force: true });
vi.stubEnv("IMAGING_CACHE_ROOT", SCRATCH_ROOT);
vi.stubEnv("IMAGER_URL", "https://imager.test/avatarimage");
counters.calls = 0;
counters.options = [];
counters.behavior = "ok";
// Drop any single-flight entries a prior test may have left behind.
globalThis.imagingTranscodes?.clear?.();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
afterAll(async () => {
await rm(SCRATCH_ROOT, { recursive: true, force: true });
});
function stubPngUpstream() {
const fetcher = vi.fn(
async () =>
new Response(new Uint8Array([9, 8, 7]), {
headers: { "content-type": "image/png" },
}),
);
vi.stubGlobal("fetch", fetcher);
return fetcher;
}
// A minimal APNG: the PNG signature followed by an `acTL` chunk, which is all
// the animated-format detector needs. sharp cannot re-encode APNG animation in
// this build, so these must be passed through untouched.
function apngBytes(): Uint8Array {
return new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0x61, 0x63,
0x54, 0x4c, 0, 0, 0, 0,
]);
}
it("re-encodes an avatar to WebP and caches the variant across requests", async () => {
const fetcher = stubPngUpstream();
const request = new NextRequest(
"https://hotel.test/imaging?figure=hd-180-1&format=webp",
);
const first = await GET(request);
expect(first.status).toBe(200);
expect(first.headers.get("content-type")).toBe("image/webp");
expect(counters.calls).toBe(1);
const fetchesAfterFirst = fetcher.mock.calls.length;
const second = await GET(request);
expect(second.status).toBe(200);
expect(second.headers.get("content-type")).toBe("image/webp");
// Served entirely from disk cache: neither the upstream nor sharp re-ran.
expect(counters.calls).toBe(1);
expect(fetcher.mock.calls.length).toBe(fetchesAfterFirst);
});
it("decodes with animation awareness for transcodeable inputs", async () => {
stubPngUpstream();
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
);
expect(result.status).toBe(200);
expect(counters.options.at(-1)).toMatchObject({ animated: true });
});
it("leaves an animated PNG untouched instead of dropping its frames", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(apngBytes(), {
headers: { "content-type": "image/png" },
}),
),
);
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
);
expect(result.status).toBe(200);
// Original bytes/content-type preserved and sharp never ran.
expect(result.headers.get("content-type")).toBe("image/png");
expect(counters.calls).toBe(0);
});
it("shares a single transcode across concurrent cold-cache requests", async () => {
stubPngUpstream();
const request = () =>
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=avif");
const [a, b] = await Promise.all([GET(request()), GET(request())]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(counters.calls).toBe(1);
});
it("falls back to the original render when the transcode fails", async () => {
stubPngUpstream();
counters.behavior = "throw";
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=hd-180-1&format=webp"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/png");
});
it("revalidates a cached avatar with a 304 when the client already has it", async () => {
stubPngUpstream();
const url = "https://hotel.test/imaging?figure=hd-55-1";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
});
it("serves the graceful silhouette instead of a broken image when every upstream is down", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("down", { status: 503 })),
);
const result = await GET(
new NextRequest("https://hotel.test/imaging?figure=xd-900-1&format=webp"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/svg+xml");
expect(result.headers.get("x-imager-source")).toBe("unavailable");
expect(result.headers.get("cache-control")).toContain("no-cache");
});
-22
View File
@@ -108,28 +108,6 @@ it("serves a previously rendered avatar from the disk cache without re-fetching"
expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
});
it("revalidates a cached avatar with a 304 when the client already has it", async () => {
const fetcher = vi.fn(
async () =>
new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "image/png" },
}),
);
vi.stubGlobal("fetch", fetcher);
vi.stubEnv("IMAGER_URL", "https://etag.test/avatarimage");
const url = "https://hotel.test/api/imaging/avatar?figure=hd-77-1";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
expect(second.headers.get("x-imager-source")).toBe("cache");
});
it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => {
const fetcher = vi.fn(
async (_input: string | URL) =>
-412
View File
@@ -2570,8 +2570,6 @@
"credits": "Credits",
"points": "Points",
"pointsType": "Type",
"pointsTypeDuckets": "Duckets",
"pointsTypeDiamonds": "Diamonds",
"newPriceHint": "Applies to new offers only; moved offers keep their current price.",
"parent": "Place pages under",
"root": "Catalog root",
@@ -6803,416 +6801,6 @@
},
"missing": "Missing: {parts}",
"unknown": "Not verified: {parts}"
},
"rail": {
"title": "Categories",
"all": "All categories",
"noMatches": "No matching categories.",
"refreshFailed": "Catalog could not refresh.",
"retry": "Retry",
"updating": "Updating catalog…"
},
"nav": {
"ariaLabel": "Catalog Studio",
"furniture": "Furniture",
"badges": "Badges",
"clothing": "Clothing",
"effects": "Effects",
"pets": "Pets",
"uploadFiles": "Upload files",
"importFromSource": "Import from a source",
"syncRepository": "Sync & repository",
"catalogAudit": "Catalog audit",
"repairIcons": "Repair icons",
"catalogMaintenance": "Catalog maintenance",
"nitroCleanup": "Nitro cleanup",
"catalogManager": "Catalog manager",
"importSync": "Import & sync",
"maintenance": "Maintenance"
},
"aria": {
"search": "Search furniture",
"searchPlaceholder": "Search furni, classnames, keywords… (/)",
"clearSearch": "Clear search",
"hideCatalogTree": "Hide catalog tree",
"showCatalogTree": "Show catalog tree",
"source": "Furniture source",
"type": "Furniture type",
"category": "Furniture category",
"status": "Import status",
"sort": "Sort furniture",
"importSpeed": "Import speed",
"translateImport": "Translate this import",
"defaultTranslation": "Default translation",
"selection": "Furniture selection",
"results": "Furniture results",
"gridView": "Grid view",
"listView": "List view",
"selectAll": "Select all",
"languageLabel": "Language {lang}",
"selectItem": "Select {name}",
"deselectItem": "Deselect {name}",
"viewItem": "View {name}",
"importItem": "Import {name}",
"editNitro": "Edit nitro {name}",
"regenerateNitro": "Regenerate nitro {name}",
"scrollToTop": "Scroll to top",
"closeCategoryPanel": "Close category panel",
"catalogCategories": "Catalog categories",
"closeCategories": "Close categories",
"findCategory": "Find a category",
"expandCategory": "Expand {name}",
"collapseCategory": "Collapse {name}",
"closeDetails": "Close furniture details"
},
"workspace": {
"title": "Studio",
"furnidata": "furnidata",
"catalog": "Catalog",
"results": "{count} results",
"statInDb": "{count} in DB",
"statInCatalog": "{count} in catalog",
"statMissingNitro": "{count} missing nitro",
"statTotal": "{count} total",
"statPresent": "{count} present",
"statToImport": "{count} to import",
"official": "(official)"
},
"types": {
"floor": "Floor",
"wall": "Wall"
},
"filters": {
"allTypes": "All types",
"floorItems": "Floor items",
"wallItems": "Wall items",
"allCategories": "All categories",
"allStatuses": "All statuses",
"allStatusesCount": "All statuses ({count} available to import)",
"statuses": {
"notImported": "Not imported",
"imported": "Imported",
"inCatalog": "In catalog",
"missingNitro": "Missing nitro",
"missingFurnidata": "Missing furnidata",
"missingCatalog": "Missing catalog entry"
},
"sorts": {
"default": "Default order",
"name": "Name A–Z",
"classname": "Classname A–Z",
"category": "Category",
"importedFirst": "Imported first",
"notImportedFirst": "Not imported first"
},
"reset": "Reset filters",
"noNitro": "{count} no nitro",
"noFurnidata": "{count} no furnidata"
},
"importOptions": {
"summary": "Import options",
"translationOn": "Translation on",
"translationOff": "Translation off",
"speed": "Speed",
"parallel": "{count} parallel",
"gentle": "1 (gentle)",
"recommended": "4 (recommended)",
"turbo": "12 (turbo)",
"translate": "Translate",
"noLanguages": "No languages",
"allLanguages": "All languages",
"languagesCount": "{count} languages",
"languages": "Languages",
"all": "All",
"none": "None",
"translateAllTitle": "Translate the entire catalog into all languages",
"translateAll": "Translate entire catalog",
"defaultFuture": "Default for future imports"
},
"selection": {
"selectVisible": "Select visible",
"deselectVisible": "Deselect visible",
"loadingAll": "Loading all…",
"selectAllResults": "Select all {count} results",
"selectedInView": "{count} selected in this view",
"clear": "Clear",
"preparingReview": "Preparing review…",
"importAllMissing": "Import all missing ({count})",
"importSelected": "Import selected ({count})",
"rebuildNitro": "Rebuild Nitro ({count})",
"addMissingFurnidata": "Add missing furnidata ({count})",
"deleteSelected": "Delete ({count})",
"hiddenWarning": "Some selected furniture is hidden by your filters. Actions apply to the visible selection."
},
"items": {
"tryAgain": "Try again",
"noFound": "No furni found for “{query}”",
"noFurni": "No furni to show yet",
"noFoundHint": "Try a different search or clear your filters.",
"noFurniHint": "Search a furni library to get started.",
"noNitro": "No nitro",
"noFurnidata": "No furnidata",
"noCatalog": "No catalog",
"imported": "Imported",
"new": "New",
"colName": "Name",
"colClassname": "Classname",
"colType": "Type",
"colCategory": "Category",
"colStatus": "Status",
"colActions": "Actions",
"loadingMore": "Loading more…",
"scrollForMore": "Scroll for more",
"endOfResults": "End of results",
"defaultLanguages": "Default languages",
"off": "Off"
},
"deleteDialog": {
"title": "Delete imported furni",
"body": "This permanently removes {name} from items_base and the catalog, plus its asset files.",
"cancel": "Cancel",
"delete": "Delete"
},
"drawer": {
"title": "Furni detail",
"revision": "rev {revision}",
"autoImportReady": "Auto-import ready",
"automaticPlacement": "Automatic placement",
"category": "Category",
"price": "Price",
"priceCredits": "{count} credits",
"priceCreditsPoints": "{credits} credits + {points} points",
"importAdd": "Import & add to catalog",
"bundleMissing": "This furni is in the DB but its bundle file is missing.",
"regenerate": "Regenerate .nitro",
"editNitro": "Edit nitro",
"deleteImported": "Delete imported furni"
},
"review": {
"titleReview": "Review furniture import",
"titleConfirm": "Confirm furniture import",
"steps": "1. Select → 2. Review → 3. Import",
"source": "Source:",
"destination": "Destination:",
"destinationValue": "local catalog, furnidata, bundle and icons",
"translation": "Translation:",
"selection": "Selection:",
"selectionCount": "{count} furniture items",
"checkingSource": "Checking source files… {count} checked",
"retrySource": "Retry source check",
"unknownSources": "Some sources could not be verified. The import will still try to download their files.",
"sourceNotFound": "Source files not found by preliminary check: {name}",
"checkingLocal": "Checking local data and assets…",
"retryInspection": "Retry inspection",
"incomplete": "Inspection is incomplete. Close and retry before importing.",
"summary": "{ready} ready to import · {existing} already in SQL (complete missing data) · {blocked} conflicts · {warnings} source file warnings",
"localUnverified": "Some local files could not be verified. Review the item details before continuing.",
"statusNew": "New",
"statusChanged": "Differences",
"statusConflict": "Conflict",
"statusUnknown": "Not fully verified",
"statusPresent": "Present",
"validatingFile": "Validating file…",
"confirmIntro": "Only the {count} eligible items below will be submitted. Existing furniture keeps its local IDs, SQL settings and catalog offers. Missing assets and catalog entries are completed; furnidata is refreshed. Conflicts are excluded.",
"confirmEstimates": "Automatic categories and prices below are estimates. Source rules may override them; final results appear in the import summary.",
"placement": "{category} · {credits} credits + {points} points",
"previous": "Previous items",
"page": "Page {page} / {total}",
"next": "Next items",
"backToReview": "Back to review",
"cancel": "Cancel",
"continue": "Continue to confirmation",
"startImport": "Start import ({count})",
"uploadFailed": "Upload failed"
},
"inspector": {
"furnidataUnreadable": "Local furnidata could not be read. Its values are not verified.",
"field": "Field",
"localNow": "Local now",
"source": "Source",
"fieldName": "Name",
"fieldDescription": "Description",
"fieldRevision": "Revision",
"fieldType": "Type",
"changed": "Changed",
"notSupplied": "Not supplied",
"compareNote": "Source values are compared with local data. Import may remap IDs and translate names. Existing furniture keeps its local IDs and SQL settings; new furniture receives a free local ID when the source ID is occupied.",
"sqlRecords": "SQL records and catalog offers",
"noSqlItem": "No local SQL item.",
"itemRow": "Item #{id} · Sprite #{sprite} · {name}",
"offerRow": "Offer #{id} · Page #{page} · {credits} credits + {points} points",
"healthComplete": "Complete",
"healthIncomplete": "Needs repair",
"healthNew": "Not imported",
"healthConflict": "Local conflict",
"healthUnknown": "Not verified",
"missing": "Missing: {parts}",
"conflictNote": "Duplicate records or mismatched IDs/types require review before repair.",
"unknownNote": "Some local data could not be checked. Retry verification before repair.",
"repairNote": "Repair adds missing parts and preserves existing IDs, settings, furnidata and catalog offers. Missing offers use the automatic category and price shown below.",
"repairQueued": "Repair queued or running…",
"repair": "Repair furni",
"recheck": "Recheck local state",
"checking": "Checking local data and assets…",
"retry": "Retry inspection",
"unavailable": "Local inspection unavailable."
},
"quality": {
"compressionTitle": "Compression · {format}",
"losslessVp8l": "Lossless (VP8L)",
"lossyVp8": "Lossy (VP8)",
"bundle": "Bundle",
"texture": "Texture",
"metadata": "Metadata",
"container": "Container",
"textureDetail": "Texture {raw} decoded · deflate ×{ratio} · sheet {width}×{height} · {frames} frames",
"legacyPngSaving": "Legacy PNG texture. Re-importing as WebP Lossless would store <strong>{size}</strong>, saving <strong>{saving}</strong> ({percent}% smaller)",
"legacyPngNoGain": "Legacy PNG texture. Re-importing as WebP Lossless would store <strong>{size}</strong> (no gain on this bundle)",
"webpSaving": "WebP Lossless stores {raw} against {png} for the same artwork as PNG, saving <strong>{saving}</strong> ({percent}% smaller)",
"webpNoGain": "WebP Lossless stores {raw} against {png} for the same artwork as PNG (PNG is not larger on this bundle)",
"pixelExact": "<strong>Pixel-exact</strong> — every visible texel round-trips unchanged.",
"invisibleDiffs": "{count} fully transparent texels carry no RGB, which VP8L discards and no renderer can see.",
"texelsDiffer": "{count} {count, plural, one {texel} other {texels}} differ after a lossless round-trip — the furniture may not render identically.",
"scaleTitle": "Scale {size} · {state}",
"stateMissing": "Not included",
"stateIssues": "Needs review",
"stateDerived": "Derived from 64",
"statePresent": "Present",
"spritesLine": "{sprites} sprites · {animations} animation definitions · directions {directions}",
"missingScale": "This bundle contains no sprites at this scale. Missing scale 32 does not by itself mean the furniture is unusable.",
"sprite": "Sprite",
"renderFailed": "This sprite could not be rendered.",
"intro": "Inspect the saved bundle at both scales. Previews show individual sprites, not the assembled furniture or animation playback.",
"recheck": "Recheck",
"checking": "Checking scales and texture references…",
"inspectionFailed": "Inspection failed"
},
"sync": {
"pageSubtitle": "Keep the catalog in sync with your sources and export it to the repository.",
"cloneSourcesTitle": "Clone sources",
"cloneSourcesBody": "Scan all configured clone sources and automatically import any furniture that is not yet present in your hotel.",
"officialFurnidataTitle": "Official furnidata",
"officialFurnidataBody": "Scan the official Habbo furnidata and automatically import any furniture that your hotel doesn't have yet.",
"gitTitle": "Gitea connection settings",
"gitIntro": "Connect your self-hosted Gitea repository over HTTPS. The CMS manages its own checkout; no Git-specific .env settings are needed.",
"providerLabel": "Provider:",
"providerValue": "Gitea self-hosted",
"repositoryUrl": "Repository URL",
"branch": "Branch",
"username": "Gitea username",
"accessToken": "Gitea access token",
"tokenSaved": "Token saved — leave blank to keep it",
"tokenEnter": "Enter a token with repository write access",
"tokenNote": "The token is stored encrypted and is never returned to the browser. Changing repository or username requires entering the token again.",
"autoExport": "Automatically export catalog changes",
"testConnection": "Test connection",
"working": "Working…",
"saveSettings": "Save Gitea settings",
"loadFailed": "Unable to read Git settings. Settings edit permission is required.",
"requestFailed": "Git configuration request failed",
"saved": "Gitea configuration saved."
},
"scale32": {
"title": "Generate scale 32 from 64",
"intro": "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.",
"saveFirst": "Save or reset your pending edits first.",
"working": "Working…",
"preview": "Preview generation",
"previewCount": "{count} sprite entries will be added. Check positioning and animations in the client after saving.",
"skipped": "{count} sprite(s) were skipped because the source bundle has no usable artwork for them.",
"original64": "Original 64",
"derived32": "Derived 32",
"saveWithBackup": "Save with backup",
"discard": "Discard preview",
"saved": "Saved. A copy of the original bundle is preserved.",
"restore": "Restore original bundle",
"generationFailed": "Generation failed"
},
"pages": {
"auditSubtitle": "Scan your entire furniture catalog for missing entries, broken references, missing files, and items available from clone sources.",
"badgesSubtitle": "Search hotels and import badge designs into your hotel.",
"cloneSubtitle": "Clone furniture from your configured external sources.",
"clothingSubtitle": "Browse and import wearable clothing assets for your hotel.",
"effectsSubtitle": "Import effect definitions and their sprite assets.",
"maintenanceSubtitle": "Detect and repair catalog integrity problems, and run legacy maintenance tasks.",
"nitroCleanupSubtitle": "Scan the nitro directories for fake bundles (no matching furniture) and broken bundles (unparseable) and remove them.",
"petsSubtitle": "Import pet types, races and their sprite assets.",
"repairIconsTitle": "Repair missing icons",
"repairIconsSubtitle": "Scan all existing furniture and download any missing icon files. Icons are extracted from local .nitro bundles or fetched from configured clone sources.",
"uploadTitle": "Upload .hab furniture",
"uploadSubtitle": "Upload a .hab bundle and optional icon to import furniture into the hotel. The database entry, catalog item, and FurnitureData.json will be updated automatically."
},
"confirm": {
"deleteTitle": "Delete {count} items?",
"deleteDescription": "All {count} selected imported items will be permanently removed from the database and the filesystem.",
"deleteConfirm": "Delete"
},
"batch": {
"ariaProgress": "Import progress",
"ariaProcessed": "Furniture processed",
"headingStopping": "Stopping import…",
"headingImporting": "Importing furniture…",
"headingStopped": "Import stopped",
"headingCompletedErrors": "Import completed with errors",
"headingCompleted": "Import completed",
"imported": "imported",
"failed": "failed",
"notCompleted": "not completed",
"remaining": "remaining",
"retryFailed": "Retry failed ({count})",
"closeSummary": "Close summary",
"stopImport": "Stop import",
"verificationIssues": "{count, plural, one {# catalog data issue still needs attention. Open Catalog audit to review it.} other {# catalog data issues still need attention. Open Catalog audit to review them.}}",
"verificationDone": "Catalog data verification completed.",
"chipFurnidataIds": "furnidata ids fixed",
"chipOfferIds": "offer ids reconciled",
"chipOwnerships": "ownerships fixed",
"chipNitros": "nitros synced",
"chipIcons": "icons synced",
"viewItemDetails": "View item details ({count})",
"showingItems": "Showing {shown} of {total} items",
"status": {
"pending": "Waiting",
"started": "Starting",
"downloading": "Downloading assets",
"converting": "Preparing assets",
"writingDb": "Saving",
"done": "Imported",
"failed": "Failed"
},
"statusNotCompleted": "Not completed"
},
"toasts": {
"allImported": "All items from this source are already imported",
"selectNotImported": "Select furni that isn't imported yet",
"loadMissingFailed": "Could not load missing furniture",
"prepareFailed": "Could not prepare the full selection",
"sourceChanged": "The source changed while preparing the selection. Refresh and retry.",
"batchFailed": "Batch import failed ({status})",
"importCancelled": "Import cancelled",
"importTimeout": "Import timed out. Progress was saved — open Import History to resume failed items.",
"networkUpload": "Network error during upload. Progress was saved — open Import History to resume failed items.",
"unexpectedImport": "Unexpected import error: {message}",
"retryFailed": "Retry failed ({status})",
"importedAgain": "{count} items imported again",
"retryConnection": "Connection failed while retrying",
"deleteFailed": "Delete failed",
"networkError": "Network error",
"deleted": "Deleted {name} ({count} files)",
"regenerated": "Nitro file regenerated for {name}",
"regenerationFailed": "Regeneration failed",
"bulkDeleted": "Deleted: {count}",
"bulkDeletedFailed": "Deleted: {deleted}, failed: {failed}",
"bulkDeleteFailed": "Bulk delete failed",
"regeneratedBulk": "Nitro regenerated: {count}",
"regeneratedBulkFailed": "Nitro regenerated: {succeeded}, failed: {failed}",
"furnidataAdded": "Furnidata added: {count}",
"furnidataAddedFailed": "Furnidata added: {fixed}, failed: {failed}",
"addFurnidataFailed": "Adding furnidata failed",
"languagesTranslated": "Languages translated ({ok}/{total} completed)",
"translationFailed": "Translation failed",
"networkTranslating": "Network error while translating languages",
"saveDefaultFailed": "Could not save the default translation setting"
}
}
},
-6
View File
@@ -103,12 +103,6 @@ export const proxy = async (req: import("next/server").NextRequest) => {
};
export const config = {
// `/imaging` and `/images` are excluded: they are raw image responses that
// carry their own long-lived Cache-Control and must not be rewritten by the
// HTML/asset cache logic. In production the edge proxy also fronts
// `/imaging` straight to the renderer, so the CMS route never sees that
// traffic; `/api/imaging/*` (avatars, badges) is NOT excluded and therefore
// still passes through the anti-DDoS gate and keeps its asset headers.
matcher: [
"/((?!_next/static|_next/image|assets|favicon.ico|swf|nitro-assets|imaging|images).*)",
],
+3
View File
@@ -0,0 +1,3 @@
test-ci-runner
# final test
# CI test via Gitea runner
+1
View File
@@ -0,0 +1 @@
# clean v5 docker install test
+1
View File
@@ -0,0 +1 @@
# CI test na complete herinstallatie
+1
View File
@@ -0,0 +1 @@
# test nieuwe label aanpak
+1
View File
@@ -0,0 +1 @@
# test ubuntu-latest alleen
+4
View File
@@ -0,0 +1,4 @@
// test: runner actief
// second test
// third test - token sync fix
// fourth test - token config fix
+1
View File
@@ -0,0 +1 @@
# test met ubuntu-latest label
+1
View File
@@ -0,0 +1 @@
# test na volledige schone installatie v5