2 Commits
Author SHA1 Message Date
openhands f3407ebfdf fix(imaging): support .hab assets, cache avatars/badges, harden routes
CI / check (push) In progress
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 16427 additions and 730 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,
"tag": "0000_premium_spirit",
"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/esm-loader': npm:tsx@^4.23.15
source-map-js: 1.2.2
postcss-selector-parser: ^7.1.6
importers:
@@ -3255,8 +3256,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==}
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'}
[email protected]:
@@ -4961,7 +4962,7 @@ snapshots:
'@tailwindcss/[email protected]([email protected])':
dependencies:
postcss-selector-parser: 6.0.10
postcss-selector-parser: 7.1.6
tailwindcss: 4.3.3
'@tanstack/[email protected]': {}
@@ -6110,7 +6111,7 @@ snapshots:
[email protected]: {}
postcss-selector-parser@6.0.10:
postcss-selector-parser@7.1.6:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
+5
View File
@@ -17,3 +17,8 @@ overrides:
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
source-map-js: '1.2.2'
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
# exactly and has no upstream bump. The API subset it uses is unchanged in
# v7, so force the patched major.
postcss-selector-parser: '^7.1.6'
-3
View File
@@ -1,3 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+1
View File
@@ -99,6 +99,7 @@ async function NewsPage({
fill
sizes="(max-width: 1024px) 50vw, 300px"
style={{ objectFit: "cover" }}
unoptimized
/>
</div>
) : (
+34 -23
View File
@@ -170,12 +170,9 @@ function GlassPanel({
}
async function getHotelData() {
const [hotelName, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
]);
const [
hotelName,
logo,
online,
users,
rooms,
@@ -185,6 +182,8 @@ async function getHotelData() {
recentUsers,
recentPhotos,
] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
cachedOnlineCount().catch(publicReadFailure("home.online")),
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
publicReadFailure("home.users"),
@@ -243,22 +242,34 @@ export default async function Home() {
const session = await auth();
if (session?.user?.id) redirect("/me");
const tp = await getTranslations("pages.home");
const te = await getTranslations("publicData");
const th = await getTranslations("header");
const tn = await getTranslations("nav");
const {
hotelName,
online,
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers: rawRecentUsers,
recentPhotos,
logo,
} = await getHotelData();
const [
tp,
te,
th,
tn,
{
hotelName,
online,
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers: rawRecentUsers,
recentPhotos,
logo,
},
captcha,
nonceHeaders,
] = await Promise.all([
getTranslations("pages.home"),
getTranslations("publicData"),
getTranslations("header"),
getTranslations("nav"),
getHotelData(),
captchaConfig(),
headers(),
]);
// Users who hide their online state must not surface in the "who is online"
// rails on the homepage either.
@@ -271,8 +282,7 @@ export default async function Home() {
: rawRecentUsers.filter(
(u) => recentUserPrivacy.get(u.id)?.online !== false,
);
const captcha = await captchaConfig();
const nonce = (await headers()).get("x-nonce") ?? undefined;
const nonce = nonceHeaders.get("x-nonce") ?? undefined;
const features = [
{
@@ -315,6 +325,7 @@ export default async function Home() {
fill
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
unoptimized
/>
<div
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
+10 -9
View File
@@ -1,5 +1,8 @@
import { ClipboardCheck } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuditClient } from "@/app/admin/import/audit/audit-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioAuditPage(_props: {
@@ -9,16 +12,14 @@ export default async function StudioAuditPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1>
<p className="mt-1 text-muted-foreground">
Scan your entire furniture catalog for missing entries, broken
references, missing files, and items available from clone sources.
</p>
</div>
<StudioPage
icon={ClipboardCheck}
title={t("nav.catalogAudit")}
subtitle={t("pages.auditSubtitle")}
>
<AuditClient />
</div>
</StudioPage>
);
}
+10 -2
View File
@@ -1,5 +1,8 @@
import { BadgeCheck } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioBadgesPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioBadgesPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="p-4">
<StudioPage
icon={BadgeCheck}
title={t("nav.badges")}
subtitle={t("pages.badgesSubtitle")}
>
<ImportBadgesClient />
</div>
</StudioPage>
);
}
+10 -2
View File
@@ -1,5 +1,8 @@
import { Copy } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClonePage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioClonePage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="p-4">
<StudioPage
icon={Copy}
title={t("nav.importFromSource")}
subtitle={t("pages.cloneSubtitle")}
>
<ImportCloneClient />
</div>
</StudioPage>
);
}
+10 -2
View File
@@ -1,5 +1,8 @@
import { Shirt } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClothingPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioClothingPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="p-4">
<StudioPage
icon={Shirt}
title={t("nav.clothing")}
subtitle={t("pages.clothingSubtitle")}
>
<ImportClothingClient />
</div>
</StudioPage>
);
}
+10 -2
View File
@@ -1,5 +1,8 @@
import { Dumbbell } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioEffectsPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioEffectsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="p-4">
<StudioPage
icon={Dumbbell}
title={t("nav.effects")}
subtitle={t("pages.effectsSubtitle")}
>
<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>
);
}
+23 -14
View File
@@ -1,7 +1,9 @@
import { Wrench } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioCatalogMaintenancePage() {
@@ -16,20 +18,27 @@ export default async function StudioCatalogMaintenancePage() {
session.user.rank,
);
const t = await getTranslations("pages.admin.catalog.integrity");
const studioT = await getTranslations("admin.studio");
return (
<div className="min-h-0 space-y-6">
<CatalogIntegrityPanel canRepair={canRepair} />
{canRepair && (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer font-medium">
{t("legacyTitle")}
</summary>
<p className="mt-3 text-sm text-muted-foreground">
{t("legacyHint")}
</p>
<CatalogMaintenancePanel />
</details>
)}
</div>
<StudioPage
icon={Wrench}
title={studioT("nav.catalogMaintenance")}
subtitle={studioT("pages.maintenanceSubtitle")}
>
<div className="min-h-0 space-y-6">
<CatalogIntegrityPanel canRepair={canRepair} />
{canRepair && (
<details className="rounded-xl border p-4">
<summary className="cursor-pointer font-medium">
{t("legacyTitle")}
</summary>
<p className="mt-3 text-sm text-muted-foreground">
{t("legacyHint")}
</p>
<CatalogMaintenancePanel />
</details>
)}
</div>
</StudioPage>
);
}
+10 -9
View File
@@ -1,5 +1,8 @@
import { FileX2 } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioNitroCleanupPage() {
@@ -7,16 +10,14 @@ export default async function StudioNitroCleanupPage() {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Nitro cleanup</h1>
<p className="mt-1 text-muted-foreground">
Scan the nitro directories for fake bundles (no matching furniture)
and broken bundles (unparseable) and remove them.
</p>
</div>
<StudioPage
icon={FileX2}
title={t("nav.nitroCleanup")}
subtitle={t("pages.nitroCleanupSubtitle")}
>
<NitroCleanupPanel />
</div>
</StudioPage>
);
}
+10 -2
View File
@@ -1,5 +1,8 @@
import { Cat } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioPetsPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioPetsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="p-4">
<StudioPage
icon={Cat}
title={t("nav.pets")}
subtitle={t("pages.petsSubtitle")}
>
<ImportPetsClient />
</div>
</StudioPage>
);
}
+10 -12
View File
@@ -1,5 +1,8 @@
import { Image as ImageIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioRepairIconsPage(_props: {
@@ -9,19 +12,14 @@ export default async function StudioRepairIconsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Repair Missing Icons
</h1>
<p className="mt-1 text-muted-foreground">
Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured
clone sources.
</p>
</div>
<StudioPage
icon={ImageIcon}
title={t("pages.repairIconsTitle")}
subtitle={t("pages.repairIconsSubtitle")}
>
<RepairIconsClient />
</div>
</StudioPage>
);
}
+35 -28
View File
@@ -17,6 +17,7 @@ import {
Wrench,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper";
import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher";
@@ -28,34 +29,38 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
type StudioLink = { href: string; label: string; icon: LucideIcon };
type StudioLink = { href: string; labelKey: string; icon: LucideIcon };
const assets: StudioLink[] = [
{ href: "/admin/studio/furni", label: "Furniture", icon: Puzzle },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat },
{ href: "/admin/studio/furni", labelKey: "furniture", icon: Puzzle },
{ href: "/admin/studio/badges", labelKey: "badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", labelKey: "clothing", icon: Shirt },
{ href: "/admin/studio/effects", labelKey: "effects", icon: Dumbbell },
{ href: "/admin/studio/pets", labelKey: "pets", icon: Cat },
];
const imports: StudioLink[] = [
{ href: "/admin/studio/upload", label: "Upload files", icon: Upload },
{ href: "/admin/studio/clone", label: "Import from a source", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw },
{ href: "/admin/studio/upload", labelKey: "uploadFiles", icon: Upload },
{ href: "/admin/studio/clone", labelKey: "importFromSource", icon: Copy },
{ href: "/admin/studio/sync", labelKey: "syncRepository", icon: RefreshCw },
];
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",
label: "Repair icons",
labelKey: "repairIcons",
icon: ImageIcon,
},
{
href: "/admin/studio/maintenance",
label: "Catalog maintenance",
labelKey: "catalogMaintenance",
icon: Wrench,
},
{
href: "/admin/studio/nitro-cleanup",
label: "Nitro cleanup",
labelKey: "nitroCleanup",
icon: FileX2,
},
];
@@ -71,6 +76,7 @@ function StudioMenu({
links: StudioLink[];
pathname: string;
}) {
const t = useTranslations("admin.studio");
const [open, setOpen] = useState(false);
const active = links.find((link) => activePath(pathname, link.href));
return (
@@ -90,7 +96,7 @@ function StudioMenu({
align="end"
className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]"
>
{links.map(({ href, label: title, icon: Icon }) => (
{links.map(({ href, labelKey, icon: Icon }) => (
<Link
key={href}
href={href}
@@ -103,8 +109,8 @@ function StudioMenu({
aria-current={activePath(pathname, href) ? "page" : undefined}
className="flex min-h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-[var(--admin-accent)]/10 aria-[current=page]:bg-[var(--admin-accent)]/15"
>
<Icon size={16} />
{title}
<Icon size={16} aria-hidden />
{t(`nav.${labelKey}`)}
</Link>
))}
</PopoverContent>
@@ -120,14 +126,15 @@ export function StudioLayoutClient({
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
const t = useTranslations("admin.studio");
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
aria-label="Catalog Studio"
className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
aria-label={t("nav.ariaLabel")}
className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
>
<div className="flex flex-wrap gap-1">
{assets.map(({ href, label, icon: Icon }) => (
<div className="flex shrink-0 gap-1">
{assets.map(({ href, labelKey, icon: Icon }) => (
<Link
key={href}
href={href}
@@ -138,35 +145,35 @@ export function StudioLayoutClient({
}}
aria-current={activePath(pathname, href) ? "page" : undefined}
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)
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<Icon size={16} />
{label}
<Icon size={16} aria-hidden />
{t(`nav.${labelKey}`)}
</Link>
))}
</div>
<div className="flex flex-wrap gap-1">
<div className="ml-auto flex shrink-0 gap-1 pl-1">
{canEditCatalog && (
<>
<CatalogManagerDialog
catalogType="normal"
canEdit
triggerLabel="Catalog manager"
triggerLabel={t("nav.catalogManager")}
/>
<OrganizeImportsLauncher />
</>
)}
<StudioMenu
label="Import & sync"
label={t("nav.importSync")}
links={imports}
pathname={pathname}
/>
<StudioMenu
label="Maintenance"
label={t("nav.maintenance")}
links={maintenance}
pathname={pathname}
/>
@@ -1,4 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -13,6 +14,7 @@ interface Configuration {
hasToken: boolean;
}
export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
const t = useTranslations("admin.studio.sync");
const [config, setConfig] = useState<Configuration | null>(null),
[token, setToken] = useState(""),
[busy, setBusy] = useState(false),
@@ -26,10 +28,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
signal: abort.signal,
cache: "no-store",
});
if (!res.ok)
throw new Error(
"Unable to read Git settings. Settings edit permission is required.",
);
if (!res.ok) throw new Error(t("loadFailed"));
const data = await res.json();
if (!abort.signal.aborted) setConfig(data);
} catch (error) {
@@ -37,7 +36,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
}
})();
return () => abort.abort();
}, []);
}, [t]);
async function submit(action: "save" | "test") {
if (!config || busy) return;
setBusy(true);
@@ -50,12 +49,11 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
body: JSON.stringify({ ...config, token, action }),
});
const data = await res.json();
if (!res.ok)
throw new Error(data.error || "Git configuration request failed");
if (!res.ok) throw new Error(data.error || t("requestFailed"));
if (action === "save") {
setConfig(data);
setToken("");
setMessage("Gitea configuration saved.");
setMessage(t("saved"));
onSaved();
} else setMessage(data.message);
} catch (error) {
@@ -70,19 +68,18 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
open
>
<summary className="cursor-pointer font-semibold">
Gitea connection settings
{t("gitTitle")}
</summary>
<p className="my-3 text-sm text-[var(--admin-text-muted)]">
Connect your self-hosted Gitea repository over HTTPS. The CMS manages
its own checkout; no Git-specific .env settings are needed.
{t("gitIntro")}
</p>
{config && (
<fieldset disabled={busy} className="space-y-3">
<p className="text-sm">
Provider: <strong>Gitea self-hosted</strong>
{t("providerLabel")} <strong>{t("providerValue")}</strong>
</p>
<label htmlFor="catalog-git-1" className="block text-sm">
Repository URL
{t("repositoryUrl")}
<Input
id="catalog-git-1"
type="url"
@@ -94,7 +91,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label htmlFor="catalog-git-2" className="block text-sm">
Branch
{t("branch")}
<Input
id="catalog-git-2"
value={config.branch}
@@ -104,7 +101,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
/>
</label>
<label htmlFor="catalog-git-3" className="block text-sm">
Gitea username
{t("username")}
<Input
id="catalog-git-3"
autoComplete="off"
@@ -116,23 +113,18 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label>
</div>
<label htmlFor="catalog-git-4" className="block text-sm">
Gitea access token
{t("accessToken")}
<Input
id="catalog-git-4"
type="password"
autoComplete="new-password"
value={token}
placeholder={
config.hasToken
? "Token saved — leave blank to keep it"
: "Enter a token with repository write access"
}
placeholder={config.hasToken ? t("tokenSaved") : t("tokenEnter")}
onChange={(event) => setToken(event.target.value)}
/>
</label>
<p className="text-xs text-[var(--admin-text-muted)]">
The token is stored encrypted and is never returned to the browser.
Changing repository or username requires entering the token again.
{t("tokenNote")}
</p>
<label className="flex items-center gap-2 text-sm">
<input
@@ -142,14 +134,14 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
setConfig({ ...config, enabled: event.target.checked })
}
/>
Automatically export catalog changes
{t("autoExport")}
</label>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void submit("test")}>
Test connection
{t("testConnection")}
</Button>
<Button onClick={() => void submit("save")}>
{busy ? "Working…" : "Save Gitea settings"}
{busy ? t("working") : t("saveSettings")}
</Button>
</div>
</fieldset>
+30 -29
View File
@@ -1,6 +1,9 @@
import { RefreshCw } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client";
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { CatalogExportClient } from "./catalog-export-client";
@@ -12,44 +15,42 @@ export default async function StudioSyncPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="space-y-8">
<CatalogExportClient
canConfigure={canAccess(
permissions,
PERMS.SETTINGS_EDIT,
session.user.rank,
)}
/>
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
<p className="text-muted-foreground mt-2">
Scan configured sources and automatically import any furniture that is
not yet present in your hotel.
</p>
<StudioPage
icon={RefreshCw}
title={t("nav.syncRepository")}
subtitle={t("sync.pageSubtitle")}
>
<div className="space-y-6">
<CatalogExportClient
canConfigure={canAccess(
permissions,
PERMS.SETTINGS_EDIT,
session.user.rank,
)}
/>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-muted-foreground mb-2">
Clone Sources
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold">
{t("sync.cloneSourcesTitle")}
</h2>
<p className="text-sm text-muted-foreground">
Scan all configured clone sources and automatically import any
furniture that is not yet present in your hotel.
<p className="text-sm text-[var(--admin-text-muted)]">
{t("sync.cloneSourcesBody")}
</p>
<SyncAllClient />
</div>
</section>
<div>
<h2 className="text-lg font-semibold text-muted-foreground mb-2">
Official Furnidata
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold">
{t("sync.officialFurnidataTitle")}
</h2>
<p className="text-sm text-muted-foreground">
Scan the official Habbo furnidata and automatically import any
furniture that your hotel doesn't have yet.
<p className="text-sm text-[var(--admin-text-muted)]">
{t("sync.officialFurnidataBody")}
</p>
<OfficialSyncClient />
</div>
</section>
</div>
</div>
</StudioPage>
);
}
+10 -12
View File
@@ -1,5 +1,8 @@
import { Upload } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioUploadPage(_props: {
@@ -9,19 +12,14 @@ export default async function StudioUploadPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("admin.studio");
return (
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Upload .hab Furniture
</h1>
<p className="mt-1 text-muted-foreground">
Upload a .hab bundle and optional icon to import furniture into the
hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically.
</p>
</div>
<StudioPage
icon={Upload}
title={t("pages.uploadTitle")}
subtitle={t("pages.uploadSubtitle")}
>
<UploadFurniClient />
</div>
</StudioPage>
);
}
+30 -1
View File
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from "next/server";
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import {
fetchAvatarImage,
ImagerUnavailableError,
@@ -43,24 +44,52 @@ export async function GET(request: NextRequest) {
parsed.params,
);
const etag = strongEtag(body);
const cacheControl = cacheControlFor(source);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control": cacheControlFor(source),
"Cache-Control": cacheControl,
ETag: etag,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
if (error instanceof ImagerUnavailableError) {
const etag = strongEtag(PLACEHOLDER_SVG_BYTES);
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControlFor("unavailable"),
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": cacheControlFor("unavailable"),
ETag: etag,
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
+90 -21
View File
@@ -7,6 +7,7 @@ import {
readImagingCache,
writeImagingCache,
} from "@/lib/imager-cache";
import { ifNoneMatchSatisfied, strongEtag } from "@/lib/imager-http";
import { resolveBadgeBase } from "@/lib/runtime-asset-config";
import { siteSettings } from "@/lib/services/site-settings";
@@ -33,6 +34,64 @@ const BADGE_PLACEHOLDER_SVG =
type BadgeSource = "upstream" | "local" | "cache" | "unavailable";
interface ResolvedBadge {
body: Uint8Array;
contentType: string;
source: BadgeSource;
}
/**
* Concurrent cold-cache requests for the same badge would otherwise each walk
* the whole candidate list, and when the first candidate is a slow/unreachable
* CDN that means every one of them waits out the fetch timeout before falling
* back. Sharing a single resolution keeps a cold cache from stacking identical
* upstream work. Held on globalThis because Next can evaluate this module more
* than once per process.
*/
const globalForBadgeFetches = globalThis as typeof globalThis & {
imagingBadgeFetches?: Map<string, Promise<ResolvedBadge>>;
};
const inFlight =
globalForBadgeFetches.imagingBadgeFetches ??
new Map<string, Promise<ResolvedBadge>>();
globalForBadgeFetches.imagingBadgeFetches = inFlight;
async function resolveBadge(
cacheKey: string,
code: string,
candidates: string[],
): Promise<ResolvedBadge> {
const pending = inFlight.get(cacheKey);
if (pending) return pending;
const work = (async (): Promise<ResolvedBadge> => {
for (const base of candidates) {
const result = await fetchCandidate(base, code);
if (result) {
await writeImagingCache(
badgeCacheDir(),
cacheKey,
result.body,
result.contentType,
);
return result;
}
}
return {
body: new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
contentType: "image/svg+xml",
source: "unavailable",
};
})();
inFlight.set(cacheKey, work);
try {
return await work;
} finally {
if (inFlight.get(cacheKey) === work) inFlight.delete(cacheKey);
}
}
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code")?.trim();
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
@@ -56,25 +115,20 @@ export async function GET(request: NextRequest) {
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
const cached = await readImagingCache(badgeCacheDir(), cacheKey);
if (cached) return badgeResponse(cached.body, cached.contentType, "cache");
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);
}
}
if (cached)
return badgeResponse(
cached.body,
cached.contentType,
"cache",
request.headers.get("if-none-match"),
);
const resolved = await resolveBadge(cacheKey, code, candidates);
return badgeResponse(
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
"image/svg+xml",
"unavailable",
resolved.body,
resolved.contentType,
resolved.source,
request.headers.get("if-none-match"),
);
}
@@ -140,16 +194,31 @@ function badgeResponse(
body: Uint8Array,
contentType: string,
source: BadgeSource,
ifNoneMatch: string | null = null,
) {
const etag = strongEtag(body);
const cacheControl =
source === "unavailable" ? "no-cache, max-age=0" : "public, max-age=3600";
// Same bytes the client already has: skip the body. Validators are hashed
// from the body, so a fallback→upstream swap still forces a fresh 200.
if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control":
source === "unavailable"
? "no-cache, max-age=0"
: "public, max-age=3600",
"Cache-Control": cacheControl,
ETag: etag,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
+172 -28
View File
@@ -1,8 +1,33 @@
import { type NextRequest, NextResponse } from "next/server";
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 TranscodeFormat = Exclude<AvatarFormat, "png">;
// Same neutral silhouette the sibling /api/imaging/avatar route serves, so a
// figure no upstream can render still shows a graceful image instead of the
// browser's broken-image glyph. This is the site-wide default imager endpoint,
// so a 502 here would blank avatars across every server-rendered page.
const PLACEHOLDER_SVG_BYTES = new TextEncoder().encode(
decodeURIComponent(
AVATAR_FALLBACK_SRC.slice("data:image/svg+xml;utf8,".length),
),
);
function resolveFormat(raw: string | null): AvatarFormat {
switch (raw?.toLowerCase()) {
@@ -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) {
const { searchParams } = new URL(request.url);
@@ -31,48 +139,84 @@ export async function GET(request: NextRequest) {
parsed.params,
);
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
// Falls back to the original PNG if conversion fails.
let respBody: Uint8Array = body;
let respContentType = contentType;
if (format !== "png") {
try {
if (format === "webp") {
respBody = new Uint8Array(
await sharp(Buffer.from(body)).webp().toBuffer(),
);
respContentType = "image/webp";
} else {
respBody = new Uint8Array(
await sharp(Buffer.from(body)).avif().toBuffer(),
);
respContentType = "image/avif";
}
} catch {
respBody = body;
respContentType = contentType;
// The variant is keyed off the exact render that produced it (the
// resolved upstream + the normalized query) plus the target format,
// mirroring how the underlying render itself is cached.
const variantKey = imagingCacheKey(
`${resolveImagerBase(new URL(request.url).origin)}\n${parsed.params.toString()}\n${format}`,
);
const converted = await transcodeToVariant(body, format, variantKey);
if (converted) {
respBody = converted.body;
respContentType = converted.contentType;
}
}
const etag = strongEtag(respBody);
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
// stale-while-revalidate keeps them fresh without blocking requests.
// The ?format= param is part of the URL, so variants cache separately.
// Fallback renders use a shorter TTL so the configured imager is
// retried soon instead of being masked for hours.
const cacheControl =
source === "fallback"
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400";
if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
"Cache-Control": cacheControl,
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
return new NextResponse(respBody as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": respContentType,
"Content-Length": String(respBody.length),
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
// stale-while-revalidate keeps them fresh without blocking requests.
// The ?format= param is part of the URL, so variants cache separately.
// Fallback renders use a shorter TTL so the configured imager is
// retried soon instead of being masked for hours.
"Cache-Control":
source === "fallback"
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
"Cache-Control": cacheControl,
ETag: etag,
"X-Imager-Source": source,
"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(
{ error: "Failed to fetch avatar from upstream" },
{ 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.
## Shared chrome
- `studio-page.tsx`: consistent header (icon tile, title, subtitle, actions) and padding for every studio sub-page; wraps `AdminPageShell`.
## Client responsibilities
- `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array.
+37 -32
View File
@@ -9,19 +9,20 @@ import {
RefreshCw,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
const statusLabels: Record<BatchItemStatus["status"], string> = {
pending: "Waiting",
started: "Starting",
downloading: "Downloading assets",
converting: "Preparing assets",
writing_db: "Saving",
done: "Imported",
failed: "Failed",
const STATUS_KEYS: Record<BatchItemStatus["status"], string> = {
pending: "status.pending",
started: "status.started",
downloading: "status.downloading",
converting: "status.converting",
writing_db: "status.writingDb",
done: "status.done",
failed: "status.failed",
};
function formatDuration(ms: number): string {
@@ -65,6 +66,7 @@ export const BatchProgress = memo(function BatchProgress({
onDismiss?: () => void;
onRetry?: (classnames: string[]) => void;
}) {
const t = useTranslations("admin.studio.batch");
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
@@ -99,20 +101,20 @@ export const BatchProgress = memo(function BatchProgress({
const rate = formatRate(completed, elapsed);
const heading = !done
? cancelling
? "Stopping import…"
: "Importing furniture…"
? t("headingStopping")
: t("headingImporting")
: unfinished
? "Import stopped"
? t("headingStopped")
: errors
? "Import completed with errors"
: "Import completed";
? t("headingCompletedErrors")
: t("headingCompleted");
const issues = verification
? verification.furniDataMissing + verification.furniDataConflicts
: 0;
return (
<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"
>
<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)]">
{imported}
</span>{" "}
imported
{t("imported")}
</span>
<span className="text-[var(--admin-border)]">·</span>
<span>
@@ -140,13 +142,13 @@ export const BatchProgress = memo(function BatchProgress({
>
{errors}
</span>{" "}
failed
{t("failed")}
</span>
{unfinished > 0 && (
<>
<span className="text-[var(--admin-border)]">·</span>
<span>
{unfinished} {done ? "not completed" : "remaining"}
{unfinished} {done ? t("notCompleted") : t("remaining")}
</span>
</>
)}
@@ -173,12 +175,12 @@ export const BatchProgress = memo(function BatchProgress({
onClick={() => onRetry(failedClassnames)}
>
<RefreshCw size={14} />
Retry failed ({failedClassnames.length})
{t("retryFailed", { count: failedClassnames.length })}
</Button>
)}
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
Close summary
{t("closeSummary")}
</Button>
</>
) : (
@@ -193,14 +195,14 @@ export const BatchProgress = memo(function BatchProgress({
) : (
<Ban size={14} />
)}
Stop import
{t("stopImport")}
</Button>
)}
</div>
</div>
<div
role="progressbar"
aria-label="Furniture processed"
aria-label={t("ariaProcessed")}
aria-valuemin={0}
aria-valuemax={total || 1}
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">
<p className="mr-1 text-sm text-[var(--admin-text-muted)]">
{issues
? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.`
: "Catalog data verification completed."}
? t("verificationIssues", { count: issues })
: t("verificationDone")}
</p>
<NodeChip
count={verification.furniDataFixedIds}
label="furnidata ids fixed"
label={t("chipFurnidataIds")}
/>
<NodeChip
count={verification.offerIdsFixed}
label="offer ids reconciled"
label={t("chipOfferIds")}
/>
<NodeChip
count={verification.ownershipFixed.length}
label="ownerships fixed"
label={t("chipOwnerships")}
/>
<NodeChip
count={verification.nitrosSynced.length}
label="nitros synced"
label={t("chipNitros")}
/>
<NodeChip
count={verification.iconsSynced.length}
label="icons synced"
label={t("chipIcons")}
/>
</div>
)}
<details>
<summary className="cursor-pointer text-xs font-medium">
View item details ({total})
{t("viewItemDetails", { count: total })}
</summary>
<div className="mt-2 max-h-44 space-y-2 overflow-auto">
{entries.length > VISIBLE_ITEMS && (
<p className="px-1 text-[0.65rem] text-[var(--admin-text-muted)]">
Showing {VISIBLE_ITEMS} of {entries.length} items
{t("showingItems", {
shown: VISIBLE_ITEMS,
total: entries.length,
})}
</p>
)}
{entries.slice(0, VISIBLE_ITEMS).map((item) => (
@@ -280,8 +285,8 @@ export const BatchProgress = memo(function BatchProgress({
</span>
<span className="shrink-0 text-[var(--admin-text-muted)]">
{done && item.status !== "done" && item.status !== "failed"
? "Not completed"
: statusLabels[item.status]}
? t("statusNotCompleted")
: t(STATUS_KEYS[item.status])}
</span>
</span>
{item.message && (
+18 -11
View File
@@ -10,6 +10,7 @@ import {
Search,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useMemo, useState, useTransition } from "react";
import {
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.
*/
function LiveCatalogNotice() {
const t = useTranslations("admin.studio");
const { refreshError, refreshing } = useLiveCatalog();
const [retrying, startRetry] = useTransition();
if (!refreshError && !refreshing) return null;
@@ -41,7 +43,7 @@ function LiveCatalogNotice() {
{refreshError ? (
<>
<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
type="button"
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"
>
<RefreshCw size={12} className={cn(retrying && "animate-spin")} />
Retry
{t("rail.retry")}
</button>
</>
) : (
<>
<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>
@@ -77,6 +79,7 @@ export const CatalogRail = memo(function CatalogRail({
onSelect: (id: number | null) => void;
onClose?: () => void;
}) {
const t = useTranslations("admin.studio");
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [search, setSearch] = useState("");
const visible = useMemo(() => {
@@ -100,7 +103,7 @@ export const CatalogRail = memo(function CatalogRail({
}
return (
<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"
>
<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 }}
className="flex-1 text-sm font-semibold"
>
Categories
{t("rail.title")}
</h2>
<button
type="button"
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"
>
<X size={16} />
@@ -125,8 +128,8 @@ export const CatalogRail = memo(function CatalogRail({
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Find a category…"
aria-label="Find a category"
placeholder={t("aria.findCategory")}
aria-label={t("aria.findCategory")}
className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none"
/>
</label>
@@ -136,13 +139,13 @@ export const CatalogRail = memo(function CatalogRail({
aria-pressed={selectedId === null}
className="mx-2 mb-2 rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--admin-accent)]/10 aria-pressed:bg-[var(--admin-accent)]/15"
>
All categories
{t("rail.all")}
</button>
<LiveCatalogNotice />
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{visible.length === 0 && (
<p className="p-3 text-sm text-[var(--admin-text-muted)]">
No matching categories.
{t("rail.noMatches")}
</p>
)}
{visible.map((node) => (
@@ -159,7 +162,11 @@ export const CatalogRail = memo(function CatalogRail({
{node.childCount > 0 && !search.trim() ? (
<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)}
onClick={() => toggle(node.id)}
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 { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { FurnitureInspector } from "./furniture-inspector";
import {
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import { getFurniImageUrl, handleImgError } from "./studio-furni-icons";
import type { FurniItem } from "./studio-types";
interface FurnitureDetailDrawerProps {
@@ -49,6 +45,10 @@ export function FurnitureDetailDrawer({
onRepair,
}: FurnitureDetailDrawerProps) {
const t = useTranslations("admin.studio.completeness");
const studioT = useTranslations("admin.studio");
const drawerT = useTranslations("admin.studio.drawer");
const typeLabel =
detail.type === "wallitem" ? studioT("types.wall") : studioT("types.floor");
const detailPreview = previewAutoCatalog(
detail.classname,
detail.type === "wallitem" ? "i" : "s",
@@ -57,11 +57,11 @@ export function FurnitureDetailDrawer({
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail
{drawerT("title")}
</span>
<button
type="button"
aria-label="Close furniture details"
aria-label={studioT("aria.closeDetails")}
onClick={onClose}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
@@ -101,17 +101,17 @@ export function FurnitureDetailDrawer({
) : null}
<div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">{typeLabel}</Badge>
<Badge variant="secondary">
{TYPE_LABELS[detail.type] ?? detail.type}
{drawerT("revision", { revision: detail.revision })}
</Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
</Badge>
) : (
<Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> Auto-import ready
<Sparkles size={10} /> {drawerT("autoImportReady")}
</Badge>
)}
</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-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)]" />
Automatic placement
{drawerT("automaticPlacement")}
</div>
<div className="space-y-2">
<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)]">
{detailPreview.categoryLabel}
</span>
</div>
<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)]">
{detailPreview.credits} credits
{detailPreview.points > 0
? ` + ${detailPreview.points} points`
: ""}
? drawerT("priceCreditsPoints", {
credits: detailPreview.credits,
points: detailPreview.points,
})
: drawerT("priceCredits", {
count: detailPreview.credits,
})}
</span>
</div>
</div>
@@ -163,14 +171,14 @@ export function FurnitureDetailDrawer({
) : (
<CloudDownload size={14} />
)}
Import & add to catalog
{drawerT("importAdd")}
</Button>
) : (
<div className="space-y-2">
{!detail.nitroExists && (
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but its bundle file is missing.
{drawerT("bundleMissing")}
</p>
<Button
variant="outline"
@@ -183,7 +191,7 @@ export function FurnitureDetailDrawer({
) : (
<RefreshCw size={14} />
)}
Regenerate .nitro
{drawerT("regenerate")}
</Button>
</>
)}
@@ -194,7 +202,7 @@ export function FurnitureDetailDrawer({
disabled={!detail.nitroExists}
>
<Pencil size={14} />
Edit nitro
{drawerT("editNitro")}
</Button>
</div>
)}
@@ -206,7 +214,7 @@ export function FurnitureDetailDrawer({
onClick={onDelete}
>
<Trash2 size={14} />
Delete imported furni
{drawerT("deleteImported")}
</Button>
)}
</div>
@@ -1,4 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
compareFurniture,
@@ -9,6 +10,21 @@ import { FurnitureCompleteness } from "./furniture-completeness";
import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection";
const HEALTH_KEYS = {
complete: "healthComplete",
incomplete: "healthIncomplete",
new: "healthNew",
conflict: "healthConflict",
unknown: "healthUnknown",
} as const;
const FIELD_KEYS = {
Name: "fieldName",
Description: "fieldDescription",
Revision: "fieldRevision",
Type: "fieldType",
} as const;
export function FurnitureComparison({
item,
local,
@@ -16,69 +32,81 @@ export function FurnitureComparison({
item: FurniItem;
local: FurnitureInspection;
}) {
const t = useTranslations("admin.studio.inspector");
const comparison = compareFurniture(item, local);
return (
<div className="space-y-3 text-sm">
<FurnitureCompleteness local={local} />
{!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]">
Local furnidata could not be read. Its values are not verified.
{t("furnidataUnreadable")}
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr>
<th className="p-2 text-left">Field</th>
<th className="p-2 text-left">Local now</th>
<th className="p-2 text-left">Source</th>
<th className="p-2 text-left">{t("field")}</th>
<th className="p-2 text-left">{t("localNow")}</th>
<th className="p-2 text-left">{t("source")}</th>
</tr>
</thead>
<tbody>
{comparison.fields.map((field) => (
<tr
key={field.label}
className={field.changed ? "bg-[var(--admin-warning)]/10" : ""}
>
<th className="p-2 text-left">
{field.label}
{field.changed ? " · Changed" : ""}
</th>
<td className="max-w-48 break-words p-2">
{field.before ?? "—"}
</td>
<td className="max-w-48 break-words p-2">
{field.after ?? "Not supplied"}
</td>
</tr>
))}
{comparison.fields.map((field) => {
const labelKey =
FIELD_KEYS[field.label as keyof typeof FIELD_KEYS];
return (
<tr
key={field.label}
className={
field.changed ? "bg-[var(--admin-warning)]/10" : ""
}
>
<th className="p-2 text-left">
{labelKey ? t(labelKey) : field.label}
{field.changed ? ` · ${t("changed")}` : ""}
</th>
<td className="max-w-48 break-words p-2">
{field.before ?? "—"}
</td>
<td className="max-w-48 break-words p-2">
{field.after ?? t("notSupplied")}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-xs text-[var(--admin-text-muted)]">
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.
{t("compareNote")}
</p>
<details>
<summary className="cursor-pointer font-medium">
SQL records and catalog offers
{t("sqlRecords")}
</summary>
<div className="mt-2 space-y-2 text-xs">
{local.sql.length ? (
local.sql.map((row) => (
<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>No local SQL item.</p>
<p>{t("noSqlItem")}</p>
)}
{local.catalog.map((row) => (
<p key={row.id}>
Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "}
{row.points} points
{t("offerRow", {
id: row.id,
page: row.pageId,
credits: row.credits,
points: row.points,
})}
</p>
))}
</div>
@@ -95,11 +123,12 @@ export function FurnitureInspector({
onRepair?: () => Promise<unknown>;
busy?: boolean;
}) {
const t = useTranslations("admin.studio.inspector");
const inspection = useFurnitureInspection([item.classname]);
if (inspection.loading)
return (
<p role="status" className="py-3 text-sm">
Checking local data and assets…
{t("checking")}
</p>
);
if (inspection.error)
@@ -107,7 +136,7 @@ export function FurnitureInspector({
<div role="alert">
<p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}>
Retry inspection
{t("retry")}
</Button>
</div>
);
@@ -118,51 +147,35 @@ export function FurnitureInspector({
return local && health ? (
<div className="space-y-3">
<section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2">
<strong>
{
{
complete: "Complete",
incomplete: "Needs repair",
new: "Not imported",
conflict: "Local conflict",
unknown: "Not verified",
}[health.state]
}
</strong>
<strong>{t(HEALTH_KEYS[health.state])}</strong>
{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" && (
<p className="text-sm">
Duplicate records or mismatched IDs/types require review before
repair.
</p>
<p className="text-sm">{t("conflictNote")}</p>
)}
{health.state === "unknown" && (
<p className="text-sm">
Some local data could not be checked. Retry verification before
repair.
</p>
<p className="text-sm">{t("unknownNote")}</p>
)}
{health.state === "incomplete" && onRepair && (
<>
<p className="text-sm text-[var(--admin-text-muted)]">
Repair adds missing parts and preserves existing IDs, settings,
furnidata and catalog offers. Missing offers use the automatic
category and price shown below.
{t("repairNote")}
</p>
<Button disabled={busy} onClick={() => void onRepair()}>
{busy ? "Repair queued or running…" : "Repair furni"}
{busy ? t("repairQueued") : t("repair")}
</Button>
</>
)}
<Button variant="outline" disabled={busy} onClick={inspection.retry}>
Recheck local state
{t("recheck")}
</Button>
</section>
<FurnitureComparison item={item} local={local} />
</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;
}) {
const preflightT = useTranslations("admin.studio.preflight");
const reviewT = useTranslations("admin.studio.review");
const [reviewFilter, setReviewFilter] = useState<
"all" | "complete" | "conflict" | "verify"
>("all");
@@ -59,14 +60,16 @@ export function ImportReview({
body: form,
});
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) => ({
...current,
[item.classname]: data.attachmentId,
}));
toast.success(diagnosticsT("attached"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed");
toast.error(
error instanceof Error ? error.message : reviewT("uploadFailed"),
);
} finally {
setUploading(null);
}
@@ -134,46 +137,44 @@ export function ImportReview({
<DialogHeader>
<DialogTitle>
{step === "review"
? "Review furniture import"
: "Confirm furniture import"}
? reviewT("titleReview")
: reviewT("titleConfirm")}
</DialogTitle>
<DialogDescription>
1. Select → 2. Review → 3. Import
</DialogDescription>
<DialogDescription>{reviewT("steps")}</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
<div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm">
<p>
<strong>Source:</strong> {source}
<strong>{reviewT("source")}</strong> {source}
</p>
<p>
<strong>Destination:</strong> local catalog, furnidata, bundle and
icons
<strong>{reviewT("destination")}</strong>{" "}
{reviewT("destinationValue")}
</p>
<p>
<strong>Translation:</strong> {translation}
<strong>{reviewT("translation")}</strong> {translation}
</p>
<p>
<strong>Selection:</strong> {items.length} furniture items
<strong>{reviewT("selection")}</strong>{" "}
{reviewT("selectionCount", { count: items.length })}
</p>
</div>
{assets.loading && !inspection.loading && (
<p role="status">
Checking source files… {assets.items.length} checked
{reviewT("checkingSource", { count: assets.items.length })}
</p>
)}
{assets.error && (
<div role="alert">
<p>{assets.error}</p>
<Button variant="outline" onClick={assets.retry}>
Retry source check
{reviewT("retrySource")}
</Button>
</div>
)}
{assets.items.some((item) => item.state === "unknown") && (
<p className="text-sm text-[var(--admin-warning)]">
Some sources could not be verified. The import will still try to
download their files.
{reviewT("unknownSources")}
</p>
)}
{unavailable.map((check) => (
@@ -182,7 +183,7 @@ export function ImportReview({
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
>
<p className="text-sm font-medium">
Source files not found by preliminary check: {check.classname}
{reviewT("sourceNotFound", { name: check.classname })}
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
{progressT("recoveryHint")}
@@ -190,24 +191,25 @@ export function ImportReview({
</section>
))}
{inspection.loading ? (
<p role="status">Checking local data and assets…</p>
<p role="status">{reviewT("checkingLocal")}</p>
) : inspection.error ? (
<div role="alert">
<p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}>
Retry inspection
{reviewT("retryInspection")}
</Button>
</div>
) : !complete ? (
<p role="alert">
Inspection is incomplete. Close and retry before importing.
</p>
<p role="alert">{reviewT("incomplete")}</p>
) : (
<>
<p role="status" className="text-sm">
{ready.length} ready to import · {existing} already in SQL
(complete missing data) · {blocked} conflicts ·{" "}
{unavailable.length} source file warnings
{reviewT("summary", {
ready: ready.length,
existing,
blocked,
warnings: unavailable.length,
})}
</p>
{blocked > 0 && (
<div
@@ -232,8 +234,7 @@ export function ImportReview({
item.nitro.exists === null,
) && (
<p className="text-sm text-[var(--admin-warning)]">
Some local files could not be verified. Review the item
details before continuing.
{reviewT("localUnverified")}
</p>
)}
{step === "review" ? (
@@ -329,14 +330,14 @@ export function ImportReview({
</span>
<span>
{comparison.state === "new"
? "New"
? reviewT("statusNew")
: comparison.state === "changed"
? "Differences"
? reviewT("statusChanged")
: comparison.state === "conflict"
? "Conflict"
? reviewT("statusConflict")
: comparison.state === "unknown"
? "Not fully verified"
: "Present"}
? reviewT("statusUnknown")
: reviewT("statusPresent")}
</span>
</button>
{expanded === item.classname && (
@@ -358,7 +359,9 @@ export function ImportReview({
}}
/>
{uploading === item.classname && (
<span role="status">Validating file…</span>
<span role="status">
{reviewT("validatingFile")}
</span>
)}
</label>
)}
@@ -381,16 +384,10 @@ export function ImportReview({
) : (
<>
<p className="text-sm">
Only the {ready.length} eligible items below will be
submitted. Existing furniture keeps its local IDs, SQL
settings and catalog offers. Missing assets and catalog
entries are completed; furnidata is refreshed. Conflicts are
excluded.
{reviewT("confirmIntro", { count: ready.length })}
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
Automatic categories and prices below are estimates. Source
rules may override them; final results appear in the import
summary.
{reviewT("confirmEstimates")}
</p>
<ul className="space-y-2">
{pageItems.map((item) => {
@@ -405,8 +402,11 @@ export function ImportReview({
>
<strong>{item.name}</strong>
<p>
{placement.categoryLabel} · {placement.credits}{" "}
credits + {placement.points} points
{reviewT("placement", {
category: placement.categoryLabel,
credits: placement.credits,
points: placement.points,
})}
</p>
</li>
);
@@ -424,17 +424,15 @@ export function ImportReview({
disabled={page === 0}
onClick={() => setPage((value) => value - 1)}
>
Previous items
{reviewT("previous")}
</Button>
<span>
Page {page + 1} / {pageCount}
</span>
<span>{reviewT("page", { page: page + 1, total: pageCount })}</span>
<Button
variant="outline"
disabled={page + 1 === pageCount}
onClick={() => setPage((value) => value + 1)}
>
Next items
{reviewT("next")}
</Button>
</div>
)}
@@ -451,7 +449,7 @@ export function ImportReview({
: onCancel
}
>
{step === "confirm" ? "Back to review" : "Cancel"}
{step === "confirm" ? reviewT("backToReview") : reviewT("cancel")}
</Button>
<Button
style={{
@@ -480,8 +478,8 @@ export function ImportReview({
}}
>
{step === "review"
? "Continue to confirmation"
: `Start import (${ready.length})`}
? reviewT("continue")
: reviewT("startImport", { count: ready.length })}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,4 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
@@ -15,6 +16,7 @@ function kb(bytes: number): string {
}
function CompressionPanel({ report }: { report: CompressionReport }) {
const t = useTranslations("admin.studio.quality");
const { texture, json } = report,
// Differences that a renderer can actually see.
visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences,
@@ -26,20 +28,20 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
return (
<section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">
Compression · {texture.format.toUpperCase()}
{t("compressionTitle", { format: texture.format.toUpperCase() })}
{texture.format === "webp"
? texture.lossless
? " Lossless (VP8L)"
: " Lossy (VP8)"
? ` ${t("losslessVp8l")}`
: ` ${t("lossyVp8")}`
: ""}
</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Bundle</dt>
<dt className="text-muted-foreground">{t("bundle")}</dt>
<dd className="font-medium">{kb(report.bundleBytes)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Texture</dt>
<dt className="text-muted-foreground">{t("texture")}</dt>
<dd className="font-medium">
{kb(texture.compressedBytes)}
<span className="text-muted-foreground">
@@ -49,75 +51,85 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
</dd>
</div>
<div>
<dt className="text-muted-foreground">Metadata</dt>
<dt className="text-muted-foreground">{t("metadata")}</dt>
<dd className="font-medium">
{kb(json.compressedBytes)}
<span className="text-muted-foreground"> · ×{json.ratio}</span>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Container</dt>
<dt className="text-muted-foreground">{t("container")}</dt>
<dd className="font-medium">{kb(report.overheadBytes)}</dd>
</div>
</dl>
<p className="text-xs text-muted-foreground">
Texture {kb(texture.rawBytes)} decoded · deflate ×{texture.ratio} ·
sheet {texture.width}×{texture.height} · {texture.spriteCount} frames
{t("textureDetail", {
raw: kb(texture.rawBytes),
ratio: texture.ratio,
width: texture.width,
height: texture.height,
frames: texture.spriteCount,
})}
</p>
{texture.format === "png" ? (
<p className="text-xs">
Legacy PNG texture. Re-importing as WebP Lossless would store{" "}
<strong>{kb(texture.webpEstimate.bytes)}</strong>
{texture.webpEstimate.savingBytes > 0 ? (
<>
, saving <strong>{kb(texture.webpEstimate.savingBytes)}</strong> (
{texture.webpEstimate.bytes > 0
? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) * 100,
)
: 0}
% smaller)
</>
) : (
" (no gain on this bundle)"
)}
{texture.webpEstimate.savingBytes > 0
? t.rich("legacyPngSaving", {
size: kb(texture.webpEstimate.bytes),
saving: kb(texture.webpEstimate.savingBytes),
percent:
texture.webpEstimate.bytes > 0
? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) *
100,
)
: 0,
strong: (chunks) => <strong>{chunks}</strong>,
})
: t.rich("legacyPngNoGain", {
size: kb(texture.webpEstimate.bytes),
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
) : (
<p className="text-xs">
WebP Lossless stores {kb(texture.rawBytes)} against{" "}
{kb(texture.pngEstimate.bytes)} for the same artwork as PNG
{pngSaving > 0 ? (
<>
, saving <strong>{kb(pngSaving)}</strong> (
{texture.pngEstimate.bytes > 0
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
: 0}
% smaller)
</>
) : (
" (PNG is not larger on this bundle)"
)}
{pngSaving > 0
? t.rich("webpSaving", {
raw: kb(texture.rawBytes),
png: kb(texture.pngEstimate.bytes),
saving: kb(pngSaving),
percent:
texture.pngEstimate.bytes > 0
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
: 0,
strong: (chunks) => <strong>{chunks}</strong>,
})
: t("webpNoGain", {
raw: kb(texture.rawBytes),
png: kb(texture.pngEstimate.bytes),
})}
</p>
)}
<p className="text-xs">
{visibleDiffs === 0 ? (
<>
<span className="font-medium text-foreground">Pixel-exact</span> —
every visible texel round-trips unchanged.
{t.rich("pixelExact", {
strong: (chunks) => (
<span className="font-medium text-foreground">{chunks}</span>
),
})}
{texture.invisibleDifferences > 0 && (
<span className="text-muted-foreground">
{" "}
{texture.invisibleDifferences.toLocaleString()} fully
transparent texels carry no RGB, which VP8L discards and no
renderer can see.
{t("invisibleDiffs", {
count: texture.invisibleDifferences,
})}
</span>
)}
</>
) : (
<strong className="text-destructive">
{visibleDiffs.toLocaleString()} texel
{visibleDiffs === 1 ? "" : "s"} differ after a lossless round-trip —
the furniture may not render identically.
{t("texelsDiffer", { count: visibleDiffs })}
</strong>
)}
</p>
@@ -136,32 +148,34 @@ function ScalePanel({
}) {
const [asset, setAsset] = useState(report.assets[0] ?? ""),
[failed, setFailed] = useState(false);
const t = useTranslations("admin.studio.quality");
const stateLabel =
report.state === "missing"
? t("stateMissing")
: report.state === "issues"
? t("stateIssues")
: report.derived
? t("stateDerived")
: t("statePresent");
return (
<section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">
Scale {report.size} ·{" "}
{report.state === "missing"
? "Not included"
: report.state === "issues"
? "Needs review"
: report.derived
? "Derived from 64"
: "Present"}
{t("scaleTitle", { size: report.size, state: stateLabel })}
</h3>
<p className="text-xs text-muted-foreground">
{report.assets.length} sprites · {report.animations.length} animation
definitions · directions {report.directions.join(", ") || "—"}
{t("spritesLine", {
sprites: report.assets.length,
animations: report.animations.length,
directions: report.directions.join(", ") || "—",
})}
</p>
{report.state === "missing" && (
<p className="text-sm">
This bundle contains no sprites at this scale. Missing scale 32 does
not by itself mean the furniture is unusable.
</p>
<p className="text-sm">{t("missingScale")}</p>
)}
{report.assets.length > 0 && (
<>
<label className="block text-sm">
Sprite
{t("sprite")}
<select
className="mt-1 block w-full rounded border bg-background p-2"
value={asset}
@@ -179,7 +193,7 @@ function ScalePanel({
</label>
<div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30">
{failed ? (
<p role="alert">This sprite could not be rendered.</p>
<p role="alert">{t("renderFailed")}</p>
) : (
<img
key={`${asset}-${revision}`}
@@ -210,6 +224,7 @@ function ScalePanel({
);
}
export function NitroQualityPanel({ classname }: { classname: string }) {
const t = useTranslations("admin.studio.quality");
const [report, setReport] = useState<NitroQuality | null>(null),
[error, setError] = useState(""),
[revision, setRevision] = useState(0);
@@ -224,35 +239,32 @@ export function NitroQualityPanel({ classname }: { classname: string }) {
{ signal: controller.signal },
);
const data = await response.json();
if (!response.ok) throw Error(data.error || "Inspection failed");
if (!response.ok) throw Error(data.error || t("inspectionFailed"));
if (!controller.signal.aborted) setReport(data.report);
} catch (error) {
if (!controller.signal.aborted)
setError(
error instanceof Error ? error.message : "Inspection failed",
error instanceof Error ? error.message : t("inspectionFailed"),
);
}
})();
return () => controller.abort();
}, [classname, revision]);
}, [classname, revision, t]);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm">
Inspect the saved bundle at both scales. Previews show individual
sprites, not the assembled furniture or animation playback.
</p>
<p className="text-sm">{t("intro")}</p>
<Button
variant="outline"
onClick={() => setRevision((value) => value + 1)}
>
Recheck
{t("recheck")}
</Button>
</div>
{error ? (
<p role="alert">{error}</p>
) : !report ? (
<p role="status">Checking scales and texture references…</p>
<p role="status">{t("checking")}</p>
) : (
<div className="space-y-3">
{report.compression && (
@@ -1,4 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
@@ -11,6 +12,7 @@ export function NitroScale32Generator({
disabled: boolean;
onChanged: (metadata: Record<string, unknown>) => void;
}) {
const t = useTranslations("admin.studio.scale32");
const [preview, setPreview] = useState<{
hash: string;
before: string;
@@ -38,7 +40,7 @@ export function NitroScale32Generator({
}),
});
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);
else {
setPreview(null);
@@ -50,45 +52,37 @@ export function NitroScale32Generator({
onChanged(data.metadata);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Generation failed");
setError(e instanceof Error ? e.message : t("generationFailed"));
} finally {
setBusy(false);
}
}
return (
<section className="space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">Generate scale 32 from 64</h3>
<p className="text-sm text-muted-foreground">
Adds derived artwork using nearest-neighbor reduction. Existing scale 32
is preserved. Preview uses the saved bundle and shows one sprite at its
original pixel size.
</p>
{disabled && (
<p className="text-sm">Save or reset your pending edits first.</p>
)}
<h3 className="font-semibold">{t("title")}</h3>
<p className="text-sm text-muted-foreground">{t("intro")}</p>
{disabled && <p className="text-sm">{t("saveFirst")}</p>}
<Button
variant="outline"
disabled={busy || disabled}
onClick={() => run("preview")}
>
{busy ? "Working…" : "Preview generation"}
{busy ? t("working") : t("preview")}
</Button>
{preview && (
<>
<p className="text-sm">
{preview.count} sprite entries will be added. Check positioning and
animations in the client after saving.
{t("previewCount", { count: preview.count })}
</p>
{preview.skipped ? (
<p className="text-sm text-destructive" role="alert">
{preview.skipped} sprite(s) were skipped because the source bundle
has no usable artwork for them.
{t("skipped", { count: preview.skipped })}
</p>
) : null}
<div className="grid grid-cols-2 gap-3">
{[
["Original 64", preview.before],
["Derived 32", preview.after],
[t("original64"), preview.before],
[t("derived32"), preview.after],
].map(([label, src]) => (
<figure key={label} className="min-w-0">
<figcaption>{label}</figcaption>
@@ -103,28 +97,26 @@ export function NitroScale32Generator({
))}
</div>
<Button disabled={busy || disabled} onClick={() => run("apply")}>
Save with backup
{t("saveWithBackup")}
</Button>
<Button
variant="ghost"
disabled={busy}
onClick={() => setPreview(null)}
>
Discard preview
{t("discard")}
</Button>
</>
)}
{undo && (
<div>
<p className="text-sm">
Saved. A copy of the original bundle is preserved.
</p>
<p className="text-sm">{t("saved")}</p>
<Button
variant="outline"
disabled={busy || disabled}
onClick={() => run("restore")}
>
Restore original bundle
{t("restore")}
</Button>
</div>
)}
@@ -851,10 +851,10 @@ export function OrganizeImportsDialog({
</SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-xs">
Duckets
{t("pointsTypeDuckets")}
</SelectItem>
<SelectItem value="5" className="text-xs">
Diamonds
{t("pointsTypeDiamonds")}
</SelectItem>
</SelectContent>
</Select>
@@ -1571,7 +1571,7 @@ export function OrganizeImportsDialog({
</span>
</div>
<Badge variant="outline" className="text-[10px]">
{previewGroup.items.length} items
{previewGroup.items.length} {t("itemsUnit")}
</Badge>
</div>
{previewGroup.layout &&
@@ -1,6 +1,7 @@
"use client";
import { FolderTree } from "lucide-react";
import { useTranslations } from "next-intl";
import { lazy, Suspense, useState } from "react";
import { StudioPanelLoading } from "./studio-panel-loading";
@@ -11,6 +12,7 @@ const OrganizeImportsDialog = lazy(() =>
);
export function OrganizeImportsLauncher() {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const [open, setOpen] = useState(false);
const [requested, setRequested] = useState(false);
return (
@@ -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"
>
<FolderTree size={16} />
Organize imports
{t("title")}
</button>
{requested && (
<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";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { setFurnidataTranslateEnabled } from "@/actions/import-furni";
@@ -24,6 +25,7 @@ const TRANSLATE_LANGS = [
] as const;
export function useStudioTranslations(defaultTranslate: boolean) {
const t = useTranslations("admin.studio");
// Translation controls (per-import toggle + language selection, plus a global
// default that persists to the site setting and seeds the per-import toggle).
const ALL_LANGS = TRANSLATE_LANGS as readonly string[];
@@ -52,13 +54,16 @@ export function useStudioTranslations(defaultTranslate: boolean) {
const langs = data.languages ?? [];
const okCount = langs.filter((l) => l.ok).length;
toast.success(
`Languages translated (${okCount}/${langs.length} completed)`,
t("toasts.languagesTranslated", {
ok: okCount,
total: langs.length,
}),
);
} else {
toast.error(data.error ?? "Translation failed");
toast.error(data.error ?? t("toasts.translationFailed"));
}
} catch {
toast.error("Network error while translating languages");
toast.error(t("toasts.networkTranslating"));
} finally {
setBuildingLanguages(false);
}
@@ -75,7 +80,7 @@ export function useStudioTranslations(defaultTranslate: boolean) {
try {
await setFurnidataTranslateEnabled({ enabled: next });
} 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
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { buildContentSecurityPolicy, createCspNonce } from "./csp";
describe("csp", () => {
@@ -19,4 +19,20 @@ describe("csp", () => {
expect(csp).toContain("https://challenges.cloudflare.com");
expect(csp).not.toContain("https://cdn.jsdelivr.net");
});
it("blocks framing and upgrades insecure requests outside development", () => {
vi.stubEnv("NODE_ENV", "production");
const csp = buildContentSecurityPolicy("n");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("upgrade-insecure-requests");
vi.unstubAllEnvs();
});
it("omits upgrade-insecure-requests in development", () => {
vi.stubEnv("NODE_ENV", "development");
const csp = buildContentSecurityPolicy("n");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).not.toContain("upgrade-insecure-requests");
vi.unstubAllEnvs();
});
});
+6
View File
@@ -28,11 +28,17 @@ export function buildContentSecurityPolicy(nonce: string): string {
"style-src-attr 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
// Clickjacking defence that also covers the `frame-ancestors`-aware
// browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is
// production-only: over plain-HTTP localhost it would rewrite same-origin
// asset URLs to https and break the dev server.
"frame-ancestors 'none'",
"connect-src 'self' https: wss:",
"font-src 'self' data: https://fonts.gstatic.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
...(isDev ? [] : ["upgrade-insecure-requests"]),
].join("; ");
}
+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`,
}));
import { fetchAvatarImage, ImagerUnavailableError } from "./imager-upstream";
import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "./imager-upstream";
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[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";
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_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);
});
it("revalidates a cached avatar with a 304 when the client already has it", async () => {
const fetcher = vi.fn(
async () =>
new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "image/png" },
}),
);
vi.stubGlobal("fetch", fetcher);
vi.stubEnv("IMAGER_URL", "https://etag.test/avatarimage");
const url = "https://hotel.test/api/imaging/avatar?figure=hd-77-1";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
expect(second.headers.get("x-imager-source")).toBe("cache");
});
it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => {
const fetcher = vi.fn(
async (_input: string | URL) =>
+412
View File
@@ -2570,6 +2570,8 @@
"credits": "Credits",
"points": "Points",
"pointsType": "Type",
"pointsTypeDuckets": "Duckets",
"pointsTypeDiamonds": "Diamonds",
"newPriceHint": "Applies to new offers only; moved offers keep their current price.",
"parent": "Place pages under",
"root": "Catalog root",
@@ -6801,6 +6803,416 @@
},
"missing": "Missing: {parts}",
"unknown": "Not verified: {parts}"
},
"rail": {
"title": "Categories",
"all": "All categories",
"noMatches": "No matching categories.",
"refreshFailed": "Catalog could not refresh.",
"retry": "Retry",
"updating": "Updating catalog…"
},
"nav": {
"ariaLabel": "Catalog Studio",
"furniture": "Furniture",
"badges": "Badges",
"clothing": "Clothing",
"effects": "Effects",
"pets": "Pets",
"uploadFiles": "Upload files",
"importFromSource": "Import from a source",
"syncRepository": "Sync & repository",
"catalogAudit": "Catalog audit",
"repairIcons": "Repair icons",
"catalogMaintenance": "Catalog maintenance",
"nitroCleanup": "Nitro cleanup",
"catalogManager": "Catalog manager",
"importSync": "Import & sync",
"maintenance": "Maintenance"
},
"aria": {
"search": "Search furniture",
"searchPlaceholder": "Search furni, classnames, keywords… (/)",
"clearSearch": "Clear search",
"hideCatalogTree": "Hide catalog tree",
"showCatalogTree": "Show catalog tree",
"source": "Furniture source",
"type": "Furniture type",
"category": "Furniture category",
"status": "Import status",
"sort": "Sort furniture",
"importSpeed": "Import speed",
"translateImport": "Translate this import",
"defaultTranslation": "Default translation",
"selection": "Furniture selection",
"results": "Furniture results",
"gridView": "Grid view",
"listView": "List view",
"selectAll": "Select all",
"languageLabel": "Language {lang}",
"selectItem": "Select {name}",
"deselectItem": "Deselect {name}",
"viewItem": "View {name}",
"importItem": "Import {name}",
"editNitro": "Edit nitro {name}",
"regenerateNitro": "Regenerate nitro {name}",
"scrollToTop": "Scroll to top",
"closeCategoryPanel": "Close category panel",
"catalogCategories": "Catalog categories",
"closeCategories": "Close categories",
"findCategory": "Find a category",
"expandCategory": "Expand {name}",
"collapseCategory": "Collapse {name}",
"closeDetails": "Close furniture details"
},
"workspace": {
"title": "Studio",
"furnidata": "furnidata",
"catalog": "Catalog",
"results": "{count} results",
"statInDb": "{count} in DB",
"statInCatalog": "{count} in catalog",
"statMissingNitro": "{count} missing nitro",
"statTotal": "{count} total",
"statPresent": "{count} present",
"statToImport": "{count} to import",
"official": "(official)"
},
"types": {
"floor": "Floor",
"wall": "Wall"
},
"filters": {
"allTypes": "All types",
"floorItems": "Floor items",
"wallItems": "Wall items",
"allCategories": "All categories",
"allStatuses": "All statuses",
"allStatusesCount": "All statuses ({count} available to import)",
"statuses": {
"notImported": "Not imported",
"imported": "Imported",
"inCatalog": "In catalog",
"missingNitro": "Missing nitro",
"missingFurnidata": "Missing furnidata",
"missingCatalog": "Missing catalog entry"
},
"sorts": {
"default": "Default order",
"name": "Name A–Z",
"classname": "Classname A–Z",
"category": "Category",
"importedFirst": "Imported first",
"notImportedFirst": "Not imported first"
},
"reset": "Reset filters",
"noNitro": "{count} no nitro",
"noFurnidata": "{count} no furnidata"
},
"importOptions": {
"summary": "Import options",
"translationOn": "Translation on",
"translationOff": "Translation off",
"speed": "Speed",
"parallel": "{count} parallel",
"gentle": "1 (gentle)",
"recommended": "4 (recommended)",
"turbo": "12 (turbo)",
"translate": "Translate",
"noLanguages": "No languages",
"allLanguages": "All languages",
"languagesCount": "{count} languages",
"languages": "Languages",
"all": "All",
"none": "None",
"translateAllTitle": "Translate the entire catalog into all languages",
"translateAll": "Translate entire catalog",
"defaultFuture": "Default for future imports"
},
"selection": {
"selectVisible": "Select visible",
"deselectVisible": "Deselect visible",
"loadingAll": "Loading all…",
"selectAllResults": "Select all {count} results",
"selectedInView": "{count} selected in this view",
"clear": "Clear",
"preparingReview": "Preparing review…",
"importAllMissing": "Import all missing ({count})",
"importSelected": "Import selected ({count})",
"rebuildNitro": "Rebuild Nitro ({count})",
"addMissingFurnidata": "Add missing furnidata ({count})",
"deleteSelected": "Delete ({count})",
"hiddenWarning": "Some selected furniture is hidden by your filters. Actions apply to the visible selection."
},
"items": {
"tryAgain": "Try again",
"noFound": "No furni found for “{query}”",
"noFurni": "No furni to show yet",
"noFoundHint": "Try a different search or clear your filters.",
"noFurniHint": "Search a furni library to get started.",
"noNitro": "No nitro",
"noFurnidata": "No furnidata",
"noCatalog": "No catalog",
"imported": "Imported",
"new": "New",
"colName": "Name",
"colClassname": "Classname",
"colType": "Type",
"colCategory": "Category",
"colStatus": "Status",
"colActions": "Actions",
"loadingMore": "Loading more…",
"scrollForMore": "Scroll for more",
"endOfResults": "End of results",
"defaultLanguages": "Default languages",
"off": "Off"
},
"deleteDialog": {
"title": "Delete imported furni",
"body": "This permanently removes {name} from items_base and the catalog, plus its asset files.",
"cancel": "Cancel",
"delete": "Delete"
},
"drawer": {
"title": "Furni detail",
"revision": "rev {revision}",
"autoImportReady": "Auto-import ready",
"automaticPlacement": "Automatic placement",
"category": "Category",
"price": "Price",
"priceCredits": "{count} credits",
"priceCreditsPoints": "{credits} credits + {points} points",
"importAdd": "Import & add to catalog",
"bundleMissing": "This furni is in the DB but its bundle file is missing.",
"regenerate": "Regenerate .nitro",
"editNitro": "Edit nitro",
"deleteImported": "Delete imported furni"
},
"review": {
"titleReview": "Review furniture import",
"titleConfirm": "Confirm furniture import",
"steps": "1. Select → 2. Review → 3. Import",
"source": "Source:",
"destination": "Destination:",
"destinationValue": "local catalog, furnidata, bundle and icons",
"translation": "Translation:",
"selection": "Selection:",
"selectionCount": "{count} furniture items",
"checkingSource": "Checking source files… {count} checked",
"retrySource": "Retry source check",
"unknownSources": "Some sources could not be verified. The import will still try to download their files.",
"sourceNotFound": "Source files not found by preliminary check: {name}",
"checkingLocal": "Checking local data and assets…",
"retryInspection": "Retry inspection",
"incomplete": "Inspection is incomplete. Close and retry before importing.",
"summary": "{ready} ready to import · {existing} already in SQL (complete missing data) · {blocked} conflicts · {warnings} source file warnings",
"localUnverified": "Some local files could not be verified. Review the item details before continuing.",
"statusNew": "New",
"statusChanged": "Differences",
"statusConflict": "Conflict",
"statusUnknown": "Not fully verified",
"statusPresent": "Present",
"validatingFile": "Validating file…",
"confirmIntro": "Only the {count} eligible items below will be submitted. Existing furniture keeps its local IDs, SQL settings and catalog offers. Missing assets and catalog entries are completed; furnidata is refreshed. Conflicts are excluded.",
"confirmEstimates": "Automatic categories and prices below are estimates. Source rules may override them; final results appear in the import summary.",
"placement": "{category} · {credits} credits + {points} points",
"previous": "Previous items",
"page": "Page {page} / {total}",
"next": "Next items",
"backToReview": "Back to review",
"cancel": "Cancel",
"continue": "Continue to confirmation",
"startImport": "Start import ({count})",
"uploadFailed": "Upload failed"
},
"inspector": {
"furnidataUnreadable": "Local furnidata could not be read. Its values are not verified.",
"field": "Field",
"localNow": "Local now",
"source": "Source",
"fieldName": "Name",
"fieldDescription": "Description",
"fieldRevision": "Revision",
"fieldType": "Type",
"changed": "Changed",
"notSupplied": "Not supplied",
"compareNote": "Source values are compared with local data. Import may remap IDs and translate names. Existing furniture keeps its local IDs and SQL settings; new furniture receives a free local ID when the source ID is occupied.",
"sqlRecords": "SQL records and catalog offers",
"noSqlItem": "No local SQL item.",
"itemRow": "Item #{id} · Sprite #{sprite} · {name}",
"offerRow": "Offer #{id} · Page #{page} · {credits} credits + {points} points",
"healthComplete": "Complete",
"healthIncomplete": "Needs repair",
"healthNew": "Not imported",
"healthConflict": "Local conflict",
"healthUnknown": "Not verified",
"missing": "Missing: {parts}",
"conflictNote": "Duplicate records or mismatched IDs/types require review before repair.",
"unknownNote": "Some local data could not be checked. Retry verification before repair.",
"repairNote": "Repair adds missing parts and preserves existing IDs, settings, furnidata and catalog offers. Missing offers use the automatic category and price shown below.",
"repairQueued": "Repair queued or running…",
"repair": "Repair furni",
"recheck": "Recheck local state",
"checking": "Checking local data and assets…",
"retry": "Retry inspection",
"unavailable": "Local inspection unavailable."
},
"quality": {
"compressionTitle": "Compression · {format}",
"losslessVp8l": "Lossless (VP8L)",
"lossyVp8": "Lossy (VP8)",
"bundle": "Bundle",
"texture": "Texture",
"metadata": "Metadata",
"container": "Container",
"textureDetail": "Texture {raw} decoded · deflate ×{ratio} · sheet {width}×{height} · {frames} frames",
"legacyPngSaving": "Legacy PNG texture. Re-importing as WebP Lossless would store <strong>{size}</strong>, saving <strong>{saving}</strong> ({percent}% smaller)",
"legacyPngNoGain": "Legacy PNG texture. Re-importing as WebP Lossless would store <strong>{size}</strong> (no gain on this bundle)",
"webpSaving": "WebP Lossless stores {raw} against {png} for the same artwork as PNG, saving <strong>{saving}</strong> ({percent}% smaller)",
"webpNoGain": "WebP Lossless stores {raw} against {png} for the same artwork as PNG (PNG is not larger on this bundle)",
"pixelExact": "<strong>Pixel-exact</strong> — every visible texel round-trips unchanged.",
"invisibleDiffs": "{count} fully transparent texels carry no RGB, which VP8L discards and no renderer can see.",
"texelsDiffer": "{count} {count, plural, one {texel} other {texels}} differ after a lossless round-trip — the furniture may not render identically.",
"scaleTitle": "Scale {size} · {state}",
"stateMissing": "Not included",
"stateIssues": "Needs review",
"stateDerived": "Derived from 64",
"statePresent": "Present",
"spritesLine": "{sprites} sprites · {animations} animation definitions · directions {directions}",
"missingScale": "This bundle contains no sprites at this scale. Missing scale 32 does not by itself mean the furniture is unusable.",
"sprite": "Sprite",
"renderFailed": "This sprite could not be rendered.",
"intro": "Inspect the saved bundle at both scales. Previews show individual sprites, not the assembled furniture or animation playback.",
"recheck": "Recheck",
"checking": "Checking scales and texture references…",
"inspectionFailed": "Inspection failed"
},
"sync": {
"pageSubtitle": "Keep the catalog in sync with your sources and export it to the repository.",
"cloneSourcesTitle": "Clone sources",
"cloneSourcesBody": "Scan all configured clone sources and automatically import any furniture that is not yet present in your hotel.",
"officialFurnidataTitle": "Official furnidata",
"officialFurnidataBody": "Scan the official Habbo furnidata and automatically import any furniture that your hotel doesn't have yet.",
"gitTitle": "Gitea connection settings",
"gitIntro": "Connect your self-hosted Gitea repository over HTTPS. The CMS manages its own checkout; no Git-specific .env settings are needed.",
"providerLabel": "Provider:",
"providerValue": "Gitea self-hosted",
"repositoryUrl": "Repository URL",
"branch": "Branch",
"username": "Gitea username",
"accessToken": "Gitea access token",
"tokenSaved": "Token saved — leave blank to keep it",
"tokenEnter": "Enter a token with repository write access",
"tokenNote": "The token is stored encrypted and is never returned to the browser. Changing repository or username requires entering the token again.",
"autoExport": "Automatically export catalog changes",
"testConnection": "Test connection",
"working": "Working…",
"saveSettings": "Save Gitea settings",
"loadFailed": "Unable to read Git settings. Settings edit permission is required.",
"requestFailed": "Git configuration request failed",
"saved": "Gitea configuration saved."
},
"scale32": {
"title": "Generate scale 32 from 64",
"intro": "Adds derived artwork using nearest-neighbor reduction. Existing scale 32 is preserved. Preview uses the saved bundle and shows one sprite at its original pixel size.",
"saveFirst": "Save or reset your pending edits first.",
"working": "Working…",
"preview": "Preview generation",
"previewCount": "{count} sprite entries will be added. Check positioning and animations in the client after saving.",
"skipped": "{count} sprite(s) were skipped because the source bundle has no usable artwork for them.",
"original64": "Original 64",
"derived32": "Derived 32",
"saveWithBackup": "Save with backup",
"discard": "Discard preview",
"saved": "Saved. A copy of the original bundle is preserved.",
"restore": "Restore original bundle",
"generationFailed": "Generation failed"
},
"pages": {
"auditSubtitle": "Scan your entire furniture catalog for missing entries, broken references, missing files, and items available from clone sources.",
"badgesSubtitle": "Search hotels and import badge designs into your hotel.",
"cloneSubtitle": "Clone furniture from your configured external sources.",
"clothingSubtitle": "Browse and import wearable clothing assets for your hotel.",
"effectsSubtitle": "Import effect definitions and their sprite assets.",
"maintenanceSubtitle": "Detect and repair catalog integrity problems, and run legacy maintenance tasks.",
"nitroCleanupSubtitle": "Scan the nitro directories for fake bundles (no matching furniture) and broken bundles (unparseable) and remove them.",
"petsSubtitle": "Import pet types, races and their sprite assets.",
"repairIconsTitle": "Repair missing icons",
"repairIconsSubtitle": "Scan all existing furniture and download any missing icon files. Icons are extracted from local .nitro bundles or fetched from configured clone sources.",
"uploadTitle": "Upload .hab furniture",
"uploadSubtitle": "Upload a .hab bundle and optional icon to import furniture into the hotel. The database entry, catalog item, and FurnitureData.json will be updated automatically."
},
"confirm": {
"deleteTitle": "Delete {count} items?",
"deleteDescription": "All {count} selected imported items will be permanently removed from the database and the filesystem.",
"deleteConfirm": "Delete"
},
"batch": {
"ariaProgress": "Import progress",
"ariaProcessed": "Furniture processed",
"headingStopping": "Stopping import…",
"headingImporting": "Importing furniture…",
"headingStopped": "Import stopped",
"headingCompletedErrors": "Import completed with errors",
"headingCompleted": "Import completed",
"imported": "imported",
"failed": "failed",
"notCompleted": "not completed",
"remaining": "remaining",
"retryFailed": "Retry failed ({count})",
"closeSummary": "Close summary",
"stopImport": "Stop import",
"verificationIssues": "{count, plural, one {# catalog data issue still needs attention. Open Catalog audit to review it.} other {# catalog data issues still need attention. Open Catalog audit to review them.}}",
"verificationDone": "Catalog data verification completed.",
"chipFurnidataIds": "furnidata ids fixed",
"chipOfferIds": "offer ids reconciled",
"chipOwnerships": "ownerships fixed",
"chipNitros": "nitros synced",
"chipIcons": "icons synced",
"viewItemDetails": "View item details ({count})",
"showingItems": "Showing {shown} of {total} items",
"status": {
"pending": "Waiting",
"started": "Starting",
"downloading": "Downloading assets",
"converting": "Preparing assets",
"writingDb": "Saving",
"done": "Imported",
"failed": "Failed"
},
"statusNotCompleted": "Not completed"
},
"toasts": {
"allImported": "All items from this source are already imported",
"selectNotImported": "Select furni that isn't imported yet",
"loadMissingFailed": "Could not load missing furniture",
"prepareFailed": "Could not prepare the full selection",
"sourceChanged": "The source changed while preparing the selection. Refresh and retry.",
"batchFailed": "Batch import failed ({status})",
"importCancelled": "Import cancelled",
"importTimeout": "Import timed out. Progress was saved — open Import History to resume failed items.",
"networkUpload": "Network error during upload. Progress was saved — open Import History to resume failed items.",
"unexpectedImport": "Unexpected import error: {message}",
"retryFailed": "Retry failed ({status})",
"importedAgain": "{count} items imported again",
"retryConnection": "Connection failed while retrying",
"deleteFailed": "Delete failed",
"networkError": "Network error",
"deleted": "Deleted {name} ({count} files)",
"regenerated": "Nitro file regenerated for {name}",
"regenerationFailed": "Regeneration failed",
"bulkDeleted": "Deleted: {count}",
"bulkDeletedFailed": "Deleted: {deleted}, failed: {failed}",
"bulkDeleteFailed": "Bulk delete failed",
"regeneratedBulk": "Nitro regenerated: {count}",
"regeneratedBulkFailed": "Nitro regenerated: {succeeded}, failed: {failed}",
"furnidataAdded": "Furnidata added: {count}",
"furnidataAddedFailed": "Furnidata added: {fixed}, failed: {failed}",
"addFurnidataFailed": "Adding furnidata failed",
"languagesTranslated": "Languages translated ({ok}/{total} completed)",
"translationFailed": "Translation failed",
"networkTranslating": "Network error while translating languages",
"saveDefaultFailed": "Could not save the default translation setting"
}
}
},
-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