Author SHA1 Message Date
remco 9af571aed6 Add renovate.json
CI / check (pull_request) Failing after 29s
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-14 19:00:12 +00:00
openhands f3407ebfdf fix(imaging): support .hab assets, cache avatars/badges, harden routes
CI / check (push) Successful in 37s
CI / tests-integration (push) Successful in 2m2s
CI / tests-unit (push) Successful in 2m9s
CI / tests-ui (push) Successful in 2m44s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m12s
The hotel moved from .nitro to .hab bundles, so the imager must treat .hab
like .nitro and the public avatar endpoints must accept the figure strings
the client sends (including extra trailing segments such as `.0`, which the
old FIGURE_RE rejected with a 400).

- imager-upstream: relax FIGURE_RE, extend parseAvatarQuery test coverage
- api/imaging/avatar + /imaging: shared validation, placeholder and ETag/304
- api/imaging/badge: single-flight upstream fetch and conditional responses
- imager-http/imager-png: strong ETag helpers and APNG passthrough guard
- csp + next/image: unoptimized external images, tightened CSP
- deps: override postcss-selector-parser to ^7.1.6
- also fold in the pending drizzle draft snapshot and stray test cleanup
2026-10-14 20:40:50 +02:00
openhands 56676effc3 feat(studio): localize catalog studio UI and polish responsive layout
CI / check (push) Failing after 43s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Route all Catalog Studio strings through the admin.studio namespace
(workspace, filters, review, inspector, quality, sync, scale32, organize
imports and page titles/subtitles) with English fallback, and add shared
StudioPage chrome plus a loading skeleton. Tighten mobile padding on the
header and sub-bars.
2026-10-13 20:31:26 +02:00
58 changed files with 16384 additions and 681 deletions

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
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,6 +8,13 @@
"when": 1788791201804, "when": 1788791201804,
"tag": "0000_premium_spirit", "tag": "0000_premium_spirit",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1791828901011,
"tag": "0001_breezy_maestro",
"breakpoints": true
} }
] ]
} }
+5 -4
View File
@@ -167,6 +167,7 @@ overrides:
'@esbuild-kit/core-utils': npm:tsx@^4.23.15 '@esbuild-kit/core-utils': npm:tsx@^4.23.15
'@esbuild-kit/esm-loader': npm:tsx@^4.23.15 '@esbuild-kit/esm-loader': npm:tsx@^4.23.15
source-map-js: 1.2.2 source-map-js: 1.2.2
postcss-selector-parser: ^7.1.6
importers: importers:
@@ -3255,8 +3256,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==} resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==}
postcss-selector-parser@6.0.10: postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'} engines: {node: '>=4'}
[email protected]: [email protected]:
@@ -4961,7 +4962,7 @@ snapshots:
'@tailwindcss/[email protected]([email protected])': '@tailwindcss/[email protected]([email protected])':
dependencies: dependencies:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 7.1.6
tailwindcss: 4.3.3 tailwindcss: 4.3.3
'@tanstack/[email protected]': {} '@tanstack/[email protected]': {}
@@ -6110,7 +6111,7 @@ snapshots:
[email protected]: {} [email protected]: {}
postcss-selector-parser@6.0.10: postcss-selector-parser@7.1.6:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
+5
View File
@@ -17,3 +17,8 @@ overrides:
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15' '@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15' '@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
source-map-js: '1.2.2' 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"
}
+1
View File
@@ -99,6 +99,7 @@ async function NewsPage({
fill fill
sizes="(max-width: 1024px) 50vw, 300px" sizes="(max-width: 1024px) 50vw, 300px"
style={{ objectFit: "cover" }} style={{ objectFit: "cover" }}
unoptimized
/> />
</div> </div>
) : ( ) : (
+24 -13
View File
@@ -170,12 +170,9 @@ function GlassPanel({
} }
async function getHotelData() { async function getHotelData() {
const [hotelName, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
]);
const [ const [
hotelName,
logo,
online, online,
users, users,
rooms, rooms,
@@ -185,6 +182,8 @@ async function getHotelData() {
recentUsers, recentUsers,
recentPhotos, recentPhotos,
] = await Promise.all([ ] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
cachedOnlineCount().catch(publicReadFailure("home.online")), cachedOnlineCount().catch(publicReadFailure("home.online")),
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch( cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
publicReadFailure("home.users"), publicReadFailure("home.users"),
@@ -243,11 +242,12 @@ export default async function Home() {
const session = await auth(); const session = await auth();
if (session?.user?.id) redirect("/me"); if (session?.user?.id) redirect("/me");
const tp = await getTranslations("pages.home"); const [
const te = await getTranslations("publicData"); tp,
const th = await getTranslations("header"); te,
const tn = await getTranslations("nav"); th,
const { tn,
{
hotelName, hotelName,
online, online,
users, users,
@@ -258,7 +258,18 @@ export default async function Home() {
recentUsers: rawRecentUsers, recentUsers: rawRecentUsers,
recentPhotos, recentPhotos,
logo, logo,
} = await getHotelData(); },
captcha,
nonceHeaders,
] = await Promise.all([
getTranslations("pages.home"),
getTranslations("publicData"),
getTranslations("header"),
getTranslations("nav"),
getHotelData(),
captchaConfig(),
headers(),
]);
// Users who hide their online state must not surface in the "who is online" // Users who hide their online state must not surface in the "who is online"
// rails on the homepage either. // rails on the homepage either.
@@ -271,8 +282,7 @@ export default async function Home() {
: rawRecentUsers.filter( : rawRecentUsers.filter(
(u) => recentUserPrivacy.get(u.id)?.online !== false, (u) => recentUserPrivacy.get(u.id)?.online !== false,
); );
const captcha = await captchaConfig(); const nonce = nonceHeaders.get("x-nonce") ?? undefined;
const nonce = (await headers()).get("x-nonce") ?? undefined;
const features = [ const features = [
{ {
@@ -315,6 +325,7 @@ export default async function Home() {
fill fill
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"} sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108" className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
unoptimized
/> />
<div <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" 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"
+10 -9
View File
@@ -1,5 +1,8 @@
import { ClipboardCheck } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuditClient } from "@/app/admin/import/audit/audit-client"; import { AuditClient } from "@/app/admin/import/audit/audit-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioAuditPage(_props: { export default async function StudioAuditPage(_props: {
@@ -9,16 +12,14 @@ export default async function StudioAuditPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={ClipboardCheck}
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1> title={t("nav.catalogAudit")}
<p className="mt-1 text-muted-foreground"> subtitle={t("pages.auditSubtitle")}
Scan your entire furniture catalog for missing entries, broken >
references, missing files, and items available from clone sources.
</p>
</div>
<AuditClient /> <AuditClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { BadgeCheck } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioBadgesPage(_props: { export default async function StudioBadgesPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioBadgesPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={BadgeCheck}
title={t("nav.badges")}
subtitle={t("pages.badgesSubtitle")}
>
<ImportBadgesClient /> <ImportBadgesClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Copy } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClonePage(_props: { export default async function StudioClonePage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioClonePage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Copy}
title={t("nav.importFromSource")}
subtitle={t("pages.cloneSubtitle")}
>
<ImportCloneClient /> <ImportCloneClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Shirt } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClothingPage(_props: { export default async function StudioClothingPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioClothingPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Shirt}
title={t("nav.clothing")}
subtitle={t("pages.clothingSubtitle")}
>
<ImportClothingClient /> <ImportClothingClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Dumbbell } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioEffectsPage(_props: { export default async function StudioEffectsPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioEffectsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Dumbbell}
title={t("nav.effects")}
subtitle={t("pages.effectsSubtitle")}
>
<ImportEffectsClient /> <ImportEffectsClient />
</div> </StudioPage>
); );
} }
+30
View File
@@ -0,0 +1,30 @@
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>
);
}
@@ -1,7 +1,9 @@
import { Wrench } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel"; import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioCatalogMaintenancePage() { export default async function StudioCatalogMaintenancePage() {
@@ -16,7 +18,13 @@ export default async function StudioCatalogMaintenancePage() {
session.user.rank, session.user.rank,
); );
const t = await getTranslations("pages.admin.catalog.integrity"); const t = await getTranslations("pages.admin.catalog.integrity");
const studioT = await getTranslations("admin.studio");
return ( return (
<StudioPage
icon={Wrench}
title={studioT("nav.catalogMaintenance")}
subtitle={studioT("pages.maintenanceSubtitle")}
>
<div className="min-h-0 space-y-6"> <div className="min-h-0 space-y-6">
<CatalogIntegrityPanel canRepair={canRepair} /> <CatalogIntegrityPanel canRepair={canRepair} />
{canRepair && ( {canRepair && (
@@ -31,5 +39,6 @@ export default async function StudioCatalogMaintenancePage() {
</details> </details>
)} )}
</div> </div>
</StudioPage>
); );
} }
+10 -9
View File
@@ -1,5 +1,8 @@
import { FileX2 } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel"; import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioNitroCleanupPage() { export default async function StudioNitroCleanupPage() {
@@ -7,16 +10,14 @@ export default async function StudioNitroCleanupPage() {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={FileX2}
<h1 className="text-2xl font-bold tracking-tight">Nitro cleanup</h1> title={t("nav.nitroCleanup")}
<p className="mt-1 text-muted-foreground"> subtitle={t("pages.nitroCleanupSubtitle")}
Scan the nitro directories for fake bundles (no matching furniture) >
and broken bundles (unparseable) and remove them.
</p>
</div>
<NitroCleanupPanel /> <NitroCleanupPanel />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Cat } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioPetsPage(_props: { export default async function StudioPetsPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioPetsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Cat}
title={t("nav.pets")}
subtitle={t("pages.petsSubtitle")}
>
<ImportPetsClient /> <ImportPetsClient />
</div> </StudioPage>
); );
} }
+10 -12
View File
@@ -1,5 +1,8 @@
import { Image as ImageIcon } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioRepairIconsPage(_props: { export default async function StudioRepairIconsPage(_props: {
@@ -9,19 +12,14 @@ export default async function StudioRepairIconsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={ImageIcon}
<h1 className="text-2xl font-bold tracking-tight"> title={t("pages.repairIconsTitle")}
Repair Missing Icons subtitle={t("pages.repairIconsSubtitle")}
</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 /> <RepairIconsClient />
</div> </StudioPage>
); );
} }
+35 -28
View File
@@ -17,6 +17,7 @@ import {
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper"; import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper";
import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher"; import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher";
@@ -28,34 +29,38 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type StudioLink = { href: string; label: string; icon: LucideIcon }; type StudioLink = { href: string; labelKey: string; icon: LucideIcon };
const assets: StudioLink[] = [ const assets: StudioLink[] = [
{ href: "/admin/studio/furni", label: "Furniture", icon: Puzzle }, { href: "/admin/studio/furni", labelKey: "furniture", icon: Puzzle },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck }, { href: "/admin/studio/badges", labelKey: "badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt }, { href: "/admin/studio/clothing", labelKey: "clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell }, { href: "/admin/studio/effects", labelKey: "effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat }, { href: "/admin/studio/pets", labelKey: "pets", icon: Cat },
]; ];
const imports: StudioLink[] = [ const imports: StudioLink[] = [
{ href: "/admin/studio/upload", label: "Upload files", icon: Upload }, { href: "/admin/studio/upload", labelKey: "uploadFiles", icon: Upload },
{ href: "/admin/studio/clone", label: "Import from a source", icon: Copy }, { href: "/admin/studio/clone", labelKey: "importFromSource", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw }, { href: "/admin/studio/sync", labelKey: "syncRepository", icon: RefreshCw },
]; ];
const maintenance: StudioLink[] = [ const maintenance: StudioLink[] = [
{ href: "/admin/studio/audit", label: "Catalog audit", icon: ClipboardCheck }, {
href: "/admin/studio/audit",
labelKey: "catalogAudit",
icon: ClipboardCheck,
},
{ {
href: "/admin/studio/repair-icons", href: "/admin/studio/repair-icons",
label: "Repair icons", labelKey: "repairIcons",
icon: ImageIcon, icon: ImageIcon,
}, },
{ {
href: "/admin/studio/maintenance", href: "/admin/studio/maintenance",
label: "Catalog maintenance", labelKey: "catalogMaintenance",
icon: Wrench, icon: Wrench,
}, },
{ {
href: "/admin/studio/nitro-cleanup", href: "/admin/studio/nitro-cleanup",
label: "Nitro cleanup", labelKey: "nitroCleanup",
icon: FileX2, icon: FileX2,
}, },
]; ];
@@ -71,6 +76,7 @@ function StudioMenu({
links: StudioLink[]; links: StudioLink[];
pathname: string; pathname: string;
}) { }) {
const t = useTranslations("admin.studio");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const active = links.find((link) => activePath(pathname, link.href)); const active = links.find((link) => activePath(pathname, link.href));
return ( return (
@@ -90,7 +96,7 @@ function StudioMenu({
align="end" align="end"
className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]" className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]"
> >
{links.map(({ href, label: title, icon: Icon }) => ( {links.map(({ href, labelKey, icon: Icon }) => (
<Link <Link
key={href} key={href}
href={href} href={href}
@@ -103,8 +109,8 @@ function StudioMenu({
aria-current={activePath(pathname, href) ? "page" : undefined} 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" 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} /> <Icon size={16} aria-hidden />
{title} {t(`nav.${labelKey}`)}
</Link> </Link>
))} ))}
</PopoverContent> </PopoverContent>
@@ -120,14 +126,15 @@ export function StudioLayoutClient({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const t = useTranslations("admin.studio");
return ( return (
<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)]"> <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]">
<nav <nav
aria-label="Catalog Studio" aria-label={t("nav.ariaLabel")}
className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2" className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
> >
<div className="flex flex-wrap gap-1"> <div className="flex shrink-0 gap-1">
{assets.map(({ href, label, icon: Icon }) => ( {assets.map(({ href, labelKey, icon: Icon }) => (
<Link <Link
key={href} key={href}
href={href} href={href}
@@ -138,35 +145,35 @@ export function StudioLayoutClient({
}} }}
aria-current={activePath(pathname, href) ? "page" : undefined} aria-current={activePath(pathname, href) ? "page" : undefined}
className={cn( className={cn(
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors", "flex min-h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-sm font-medium transition-colors",
activePath(pathname, href) activePath(pathname, href)
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]" ? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10", : "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)} )}
> >
<Icon size={16} /> <Icon size={16} aria-hidden />
{label} {t(`nav.${labelKey}`)}
</Link> </Link>
))} ))}
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="ml-auto flex shrink-0 gap-1 pl-1">
{canEditCatalog && ( {canEditCatalog && (
<> <>
<CatalogManagerDialog <CatalogManagerDialog
catalogType="normal" catalogType="normal"
canEdit canEdit
triggerLabel="Catalog manager" triggerLabel={t("nav.catalogManager")}
/> />
<OrganizeImportsLauncher /> <OrganizeImportsLauncher />
</> </>
)} )}
<StudioMenu <StudioMenu
label="Import & sync" label={t("nav.importSync")}
links={imports} links={imports}
pathname={pathname} pathname={pathname}
/> />
<StudioMenu <StudioMenu
label="Maintenance" label={t("nav.maintenance")}
links={maintenance} links={maintenance}
pathname={pathname} pathname={pathname}
/> />
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -13,6 +14,7 @@ interface Configuration {
hasToken: boolean; hasToken: boolean;
} }
export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) { export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
const t = useTranslations("admin.studio.sync");
const [config, setConfig] = useState<Configuration | null>(null), const [config, setConfig] = useState<Configuration | null>(null),
[token, setToken] = useState(""), [token, setToken] = useState(""),
[busy, setBusy] = useState(false), [busy, setBusy] = useState(false),
@@ -26,10 +28,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
signal: abort.signal, signal: abort.signal,
cache: "no-store", cache: "no-store",
}); });
if (!res.ok) if (!res.ok) throw new Error(t("loadFailed"));
throw new Error(
"Unable to read Git settings. Settings edit permission is required.",
);
const data = await res.json(); const data = await res.json();
if (!abort.signal.aborted) setConfig(data); if (!abort.signal.aborted) setConfig(data);
} catch (error) { } catch (error) {
@@ -37,7 +36,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
} }
})(); })();
return () => abort.abort(); return () => abort.abort();
}, []); }, [t]);
async function submit(action: "save" | "test") { async function submit(action: "save" | "test") {
if (!config || busy) return; if (!config || busy) return;
setBusy(true); setBusy(true);
@@ -50,12 +49,11 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
body: JSON.stringify({ ...config, token, action }), body: JSON.stringify({ ...config, token, action }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) if (!res.ok) throw new Error(data.error || t("requestFailed"));
throw new Error(data.error || "Git configuration request failed");
if (action === "save") { if (action === "save") {
setConfig(data); setConfig(data);
setToken(""); setToken("");
setMessage("Gitea configuration saved."); setMessage(t("saved"));
onSaved(); onSaved();
} else setMessage(data.message); } else setMessage(data.message);
} catch (error) { } catch (error) {
@@ -70,19 +68,18 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
open open
> >
<summary className="cursor-pointer font-semibold"> <summary className="cursor-pointer font-semibold">
Gitea connection settings {t("gitTitle")}
</summary> </summary>
<p className="my-3 text-sm text-[var(--admin-text-muted)]"> <p className="my-3 text-sm text-[var(--admin-text-muted)]">
Connect your self-hosted Gitea repository over HTTPS. The CMS manages {t("gitIntro")}
its own checkout; no Git-specific .env settings are needed.
</p> </p>
{config && ( {config && (
<fieldset disabled={busy} className="space-y-3"> <fieldset disabled={busy} className="space-y-3">
<p className="text-sm"> <p className="text-sm">
Provider: <strong>Gitea self-hosted</strong> {t("providerLabel")} <strong>{t("providerValue")}</strong>
</p> </p>
<label htmlFor="catalog-git-1" className="block text-sm"> <label htmlFor="catalog-git-1" className="block text-sm">
Repository URL {t("repositoryUrl")}
<Input <Input
id="catalog-git-1" id="catalog-git-1"
type="url" type="url"
@@ -94,7 +91,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label> </label>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<label htmlFor="catalog-git-2" className="block text-sm"> <label htmlFor="catalog-git-2" className="block text-sm">
Branch {t("branch")}
<Input <Input
id="catalog-git-2" id="catalog-git-2"
value={config.branch} value={config.branch}
@@ -104,7 +101,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
/> />
</label> </label>
<label htmlFor="catalog-git-3" className="block text-sm"> <label htmlFor="catalog-git-3" className="block text-sm">
Gitea username {t("username")}
<Input <Input
id="catalog-git-3" id="catalog-git-3"
autoComplete="off" autoComplete="off"
@@ -116,23 +113,18 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label> </label>
</div> </div>
<label htmlFor="catalog-git-4" className="block text-sm"> <label htmlFor="catalog-git-4" className="block text-sm">
Gitea access token {t("accessToken")}
<Input <Input
id="catalog-git-4" id="catalog-git-4"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={token} value={token}
placeholder={ placeholder={config.hasToken ? t("tokenSaved") : t("tokenEnter")}
config.hasToken
? "Token saved — leave blank to keep it"
: "Enter a token with repository write access"
}
onChange={(event) => setToken(event.target.value)} onChange={(event) => setToken(event.target.value)}
/> />
</label> </label>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
The token is stored encrypted and is never returned to the browser. {t("tokenNote")}
Changing repository or username requires entering the token again.
</p> </p>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<input <input
@@ -142,14 +134,14 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
setConfig({ ...config, enabled: event.target.checked }) setConfig({ ...config, enabled: event.target.checked })
} }
/> />
Automatically export catalog changes {t("autoExport")}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void submit("test")}> <Button variant="outline" onClick={() => void submit("test")}>
Test connection {t("testConnection")}
</Button> </Button>
<Button onClick={() => void submit("save")}> <Button onClick={() => void submit("save")}>
{busy ? "Working…" : "Save Gitea settings"} {busy ? t("working") : t("saveSettings")}
</Button> </Button>
</div> </div>
</fieldset> </fieldset>
+23 -22
View File
@@ -1,6 +1,9 @@
import { RefreshCw } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client"; import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client";
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { CatalogExportClient } from "./catalog-export-client"; import { CatalogExportClient } from "./catalog-export-client";
@@ -12,8 +15,14 @@ export default async function StudioSyncPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-8"> <StudioPage
icon={RefreshCw}
title={t("nav.syncRepository")}
subtitle={t("sync.pageSubtitle")}
>
<div className="space-y-6">
<CatalogExportClient <CatalogExportClient
canConfigure={canAccess( canConfigure={canAccess(
permissions, permissions,
@@ -21,35 +30,27 @@ export default async function StudioSyncPage(_props: {
session.user.rank, 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>
<div className="space-y-4"> <section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<div> <h2 className="text-lg font-semibold">
<h2 className="text-lg font-semibold text-muted-foreground mb-2"> {t("sync.cloneSourcesTitle")}
Clone Sources
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-[var(--admin-text-muted)]">
Scan all configured clone sources and automatically import any {t("sync.cloneSourcesBody")}
furniture that is not yet present in your hotel.
</p> </p>
<SyncAllClient /> <SyncAllClient />
</div> </section>
<div> <section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold text-muted-foreground mb-2"> <h2 className="text-lg font-semibold">
Official Furnidata {t("sync.officialFurnidataTitle")}
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-[var(--admin-text-muted)]">
Scan the official Habbo furnidata and automatically import any {t("sync.officialFurnidataBody")}
furniture that your hotel doesn't have yet.
</p> </p>
<OfficialSyncClient /> <OfficialSyncClient />
</section>
</div> </div>
</div> </StudioPage>
</div>
); );
} }
+10 -12
View File
@@ -1,5 +1,8 @@
import { Upload } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client"; 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"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioUploadPage(_props: { export default async function StudioUploadPage(_props: {
@@ -9,19 +12,14 @@ export default async function StudioUploadPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={Upload}
<h1 className="text-2xl font-bold tracking-tight"> title={t("pages.uploadTitle")}
Upload .hab Furniture subtitle={t("pages.uploadSubtitle")}
</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 /> <UploadFurniClient />
</div> </StudioPage>
); );
} }
+30 -1
View File
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { AVATAR_FALLBACK_SRC } from "@/lib/imager"; import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { import {
fetchAvatarImage, fetchAvatarImage,
ImagerUnavailableError, ImagerUnavailableError,
@@ -43,24 +44,52 @@ export async function GET(request: NextRequest) {
parsed.params, 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, { return new NextResponse(body as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
"Cache-Control": cacheControlFor(source), "Cache-Control": cacheControl,
ETag: etag,
"X-Imager-Source": source, "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
}); });
} catch (error) { } catch (error) {
if (error instanceof ImagerUnavailableError) { 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, { return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "image/svg+xml", "Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length), "Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": cacheControlFor("unavailable"), "Cache-Control": cacheControlFor("unavailable"),
ETag: etag,
"X-Imager-Source": "unavailable", "X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
+91 -22
View File
@@ -7,6 +7,7 @@ import {
readImagingCache, readImagingCache,
writeImagingCache, writeImagingCache,
} from "@/lib/imager-cache"; } from "@/lib/imager-cache";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { resolveBadgeBase } from "@/lib/runtime-asset-config"; import { resolveBadgeBase } from "@/lib/runtime-asset-config";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -33,6 +34,64 @@ const BADGE_PLACEHOLDER_SVG =
type BadgeSource = "upstream" | "local" | "cache" | "unavailable"; 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) { export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code")?.trim(); const code = request.nextUrl.searchParams.get("code")?.trim();
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) { if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
@@ -56,25 +115,20 @@ export async function GET(request: NextRequest) {
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`); const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
const cached = await readImagingCache(badgeCacheDir(), cacheKey); const cached = await readImagingCache(badgeCacheDir(), cacheKey);
if (cached) return badgeResponse(cached.body, cached.contentType, "cache"); if (cached)
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);
}
}
return badgeResponse( return badgeResponse(
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG), cached.body,
"image/svg+xml", cached.contentType,
"unavailable", "cache",
request.headers.get("if-none-match"),
);
const resolved = await resolveBadge(cacheKey, code, candidates);
return badgeResponse(
resolved.body,
resolved.contentType,
resolved.source,
request.headers.get("if-none-match"),
); );
} }
@@ -140,16 +194,31 @@ function badgeResponse(
body: Uint8Array, body: Uint8Array,
contentType: string, contentType: string,
source: BadgeSource, 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, { return new NextResponse(body as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
"Cache-Control": "Cache-Control": cacheControl,
source === "unavailable" ETag: etag,
? "no-cache, max-age=0"
: "public, max-age=3600",
"X-Imager-Source": source, "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
+170 -26
View File
@@ -1,8 +1,33 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import sharp from "sharp"; import sharp from "sharp";
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream"; 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";
type AvatarFormat = "png" | "webp" | "avif"; 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 { function resolveFormat(raw: string | null): AvatarFormat {
switch (raw?.toLowerCase()) { switch (raw?.toLowerCase()) {
@@ -15,6 +40,89 @@ 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) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -31,27 +139,42 @@ export async function GET(request: NextRequest) {
parsed.params, 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 respBody: Uint8Array = body;
let respContentType = contentType; let respContentType = contentType;
if (format !== "png") { if (format !== "png") {
try { // The variant is keyed off the exact render that produced it (the
if (format === "webp") { // resolved upstream + the normalized query) plus the target format,
respBody = new Uint8Array( // mirroring how the underlying render itself is cached.
await sharp(Buffer.from(body)).webp().toBuffer(), const variantKey = imagingCacheKey(
`${resolveImagerBase(new URL(request.url).origin)}\n${parsed.params.toString()}\n${format}`,
); );
respContentType = "image/webp"; const converted = await transcodeToVariant(body, format, variantKey);
} else { if (converted) {
respBody = new Uint8Array( respBody = converted.body;
await sharp(Buffer.from(body)).avif().toBuffer(), respContentType = converted.contentType;
);
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, { return new NextResponse(respBody as unknown as BodyInit, {
@@ -59,20 +182,41 @@ export async function GET(request: NextRequest) {
headers: { headers: {
"Content-Type": respContentType, "Content-Type": respContentType,
"Content-Length": String(respBody.length), "Content-Length": String(respBody.length),
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC), "Cache-Control": cacheControl,
// stale-while-revalidate keeps them fresh without blocking requests. ETag: etag,
// 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, "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
}); });
} catch { } 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": "*",
},
});
}
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch avatar from upstream" }, { error: "Failed to fetch avatar from upstream" },
{ status: 502 }, { status: 502 },
+4
View File
@@ -2,6 +2,10 @@
`studio-client.tsx` composes the workspace, selection and actions. Keep reusable data handling outside its render tree. `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 ## Client responsibilities
- `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array. - `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array.
+37 -32
View File
@@ -9,19 +9,20 @@ import {
RefreshCw, RefreshCw,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useEffect, useMemo, useState } from "react"; import { memo, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { BatchItemStatus, VerificationSummary } from "./studio-types"; import type { BatchItemStatus, VerificationSummary } from "./studio-types";
const statusLabels: Record<BatchItemStatus["status"], string> = { const STATUS_KEYS: Record<BatchItemStatus["status"], string> = {
pending: "Waiting", pending: "status.pending",
started: "Starting", started: "status.started",
downloading: "Downloading assets", downloading: "status.downloading",
converting: "Preparing assets", converting: "status.converting",
writing_db: "Saving", writing_db: "status.writingDb",
done: "Imported", done: "status.done",
failed: "Failed", failed: "status.failed",
}; };
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -65,6 +66,7 @@ export const BatchProgress = memo(function BatchProgress({
onDismiss?: () => void; onDismiss?: () => void;
onRetry?: (classnames: string[]) => void; onRetry?: (classnames: string[]) => void;
}) { }) {
const t = useTranslations("admin.studio.batch");
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
@@ -99,20 +101,20 @@ export const BatchProgress = memo(function BatchProgress({
const rate = formatRate(completed, elapsed); const rate = formatRate(completed, elapsed);
const heading = !done const heading = !done
? cancelling ? cancelling
? "Stopping import…" ? t("headingStopping")
: "Importing furniture…" : t("headingImporting")
: unfinished : unfinished
? "Import stopped" ? t("headingStopped")
: errors : errors
? "Import completed with errors" ? t("headingCompletedErrors")
: "Import completed"; : t("headingCompleted");
const issues = verification const issues = verification
? verification.furniDataMissing + verification.furniDataConflicts ? verification.furniDataMissing + verification.furniDataConflicts
: 0; : 0;
return ( return (
<section <section
aria-label="Import progress" aria-label={t("ariaProgress")}
className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4" 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"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -128,7 +130,7 @@ export const BatchProgress = memo(function BatchProgress({
<span className="font-semibold text-[var(--admin-success)]"> <span className="font-semibold text-[var(--admin-success)]">
{imported} {imported}
</span>{" "} </span>{" "}
imported {t("imported")}
</span> </span>
<span className="text-[var(--admin-border)]">·</span> <span className="text-[var(--admin-border)]">·</span>
<span> <span>
@@ -140,13 +142,13 @@ export const BatchProgress = memo(function BatchProgress({
> >
{errors} {errors}
</span>{" "} </span>{" "}
failed {t("failed")}
</span> </span>
{unfinished > 0 && ( {unfinished > 0 && (
<> <>
<span className="text-[var(--admin-border)]">·</span> <span className="text-[var(--admin-border)]">·</span>
<span> <span>
{unfinished} {done ? "not completed" : "remaining"} {unfinished} {done ? t("notCompleted") : t("remaining")}
</span> </span>
</> </>
)} )}
@@ -173,12 +175,12 @@ export const BatchProgress = memo(function BatchProgress({
onClick={() => onRetry(failedClassnames)} onClick={() => onRetry(failedClassnames)}
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
Retry failed ({failedClassnames.length}) {t("retryFailed", { count: failedClassnames.length })}
</Button> </Button>
)} )}
<Button size="sm" variant="secondary" onClick={onDismiss}> <Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} /> <Check size={14} />
Close summary {t("closeSummary")}
</Button> </Button>
</> </>
) : ( ) : (
@@ -193,14 +195,14 @@ export const BatchProgress = memo(function BatchProgress({
) : ( ) : (
<Ban size={14} /> <Ban size={14} />
)} )}
Stop import {t("stopImport")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
<div <div
role="progressbar" role="progressbar"
aria-label="Furniture processed" aria-label={t("ariaProcessed")}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={total || 1} aria-valuemax={total || 1}
aria-valuenow={completed} aria-valuenow={completed}
@@ -215,39 +217,42 @@ export const BatchProgress = memo(function BatchProgress({
<div className="mb-3 flex flex-wrap items-center gap-1.5"> <div className="mb-3 flex flex-wrap items-center gap-1.5">
<p className="mr-1 text-sm text-[var(--admin-text-muted)]"> <p className="mr-1 text-sm text-[var(--admin-text-muted)]">
{issues {issues
? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.` ? t("verificationIssues", { count: issues })
: "Catalog data verification completed."} : t("verificationDone")}
</p> </p>
<NodeChip <NodeChip
count={verification.furniDataFixedIds} count={verification.furniDataFixedIds}
label="furnidata ids fixed" label={t("chipFurnidataIds")}
/> />
<NodeChip <NodeChip
count={verification.offerIdsFixed} count={verification.offerIdsFixed}
label="offer ids reconciled" label={t("chipOfferIds")}
/> />
<NodeChip <NodeChip
count={verification.ownershipFixed.length} count={verification.ownershipFixed.length}
label="ownerships fixed" label={t("chipOwnerships")}
/> />
<NodeChip <NodeChip
count={verification.nitrosSynced.length} count={verification.nitrosSynced.length}
label="nitros synced" label={t("chipNitros")}
/> />
<NodeChip <NodeChip
count={verification.iconsSynced.length} count={verification.iconsSynced.length}
label="icons synced" label={t("chipIcons")}
/> />
</div> </div>
)} )}
<details> <details>
<summary className="cursor-pointer text-xs font-medium"> <summary className="cursor-pointer text-xs font-medium">
View item details ({total}) {t("viewItemDetails", { count: total })}
</summary> </summary>
<div className="mt-2 max-h-44 space-y-2 overflow-auto"> <div className="mt-2 max-h-44 space-y-2 overflow-auto">
{entries.length > VISIBLE_ITEMS && ( {entries.length > VISIBLE_ITEMS && (
<p className="px-1 text-[0.65rem] text-[var(--admin-text-muted)]"> <p className="px-1 text-[0.65rem] text-[var(--admin-text-muted)]">
Showing {VISIBLE_ITEMS} of {entries.length} items {t("showingItems", {
shown: VISIBLE_ITEMS,
total: entries.length,
})}
</p> </p>
)} )}
{entries.slice(0, VISIBLE_ITEMS).map((item) => ( {entries.slice(0, VISIBLE_ITEMS).map((item) => (
@@ -280,8 +285,8 @@ export const BatchProgress = memo(function BatchProgress({
</span> </span>
<span className="shrink-0 text-[var(--admin-text-muted)]"> <span className="shrink-0 text-[var(--admin-text-muted)]">
{done && item.status !== "done" && item.status !== "failed" {done && item.status !== "done" && item.status !== "failed"
? "Not completed" ? t("statusNotCompleted")
: statusLabels[item.status]} : t(STATUS_KEYS[item.status])}
</span> </span>
</span> </span>
{item.message && ( {item.message && (
+18 -11
View File
@@ -10,6 +10,7 @@ import {
Search, Search,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useMemo, useState, useTransition } from "react"; import { memo, useMemo, useState, useTransition } from "react";
import { import {
refreshCatalogTree, refreshCatalogTree,
@@ -25,6 +26,7 @@ import type { TreeNode } from "@/types/catalog";
* member has no other signal that the import they just ran is not on screen. * member has no other signal that the import they just ran is not on screen.
*/ */
function LiveCatalogNotice() { function LiveCatalogNotice() {
const t = useTranslations("admin.studio");
const { refreshError, refreshing } = useLiveCatalog(); const { refreshError, refreshing } = useLiveCatalog();
const [retrying, startRetry] = useTransition(); const [retrying, startRetry] = useTransition();
if (!refreshError && !refreshing) return null; if (!refreshError && !refreshing) return null;
@@ -41,7 +43,7 @@ function LiveCatalogNotice() {
{refreshError ? ( {refreshError ? (
<> <>
<AlertTriangle size={14} className="shrink-0" /> <AlertTriangle size={14} className="shrink-0" />
<span className="min-w-0 flex-1">Catalog could not refresh.</span> <span className="min-w-0 flex-1">{t("rail.refreshFailed")}</span>
<button <button
type="button" type="button"
disabled={retrying} disabled={retrying}
@@ -53,13 +55,13 @@ function LiveCatalogNotice() {
className="flex items-center gap-1 rounded px-1.5 py-0.5 font-medium underline disabled:opacity-50" 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")} /> <RefreshCw size={12} className={cn(retrying && "animate-spin")} />
Retry {t("rail.retry")}
</button> </button>
</> </>
) : ( ) : (
<> <>
<Loader2 size={14} className="shrink-0 animate-spin" /> <Loader2 size={14} className="shrink-0 animate-spin" />
<span className="min-w-0 flex-1">Updating catalog…</span> <span className="min-w-0 flex-1">{t("rail.updating")}</span>
</> </>
)} )}
</p> </p>
@@ -77,6 +79,7 @@ export const CatalogRail = memo(function CatalogRail({
onSelect: (id: number | null) => void; onSelect: (id: number | null) => void;
onClose?: () => void; onClose?: () => void;
}) { }) {
const t = useTranslations("admin.studio");
const [collapsed, setCollapsed] = useState<Set<number>>(new Set()); const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const visible = useMemo(() => { const visible = useMemo(() => {
@@ -100,7 +103,7 @@ export const CatalogRail = memo(function CatalogRail({
} }
return ( return (
<aside <aside
aria-label="Catalog categories" aria-label={t("aria.catalogCategories")}
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" 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"> <div className="flex items-center gap-2 px-3 pt-3">
@@ -109,12 +112,12 @@ export const CatalogRail = memo(function CatalogRail({
style={{ fontSize: 14, margin: 0 }} style={{ fontSize: 14, margin: 0 }}
className="flex-1 text-sm font-semibold" className="flex-1 text-sm font-semibold"
> >
Categories {t("rail.title")}
</h2> </h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close categories" aria-label={t("aria.closeCategories")}
className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10" className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
> >
<X size={16} /> <X size={16} />
@@ -125,8 +128,8 @@ export const CatalogRail = memo(function CatalogRail({
<input <input
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
placeholder="Find a category…" placeholder={t("aria.findCategory")}
aria-label="Find a category" aria-label={t("aria.findCategory")}
className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none" className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none"
/> />
</label> </label>
@@ -136,13 +139,13 @@ export const CatalogRail = memo(function CatalogRail({
aria-pressed={selectedId === null} 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" 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"
> >
All categories {t("rail.all")}
</button> </button>
<LiveCatalogNotice /> <LiveCatalogNotice />
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{visible.length === 0 && ( {visible.length === 0 && (
<p className="p-3 text-sm text-[var(--admin-text-muted)]"> <p className="p-3 text-sm text-[var(--admin-text-muted)]">
No matching categories. {t("rail.noMatches")}
</p> </p>
)} )}
{visible.map((node) => ( {visible.map((node) => (
@@ -159,7 +162,11 @@ export const CatalogRail = memo(function CatalogRail({
{node.childCount > 0 && !search.trim() ? ( {node.childCount > 0 && !search.trim() ? (
<button <button
type="button" type="button"
aria-label={`${collapsed.has(node.id) ? "Expand" : "Collapse"} ${node.caption}`} aria-label={
collapsed.has(node.id)
? t("aria.expandCategory", { name: node.caption })
: t("aria.collapseCategory", { name: node.caption })
}
aria-expanded={!collapsed.has(node.id)} aria-expanded={!collapsed.has(node.id)}
onClick={() => toggle(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" className="grid h-10 w-8 shrink-0 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
@@ -15,11 +15,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog"; import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { FurnitureInspector } from "./furniture-inspector"; import { FurnitureInspector } from "./furniture-inspector";
import { import { getFurniImageUrl, handleImgError } from "./studio-furni-icons";
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import type { FurniItem } from "./studio-types"; import type { FurniItem } from "./studio-types";
interface FurnitureDetailDrawerProps { interface FurnitureDetailDrawerProps {
@@ -49,6 +45,10 @@ export function FurnitureDetailDrawer({
onRepair, onRepair,
}: FurnitureDetailDrawerProps) { }: FurnitureDetailDrawerProps) {
const t = useTranslations("admin.studio.completeness"); 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( const detailPreview = previewAutoCatalog(
detail.classname, detail.classname,
detail.type === "wallitem" ? "i" : "s", 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"> <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"> <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)]"> <span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail {drawerT("title")}
</span> </span>
<button <button
type="button" type="button"
aria-label="Close furniture details" aria-label={studioT("aria.closeDetails")}
onClick={onClose} onClick={onClose}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]" 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} ) : null}
<div className="mb-4 flex flex-wrap gap-1.5"> <div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">{typeLabel}</Badge>
<Badge variant="secondary"> <Badge variant="secondary">
{TYPE_LABELS[detail.type] ?? detail.type} {drawerT("revision", { revision: detail.revision })}
</Badge> </Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? ( {detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40"> <Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")} <Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
</Badge> </Badge>
) : ( ) : (
<Badge className="text-[var(--admin-accent-foreground)]"> <Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> Auto-import ready <Sparkles size={10} /> {drawerT("autoImportReady")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -127,22 +127,30 @@ export function FurnitureDetailDrawer({
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3"> <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)]"> <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)]" /> <WandSparkles size={12} className="text-[var(--admin-accent)]" />
Automatic placement {drawerT("automaticPlacement")}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Category</span> <span className="text-[var(--admin-text-muted)]">
{drawerT("category")}
</span>
<span className="font-semibold text-[var(--admin-text)]"> <span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel} {detailPreview.categoryLabel}
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Price</span> <span className="text-[var(--admin-text-muted)]">
{drawerT("price")}
</span>
<span className="font-semibold text-[var(--admin-text)]"> <span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0 {detailPreview.points > 0
? ` + ${detailPreview.points} points` ? drawerT("priceCreditsPoints", {
: ""} credits: detailPreview.credits,
points: detailPreview.points,
})
: drawerT("priceCredits", {
count: detailPreview.credits,
})}
</span> </span>
</div> </div>
</div> </div>
@@ -163,14 +171,14 @@ export function FurnitureDetailDrawer({
) : ( ) : (
<CloudDownload size={14} /> <CloudDownload size={14} />
)} )}
Import & add to catalog {drawerT("importAdd")}
</Button> </Button>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{!detail.nitroExists && ( {!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)]"> <p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but its bundle file is missing. {drawerT("bundleMissing")}
</p> </p>
<Button <Button
variant="outline" variant="outline"
@@ -183,7 +191,7 @@ export function FurnitureDetailDrawer({
) : ( ) : (
<RefreshCw size={14} /> <RefreshCw size={14} />
)} )}
Regenerate .nitro {drawerT("regenerate")}
</Button> </Button>
</> </>
)} )}
@@ -194,7 +202,7 @@ export function FurnitureDetailDrawer({
disabled={!detail.nitroExists} disabled={!detail.nitroExists}
> >
<Pencil size={14} /> <Pencil size={14} />
Edit nitro {drawerT("editNitro")}
</Button> </Button>
</div> </div>
)} )}
@@ -206,7 +214,7 @@ export function FurnitureDetailDrawer({
onClick={onDelete} onClick={onDelete}
> >
<Trash2 size={14} /> <Trash2 size={14} />
Delete imported furni {drawerT("deleteImported")}
</Button> </Button>
)} )}
</div> </div>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
compareFurniture, compareFurniture,
@@ -9,6 +10,21 @@ import { FurnitureCompleteness } from "./furniture-completeness";
import type { FurniItem } from "./studio-types"; import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection"; 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({ export function FurnitureComparison({
item, item,
local, local,
@@ -16,69 +32,81 @@ export function FurnitureComparison({
item: FurniItem; item: FurniItem;
local: FurnitureInspection; local: FurnitureInspection;
}) { }) {
const t = useTranslations("admin.studio.inspector");
const comparison = compareFurniture(item, local); const comparison = compareFurniture(item, local);
return ( return (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<FurnitureCompleteness local={local} /> <FurnitureCompleteness local={local} />
{!local.furnidataReadable && ( {!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]"> <p className="text-[var(--admin-warning)]">
Local furnidata could not be read. Its values are not verified. {t("furnidataUnreadable")}
</p> </p>
)} )}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr> <tr>
<th className="p-2 text-left">Field</th> <th className="p-2 text-left">{t("field")}</th>
<th className="p-2 text-left">Local now</th> <th className="p-2 text-left">{t("localNow")}</th>
<th className="p-2 text-left">Source</th> <th className="p-2 text-left">{t("source")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{comparison.fields.map((field) => ( {comparison.fields.map((field) => {
const labelKey =
FIELD_KEYS[field.label as keyof typeof FIELD_KEYS];
return (
<tr <tr
key={field.label} key={field.label}
className={field.changed ? "bg-[var(--admin-warning)]/10" : ""} className={
field.changed ? "bg-[var(--admin-warning)]/10" : ""
}
> >
<th className="p-2 text-left"> <th className="p-2 text-left">
{field.label} {labelKey ? t(labelKey) : field.label}
{field.changed ? " · Changed" : ""} {field.changed ? ` · ${t("changed")}` : ""}
</th> </th>
<td className="max-w-48 break-words p-2"> <td className="max-w-48 break-words p-2">
{field.before ?? "—"} {field.before ?? "—"}
</td> </td>
<td className="max-w-48 break-words p-2"> <td className="max-w-48 break-words p-2">
{field.after ?? "Not supplied"} {field.after ?? t("notSupplied")}
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
Source values are compared with local data. Import may remap IDs and {t("compareNote")}
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> </p>
<details> <details>
<summary className="cursor-pointer font-medium"> <summary className="cursor-pointer font-medium">
SQL records and catalog offers {t("sqlRecords")}
</summary> </summary>
<div className="mt-2 space-y-2 text-xs"> <div className="mt-2 space-y-2 text-xs">
{local.sql.length ? ( {local.sql.length ? (
local.sql.map((row) => ( local.sql.map((row) => (
<p key={row.id}> <p key={row.id}>
Item #{row.id} · Sprite #{row.spriteId} · {row.name} {t("itemRow", {
id: row.id,
sprite: row.spriteId,
name: row.name,
})}
</p> </p>
)) ))
) : ( ) : (
<p>No local SQL item.</p> <p>{t("noSqlItem")}</p>
)} )}
{local.catalog.map((row) => ( {local.catalog.map((row) => (
<p key={row.id}> <p key={row.id}>
Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "} {t("offerRow", {
{row.points} points id: row.id,
page: row.pageId,
credits: row.credits,
points: row.points,
})}
</p> </p>
))} ))}
</div> </div>
@@ -95,11 +123,12 @@ export function FurnitureInspector({
onRepair?: () => Promise<unknown>; onRepair?: () => Promise<unknown>;
busy?: boolean; busy?: boolean;
}) { }) {
const t = useTranslations("admin.studio.inspector");
const inspection = useFurnitureInspection([item.classname]); const inspection = useFurnitureInspection([item.classname]);
if (inspection.loading) if (inspection.loading)
return ( return (
<p role="status" className="py-3 text-sm"> <p role="status" className="py-3 text-sm">
Checking local data and assets… {t("checking")}
</p> </p>
); );
if (inspection.error) if (inspection.error)
@@ -107,7 +136,7 @@ export function FurnitureInspector({
<div role="alert"> <div role="alert">
<p>{inspection.error}</p> <p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}> <Button variant="outline" onClick={inspection.retry}>
Retry inspection {t("retry")}
</Button> </Button>
</div> </div>
); );
@@ -118,51 +147,35 @@ export function FurnitureInspector({
return local && health ? ( return local && health ? (
<div className="space-y-3"> <div className="space-y-3">
<section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2"> <section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2">
<strong> <strong>{t(HEALTH_KEYS[health.state])}</strong>
{
{
complete: "Complete",
incomplete: "Needs repair",
new: "Not imported",
conflict: "Local conflict",
unknown: "Not verified",
}[health.state]
}
</strong>
{health.missing.length > 0 && ( {health.missing.length > 0 && (
<p className="text-sm">Missing: {health.missing.join(", ")}</p> <p className="text-sm">
{t("missing", { parts: health.missing.join(", ") })}
</p>
)} )}
{health.state === "conflict" && ( {health.state === "conflict" && (
<p className="text-sm"> <p className="text-sm">{t("conflictNote")}</p>
Duplicate records or mismatched IDs/types require review before
repair.
</p>
)} )}
{health.state === "unknown" && ( {health.state === "unknown" && (
<p className="text-sm"> <p className="text-sm">{t("unknownNote")}</p>
Some local data could not be checked. Retry verification before
repair.
</p>
)} )}
{health.state === "incomplete" && onRepair && ( {health.state === "incomplete" && onRepair && (
<> <>
<p className="text-sm text-[var(--admin-text-muted)]"> <p className="text-sm text-[var(--admin-text-muted)]">
Repair adds missing parts and preserves existing IDs, settings, {t("repairNote")}
furnidata and catalog offers. Missing offers use the automatic
category and price shown below.
</p> </p>
<Button disabled={busy} onClick={() => void onRepair()}> <Button disabled={busy} onClick={() => void onRepair()}>
{busy ? "Repair queued or running…" : "Repair furni"} {busy ? t("repairQueued") : t("repair")}
</Button> </Button>
</> </>
)} )}
<Button variant="outline" disabled={busy} onClick={inspection.retry}> <Button variant="outline" disabled={busy} onClick={inspection.retry}>
Recheck local state {t("recheck")}
</Button> </Button>
</section> </section>
<FurnitureComparison item={item} local={local} /> <FurnitureComparison item={item} local={local} />
</div> </div>
) : ( ) : (
<p>Local inspection unavailable.</p> <p>{t("unavailable")}</p>
); );
} }
+49 -51
View File
@@ -41,6 +41,7 @@ export function ImportReview({
onConfirm: (items: FurniItem[]) => void; onConfirm: (items: FurniItem[]) => void;
}) { }) {
const preflightT = useTranslations("admin.studio.preflight"); const preflightT = useTranslations("admin.studio.preflight");
const reviewT = useTranslations("admin.studio.review");
const [reviewFilter, setReviewFilter] = useState< const [reviewFilter, setReviewFilter] = useState<
"all" | "complete" | "conflict" | "verify" "all" | "complete" | "conflict" | "verify"
>("all"); >("all");
@@ -59,14 +60,16 @@ export function ImportReview({
body: form, body: form,
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(data.error || "Upload failed"); if (!response.ok) throw Error(data.error || reviewT("uploadFailed"));
setAttachments((current) => ({ setAttachments((current) => ({
...current, ...current,
[item.classname]: data.attachmentId, [item.classname]: data.attachmentId,
})); }));
toast.success(diagnosticsT("attached")); toast.success(diagnosticsT("attached"));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed"); toast.error(
error instanceof Error ? error.message : reviewT("uploadFailed"),
);
} finally { } finally {
setUploading(null); setUploading(null);
} }
@@ -134,46 +137,44 @@ export function ImportReview({
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{step === "review" {step === "review"
? "Review furniture import" ? reviewT("titleReview")
: "Confirm furniture import"} : reviewT("titleConfirm")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>{reviewT("steps")}</DialogDescription>
1. Select → 2. Review → 3. Import
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto"> <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"> <div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm">
<p> <p>
<strong>Source:</strong> {source} <strong>{reviewT("source")}</strong> {source}
</p> </p>
<p> <p>
<strong>Destination:</strong> local catalog, furnidata, bundle and <strong>{reviewT("destination")}</strong>{" "}
icons {reviewT("destinationValue")}
</p> </p>
<p> <p>
<strong>Translation:</strong> {translation} <strong>{reviewT("translation")}</strong> {translation}
</p> </p>
<p> <p>
<strong>Selection:</strong> {items.length} furniture items <strong>{reviewT("selection")}</strong>{" "}
{reviewT("selectionCount", { count: items.length })}
</p> </p>
</div> </div>
{assets.loading && !inspection.loading && ( {assets.loading && !inspection.loading && (
<p role="status"> <p role="status">
Checking source files… {assets.items.length} checked {reviewT("checkingSource", { count: assets.items.length })}
</p> </p>
)} )}
{assets.error && ( {assets.error && (
<div role="alert"> <div role="alert">
<p>{assets.error}</p> <p>{assets.error}</p>
<Button variant="outline" onClick={assets.retry}> <Button variant="outline" onClick={assets.retry}>
Retry source check {reviewT("retrySource")}
</Button> </Button>
</div> </div>
)} )}
{assets.items.some((item) => item.state === "unknown") && ( {assets.items.some((item) => item.state === "unknown") && (
<p className="text-sm text-[var(--admin-warning)]"> <p className="text-sm text-[var(--admin-warning)]">
Some sources could not be verified. The import will still try to {reviewT("unknownSources")}
download their files.
</p> </p>
)} )}
{unavailable.map((check) => ( {unavailable.map((check) => (
@@ -182,7 +183,7 @@ export function ImportReview({
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2" className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
> >
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Source files not found by preliminary check: {check.classname} {reviewT("sourceNotFound", { name: check.classname })}
</p> </p>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
{progressT("recoveryHint")} {progressT("recoveryHint")}
@@ -190,24 +191,25 @@ export function ImportReview({
</section> </section>
))} ))}
{inspection.loading ? ( {inspection.loading ? (
<p role="status">Checking local data and assets…</p> <p role="status">{reviewT("checkingLocal")}</p>
) : inspection.error ? ( ) : inspection.error ? (
<div role="alert"> <div role="alert">
<p>{inspection.error}</p> <p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}> <Button variant="outline" onClick={inspection.retry}>
Retry inspection {reviewT("retryInspection")}
</Button> </Button>
</div> </div>
) : !complete ? ( ) : !complete ? (
<p role="alert"> <p role="alert">{reviewT("incomplete")}</p>
Inspection is incomplete. Close and retry before importing.
</p>
) : ( ) : (
<> <>
<p role="status" className="text-sm"> <p role="status" className="text-sm">
{ready.length} ready to import · {existing} already in SQL {reviewT("summary", {
(complete missing data) · {blocked} conflicts ·{" "} ready: ready.length,
{unavailable.length} source file warnings existing,
blocked,
warnings: unavailable.length,
})}
</p> </p>
{blocked > 0 && ( {blocked > 0 && (
<div <div
@@ -232,8 +234,7 @@ export function ImportReview({
item.nitro.exists === null, item.nitro.exists === null,
) && ( ) && (
<p className="text-sm text-[var(--admin-warning)]"> <p className="text-sm text-[var(--admin-warning)]">
Some local files could not be verified. Review the item {reviewT("localUnverified")}
details before continuing.
</p> </p>
)} )}
{step === "review" ? ( {step === "review" ? (
@@ -329,14 +330,14 @@ export function ImportReview({
</span> </span>
<span> <span>
{comparison.state === "new" {comparison.state === "new"
? "New" ? reviewT("statusNew")
: comparison.state === "changed" : comparison.state === "changed"
? "Differences" ? reviewT("statusChanged")
: comparison.state === "conflict" : comparison.state === "conflict"
? "Conflict" ? reviewT("statusConflict")
: comparison.state === "unknown" : comparison.state === "unknown"
? "Not fully verified" ? reviewT("statusUnknown")
: "Present"} : reviewT("statusPresent")}
</span> </span>
</button> </button>
{expanded === item.classname && ( {expanded === item.classname && (
@@ -358,7 +359,9 @@ export function ImportReview({
}} }}
/> />
{uploading === item.classname && ( {uploading === item.classname && (
<span role="status">Validating file…</span> <span role="status">
{reviewT("validatingFile")}
</span>
)} )}
</label> </label>
)} )}
@@ -381,16 +384,10 @@ export function ImportReview({
) : ( ) : (
<> <>
<p className="text-sm"> <p className="text-sm">
Only the {ready.length} eligible items below will be {reviewT("confirmIntro", { count: ready.length })}
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>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
Automatic categories and prices below are estimates. Source {reviewT("confirmEstimates")}
rules may override them; final results appear in the import
summary.
</p> </p>
<ul className="space-y-2"> <ul className="space-y-2">
{pageItems.map((item) => { {pageItems.map((item) => {
@@ -405,8 +402,11 @@ export function ImportReview({
> >
<strong>{item.name}</strong> <strong>{item.name}</strong>
<p> <p>
{placement.categoryLabel} · {placement.credits}{" "} {reviewT("placement", {
credits + {placement.points} points category: placement.categoryLabel,
credits: placement.credits,
points: placement.points,
})}
</p> </p>
</li> </li>
); );
@@ -424,17 +424,15 @@ export function ImportReview({
disabled={page === 0} disabled={page === 0}
onClick={() => setPage((value) => value - 1)} onClick={() => setPage((value) => value - 1)}
> >
Previous items {reviewT("previous")}
</Button> </Button>
<span> <span>{reviewT("page", { page: page + 1, total: pageCount })}</span>
Page {page + 1} / {pageCount}
</span>
<Button <Button
variant="outline" variant="outline"
disabled={page + 1 === pageCount} disabled={page + 1 === pageCount}
onClick={() => setPage((value) => value + 1)} onClick={() => setPage((value) => value + 1)}
> >
Next items {reviewT("next")}
</Button> </Button>
</div> </div>
)} )}
@@ -451,7 +449,7 @@ export function ImportReview({
: onCancel : onCancel
} }
> >
{step === "confirm" ? "Back to review" : "Cancel"} {step === "confirm" ? reviewT("backToReview") : reviewT("cancel")}
</Button> </Button>
<Button <Button
style={{ style={{
@@ -480,8 +478,8 @@ export function ImportReview({
}} }}
> >
{step === "review" {step === "review"
? "Continue to confirmation" ? reviewT("continue")
: `Start import (${ready.length})`} : reviewT("startImport", { count: ready.length })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
@@ -15,6 +16,7 @@ function kb(bytes: number): string {
} }
function CompressionPanel({ report }: { report: CompressionReport }) { function CompressionPanel({ report }: { report: CompressionReport }) {
const t = useTranslations("admin.studio.quality");
const { texture, json } = report, const { texture, json } = report,
// Differences that a renderer can actually see. // Differences that a renderer can actually see.
visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences, visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences,
@@ -26,20 +28,20 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
return ( return (
<section className="min-w-0 space-y-3 rounded-lg border p-3"> <section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold"> <h3 className="font-semibold">
Compression · {texture.format.toUpperCase()} {t("compressionTitle", { format: texture.format.toUpperCase() })}
{texture.format === "webp" {texture.format === "webp"
? texture.lossless ? texture.lossless
? " Lossless (VP8L)" ? ` ${t("losslessVp8l")}`
: " Lossy (VP8)" : ` ${t("lossyVp8")}`
: ""} : ""}
</h3> </h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4"> <dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<div> <div>
<dt className="text-muted-foreground">Bundle</dt> <dt className="text-muted-foreground">{t("bundle")}</dt>
<dd className="font-medium">{kb(report.bundleBytes)}</dd> <dd className="font-medium">{kb(report.bundleBytes)}</dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Texture</dt> <dt className="text-muted-foreground">{t("texture")}</dt>
<dd className="font-medium"> <dd className="font-medium">
{kb(texture.compressedBytes)} {kb(texture.compressedBytes)}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@@ -49,75 +51,85 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Metadata</dt> <dt className="text-muted-foreground">{t("metadata")}</dt>
<dd className="font-medium"> <dd className="font-medium">
{kb(json.compressedBytes)} {kb(json.compressedBytes)}
<span className="text-muted-foreground"> · ×{json.ratio}</span> <span className="text-muted-foreground"> · ×{json.ratio}</span>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Container</dt> <dt className="text-muted-foreground">{t("container")}</dt>
<dd className="font-medium">{kb(report.overheadBytes)}</dd> <dd className="font-medium">{kb(report.overheadBytes)}</dd>
</div> </div>
</dl> </dl>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Texture {kb(texture.rawBytes)} decoded · deflate ×{texture.ratio} · {t("textureDetail", {
sheet {texture.width}×{texture.height} · {texture.spriteCount} frames raw: kb(texture.rawBytes),
ratio: texture.ratio,
width: texture.width,
height: texture.height,
frames: texture.spriteCount,
})}
</p> </p>
{texture.format === "png" ? ( {texture.format === "png" ? (
<p className="text-xs"> <p className="text-xs">
Legacy PNG texture. Re-importing as WebP Lossless would store{" "} {texture.webpEstimate.savingBytes > 0
<strong>{kb(texture.webpEstimate.bytes)}</strong> ? t.rich("legacyPngSaving", {
{texture.webpEstimate.savingBytes > 0 ? ( size: kb(texture.webpEstimate.bytes),
<> saving: kb(texture.webpEstimate.savingBytes),
, saving <strong>{kb(texture.webpEstimate.savingBytes)}</strong> ( percent:
{texture.webpEstimate.bytes > 0 texture.webpEstimate.bytes > 0
? Math.round( ? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) * 100, (1 - texture.webpEstimate.bytes / texture.rawBytes) *
100,
) )
: 0} : 0,
% smaller) strong: (chunks) => <strong>{chunks}</strong>,
</> })
) : ( : t.rich("legacyPngNoGain", {
" (no gain on this bundle)" size: kb(texture.webpEstimate.bytes),
)} strong: (chunks) => <strong>{chunks}</strong>,
})}
</p> </p>
) : ( ) : (
<p className="text-xs"> <p className="text-xs">
WebP Lossless stores {kb(texture.rawBytes)} against{" "} {pngSaving > 0
{kb(texture.pngEstimate.bytes)} for the same artwork as PNG ? t.rich("webpSaving", {
{pngSaving > 0 ? ( raw: kb(texture.rawBytes),
<> png: kb(texture.pngEstimate.bytes),
, saving <strong>{kb(pngSaving)}</strong> ( saving: kb(pngSaving),
{texture.pngEstimate.bytes > 0 percent:
texture.pngEstimate.bytes > 0
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100) ? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
: 0} : 0,
% smaller) strong: (chunks) => <strong>{chunks}</strong>,
</> })
) : ( : t("webpNoGain", {
" (PNG is not larger on this bundle)" raw: kb(texture.rawBytes),
)} png: kb(texture.pngEstimate.bytes),
})}
</p> </p>
)} )}
<p className="text-xs"> <p className="text-xs">
{visibleDiffs === 0 ? ( {visibleDiffs === 0 ? (
<> <>
<span className="font-medium text-foreground">Pixel-exact</span> — {t.rich("pixelExact", {
every visible texel round-trips unchanged. strong: (chunks) => (
<span className="font-medium text-foreground">{chunks}</span>
),
})}
{texture.invisibleDifferences > 0 && ( {texture.invisibleDifferences > 0 && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{" "} {" "}
{texture.invisibleDifferences.toLocaleString()} fully {t("invisibleDiffs", {
transparent texels carry no RGB, which VP8L discards and no count: texture.invisibleDifferences,
renderer can see. })}
</span> </span>
)} )}
</> </>
) : ( ) : (
<strong className="text-destructive"> <strong className="text-destructive">
{visibleDiffs.toLocaleString()} texel {t("texelsDiffer", { count: visibleDiffs })}
{visibleDiffs === 1 ? "" : "s"} differ after a lossless round-trip —
the furniture may not render identically.
</strong> </strong>
)} )}
</p> </p>
@@ -136,32 +148,34 @@ function ScalePanel({
}) { }) {
const [asset, setAsset] = useState(report.assets[0] ?? ""), const [asset, setAsset] = useState(report.assets[0] ?? ""),
[failed, setFailed] = useState(false); [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 ( return (
<section className="min-w-0 space-y-3 rounded-lg border p-3"> <section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold"> <h3 className="font-semibold">
Scale {report.size} ·{" "} {t("scaleTitle", { size: report.size, state: stateLabel })}
{report.state === "missing"
? "Not included"
: report.state === "issues"
? "Needs review"
: report.derived
? "Derived from 64"
: "Present"}
</h3> </h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{report.assets.length} sprites · {report.animations.length} animation {t("spritesLine", {
definitions · directions {report.directions.join(", ") || "—"} sprites: report.assets.length,
animations: report.animations.length,
directions: report.directions.join(", ") || "—",
})}
</p> </p>
{report.state === "missing" && ( {report.state === "missing" && (
<p className="text-sm"> <p className="text-sm">{t("missingScale")}</p>
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 && ( {report.assets.length > 0 && (
<> <>
<label className="block text-sm"> <label className="block text-sm">
Sprite {t("sprite")}
<select <select
className="mt-1 block w-full rounded border bg-background p-2" className="mt-1 block w-full rounded border bg-background p-2"
value={asset} value={asset}
@@ -179,7 +193,7 @@ function ScalePanel({
</label> </label>
<div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30"> <div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30">
{failed ? ( {failed ? (
<p role="alert">This sprite could not be rendered.</p> <p role="alert">{t("renderFailed")}</p>
) : ( ) : (
<img <img
key={`${asset}-${revision}`} key={`${asset}-${revision}`}
@@ -210,6 +224,7 @@ function ScalePanel({
); );
} }
export function NitroQualityPanel({ classname }: { classname: string }) { export function NitroQualityPanel({ classname }: { classname: string }) {
const t = useTranslations("admin.studio.quality");
const [report, setReport] = useState<NitroQuality | null>(null), const [report, setReport] = useState<NitroQuality | null>(null),
[error, setError] = useState(""), [error, setError] = useState(""),
[revision, setRevision] = useState(0); [revision, setRevision] = useState(0);
@@ -224,35 +239,32 @@ export function NitroQualityPanel({ classname }: { classname: string }) {
{ signal: controller.signal }, { signal: controller.signal },
); );
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(data.error || "Inspection failed"); if (!response.ok) throw Error(data.error || t("inspectionFailed"));
if (!controller.signal.aborted) setReport(data.report); if (!controller.signal.aborted) setReport(data.report);
} catch (error) { } catch (error) {
if (!controller.signal.aborted) if (!controller.signal.aborted)
setError( setError(
error instanceof Error ? error.message : "Inspection failed", error instanceof Error ? error.message : t("inspectionFailed"),
); );
} }
})(); })();
return () => controller.abort(); return () => controller.abort();
}, [classname, revision]); }, [classname, revision, t]);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-sm"> <p className="text-sm">{t("intro")}</p>
Inspect the saved bundle at both scales. Previews show individual
sprites, not the assembled furniture or animation playback.
</p>
<Button <Button
variant="outline" variant="outline"
onClick={() => setRevision((value) => value + 1)} onClick={() => setRevision((value) => value + 1)}
> >
Recheck {t("recheck")}
</Button> </Button>
</div> </div>
{error ? ( {error ? (
<p role="alert">{error}</p> <p role="alert">{error}</p>
) : !report ? ( ) : !report ? (
<p role="status">Checking scales and texture references…</p> <p role="status">{t("checking")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{report.compression && ( {report.compression && (
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
@@ -11,6 +12,7 @@ export function NitroScale32Generator({
disabled: boolean; disabled: boolean;
onChanged: (metadata: Record<string, unknown>) => void; onChanged: (metadata: Record<string, unknown>) => void;
}) { }) {
const t = useTranslations("admin.studio.scale32");
const [preview, setPreview] = useState<{ const [preview, setPreview] = useState<{
hash: string; hash: string;
before: string; before: string;
@@ -38,7 +40,7 @@ export function NitroScale32Generator({
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(data.error || "Generation failed"); if (!response.ok) throw Error(data.error || t("generationFailed"));
if (action === "preview") setPreview(data); if (action === "preview") setPreview(data);
else { else {
setPreview(null); setPreview(null);
@@ -50,45 +52,37 @@ export function NitroScale32Generator({
onChanged(data.metadata); onChanged(data.metadata);
} }
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Generation failed"); setError(e instanceof Error ? e.message : t("generationFailed"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
} }
return ( return (
<section className="space-y-3 rounded-lg border p-3"> <section className="space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">Generate scale 32 from 64</h3> <h3 className="font-semibold">{t("title")}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t("intro")}</p>
Adds derived artwork using nearest-neighbor reduction. Existing scale 32 {disabled && <p className="text-sm">{t("saveFirst")}</p>}
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 <Button
variant="outline" variant="outline"
disabled={busy || disabled} disabled={busy || disabled}
onClick={() => run("preview")} onClick={() => run("preview")}
> >
{busy ? "Working…" : "Preview generation"} {busy ? t("working") : t("preview")}
</Button> </Button>
{preview && ( {preview && (
<> <>
<p className="text-sm"> <p className="text-sm">
{preview.count} sprite entries will be added. Check positioning and {t("previewCount", { count: preview.count })}
animations in the client after saving.
</p> </p>
{preview.skipped ? ( {preview.skipped ? (
<p className="text-sm text-destructive" role="alert"> <p className="text-sm text-destructive" role="alert">
{preview.skipped} sprite(s) were skipped because the source bundle {t("skipped", { count: preview.skipped })}
has no usable artwork for them.
</p> </p>
) : null} ) : null}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{[ {[
["Original 64", preview.before], [t("original64"), preview.before],
["Derived 32", preview.after], [t("derived32"), preview.after],
].map(([label, src]) => ( ].map(([label, src]) => (
<figure key={label} className="min-w-0"> <figure key={label} className="min-w-0">
<figcaption>{label}</figcaption> <figcaption>{label}</figcaption>
@@ -103,28 +97,26 @@ export function NitroScale32Generator({
))} ))}
</div> </div>
<Button disabled={busy || disabled} onClick={() => run("apply")}> <Button disabled={busy || disabled} onClick={() => run("apply")}>
Save with backup {t("saveWithBackup")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
disabled={busy} disabled={busy}
onClick={() => setPreview(null)} onClick={() => setPreview(null)}
> >
Discard preview {t("discard")}
</Button> </Button>
</> </>
)} )}
{undo && ( {undo && (
<div> <div>
<p className="text-sm"> <p className="text-sm">{t("saved")}</p>
Saved. A copy of the original bundle is preserved.
</p>
<Button <Button
variant="outline" variant="outline"
disabled={busy || disabled} disabled={busy || disabled}
onClick={() => run("restore")} onClick={() => run("restore")}
> >
Restore original bundle {t("restore")}
</Button> </Button>
</div> </div>
)} )}
@@ -851,10 +851,10 @@ export function OrganizeImportsDialog({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="0" className="text-xs"> <SelectItem value="0" className="text-xs">
Duckets {t("pointsTypeDuckets")}
</SelectItem> </SelectItem>
<SelectItem value="5" className="text-xs"> <SelectItem value="5" className="text-xs">
Diamonds {t("pointsTypeDiamonds")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1571,7 +1571,7 @@ export function OrganizeImportsDialog({
</span> </span>
</div> </div>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{previewGroup.items.length} items {previewGroup.items.length} {t("itemsUnit")}
</Badge> </Badge>
</div> </div>
{previewGroup.layout && {previewGroup.layout &&
@@ -1,6 +1,7 @@
"use client"; "use client";
import { FolderTree } from "lucide-react"; import { FolderTree } from "lucide-react";
import { useTranslations } from "next-intl";
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { StudioPanelLoading } from "./studio-panel-loading"; import { StudioPanelLoading } from "./studio-panel-loading";
@@ -11,6 +12,7 @@ const OrganizeImportsDialog = lazy(() =>
); );
export function OrganizeImportsLauncher() { export function OrganizeImportsLauncher() {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [requested, setRequested] = useState(false); const [requested, setRequested] = useState(false);
return ( return (
@@ -24,7 +26,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" 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} /> <FolderTree size={16} />
Organize imports {t("title")}
</button> </button>
{requested && ( {requested && (
<Suspense <Suspense
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,37 @@
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,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { setFurnidataTranslateEnabled } from "@/actions/import-furni"; import { setFurnidataTranslateEnabled } from "@/actions/import-furni";
@@ -24,6 +25,7 @@ const TRANSLATE_LANGS = [
] as const; ] as const;
export function useStudioTranslations(defaultTranslate: boolean) { export function useStudioTranslations(defaultTranslate: boolean) {
const t = useTranslations("admin.studio");
// Translation controls (per-import toggle + language selection, plus a global // Translation controls (per-import toggle + language selection, plus a global
// default that persists to the site setting and seeds the per-import toggle). // default that persists to the site setting and seeds the per-import toggle).
const ALL_LANGS = TRANSLATE_LANGS as readonly string[]; const ALL_LANGS = TRANSLATE_LANGS as readonly string[];
@@ -52,13 +54,16 @@ export function useStudioTranslations(defaultTranslate: boolean) {
const langs = data.languages ?? []; const langs = data.languages ?? [];
const okCount = langs.filter((l) => l.ok).length; const okCount = langs.filter((l) => l.ok).length;
toast.success( toast.success(
`Languages translated (${okCount}/${langs.length} completed)`, t("toasts.languagesTranslated", {
ok: okCount,
total: langs.length,
}),
); );
} else { } else {
toast.error(data.error ?? "Translation failed"); toast.error(data.error ?? t("toasts.translationFailed"));
} }
} catch { } catch {
toast.error("Network error while translating languages"); toast.error(t("toasts.networkTranslating"));
} finally { } finally {
setBuildingLanguages(false); setBuildingLanguages(false);
} }
@@ -75,7 +80,7 @@ export function useStudioTranslations(defaultTranslate: boolean) {
try { try {
await setFurnidataTranslateEnabled({ enabled: next }); await setFurnidataTranslateEnabled({ enabled: next });
} catch { } catch {
toast.error("Could not save the default translation setting"); toast.error(t("toasts.saveDefaultFailed"));
} }
}; };
+107
View File
@@ -0,0 +1,107 @@
// @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();
});
+17 -1
View File
@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { buildContentSecurityPolicy, createCspNonce } from "./csp"; import { buildContentSecurityPolicy, createCspNonce } from "./csp";
describe("csp", () => { describe("csp", () => {
@@ -19,4 +19,20 @@ describe("csp", () => {
expect(csp).toContain("https://challenges.cloudflare.com"); expect(csp).toContain("https://challenges.cloudflare.com");
expect(csp).not.toContain("https://cdn.jsdelivr.net"); 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,11 +28,17 @@ export function buildContentSecurityPolicy(nonce: string): string {
"style-src-attr 'unsafe-inline'", "style-src-attr 'unsafe-inline'",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/", "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:", "connect-src 'self' https: wss:",
"font-src 'self' data: https://fonts.gstatic.com", "font-src 'self' data: https://fonts.gstatic.com",
"object-src 'none'", "object-src 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",
...(isDev ? [] : ["upgrade-insecure-requests"]),
].join("; "); ].join("; ");
} }
+39
View File
@@ -0,0 +1,39 @@
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
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,62 @@
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
@@ -0,0 +1,43 @@
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;
}
+21 -1
View File
@@ -15,7 +15,11 @@ vi.mock("@/lib/runtime-asset-config", () => ({
resolveImagerBase: (origin: string) => `${origin}/imager`, resolveImagerBase: (origin: string) => `${origin}/imager`,
})); }));
import { fetchAvatarImage, ImagerUnavailableError } from "./imager-upstream"; import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "./imager-upstream";
const params = new URLSearchParams({ figure: "hr-1", direction: "2" }); const params = new URLSearchParams({ figure: "hr-1", direction: "2" });
@@ -136,3 +140,19 @@ it("does not queue a new caller behind a render that is already too old", async
expect([...shared[0].body]).toEqual([9]); expect([...shared[0].body]).toEqual([9]);
expect(shared[1].body).toBe(shared[0].body); 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);
}
});
+6 -1
View File
@@ -8,7 +8,12 @@ import {
} from "@/lib/imager-cache"; } from "@/lib/imager-cache";
import { resolveImagerBase } from "@/lib/runtime-asset-config"; import { resolveImagerBase } from "@/lib/runtime-asset-config";
export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i; // 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_MAX_LEN = 512; export const FIGURE_MAX_LEN = 512;
export const FIGURE_MAX_PARTS = 24; export const FIGURE_MAX_PARTS = 24;
+171
View File
@@ -0,0 +1,171 @@
// @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,6 +108,28 @@ it("serves a previously rendered avatar from the disk cache without re-fetching"
expect(fetcher.mock.calls.length).toBe(callsAfterFirst); 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 () => { it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => {
const fetcher = vi.fn( const fetcher = vi.fn(
async (_input: string | URL) => async (_input: string | URL) =>
+412
View File
@@ -2570,6 +2570,8 @@
"credits": "Credits", "credits": "Credits",
"points": "Points", "points": "Points",
"pointsType": "Type", "pointsType": "Type",
"pointsTypeDuckets": "Duckets",
"pointsTypeDiamonds": "Diamonds",
"newPriceHint": "Applies to new offers only; moved offers keep their current price.", "newPriceHint": "Applies to new offers only; moved offers keep their current price.",
"parent": "Place pages under", "parent": "Place pages under",
"root": "Catalog root", "root": "Catalog root",
@@ -6801,6 +6803,416 @@
}, },
"missing": "Missing: {parts}", "missing": "Missing: {parts}",
"unknown": "Not verified: {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"
} }
} }
}, },
-3
View File
@@ -1,3 +0,0 @@
test-ci-runner
# final test
# CI test via Gitea runner
-1
View File
@@ -1 +0,0 @@
# clean v5 docker install test
-1
View File
@@ -1 +0,0 @@
# CI test na complete herinstallatie
-1
View File
@@ -1 +0,0 @@
# test nieuwe label aanpak
-1
View File
@@ -1 +0,0 @@
# test ubuntu-latest alleen
-4
View File
@@ -1,4 +0,0 @@
// test: runner actief
// second test
// third test - token sync fix
// fourth test - token config fix
-1
View File
@@ -1 +0,0 @@
# test met ubuntu-latest label
-1
View File
@@ -1 +0,0 @@
# test na volledige schone installatie v5