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

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

No files matched your search

+10 -9
View File
@@ -1,5 +1,8 @@
import { ClipboardCheck } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuditClient } from "@/app/admin/import/audit/audit-client"; import { AuditClient } from "@/app/admin/import/audit/audit-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioAuditPage(_props: { export default async function StudioAuditPage(_props: {
@@ -9,16 +12,14 @@ export default async function StudioAuditPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={ClipboardCheck}
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1> title={t("nav.catalogAudit")}
<p className="mt-1 text-muted-foreground"> subtitle={t("pages.auditSubtitle")}
Scan your entire furniture catalog for missing entries, broken >
references, missing files, and items available from clone sources.
</p>
</div>
<AuditClient /> <AuditClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { BadgeCheck } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client"; import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioBadgesPage(_props: { export default async function StudioBadgesPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioBadgesPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={BadgeCheck}
title={t("nav.badges")}
subtitle={t("pages.badgesSubtitle")}
>
<ImportBadgesClient /> <ImportBadgesClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Copy } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client"; import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClonePage(_props: { export default async function StudioClonePage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioClonePage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Copy}
title={t("nav.importFromSource")}
subtitle={t("pages.cloneSubtitle")}
>
<ImportCloneClient /> <ImportCloneClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Shirt } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client"; import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClothingPage(_props: { export default async function StudioClothingPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioClothingPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Shirt}
title={t("nav.clothing")}
subtitle={t("pages.clothingSubtitle")}
>
<ImportClothingClient /> <ImportClothingClient />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Dumbbell } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client"; import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioEffectsPage(_props: { export default async function StudioEffectsPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioEffectsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Dumbbell}
title={t("nav.effects")}
subtitle={t("pages.effectsSubtitle")}
>
<ImportEffectsClient /> <ImportEffectsClient />
</div> </StudioPage>
); );
} }
+30
View File
@@ -0,0 +1,30 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state for any Catalog Studio sub-page. Mirrors the StudioPage
* header (icon tile, title, subtitle) and a body block so navigation between
* studio tools shows a stable skeleton instead of a blank pause.
*/
export default function StudioLoading() {
return (
<div className="p-4">
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<Skeleton className="h-10 w-10 shrink-0 rounded-xl bg-[var(--admin-accent)]/10" />
<div className="min-w-0">
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-72 max-w-full" />
</div>
</div>
</div>
<div className="space-y-3 rounded-xl border border-[var(--admin-border)] p-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-9 w-32" />
</div>
</div>
</div>
);
}
+23 -14
View File
@@ -1,7 +1,9 @@
import { Wrench } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel"; import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel"; import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioCatalogMaintenancePage() { export default async function StudioCatalogMaintenancePage() {
@@ -16,20 +18,27 @@ export default async function StudioCatalogMaintenancePage() {
session.user.rank, session.user.rank,
); );
const t = await getTranslations("pages.admin.catalog.integrity"); const t = await getTranslations("pages.admin.catalog.integrity");
const studioT = await getTranslations("admin.studio");
return ( return (
<div className="min-h-0 space-y-6"> <StudioPage
<CatalogIntegrityPanel canRepair={canRepair} /> icon={Wrench}
{canRepair && ( title={studioT("nav.catalogMaintenance")}
<details className="rounded-xl border p-4"> subtitle={studioT("pages.maintenanceSubtitle")}
<summary className="cursor-pointer font-medium"> >
{t("legacyTitle")} <div className="min-h-0 space-y-6">
</summary> <CatalogIntegrityPanel canRepair={canRepair} />
<p className="mt-3 text-sm text-muted-foreground"> {canRepair && (
{t("legacyHint")} <details className="rounded-xl border p-4">
</p> <summary className="cursor-pointer font-medium">
<CatalogMaintenancePanel /> {t("legacyTitle")}
</details> </summary>
)} <p className="mt-3 text-sm text-muted-foreground">
</div> {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 { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel"; import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioNitroCleanupPage() { export default async function StudioNitroCleanupPage() {
@@ -7,16 +10,14 @@ export default async function StudioNitroCleanupPage() {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={FileX2}
<h1 className="text-2xl font-bold tracking-tight">Nitro cleanup</h1> title={t("nav.nitroCleanup")}
<p className="mt-1 text-muted-foreground"> subtitle={t("pages.nitroCleanupSubtitle")}
Scan the nitro directories for fake bundles (no matching furniture) >
and broken bundles (unparseable) and remove them.
</p>
</div>
<NitroCleanupPanel /> <NitroCleanupPanel />
</div> </StudioPage>
); );
} }
+10 -2
View File
@@ -1,5 +1,8 @@
import { Cat } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client"; import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioPetsPage(_props: { export default async function StudioPetsPage(_props: {
@@ -9,9 +12,14 @@ export default async function StudioPetsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="p-4"> <StudioPage
icon={Cat}
title={t("nav.pets")}
subtitle={t("pages.petsSubtitle")}
>
<ImportPetsClient /> <ImportPetsClient />
</div> </StudioPage>
); );
} }
+10 -12
View File
@@ -1,5 +1,8 @@
import { Image as ImageIcon } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client"; import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioRepairIconsPage(_props: { export default async function StudioRepairIconsPage(_props: {
@@ -9,19 +12,14 @@ export default async function StudioRepairIconsPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={ImageIcon}
<h1 className="text-2xl font-bold tracking-tight"> title={t("pages.repairIconsTitle")}
Repair Missing Icons subtitle={t("pages.repairIconsSubtitle")}
</h1> >
<p className="mt-1 text-muted-foreground">
Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured
clone sources.
</p>
</div>
<RepairIconsClient /> <RepairIconsClient />
</div> </StudioPage>
); );
} }
+35 -28
View File
@@ -17,6 +17,7 @@ import {
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper"; import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper";
import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher"; import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher";
@@ -28,34 +29,38 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type StudioLink = { href: string; label: string; icon: LucideIcon }; type StudioLink = { href: string; labelKey: string; icon: LucideIcon };
const assets: StudioLink[] = [ const assets: StudioLink[] = [
{ href: "/admin/studio/furni", label: "Furniture", icon: Puzzle }, { href: "/admin/studio/furni", labelKey: "furniture", icon: Puzzle },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck }, { href: "/admin/studio/badges", labelKey: "badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt }, { href: "/admin/studio/clothing", labelKey: "clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell }, { href: "/admin/studio/effects", labelKey: "effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat }, { href: "/admin/studio/pets", labelKey: "pets", icon: Cat },
]; ];
const imports: StudioLink[] = [ const imports: StudioLink[] = [
{ href: "/admin/studio/upload", label: "Upload files", icon: Upload }, { href: "/admin/studio/upload", labelKey: "uploadFiles", icon: Upload },
{ href: "/admin/studio/clone", label: "Import from a source", icon: Copy }, { href: "/admin/studio/clone", labelKey: "importFromSource", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw }, { href: "/admin/studio/sync", labelKey: "syncRepository", icon: RefreshCw },
]; ];
const maintenance: StudioLink[] = [ const maintenance: StudioLink[] = [
{ href: "/admin/studio/audit", label: "Catalog audit", icon: ClipboardCheck }, {
href: "/admin/studio/audit",
labelKey: "catalogAudit",
icon: ClipboardCheck,
},
{ {
href: "/admin/studio/repair-icons", href: "/admin/studio/repair-icons",
label: "Repair icons", labelKey: "repairIcons",
icon: ImageIcon, icon: ImageIcon,
}, },
{ {
href: "/admin/studio/maintenance", href: "/admin/studio/maintenance",
label: "Catalog maintenance", labelKey: "catalogMaintenance",
icon: Wrench, icon: Wrench,
}, },
{ {
href: "/admin/studio/nitro-cleanup", href: "/admin/studio/nitro-cleanup",
label: "Nitro cleanup", labelKey: "nitroCleanup",
icon: FileX2, icon: FileX2,
}, },
]; ];
@@ -71,6 +76,7 @@ function StudioMenu({
links: StudioLink[]; links: StudioLink[];
pathname: string; pathname: string;
}) { }) {
const t = useTranslations("admin.studio");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const active = links.find((link) => activePath(pathname, link.href)); const active = links.find((link) => activePath(pathname, link.href));
return ( return (
@@ -90,7 +96,7 @@ function StudioMenu({
align="end" align="end"
className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]" className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]"
> >
{links.map(({ href, label: title, icon: Icon }) => ( {links.map(({ href, labelKey, icon: Icon }) => (
<Link <Link
key={href} key={href}
href={href} href={href}
@@ -103,8 +109,8 @@ function StudioMenu({
aria-current={activePath(pathname, href) ? "page" : undefined} aria-current={activePath(pathname, href) ? "page" : undefined}
className="flex min-h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-[var(--admin-accent)]/10 aria-[current=page]:bg-[var(--admin-accent)]/15" className="flex min-h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-[var(--admin-accent)]/10 aria-[current=page]:bg-[var(--admin-accent)]/15"
> >
<Icon size={16} /> <Icon size={16} aria-hidden />
{title} {t(`nav.${labelKey}`)}
</Link> </Link>
))} ))}
</PopoverContent> </PopoverContent>
@@ -120,14 +126,15 @@ export function StudioLayoutClient({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const t = useTranslations("admin.studio");
return ( return (
<div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"> <div className="flex h-[calc(100dvh-7rem)] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] sm:min-h-[600px]">
<nav <nav
aria-label="Catalog Studio" aria-label={t("nav.ariaLabel")}
className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2" className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
> >
<div className="flex flex-wrap gap-1"> <div className="flex shrink-0 gap-1">
{assets.map(({ href, label, icon: Icon }) => ( {assets.map(({ href, labelKey, icon: Icon }) => (
<Link <Link
key={href} key={href}
href={href} href={href}
@@ -138,35 +145,35 @@ export function StudioLayoutClient({
}} }}
aria-current={activePath(pathname, href) ? "page" : undefined} aria-current={activePath(pathname, href) ? "page" : undefined}
className={cn( className={cn(
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors", "flex min-h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-sm font-medium transition-colors",
activePath(pathname, href) activePath(pathname, href)
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]" ? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10", : "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)} )}
> >
<Icon size={16} /> <Icon size={16} aria-hidden />
{label} {t(`nav.${labelKey}`)}
</Link> </Link>
))} ))}
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="ml-auto flex shrink-0 gap-1 pl-1">
{canEditCatalog && ( {canEditCatalog && (
<> <>
<CatalogManagerDialog <CatalogManagerDialog
catalogType="normal" catalogType="normal"
canEdit canEdit
triggerLabel="Catalog manager" triggerLabel={t("nav.catalogManager")}
/> />
<OrganizeImportsLauncher /> <OrganizeImportsLauncher />
</> </>
)} )}
<StudioMenu <StudioMenu
label="Import & sync" label={t("nav.importSync")}
links={imports} links={imports}
pathname={pathname} pathname={pathname}
/> />
<StudioMenu <StudioMenu
label="Maintenance" label={t("nav.maintenance")}
links={maintenance} links={maintenance}
pathname={pathname} pathname={pathname}
/> />
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -13,6 +14,7 @@ interface Configuration {
hasToken: boolean; hasToken: boolean;
} }
export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) { export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
const t = useTranslations("admin.studio.sync");
const [config, setConfig] = useState<Configuration | null>(null), const [config, setConfig] = useState<Configuration | null>(null),
[token, setToken] = useState(""), [token, setToken] = useState(""),
[busy, setBusy] = useState(false), [busy, setBusy] = useState(false),
@@ -26,10 +28,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
signal: abort.signal, signal: abort.signal,
cache: "no-store", cache: "no-store",
}); });
if (!res.ok) if (!res.ok) throw new Error(t("loadFailed"));
throw new Error(
"Unable to read Git settings. Settings edit permission is required.",
);
const data = await res.json(); const data = await res.json();
if (!abort.signal.aborted) setConfig(data); if (!abort.signal.aborted) setConfig(data);
} catch (error) { } catch (error) {
@@ -37,7 +36,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
} }
})(); })();
return () => abort.abort(); return () => abort.abort();
}, []); }, [t]);
async function submit(action: "save" | "test") { async function submit(action: "save" | "test") {
if (!config || busy) return; if (!config || busy) return;
setBusy(true); setBusy(true);
@@ -50,12 +49,11 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
body: JSON.stringify({ ...config, token, action }), body: JSON.stringify({ ...config, token, action }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) if (!res.ok) throw new Error(data.error || t("requestFailed"));
throw new Error(data.error || "Git configuration request failed");
if (action === "save") { if (action === "save") {
setConfig(data); setConfig(data);
setToken(""); setToken("");
setMessage("Gitea configuration saved."); setMessage(t("saved"));
onSaved(); onSaved();
} else setMessage(data.message); } else setMessage(data.message);
} catch (error) { } catch (error) {
@@ -70,19 +68,18 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
open open
> >
<summary className="cursor-pointer font-semibold"> <summary className="cursor-pointer font-semibold">
Gitea connection settings {t("gitTitle")}
</summary> </summary>
<p className="my-3 text-sm text-[var(--admin-text-muted)]"> <p className="my-3 text-sm text-[var(--admin-text-muted)]">
Connect your self-hosted Gitea repository over HTTPS. The CMS manages {t("gitIntro")}
its own checkout; no Git-specific .env settings are needed.
</p> </p>
{config && ( {config && (
<fieldset disabled={busy} className="space-y-3"> <fieldset disabled={busy} className="space-y-3">
<p className="text-sm"> <p className="text-sm">
Provider: <strong>Gitea self-hosted</strong> {t("providerLabel")} <strong>{t("providerValue")}</strong>
</p> </p>
<label htmlFor="catalog-git-1" className="block text-sm"> <label htmlFor="catalog-git-1" className="block text-sm">
Repository URL {t("repositoryUrl")}
<Input <Input
id="catalog-git-1" id="catalog-git-1"
type="url" type="url"
@@ -94,7 +91,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label> </label>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<label htmlFor="catalog-git-2" className="block text-sm"> <label htmlFor="catalog-git-2" className="block text-sm">
Branch {t("branch")}
<Input <Input
id="catalog-git-2" id="catalog-git-2"
value={config.branch} value={config.branch}
@@ -104,7 +101,7 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
/> />
</label> </label>
<label htmlFor="catalog-git-3" className="block text-sm"> <label htmlFor="catalog-git-3" className="block text-sm">
Gitea username {t("username")}
<Input <Input
id="catalog-git-3" id="catalog-git-3"
autoComplete="off" autoComplete="off"
@@ -116,23 +113,18 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
</label> </label>
</div> </div>
<label htmlFor="catalog-git-4" className="block text-sm"> <label htmlFor="catalog-git-4" className="block text-sm">
Gitea access token {t("accessToken")}
<Input <Input
id="catalog-git-4" id="catalog-git-4"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={token} value={token}
placeholder={ placeholder={config.hasToken ? t("tokenSaved") : t("tokenEnter")}
config.hasToken
? "Token saved — leave blank to keep it"
: "Enter a token with repository write access"
}
onChange={(event) => setToken(event.target.value)} onChange={(event) => setToken(event.target.value)}
/> />
</label> </label>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
The token is stored encrypted and is never returned to the browser. {t("tokenNote")}
Changing repository or username requires entering the token again.
</p> </p>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<input <input
@@ -142,14 +134,14 @@ export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
setConfig({ ...config, enabled: event.target.checked }) setConfig({ ...config, enabled: event.target.checked })
} }
/> />
Automatically export catalog changes {t("autoExport")}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void submit("test")}> <Button variant="outline" onClick={() => void submit("test")}>
Test connection {t("testConnection")}
</Button> </Button>
<Button onClick={() => void submit("save")}> <Button onClick={() => void submit("save")}>
{busy ? "Working…" : "Save Gitea settings"} {busy ? t("working") : t("saveSettings")}
</Button> </Button>
</div> </div>
</fieldset> </fieldset>
+30 -29
View File
@@ -1,6 +1,9 @@
import { RefreshCw } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client"; import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client";
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client"; import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { CatalogExportClient } from "./catalog-export-client"; import { CatalogExportClient } from "./catalog-export-client";
@@ -12,44 +15,42 @@ export default async function StudioSyncPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-8"> <StudioPage
<CatalogExportClient icon={RefreshCw}
canConfigure={canAccess( title={t("nav.syncRepository")}
permissions, subtitle={t("sync.pageSubtitle")}
PERMS.SETTINGS_EDIT, >
session.user.rank, <div className="space-y-6">
)} <CatalogExportClient
/> canConfigure={canAccess(
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1> permissions,
<p className="text-muted-foreground mt-2"> PERMS.SETTINGS_EDIT,
Scan configured sources and automatically import any furniture that is session.user.rank,
not yet present in your hotel. )}
</p> />
<div className="space-y-4"> <section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<div> <h2 className="text-lg font-semibold">
<h2 className="text-lg font-semibold text-muted-foreground mb-2"> {t("sync.cloneSourcesTitle")}
Clone Sources
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-[var(--admin-text-muted)]">
Scan all configured clone sources and automatically import any {t("sync.cloneSourcesBody")}
furniture that is not yet present in your hotel.
</p> </p>
<SyncAllClient /> <SyncAllClient />
</div> </section>
<div> <section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold text-muted-foreground mb-2"> <h2 className="text-lg font-semibold">
Official Furnidata {t("sync.officialFurnidataTitle")}
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-[var(--admin-text-muted)]">
Scan the official Habbo furnidata and automatically import any {t("sync.officialFurnidataBody")}
furniture that your hotel doesn't have yet.
</p> </p>
<OfficialSyncClient /> <OfficialSyncClient />
</div> </section>
</div> </div>
</div> </StudioPage>
); );
} }
+10 -12
View File
@@ -1,5 +1,8 @@
import { Upload } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client"; import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client";
import { StudioPage } from "@/components/admin/studio/studio-page";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioUploadPage(_props: { export default async function StudioUploadPage(_props: {
@@ -9,19 +12,14 @@ export default async function StudioUploadPage(_props: {
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
const t = await getTranslations("admin.studio");
return ( return (
<div className="space-y-6 p-4"> <StudioPage
<div> icon={Upload}
<h1 className="text-2xl font-bold tracking-tight"> title={t("pages.uploadTitle")}
Upload .hab Furniture subtitle={t("pages.uploadSubtitle")}
</h1> >
<p className="mt-1 text-muted-foreground">
Upload a .hab bundle and optional icon to import furniture into the
hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically.
</p>
</div>
<UploadFurniClient /> <UploadFurniClient />
</div> </StudioPage>
); );
} }
+4
View File
@@ -2,6 +2,10 @@
`studio-client.tsx` composes the workspace, selection and actions. Keep reusable data handling outside its render tree. `studio-client.tsx` composes the workspace, selection and actions. Keep reusable data handling outside its render tree.
## Shared chrome
- `studio-page.tsx`: consistent header (icon tile, title, subtitle, actions) and padding for every studio sub-page; wraps `AdminPageShell`.
## Client responsibilities ## Client responsibilities
- `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array. - `studio-filters.ts`: pure filtering and sorting of the loaded furniture list; never mutates the input array.
+37 -32
View File
@@ -9,19 +9,20 @@ import {
RefreshCw, RefreshCw,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useEffect, useMemo, useState } from "react"; import { memo, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { BatchItemStatus, VerificationSummary } from "./studio-types"; import type { BatchItemStatus, VerificationSummary } from "./studio-types";
const statusLabels: Record<BatchItemStatus["status"], string> = { const STATUS_KEYS: Record<BatchItemStatus["status"], string> = {
pending: "Waiting", pending: "status.pending",
started: "Starting", started: "status.started",
downloading: "Downloading assets", downloading: "status.downloading",
converting: "Preparing assets", converting: "status.converting",
writing_db: "Saving", writing_db: "status.writingDb",
done: "Imported", done: "status.done",
failed: "Failed", failed: "status.failed",
}; };
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -65,6 +66,7 @@ export const BatchProgress = memo(function BatchProgress({
onDismiss?: () => void; onDismiss?: () => void;
onRetry?: (classnames: string[]) => void; onRetry?: (classnames: string[]) => void;
}) { }) {
const t = useTranslations("admin.studio.batch");
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
@@ -99,20 +101,20 @@ export const BatchProgress = memo(function BatchProgress({
const rate = formatRate(completed, elapsed); const rate = formatRate(completed, elapsed);
const heading = !done const heading = !done
? cancelling ? cancelling
? "Stopping import…" ? t("headingStopping")
: "Importing furniture…" : t("headingImporting")
: unfinished : unfinished
? "Import stopped" ? t("headingStopped")
: errors : errors
? "Import completed with errors" ? t("headingCompletedErrors")
: "Import completed"; : t("headingCompleted");
const issues = verification const issues = verification
? verification.furniDataMissing + verification.furniDataConflicts ? verification.furniDataMissing + verification.furniDataConflicts
: 0; : 0;
return ( return (
<section <section
aria-label="Import progress" aria-label={t("ariaProgress")}
className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4" className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
> >
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -128,7 +130,7 @@ export const BatchProgress = memo(function BatchProgress({
<span className="font-semibold text-[var(--admin-success)]"> <span className="font-semibold text-[var(--admin-success)]">
{imported} {imported}
</span>{" "} </span>{" "}
imported {t("imported")}
</span> </span>
<span className="text-[var(--admin-border)]">·</span> <span className="text-[var(--admin-border)]">·</span>
<span> <span>
@@ -140,13 +142,13 @@ export const BatchProgress = memo(function BatchProgress({
> >
{errors} {errors}
</span>{" "} </span>{" "}
failed {t("failed")}
</span> </span>
{unfinished > 0 && ( {unfinished > 0 && (
<> <>
<span className="text-[var(--admin-border)]">·</span> <span className="text-[var(--admin-border)]">·</span>
<span> <span>
{unfinished} {done ? "not completed" : "remaining"} {unfinished} {done ? t("notCompleted") : t("remaining")}
</span> </span>
</> </>
)} )}
@@ -173,12 +175,12 @@ export const BatchProgress = memo(function BatchProgress({
onClick={() => onRetry(failedClassnames)} onClick={() => onRetry(failedClassnames)}
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
Retry failed ({failedClassnames.length}) {t("retryFailed", { count: failedClassnames.length })}
</Button> </Button>
)} )}
<Button size="sm" variant="secondary" onClick={onDismiss}> <Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} /> <Check size={14} />
Close summary {t("closeSummary")}
</Button> </Button>
</> </>
) : ( ) : (
@@ -193,14 +195,14 @@ export const BatchProgress = memo(function BatchProgress({
) : ( ) : (
<Ban size={14} /> <Ban size={14} />
)} )}
Stop import {t("stopImport")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
<div <div
role="progressbar" role="progressbar"
aria-label="Furniture processed" aria-label={t("ariaProcessed")}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={total || 1} aria-valuemax={total || 1}
aria-valuenow={completed} aria-valuenow={completed}
@@ -215,39 +217,42 @@ export const BatchProgress = memo(function BatchProgress({
<div className="mb-3 flex flex-wrap items-center gap-1.5"> <div className="mb-3 flex flex-wrap items-center gap-1.5">
<p className="mr-1 text-sm text-[var(--admin-text-muted)]"> <p className="mr-1 text-sm text-[var(--admin-text-muted)]">
{issues {issues
? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.` ? t("verificationIssues", { count: issues })
: "Catalog data verification completed."} : t("verificationDone")}
</p> </p>
<NodeChip <NodeChip
count={verification.furniDataFixedIds} count={verification.furniDataFixedIds}
label="furnidata ids fixed" label={t("chipFurnidataIds")}
/> />
<NodeChip <NodeChip
count={verification.offerIdsFixed} count={verification.offerIdsFixed}
label="offer ids reconciled" label={t("chipOfferIds")}
/> />
<NodeChip <NodeChip
count={verification.ownershipFixed.length} count={verification.ownershipFixed.length}
label="ownerships fixed" label={t("chipOwnerships")}
/> />
<NodeChip <NodeChip
count={verification.nitrosSynced.length} count={verification.nitrosSynced.length}
label="nitros synced" label={t("chipNitros")}
/> />
<NodeChip <NodeChip
count={verification.iconsSynced.length} count={verification.iconsSynced.length}
label="icons synced" label={t("chipIcons")}
/> />
</div> </div>
)} )}
<details> <details>
<summary className="cursor-pointer text-xs font-medium"> <summary className="cursor-pointer text-xs font-medium">
View item details ({total}) {t("viewItemDetails", { count: total })}
</summary> </summary>
<div className="mt-2 max-h-44 space-y-2 overflow-auto"> <div className="mt-2 max-h-44 space-y-2 overflow-auto">
{entries.length > VISIBLE_ITEMS && ( {entries.length > VISIBLE_ITEMS && (
<p className="px-1 text-[0.65rem] text-[var(--admin-text-muted)]"> <p className="px-1 text-[0.65rem] text-[var(--admin-text-muted)]">
Showing {VISIBLE_ITEMS} of {entries.length} items {t("showingItems", {
shown: VISIBLE_ITEMS,
total: entries.length,
})}
</p> </p>
)} )}
{entries.slice(0, VISIBLE_ITEMS).map((item) => ( {entries.slice(0, VISIBLE_ITEMS).map((item) => (
@@ -280,8 +285,8 @@ export const BatchProgress = memo(function BatchProgress({
</span> </span>
<span className="shrink-0 text-[var(--admin-text-muted)]"> <span className="shrink-0 text-[var(--admin-text-muted)]">
{done && item.status !== "done" && item.status !== "failed" {done && item.status !== "done" && item.status !== "failed"
? "Not completed" ? t("statusNotCompleted")
: statusLabels[item.status]} : t(STATUS_KEYS[item.status])}
</span> </span>
</span> </span>
{item.message && ( {item.message && (
+18 -11
View File
@@ -10,6 +10,7 @@ import {
Search, Search,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { memo, useMemo, useState, useTransition } from "react"; import { memo, useMemo, useState, useTransition } from "react";
import { import {
refreshCatalogTree, refreshCatalogTree,
@@ -25,6 +26,7 @@ import type { TreeNode } from "@/types/catalog";
* member has no other signal that the import they just ran is not on screen. * member has no other signal that the import they just ran is not on screen.
*/ */
function LiveCatalogNotice() { function LiveCatalogNotice() {
const t = useTranslations("admin.studio");
const { refreshError, refreshing } = useLiveCatalog(); const { refreshError, refreshing } = useLiveCatalog();
const [retrying, startRetry] = useTransition(); const [retrying, startRetry] = useTransition();
if (!refreshError && !refreshing) return null; if (!refreshError && !refreshing) return null;
@@ -41,7 +43,7 @@ function LiveCatalogNotice() {
{refreshError ? ( {refreshError ? (
<> <>
<AlertTriangle size={14} className="shrink-0" /> <AlertTriangle size={14} className="shrink-0" />
<span className="min-w-0 flex-1">Catalog could not refresh.</span> <span className="min-w-0 flex-1">{t("rail.refreshFailed")}</span>
<button <button
type="button" type="button"
disabled={retrying} disabled={retrying}
@@ -53,13 +55,13 @@ function LiveCatalogNotice() {
className="flex items-center gap-1 rounded px-1.5 py-0.5 font-medium underline disabled:opacity-50" className="flex items-center gap-1 rounded px-1.5 py-0.5 font-medium underline disabled:opacity-50"
> >
<RefreshCw size={12} className={cn(retrying && "animate-spin")} /> <RefreshCw size={12} className={cn(retrying && "animate-spin")} />
Retry {t("rail.retry")}
</button> </button>
</> </>
) : ( ) : (
<> <>
<Loader2 size={14} className="shrink-0 animate-spin" /> <Loader2 size={14} className="shrink-0 animate-spin" />
<span className="min-w-0 flex-1">Updating catalog…</span> <span className="min-w-0 flex-1">{t("rail.updating")}</span>
</> </>
)} )}
</p> </p>
@@ -77,6 +79,7 @@ export const CatalogRail = memo(function CatalogRail({
onSelect: (id: number | null) => void; onSelect: (id: number | null) => void;
onClose?: () => void; onClose?: () => void;
}) { }) {
const t = useTranslations("admin.studio");
const [collapsed, setCollapsed] = useState<Set<number>>(new Set()); const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const visible = useMemo(() => { const visible = useMemo(() => {
@@ -100,7 +103,7 @@ export const CatalogRail = memo(function CatalogRail({
} }
return ( return (
<aside <aside
aria-label="Catalog categories" aria-label={t("aria.catalogCategories")}
className="absolute inset-y-0 left-0 z-30 flex w-64 max-w-[85%] flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:z-auto lg:shrink-0 lg:shadow-none" className="absolute inset-y-0 left-0 z-30 flex w-64 max-w-[85%] flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:z-auto lg:shrink-0 lg:shadow-none"
> >
<div className="flex items-center gap-2 px-3 pt-3"> <div className="flex items-center gap-2 px-3 pt-3">
@@ -109,12 +112,12 @@ export const CatalogRail = memo(function CatalogRail({
style={{ fontSize: 14, margin: 0 }} style={{ fontSize: 14, margin: 0 }}
className="flex-1 text-sm font-semibold" className="flex-1 text-sm font-semibold"
> >
Categories {t("rail.title")}
</h2> </h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close categories" aria-label={t("aria.closeCategories")}
className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10" className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
> >
<X size={16} /> <X size={16} />
@@ -125,8 +128,8 @@ export const CatalogRail = memo(function CatalogRail({
<input <input
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
placeholder="Find a category…" placeholder={t("aria.findCategory")}
aria-label="Find a category" aria-label={t("aria.findCategory")}
className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none" className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none"
/> />
</label> </label>
@@ -136,13 +139,13 @@ export const CatalogRail = memo(function CatalogRail({
aria-pressed={selectedId === null} aria-pressed={selectedId === null}
className="mx-2 mb-2 rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--admin-accent)]/10 aria-pressed:bg-[var(--admin-accent)]/15" className="mx-2 mb-2 rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--admin-accent)]/10 aria-pressed:bg-[var(--admin-accent)]/15"
> >
All categories {t("rail.all")}
</button> </button>
<LiveCatalogNotice /> <LiveCatalogNotice />
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{visible.length === 0 && ( {visible.length === 0 && (
<p className="p-3 text-sm text-[var(--admin-text-muted)]"> <p className="p-3 text-sm text-[var(--admin-text-muted)]">
No matching categories. {t("rail.noMatches")}
</p> </p>
)} )}
{visible.map((node) => ( {visible.map((node) => (
@@ -159,7 +162,11 @@ export const CatalogRail = memo(function CatalogRail({
{node.childCount > 0 && !search.trim() ? ( {node.childCount > 0 && !search.trim() ? (
<button <button
type="button" type="button"
aria-label={`${collapsed.has(node.id) ? "Expand" : "Collapse"} ${node.caption}`} aria-label={
collapsed.has(node.id)
? t("aria.expandCategory", { name: node.caption })
: t("aria.collapseCategory", { name: node.caption })
}
aria-expanded={!collapsed.has(node.id)} aria-expanded={!collapsed.has(node.id)}
onClick={() => toggle(node.id)} onClick={() => toggle(node.id)}
className="grid h-10 w-8 shrink-0 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10" className="grid h-10 w-8 shrink-0 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
@@ -15,11 +15,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog"; import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { FurnitureInspector } from "./furniture-inspector"; import { FurnitureInspector } from "./furniture-inspector";
import { import { getFurniImageUrl, handleImgError } from "./studio-furni-icons";
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import type { FurniItem } from "./studio-types"; import type { FurniItem } from "./studio-types";
interface FurnitureDetailDrawerProps { interface FurnitureDetailDrawerProps {
@@ -49,6 +45,10 @@ export function FurnitureDetailDrawer({
onRepair, onRepair,
}: FurnitureDetailDrawerProps) { }: FurnitureDetailDrawerProps) {
const t = useTranslations("admin.studio.completeness"); const t = useTranslations("admin.studio.completeness");
const studioT = useTranslations("admin.studio");
const drawerT = useTranslations("admin.studio.drawer");
const typeLabel =
detail.type === "wallitem" ? studioT("types.wall") : studioT("types.floor");
const detailPreview = previewAutoCatalog( const detailPreview = previewAutoCatalog(
detail.classname, detail.classname,
detail.type === "wallitem" ? "i" : "s", detail.type === "wallitem" ? "i" : "s",
@@ -57,11 +57,11 @@ export function FurnitureDetailDrawer({
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none"> <aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5"> <div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]"> <span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail {drawerT("title")}
</span> </span>
<button <button
type="button" type="button"
aria-label="Close furniture details" aria-label={studioT("aria.closeDetails")}
onClick={onClose} onClick={onClose}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]" className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
> >
@@ -101,17 +101,17 @@ export function FurnitureDetailDrawer({
) : null} ) : null}
<div className="mb-4 flex flex-wrap gap-1.5"> <div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">{typeLabel}</Badge>
<Badge variant="secondary"> <Badge variant="secondary">
{TYPE_LABELS[detail.type] ?? detail.type} {drawerT("revision", { revision: detail.revision })}
</Badge> </Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? ( {detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40"> <Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")} <Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
</Badge> </Badge>
) : ( ) : (
<Badge className="text-[var(--admin-accent-foreground)]"> <Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> Auto-import ready <Sparkles size={10} /> {drawerT("autoImportReady")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -127,22 +127,30 @@ export function FurnitureDetailDrawer({
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3"> <div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]"> <div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<WandSparkles size={12} className="text-[var(--admin-accent)]" /> <WandSparkles size={12} className="text-[var(--admin-accent)]" />
Automatic placement {drawerT("automaticPlacement")}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Category</span> <span className="text-[var(--admin-text-muted)]">
{drawerT("category")}
</span>
<span className="font-semibold text-[var(--admin-text)]"> <span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel} {detailPreview.categoryLabel}
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Price</span> <span className="text-[var(--admin-text-muted)]">
{drawerT("price")}
</span>
<span className="font-semibold text-[var(--admin-text)]"> <span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0 {detailPreview.points > 0
? ` + ${detailPreview.points} points` ? drawerT("priceCreditsPoints", {
: ""} credits: detailPreview.credits,
points: detailPreview.points,
})
: drawerT("priceCredits", {
count: detailPreview.credits,
})}
</span> </span>
</div> </div>
</div> </div>
@@ -163,14 +171,14 @@ export function FurnitureDetailDrawer({
) : ( ) : (
<CloudDownload size={14} /> <CloudDownload size={14} />
)} )}
Import & add to catalog {drawerT("importAdd")}
</Button> </Button>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{!detail.nitroExists && ( {!detail.nitroExists && (
<> <>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]"> <p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but its bundle file is missing. {drawerT("bundleMissing")}
</p> </p>
<Button <Button
variant="outline" variant="outline"
@@ -183,7 +191,7 @@ export function FurnitureDetailDrawer({
) : ( ) : (
<RefreshCw size={14} /> <RefreshCw size={14} />
)} )}
Regenerate .nitro {drawerT("regenerate")}
</Button> </Button>
</> </>
)} )}
@@ -194,7 +202,7 @@ export function FurnitureDetailDrawer({
disabled={!detail.nitroExists} disabled={!detail.nitroExists}
> >
<Pencil size={14} /> <Pencil size={14} />
Edit nitro {drawerT("editNitro")}
</Button> </Button>
</div> </div>
)} )}
@@ -206,7 +214,7 @@ export function FurnitureDetailDrawer({
onClick={onDelete} onClick={onDelete}
> >
<Trash2 size={14} /> <Trash2 size={14} />
Delete imported furni {drawerT("deleteImported")}
</Button> </Button>
)} )}
</div> </div>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
compareFurniture, compareFurniture,
@@ -9,6 +10,21 @@ import { FurnitureCompleteness } from "./furniture-completeness";
import type { FurniItem } from "./studio-types"; import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection"; import { useFurnitureInspection } from "./use-furniture-inspection";
const HEALTH_KEYS = {
complete: "healthComplete",
incomplete: "healthIncomplete",
new: "healthNew",
conflict: "healthConflict",
unknown: "healthUnknown",
} as const;
const FIELD_KEYS = {
Name: "fieldName",
Description: "fieldDescription",
Revision: "fieldRevision",
Type: "fieldType",
} as const;
export function FurnitureComparison({ export function FurnitureComparison({
item, item,
local, local,
@@ -16,69 +32,81 @@ export function FurnitureComparison({
item: FurniItem; item: FurniItem;
local: FurnitureInspection; local: FurnitureInspection;
}) { }) {
const t = useTranslations("admin.studio.inspector");
const comparison = compareFurniture(item, local); const comparison = compareFurniture(item, local);
return ( return (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<FurnitureCompleteness local={local} /> <FurnitureCompleteness local={local} />
{!local.furnidataReadable && ( {!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]"> <p className="text-[var(--admin-warning)]">
Local furnidata could not be read. Its values are not verified. {t("furnidataUnreadable")}
</p> </p>
)} )}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr> <tr>
<th className="p-2 text-left">Field</th> <th className="p-2 text-left">{t("field")}</th>
<th className="p-2 text-left">Local now</th> <th className="p-2 text-left">{t("localNow")}</th>
<th className="p-2 text-left">Source</th> <th className="p-2 text-left">{t("source")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{comparison.fields.map((field) => ( {comparison.fields.map((field) => {
<tr const labelKey =
key={field.label} FIELD_KEYS[field.label as keyof typeof FIELD_KEYS];
className={field.changed ? "bg-[var(--admin-warning)]/10" : ""} return (
> <tr
<th className="p-2 text-left"> key={field.label}
{field.label} className={
{field.changed ? " · Changed" : ""} field.changed ? "bg-[var(--admin-warning)]/10" : ""
</th> }
<td className="max-w-48 break-words p-2"> >
{field.before ?? "—"} <th className="p-2 text-left">
</td> {labelKey ? t(labelKey) : field.label}
<td className="max-w-48 break-words p-2"> {field.changed ? ` · ${t("changed")}` : ""}
{field.after ?? "Not supplied"} </th>
</td> <td className="max-w-48 break-words p-2">
</tr> {field.before ?? "—"}
))} </td>
<td className="max-w-48 break-words p-2">
{field.after ?? t("notSupplied")}
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
Source values are compared with local data. Import may remap IDs and {t("compareNote")}
translate names. Existing furniture keeps its local IDs and SQL
settings; new furniture receives a free local ID when the source ID is
occupied.
</p> </p>
<details> <details>
<summary className="cursor-pointer font-medium"> <summary className="cursor-pointer font-medium">
SQL records and catalog offers {t("sqlRecords")}
</summary> </summary>
<div className="mt-2 space-y-2 text-xs"> <div className="mt-2 space-y-2 text-xs">
{local.sql.length ? ( {local.sql.length ? (
local.sql.map((row) => ( local.sql.map((row) => (
<p key={row.id}> <p key={row.id}>
Item #{row.id} · Sprite #{row.spriteId} · {row.name} {t("itemRow", {
id: row.id,
sprite: row.spriteId,
name: row.name,
})}
</p> </p>
)) ))
) : ( ) : (
<p>No local SQL item.</p> <p>{t("noSqlItem")}</p>
)} )}
{local.catalog.map((row) => ( {local.catalog.map((row) => (
<p key={row.id}> <p key={row.id}>
Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "} {t("offerRow", {
{row.points} points id: row.id,
page: row.pageId,
credits: row.credits,
points: row.points,
})}
</p> </p>
))} ))}
</div> </div>
@@ -95,11 +123,12 @@ export function FurnitureInspector({
onRepair?: () => Promise<unknown>; onRepair?: () => Promise<unknown>;
busy?: boolean; busy?: boolean;
}) { }) {
const t = useTranslations("admin.studio.inspector");
const inspection = useFurnitureInspection([item.classname]); const inspection = useFurnitureInspection([item.classname]);
if (inspection.loading) if (inspection.loading)
return ( return (
<p role="status" className="py-3 text-sm"> <p role="status" className="py-3 text-sm">
Checking local data and assets… {t("checking")}
</p> </p>
); );
if (inspection.error) if (inspection.error)
@@ -107,7 +136,7 @@ export function FurnitureInspector({
<div role="alert"> <div role="alert">
<p>{inspection.error}</p> <p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}> <Button variant="outline" onClick={inspection.retry}>
Retry inspection {t("retry")}
</Button> </Button>
</div> </div>
); );
@@ -118,51 +147,35 @@ export function FurnitureInspector({
return local && health ? ( return local && health ? (
<div className="space-y-3"> <div className="space-y-3">
<section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2"> <section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2">
<strong> <strong>{t(HEALTH_KEYS[health.state])}</strong>
{
{
complete: "Complete",
incomplete: "Needs repair",
new: "Not imported",
conflict: "Local conflict",
unknown: "Not verified",
}[health.state]
}
</strong>
{health.missing.length > 0 && ( {health.missing.length > 0 && (
<p className="text-sm">Missing: {health.missing.join(", ")}</p> <p className="text-sm">
{t("missing", { parts: health.missing.join(", ") })}
</p>
)} )}
{health.state === "conflict" && ( {health.state === "conflict" && (
<p className="text-sm"> <p className="text-sm">{t("conflictNote")}</p>
Duplicate records or mismatched IDs/types require review before
repair.
</p>
)} )}
{health.state === "unknown" && ( {health.state === "unknown" && (
<p className="text-sm"> <p className="text-sm">{t("unknownNote")}</p>
Some local data could not be checked. Retry verification before
repair.
</p>
)} )}
{health.state === "incomplete" && onRepair && ( {health.state === "incomplete" && onRepair && (
<> <>
<p className="text-sm text-[var(--admin-text-muted)]"> <p className="text-sm text-[var(--admin-text-muted)]">
Repair adds missing parts and preserves existing IDs, settings, {t("repairNote")}
furnidata and catalog offers. Missing offers use the automatic
category and price shown below.
</p> </p>
<Button disabled={busy} onClick={() => void onRepair()}> <Button disabled={busy} onClick={() => void onRepair()}>
{busy ? "Repair queued or running…" : "Repair furni"} {busy ? t("repairQueued") : t("repair")}
</Button> </Button>
</> </>
)} )}
<Button variant="outline" disabled={busy} onClick={inspection.retry}> <Button variant="outline" disabled={busy} onClick={inspection.retry}>
Recheck local state {t("recheck")}
</Button> </Button>
</section> </section>
<FurnitureComparison item={item} local={local} /> <FurnitureComparison item={item} local={local} />
</div> </div>
) : ( ) : (
<p>Local inspection unavailable.</p> <p>{t("unavailable")}</p>
); );
} }
+49 -51
View File
@@ -41,6 +41,7 @@ export function ImportReview({
onConfirm: (items: FurniItem[]) => void; onConfirm: (items: FurniItem[]) => void;
}) { }) {
const preflightT = useTranslations("admin.studio.preflight"); const preflightT = useTranslations("admin.studio.preflight");
const reviewT = useTranslations("admin.studio.review");
const [reviewFilter, setReviewFilter] = useState< const [reviewFilter, setReviewFilter] = useState<
"all" | "complete" | "conflict" | "verify" "all" | "complete" | "conflict" | "verify"
>("all"); >("all");
@@ -59,14 +60,16 @@ export function ImportReview({
body: form, body: form,
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(data.error || "Upload failed"); if (!response.ok) throw Error(data.error || reviewT("uploadFailed"));
setAttachments((current) => ({ setAttachments((current) => ({
...current, ...current,
[item.classname]: data.attachmentId, [item.classname]: data.attachmentId,
})); }));
toast.success(diagnosticsT("attached")); toast.success(diagnosticsT("attached"));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed"); toast.error(
error instanceof Error ? error.message : reviewT("uploadFailed"),
);
} finally { } finally {
setUploading(null); setUploading(null);
} }
@@ -134,46 +137,44 @@ export function ImportReview({
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{step === "review" {step === "review"
? "Review furniture import" ? reviewT("titleReview")
: "Confirm furniture import"} : reviewT("titleConfirm")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>{reviewT("steps")}</DialogDescription>
1. Select → 2. Review → 3. Import
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto"> <div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
<div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm"> <div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm">
<p> <p>
<strong>Source:</strong> {source} <strong>{reviewT("source")}</strong> {source}
</p> </p>
<p> <p>
<strong>Destination:</strong> local catalog, furnidata, bundle and <strong>{reviewT("destination")}</strong>{" "}
icons {reviewT("destinationValue")}
</p> </p>
<p> <p>
<strong>Translation:</strong> {translation} <strong>{reviewT("translation")}</strong> {translation}
</p> </p>
<p> <p>
<strong>Selection:</strong> {items.length} furniture items <strong>{reviewT("selection")}</strong>{" "}
{reviewT("selectionCount", { count: items.length })}
</p> </p>
</div> </div>
{assets.loading && !inspection.loading && ( {assets.loading && !inspection.loading && (
<p role="status"> <p role="status">
Checking source files… {assets.items.length} checked {reviewT("checkingSource", { count: assets.items.length })}
</p> </p>
)} )}
{assets.error && ( {assets.error && (
<div role="alert"> <div role="alert">
<p>{assets.error}</p> <p>{assets.error}</p>
<Button variant="outline" onClick={assets.retry}> <Button variant="outline" onClick={assets.retry}>
Retry source check {reviewT("retrySource")}
</Button> </Button>
</div> </div>
)} )}
{assets.items.some((item) => item.state === "unknown") && ( {assets.items.some((item) => item.state === "unknown") && (
<p className="text-sm text-[var(--admin-warning)]"> <p className="text-sm text-[var(--admin-warning)]">
Some sources could not be verified. The import will still try to {reviewT("unknownSources")}
download their files.
</p> </p>
)} )}
{unavailable.map((check) => ( {unavailable.map((check) => (
@@ -182,7 +183,7 @@ export function ImportReview({
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2" className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
> >
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Source files not found by preliminary check: {check.classname} {reviewT("sourceNotFound", { name: check.classname })}
</p> </p>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
{progressT("recoveryHint")} {progressT("recoveryHint")}
@@ -190,24 +191,25 @@ export function ImportReview({
</section> </section>
))} ))}
{inspection.loading ? ( {inspection.loading ? (
<p role="status">Checking local data and assets…</p> <p role="status">{reviewT("checkingLocal")}</p>
) : inspection.error ? ( ) : inspection.error ? (
<div role="alert"> <div role="alert">
<p>{inspection.error}</p> <p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}> <Button variant="outline" onClick={inspection.retry}>
Retry inspection {reviewT("retryInspection")}
</Button> </Button>
</div> </div>
) : !complete ? ( ) : !complete ? (
<p role="alert"> <p role="alert">{reviewT("incomplete")}</p>
Inspection is incomplete. Close and retry before importing.
</p>
) : ( ) : (
<> <>
<p role="status" className="text-sm"> <p role="status" className="text-sm">
{ready.length} ready to import · {existing} already in SQL {reviewT("summary", {
(complete missing data) · {blocked} conflicts ·{" "} ready: ready.length,
{unavailable.length} source file warnings existing,
blocked,
warnings: unavailable.length,
})}
</p> </p>
{blocked > 0 && ( {blocked > 0 && (
<div <div
@@ -232,8 +234,7 @@ export function ImportReview({
item.nitro.exists === null, item.nitro.exists === null,
) && ( ) && (
<p className="text-sm text-[var(--admin-warning)]"> <p className="text-sm text-[var(--admin-warning)]">
Some local files could not be verified. Review the item {reviewT("localUnverified")}
details before continuing.
</p> </p>
)} )}
{step === "review" ? ( {step === "review" ? (
@@ -329,14 +330,14 @@ export function ImportReview({
</span> </span>
<span> <span>
{comparison.state === "new" {comparison.state === "new"
? "New" ? reviewT("statusNew")
: comparison.state === "changed" : comparison.state === "changed"
? "Differences" ? reviewT("statusChanged")
: comparison.state === "conflict" : comparison.state === "conflict"
? "Conflict" ? reviewT("statusConflict")
: comparison.state === "unknown" : comparison.state === "unknown"
? "Not fully verified" ? reviewT("statusUnknown")
: "Present"} : reviewT("statusPresent")}
</span> </span>
</button> </button>
{expanded === item.classname && ( {expanded === item.classname && (
@@ -358,7 +359,9 @@ export function ImportReview({
}} }}
/> />
{uploading === item.classname && ( {uploading === item.classname && (
<span role="status">Validating file…</span> <span role="status">
{reviewT("validatingFile")}
</span>
)} )}
</label> </label>
)} )}
@@ -381,16 +384,10 @@ export function ImportReview({
) : ( ) : (
<> <>
<p className="text-sm"> <p className="text-sm">
Only the {ready.length} eligible items below will be {reviewT("confirmIntro", { count: ready.length })}
submitted. Existing furniture keeps its local IDs, SQL
settings and catalog offers. Missing assets and catalog
entries are completed; furnidata is refreshed. Conflicts are
excluded.
</p> </p>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
Automatic categories and prices below are estimates. Source {reviewT("confirmEstimates")}
rules may override them; final results appear in the import
summary.
</p> </p>
<ul className="space-y-2"> <ul className="space-y-2">
{pageItems.map((item) => { {pageItems.map((item) => {
@@ -405,8 +402,11 @@ export function ImportReview({
> >
<strong>{item.name}</strong> <strong>{item.name}</strong>
<p> <p>
{placement.categoryLabel} · {placement.credits}{" "} {reviewT("placement", {
credits + {placement.points} points category: placement.categoryLabel,
credits: placement.credits,
points: placement.points,
})}
</p> </p>
</li> </li>
); );
@@ -424,17 +424,15 @@ export function ImportReview({
disabled={page === 0} disabled={page === 0}
onClick={() => setPage((value) => value - 1)} onClick={() => setPage((value) => value - 1)}
> >
Previous items {reviewT("previous")}
</Button> </Button>
<span> <span>{reviewT("page", { page: page + 1, total: pageCount })}</span>
Page {page + 1} / {pageCount}
</span>
<Button <Button
variant="outline" variant="outline"
disabled={page + 1 === pageCount} disabled={page + 1 === pageCount}
onClick={() => setPage((value) => value + 1)} onClick={() => setPage((value) => value + 1)}
> >
Next items {reviewT("next")}
</Button> </Button>
</div> </div>
)} )}
@@ -451,7 +449,7 @@ export function ImportReview({
: onCancel : onCancel
} }
> >
{step === "confirm" ? "Back to review" : "Cancel"} {step === "confirm" ? reviewT("backToReview") : reviewT("cancel")}
</Button> </Button>
<Button <Button
style={{ style={{
@@ -480,8 +478,8 @@ export function ImportReview({
}} }}
> >
{step === "review" {step === "review"
? "Continue to confirmation" ? reviewT("continue")
: `Start import (${ready.length})`} : reviewT("startImport", { count: ready.length })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
@@ -15,6 +16,7 @@ function kb(bytes: number): string {
} }
function CompressionPanel({ report }: { report: CompressionReport }) { function CompressionPanel({ report }: { report: CompressionReport }) {
const t = useTranslations("admin.studio.quality");
const { texture, json } = report, const { texture, json } = report,
// Differences that a renderer can actually see. // Differences that a renderer can actually see.
visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences, visibleDiffs = texture.mismatchedPixels - texture.invisibleDifferences,
@@ -26,20 +28,20 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
return ( return (
<section className="min-w-0 space-y-3 rounded-lg border p-3"> <section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold"> <h3 className="font-semibold">
Compression · {texture.format.toUpperCase()} {t("compressionTitle", { format: texture.format.toUpperCase() })}
{texture.format === "webp" {texture.format === "webp"
? texture.lossless ? texture.lossless
? " Lossless (VP8L)" ? ` ${t("losslessVp8l")}`
: " Lossy (VP8)" : ` ${t("lossyVp8")}`
: ""} : ""}
</h3> </h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4"> <dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<div> <div>
<dt className="text-muted-foreground">Bundle</dt> <dt className="text-muted-foreground">{t("bundle")}</dt>
<dd className="font-medium">{kb(report.bundleBytes)}</dd> <dd className="font-medium">{kb(report.bundleBytes)}</dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Texture</dt> <dt className="text-muted-foreground">{t("texture")}</dt>
<dd className="font-medium"> <dd className="font-medium">
{kb(texture.compressedBytes)} {kb(texture.compressedBytes)}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@@ -49,75 +51,85 @@ function CompressionPanel({ report }: { report: CompressionReport }) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Metadata</dt> <dt className="text-muted-foreground">{t("metadata")}</dt>
<dd className="font-medium"> <dd className="font-medium">
{kb(json.compressedBytes)} {kb(json.compressedBytes)}
<span className="text-muted-foreground"> · ×{json.ratio}</span> <span className="text-muted-foreground"> · ×{json.ratio}</span>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Container</dt> <dt className="text-muted-foreground">{t("container")}</dt>
<dd className="font-medium">{kb(report.overheadBytes)}</dd> <dd className="font-medium">{kb(report.overheadBytes)}</dd>
</div> </div>
</dl> </dl>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Texture {kb(texture.rawBytes)} decoded · deflate ×{texture.ratio} · {t("textureDetail", {
sheet {texture.width}×{texture.height} · {texture.spriteCount} frames raw: kb(texture.rawBytes),
ratio: texture.ratio,
width: texture.width,
height: texture.height,
frames: texture.spriteCount,
})}
</p> </p>
{texture.format === "png" ? ( {texture.format === "png" ? (
<p className="text-xs"> <p className="text-xs">
Legacy PNG texture. Re-importing as WebP Lossless would store{" "} {texture.webpEstimate.savingBytes > 0
<strong>{kb(texture.webpEstimate.bytes)}</strong> ? t.rich("legacyPngSaving", {
{texture.webpEstimate.savingBytes > 0 ? ( size: kb(texture.webpEstimate.bytes),
<> saving: kb(texture.webpEstimate.savingBytes),
, saving <strong>{kb(texture.webpEstimate.savingBytes)}</strong> ( percent:
{texture.webpEstimate.bytes > 0 texture.webpEstimate.bytes > 0
? Math.round( ? Math.round(
(1 - texture.webpEstimate.bytes / texture.rawBytes) * 100, (1 - texture.webpEstimate.bytes / texture.rawBytes) *
) 100,
: 0} )
% smaller) : 0,
</> strong: (chunks) => <strong>{chunks}</strong>,
) : ( })
" (no gain on this bundle)" : t.rich("legacyPngNoGain", {
)} size: kb(texture.webpEstimate.bytes),
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p> </p>
) : ( ) : (
<p className="text-xs"> <p className="text-xs">
WebP Lossless stores {kb(texture.rawBytes)} against{" "} {pngSaving > 0
{kb(texture.pngEstimate.bytes)} for the same artwork as PNG ? t.rich("webpSaving", {
{pngSaving > 0 ? ( raw: kb(texture.rawBytes),
<> png: kb(texture.pngEstimate.bytes),
, saving <strong>{kb(pngSaving)}</strong> ( saving: kb(pngSaving),
{texture.pngEstimate.bytes > 0 percent:
? Math.round((pngSaving / texture.pngEstimate.bytes) * 100) texture.pngEstimate.bytes > 0
: 0} ? Math.round((pngSaving / texture.pngEstimate.bytes) * 100)
% smaller) : 0,
</> strong: (chunks) => <strong>{chunks}</strong>,
) : ( })
" (PNG is not larger on this bundle)" : t("webpNoGain", {
)} raw: kb(texture.rawBytes),
png: kb(texture.pngEstimate.bytes),
})}
</p> </p>
)} )}
<p className="text-xs"> <p className="text-xs">
{visibleDiffs === 0 ? ( {visibleDiffs === 0 ? (
<> <>
<span className="font-medium text-foreground">Pixel-exact</span> — {t.rich("pixelExact", {
every visible texel round-trips unchanged. strong: (chunks) => (
<span className="font-medium text-foreground">{chunks}</span>
),
})}
{texture.invisibleDifferences > 0 && ( {texture.invisibleDifferences > 0 && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{" "} {" "}
{texture.invisibleDifferences.toLocaleString()} fully {t("invisibleDiffs", {
transparent texels carry no RGB, which VP8L discards and no count: texture.invisibleDifferences,
renderer can see. })}
</span> </span>
)} )}
</> </>
) : ( ) : (
<strong className="text-destructive"> <strong className="text-destructive">
{visibleDiffs.toLocaleString()} texel {t("texelsDiffer", { count: visibleDiffs })}
{visibleDiffs === 1 ? "" : "s"} differ after a lossless round-trip —
the furniture may not render identically.
</strong> </strong>
)} )}
</p> </p>
@@ -136,32 +148,34 @@ function ScalePanel({
}) { }) {
const [asset, setAsset] = useState(report.assets[0] ?? ""), const [asset, setAsset] = useState(report.assets[0] ?? ""),
[failed, setFailed] = useState(false); [failed, setFailed] = useState(false);
const t = useTranslations("admin.studio.quality");
const stateLabel =
report.state === "missing"
? t("stateMissing")
: report.state === "issues"
? t("stateIssues")
: report.derived
? t("stateDerived")
: t("statePresent");
return ( return (
<section className="min-w-0 space-y-3 rounded-lg border p-3"> <section className="min-w-0 space-y-3 rounded-lg border p-3">
<h3 className="font-semibold"> <h3 className="font-semibold">
Scale {report.size} ·{" "} {t("scaleTitle", { size: report.size, state: stateLabel })}
{report.state === "missing"
? "Not included"
: report.state === "issues"
? "Needs review"
: report.derived
? "Derived from 64"
: "Present"}
</h3> </h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{report.assets.length} sprites · {report.animations.length} animation {t("spritesLine", {
definitions · directions {report.directions.join(", ") || "—"} sprites: report.assets.length,
animations: report.animations.length,
directions: report.directions.join(", ") || "—",
})}
</p> </p>
{report.state === "missing" && ( {report.state === "missing" && (
<p className="text-sm"> <p className="text-sm">{t("missingScale")}</p>
This bundle contains no sprites at this scale. Missing scale 32 does
not by itself mean the furniture is unusable.
</p>
)} )}
{report.assets.length > 0 && ( {report.assets.length > 0 && (
<> <>
<label className="block text-sm"> <label className="block text-sm">
Sprite {t("sprite")}
<select <select
className="mt-1 block w-full rounded border bg-background p-2" className="mt-1 block w-full rounded border bg-background p-2"
value={asset} value={asset}
@@ -179,7 +193,7 @@ function ScalePanel({
</label> </label>
<div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30"> <div className="flex h-56 items-center justify-center overflow-auto rounded border bg-muted/30">
{failed ? ( {failed ? (
<p role="alert">This sprite could not be rendered.</p> <p role="alert">{t("renderFailed")}</p>
) : ( ) : (
<img <img
key={`${asset}-${revision}`} key={`${asset}-${revision}`}
@@ -210,6 +224,7 @@ function ScalePanel({
); );
} }
export function NitroQualityPanel({ classname }: { classname: string }) { export function NitroQualityPanel({ classname }: { classname: string }) {
const t = useTranslations("admin.studio.quality");
const [report, setReport] = useState<NitroQuality | null>(null), const [report, setReport] = useState<NitroQuality | null>(null),
[error, setError] = useState(""), [error, setError] = useState(""),
[revision, setRevision] = useState(0); [revision, setRevision] = useState(0);
@@ -224,35 +239,32 @@ export function NitroQualityPanel({ classname }: { classname: string }) {
{ signal: controller.signal }, { signal: controller.signal },
); );
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(data.error || "Inspection failed"); if (!response.ok) throw Error(data.error || t("inspectionFailed"));
if (!controller.signal.aborted) setReport(data.report); if (!controller.signal.aborted) setReport(data.report);
} catch (error) { } catch (error) {
if (!controller.signal.aborted) if (!controller.signal.aborted)
setError( setError(
error instanceof Error ? error.message : "Inspection failed", error instanceof Error ? error.message : t("inspectionFailed"),
); );
} }
})(); })();
return () => controller.abort(); return () => controller.abort();
}, [classname, revision]); }, [classname, revision, t]);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-sm"> <p className="text-sm">{t("intro")}</p>
Inspect the saved bundle at both scales. Previews show individual
sprites, not the assembled furniture or animation playback.
</p>
<Button <Button
variant="outline" variant="outline"
onClick={() => setRevision((value) => value + 1)} onClick={() => setRevision((value) => value + 1)}
> >
Recheck {t("recheck")}
</Button> </Button>
</div> </div>
{error ? ( {error ? (
<p role="alert">{error}</p> <p role="alert">{error}</p>
) : !report ? ( ) : !report ? (
<p role="status">Checking scales and texture references…</p> <p role="status">{t("checking")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{report.compression && ( {report.compression && (
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
@@ -11,6 +12,7 @@ export function NitroScale32Generator({
disabled: boolean; disabled: boolean;
onChanged: (metadata: Record<string, unknown>) => void; onChanged: (metadata: Record<string, unknown>) => void;
}) { }) {
const t = useTranslations("admin.studio.scale32");
const [preview, setPreview] = useState<{ const [preview, setPreview] = useState<{
hash: string; hash: string;
before: string; before: string;
@@ -38,7 +40,7 @@ export function NitroScale32Generator({
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(data.error || "Generation failed"); if (!response.ok) throw Error(data.error || t("generationFailed"));
if (action === "preview") setPreview(data); if (action === "preview") setPreview(data);
else { else {
setPreview(null); setPreview(null);
@@ -50,45 +52,37 @@ export function NitroScale32Generator({
onChanged(data.metadata); onChanged(data.metadata);
} }
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Generation failed"); setError(e instanceof Error ? e.message : t("generationFailed"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
} }
return ( return (
<section className="space-y-3 rounded-lg border p-3"> <section className="space-y-3 rounded-lg border p-3">
<h3 className="font-semibold">Generate scale 32 from 64</h3> <h3 className="font-semibold">{t("title")}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t("intro")}</p>
Adds derived artwork using nearest-neighbor reduction. Existing scale 32 {disabled && <p className="text-sm">{t("saveFirst")}</p>}
is preserved. Preview uses the saved bundle and shows one sprite at its
original pixel size.
</p>
{disabled && (
<p className="text-sm">Save or reset your pending edits first.</p>
)}
<Button <Button
variant="outline" variant="outline"
disabled={busy || disabled} disabled={busy || disabled}
onClick={() => run("preview")} onClick={() => run("preview")}
> >
{busy ? "Working…" : "Preview generation"} {busy ? t("working") : t("preview")}
</Button> </Button>
{preview && ( {preview && (
<> <>
<p className="text-sm"> <p className="text-sm">
{preview.count} sprite entries will be added. Check positioning and {t("previewCount", { count: preview.count })}
animations in the client after saving.
</p> </p>
{preview.skipped ? ( {preview.skipped ? (
<p className="text-sm text-destructive" role="alert"> <p className="text-sm text-destructive" role="alert">
{preview.skipped} sprite(s) were skipped because the source bundle {t("skipped", { count: preview.skipped })}
has no usable artwork for them.
</p> </p>
) : null} ) : null}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{[ {[
["Original 64", preview.before], [t("original64"), preview.before],
["Derived 32", preview.after], [t("derived32"), preview.after],
].map(([label, src]) => ( ].map(([label, src]) => (
<figure key={label} className="min-w-0"> <figure key={label} className="min-w-0">
<figcaption>{label}</figcaption> <figcaption>{label}</figcaption>
@@ -103,28 +97,26 @@ export function NitroScale32Generator({
))} ))}
</div> </div>
<Button disabled={busy || disabled} onClick={() => run("apply")}> <Button disabled={busy || disabled} onClick={() => run("apply")}>
Save with backup {t("saveWithBackup")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
disabled={busy} disabled={busy}
onClick={() => setPreview(null)} onClick={() => setPreview(null)}
> >
Discard preview {t("discard")}
</Button> </Button>
</> </>
)} )}
{undo && ( {undo && (
<div> <div>
<p className="text-sm"> <p className="text-sm">{t("saved")}</p>
Saved. A copy of the original bundle is preserved.
</p>
<Button <Button
variant="outline" variant="outline"
disabled={busy || disabled} disabled={busy || disabled}
onClick={() => run("restore")} onClick={() => run("restore")}
> >
Restore original bundle {t("restore")}
</Button> </Button>
</div> </div>
)} )}
@@ -851,10 +851,10 @@ export function OrganizeImportsDialog({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="0" className="text-xs"> <SelectItem value="0" className="text-xs">
Duckets {t("pointsTypeDuckets")}
</SelectItem> </SelectItem>
<SelectItem value="5" className="text-xs"> <SelectItem value="5" className="text-xs">
Diamonds {t("pointsTypeDiamonds")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1571,7 +1571,7 @@ export function OrganizeImportsDialog({
</span> </span>
</div> </div>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{previewGroup.items.length} items {previewGroup.items.length} {t("itemsUnit")}
</Badge> </Badge>
</div> </div>
{previewGroup.layout && {previewGroup.layout &&
@@ -1,6 +1,7 @@
"use client"; "use client";
import { FolderTree } from "lucide-react"; import { FolderTree } from "lucide-react";
import { useTranslations } from "next-intl";
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { StudioPanelLoading } from "./studio-panel-loading"; import { StudioPanelLoading } from "./studio-panel-loading";
@@ -11,6 +12,7 @@ const OrganizeImportsDialog = lazy(() =>
); );
export function OrganizeImportsLauncher() { export function OrganizeImportsLauncher() {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [requested, setRequested] = useState(false); const [requested, setRequested] = useState(false);
return ( return (
@@ -24,7 +26,7 @@ export function OrganizeImportsLauncher() {
className="flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10" className="flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10"
> >
<FolderTree size={16} /> <FolderTree size={16} />
Organize imports {t("title")}
</button> </button>
{requested && ( {requested && (
<Suspense <Suspense
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,37 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
interface StudioPageProps {
icon?: LucideIcon;
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}
/**
* Consistent chrome for Catalog Studio sub-pages: the shared admin page
* header (icon tile, title, subtitle, optional actions) with the studio's
* standard padding, so every tool page lines up the same way.
*/
export function StudioPage({
icon,
title,
subtitle,
actions,
children,
}: StudioPageProps) {
return (
<div className="p-3 sm:p-4">
<AdminPageShell
icon={icon}
title={title}
subtitle={subtitle}
actions={actions}
>
{children}
</AdminPageShell>
</div>
);
}
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { setFurnidataTranslateEnabled } from "@/actions/import-furni"; import { setFurnidataTranslateEnabled } from "@/actions/import-furni";
@@ -24,6 +25,7 @@ const TRANSLATE_LANGS = [
] as const; ] as const;
export function useStudioTranslations(defaultTranslate: boolean) { export function useStudioTranslations(defaultTranslate: boolean) {
const t = useTranslations("admin.studio");
// Translation controls (per-import toggle + language selection, plus a global // Translation controls (per-import toggle + language selection, plus a global
// default that persists to the site setting and seeds the per-import toggle). // default that persists to the site setting and seeds the per-import toggle).
const ALL_LANGS = TRANSLATE_LANGS as readonly string[]; const ALL_LANGS = TRANSLATE_LANGS as readonly string[];
@@ -52,13 +54,16 @@ export function useStudioTranslations(defaultTranslate: boolean) {
const langs = data.languages ?? []; const langs = data.languages ?? [];
const okCount = langs.filter((l) => l.ok).length; const okCount = langs.filter((l) => l.ok).length;
toast.success( toast.success(
`Languages translated (${okCount}/${langs.length} completed)`, t("toasts.languagesTranslated", {
ok: okCount,
total: langs.length,
}),
); );
} else { } else {
toast.error(data.error ?? "Translation failed"); toast.error(data.error ?? t("toasts.translationFailed"));
} }
} catch { } catch {
toast.error("Network error while translating languages"); toast.error(t("toasts.networkTranslating"));
} finally { } finally {
setBuildingLanguages(false); setBuildingLanguages(false);
} }
@@ -75,7 +80,7 @@ export function useStudioTranslations(defaultTranslate: boolean) {
try { try {
await setFurnidataTranslateEnabled({ enabled: next }); await setFurnidataTranslateEnabled({ enabled: next });
} catch { } catch {
toast.error("Could not save the default translation setting"); toast.error(t("toasts.saveDefaultFailed"));
} }
}; };
+412
View File
@@ -2570,6 +2570,8 @@
"credits": "Credits", "credits": "Credits",
"points": "Points", "points": "Points",
"pointsType": "Type", "pointsType": "Type",
"pointsTypeDuckets": "Duckets",
"pointsTypeDiamonds": "Diamonds",
"newPriceHint": "Applies to new offers only; moved offers keep their current price.", "newPriceHint": "Applies to new offers only; moved offers keep their current price.",
"parent": "Place pages under", "parent": "Place pages under",
"root": "Catalog root", "root": "Catalog root",
@@ -6801,6 +6803,416 @@
}, },
"missing": "Missing: {parts}", "missing": "Missing: {parts}",
"unknown": "Not verified: {parts}" "unknown": "Not verified: {parts}"
},
"rail": {
"title": "Categories",
"all": "All categories",
"noMatches": "No matching categories.",
"refreshFailed": "Catalog could not refresh.",
"retry": "Retry",
"updating": "Updating catalog…"
},
"nav": {
"ariaLabel": "Catalog Studio",
"furniture": "Furniture",
"badges": "Badges",
"clothing": "Clothing",
"effects": "Effects",
"pets": "Pets",
"uploadFiles": "Upload files",
"importFromSource": "Import from a source",
"syncRepository": "Sync & repository",
"catalogAudit": "Catalog audit",
"repairIcons": "Repair icons",
"catalogMaintenance": "Catalog maintenance",
"nitroCleanup": "Nitro cleanup",
"catalogManager": "Catalog manager",
"importSync": "Import & sync",
"maintenance": "Maintenance"
},
"aria": {
"search": "Search furniture",
"searchPlaceholder": "Search furni, classnames, keywords… (/)",
"clearSearch": "Clear search",
"hideCatalogTree": "Hide catalog tree",
"showCatalogTree": "Show catalog tree",
"source": "Furniture source",
"type": "Furniture type",
"category": "Furniture category",
"status": "Import status",
"sort": "Sort furniture",
"importSpeed": "Import speed",
"translateImport": "Translate this import",
"defaultTranslation": "Default translation",
"selection": "Furniture selection",
"results": "Furniture results",
"gridView": "Grid view",
"listView": "List view",
"selectAll": "Select all",
"languageLabel": "Language {lang}",
"selectItem": "Select {name}",
"deselectItem": "Deselect {name}",
"viewItem": "View {name}",
"importItem": "Import {name}",
"editNitro": "Edit nitro {name}",
"regenerateNitro": "Regenerate nitro {name}",
"scrollToTop": "Scroll to top",
"closeCategoryPanel": "Close category panel",
"catalogCategories": "Catalog categories",
"closeCategories": "Close categories",
"findCategory": "Find a category",
"expandCategory": "Expand {name}",
"collapseCategory": "Collapse {name}",
"closeDetails": "Close furniture details"
},
"workspace": {
"title": "Studio",
"furnidata": "furnidata",
"catalog": "Catalog",
"results": "{count} results",
"statInDb": "{count} in DB",
"statInCatalog": "{count} in catalog",
"statMissingNitro": "{count} missing nitro",
"statTotal": "{count} total",
"statPresent": "{count} present",
"statToImport": "{count} to import",
"official": "(official)"
},
"types": {
"floor": "Floor",
"wall": "Wall"
},
"filters": {
"allTypes": "All types",
"floorItems": "Floor items",
"wallItems": "Wall items",
"allCategories": "All categories",
"allStatuses": "All statuses",
"allStatusesCount": "All statuses ({count} available to import)",
"statuses": {
"notImported": "Not imported",
"imported": "Imported",
"inCatalog": "In catalog",
"missingNitro": "Missing nitro",
"missingFurnidata": "Missing furnidata",
"missingCatalog": "Missing catalog entry"
},
"sorts": {
"default": "Default order",
"name": "Name A–Z",
"classname": "Classname A–Z",
"category": "Category",
"importedFirst": "Imported first",
"notImportedFirst": "Not imported first"
},
"reset": "Reset filters",
"noNitro": "{count} no nitro",
"noFurnidata": "{count} no furnidata"
},
"importOptions": {
"summary": "Import options",
"translationOn": "Translation on",
"translationOff": "Translation off",
"speed": "Speed",
"parallel": "{count} parallel",
"gentle": "1 (gentle)",
"recommended": "4 (recommended)",
"turbo": "12 (turbo)",
"translate": "Translate",
"noLanguages": "No languages",
"allLanguages": "All languages",
"languagesCount": "{count} languages",
"languages": "Languages",
"all": "All",
"none": "None",
"translateAllTitle": "Translate the entire catalog into all languages",
"translateAll": "Translate entire catalog",
"defaultFuture": "Default for future imports"
},
"selection": {
"selectVisible": "Select visible",
"deselectVisible": "Deselect visible",
"loadingAll": "Loading all…",
"selectAllResults": "Select all {count} results",
"selectedInView": "{count} selected in this view",
"clear": "Clear",
"preparingReview": "Preparing review…",
"importAllMissing": "Import all missing ({count})",
"importSelected": "Import selected ({count})",
"rebuildNitro": "Rebuild Nitro ({count})",
"addMissingFurnidata": "Add missing furnidata ({count})",
"deleteSelected": "Delete ({count})",
"hiddenWarning": "Some selected furniture is hidden by your filters. Actions apply to the visible selection."
},
"items": {
"tryAgain": "Try again",
"noFound": "No furni found for “{query}”",
"noFurni": "No furni to show yet",
"noFoundHint": "Try a different search or clear your filters.",
"noFurniHint": "Search a furni library to get started.",
"noNitro": "No nitro",
"noFurnidata": "No furnidata",
"noCatalog": "No catalog",
"imported": "Imported",
"new": "New",
"colName": "Name",
"colClassname": "Classname",
"colType": "Type",
"colCategory": "Category",
"colStatus": "Status",
"colActions": "Actions",
"loadingMore": "Loading more…",
"scrollForMore": "Scroll for more",
"endOfResults": "End of results",
"defaultLanguages": "Default languages",
"off": "Off"
},
"deleteDialog": {
"title": "Delete imported furni",
"body": "This permanently removes {name} from items_base and the catalog, plus its asset files.",
"cancel": "Cancel",
"delete": "Delete"
},
"drawer": {
"title": "Furni detail",
"revision": "rev {revision}",
"autoImportReady": "Auto-import ready",
"automaticPlacement": "Automatic placement",
"category": "Category",
"price": "Price",
"priceCredits": "{count} credits",
"priceCreditsPoints": "{credits} credits + {points} points",
"importAdd": "Import & add to catalog",
"bundleMissing": "This furni is in the DB but its bundle file is missing.",
"regenerate": "Regenerate .nitro",
"editNitro": "Edit nitro",
"deleteImported": "Delete imported furni"
},
"review": {
"titleReview": "Review furniture import",
"titleConfirm": "Confirm furniture import",
"steps": "1. Select → 2. Review → 3. Import",
"source": "Source:",
"destination": "Destination:",
"destinationValue": "local catalog, furnidata, bundle and icons",
"translation": "Translation:",
"selection": "Selection:",
"selectionCount": "{count} furniture items",
"checkingSource": "Checking source files… {count} checked",
"retrySource": "Retry source check",
"unknownSources": "Some sources could not be verified. The import will still try to download their files.",
"sourceNotFound": "Source files not found by preliminary check: {name}",
"checkingLocal": "Checking local data and assets…",
"retryInspection": "Retry inspection",
"incomplete": "Inspection is incomplete. Close and retry before importing.",
"summary": "{ready} ready to import · {existing} already in SQL (complete missing data) · {blocked} conflicts · {warnings} source file warnings",
"localUnverified": "Some local files could not be verified. Review the item details before continuing.",
"statusNew": "New",
"statusChanged": "Differences",
"statusConflict": "Conflict",
"statusUnknown": "Not fully verified",
"statusPresent": "Present",
"validatingFile": "Validating file…",
"confirmIntro": "Only the {count} eligible items below will be submitted. Existing furniture keeps its local IDs, SQL settings and catalog offers. Missing assets and catalog entries are completed; furnidata is refreshed. Conflicts are excluded.",
"confirmEstimates": "Automatic categories and prices below are estimates. Source rules may override them; final results appear in the import summary.",
"placement": "{category} · {credits} credits + {points} points",
"previous": "Previous items",
"page": "Page {page} / {total}",
"next": "Next items",
"backToReview": "Back to review",
"cancel": "Cancel",
"continue": "Continue to confirmation",
"startImport": "Start import ({count})",
"uploadFailed": "Upload failed"
},
"inspector": {
"furnidataUnreadable": "Local furnidata could not be read. Its values are not verified.",
"field": "Field",
"localNow": "Local now",
"source": "Source",
"fieldName": "Name",
"fieldDescription": "Description",
"fieldRevision": "Revision",
"fieldType": "Type",
"changed": "Changed",
"notSupplied": "Not supplied",
"compareNote": "Source values are compared with local data. Import may remap IDs and translate names. Existing furniture keeps its local IDs and SQL settings; new furniture receives a free local ID when the source ID is occupied.",
"sqlRecords": "SQL records and catalog offers",
"noSqlItem": "No local SQL item.",
"itemRow": "Item #{id} · Sprite #{sprite} · {name}",
"offerRow": "Offer #{id} · Page #{page} · {credits} credits + {points} points",
"healthComplete": "Complete",
"healthIncomplete": "Needs repair",
"healthNew": "Not imported",
"healthConflict": "Local conflict",
"healthUnknown": "Not verified",
"missing": "Missing: {parts}",
"conflictNote": "Duplicate records or mismatched IDs/types require review before repair.",
"unknownNote": "Some local data could not be checked. Retry verification before repair.",
"repairNote": "Repair adds missing parts and preserves existing IDs, settings, furnidata and catalog offers. Missing offers use the automatic category and price shown below.",
"repairQueued": "Repair queued or running…",
"repair": "Repair furni",
"recheck": "Recheck local state",
"checking": "Checking local data and assets…",
"retry": "Retry inspection",
"unavailable": "Local inspection unavailable."
},
"quality": {
"compressionTitle": "Compression · {format}",
"losslessVp8l": "Lossless (VP8L)",
"lossyVp8": "Lossy (VP8)",
"bundle": "Bundle",
"texture": "Texture",
"metadata": "Metadata",
"container": "Container",
"textureDetail": "Texture {raw} decoded · deflate ×{ratio} · sheet {width}×{height} · {frames} frames",
"legacyPngSaving": "Legacy PNG texture. Re-importing as WebP Lossless would store <strong>{size}</strong>, saving <strong>{saving}</strong> ({percent}% smaller)",
"legacyPngNoGain": "Legacy PNG texture. Re-importing as WebP Lossless would store <strong>{size}</strong> (no gain on this bundle)",
"webpSaving": "WebP Lossless stores {raw} against {png} for the same artwork as PNG, saving <strong>{saving}</strong> ({percent}% smaller)",
"webpNoGain": "WebP Lossless stores {raw} against {png} for the same artwork as PNG (PNG is not larger on this bundle)",
"pixelExact": "<strong>Pixel-exact</strong> — every visible texel round-trips unchanged.",
"invisibleDiffs": "{count} fully transparent texels carry no RGB, which VP8L discards and no renderer can see.",
"texelsDiffer": "{count} {count, plural, one {texel} other {texels}} differ after a lossless round-trip — the furniture may not render identically.",
"scaleTitle": "Scale {size} · {state}",
"stateMissing": "Not included",
"stateIssues": "Needs review",
"stateDerived": "Derived from 64",
"statePresent": "Present",
"spritesLine": "{sprites} sprites · {animations} animation definitions · directions {directions}",
"missingScale": "This bundle contains no sprites at this scale. Missing scale 32 does not by itself mean the furniture is unusable.",
"sprite": "Sprite",
"renderFailed": "This sprite could not be rendered.",
"intro": "Inspect the saved bundle at both scales. Previews show individual sprites, not the assembled furniture or animation playback.",
"recheck": "Recheck",
"checking": "Checking scales and texture references…",
"inspectionFailed": "Inspection failed"
},
"sync": {
"pageSubtitle": "Keep the catalog in sync with your sources and export it to the repository.",
"cloneSourcesTitle": "Clone sources",
"cloneSourcesBody": "Scan all configured clone sources and automatically import any furniture that is not yet present in your hotel.",
"officialFurnidataTitle": "Official furnidata",
"officialFurnidataBody": "Scan the official Habbo furnidata and automatically import any furniture that your hotel doesn't have yet.",
"gitTitle": "Gitea connection settings",
"gitIntro": "Connect your self-hosted Gitea repository over HTTPS. The CMS manages its own checkout; no Git-specific .env settings are needed.",
"providerLabel": "Provider:",
"providerValue": "Gitea self-hosted",
"repositoryUrl": "Repository URL",
"branch": "Branch",
"username": "Gitea username",
"accessToken": "Gitea access token",
"tokenSaved": "Token saved — leave blank to keep it",
"tokenEnter": "Enter a token with repository write access",
"tokenNote": "The token is stored encrypted and is never returned to the browser. Changing repository or username requires entering the token again.",
"autoExport": "Automatically export catalog changes",
"testConnection": "Test connection",
"working": "Working…",
"saveSettings": "Save Gitea settings",
"loadFailed": "Unable to read Git settings. Settings edit permission is required.",
"requestFailed": "Git configuration request failed",
"saved": "Gitea configuration saved."
},
"scale32": {
"title": "Generate scale 32 from 64",
"intro": "Adds derived artwork using nearest-neighbor reduction. Existing scale 32 is preserved. Preview uses the saved bundle and shows one sprite at its original pixel size.",
"saveFirst": "Save or reset your pending edits first.",
"working": "Working…",
"preview": "Preview generation",
"previewCount": "{count} sprite entries will be added. Check positioning and animations in the client after saving.",
"skipped": "{count} sprite(s) were skipped because the source bundle has no usable artwork for them.",
"original64": "Original 64",
"derived32": "Derived 32",
"saveWithBackup": "Save with backup",
"discard": "Discard preview",
"saved": "Saved. A copy of the original bundle is preserved.",
"restore": "Restore original bundle",
"generationFailed": "Generation failed"
},
"pages": {
"auditSubtitle": "Scan your entire furniture catalog for missing entries, broken references, missing files, and items available from clone sources.",
"badgesSubtitle": "Search hotels and import badge designs into your hotel.",
"cloneSubtitle": "Clone furniture from your configured external sources.",
"clothingSubtitle": "Browse and import wearable clothing assets for your hotel.",
"effectsSubtitle": "Import effect definitions and their sprite assets.",
"maintenanceSubtitle": "Detect and repair catalog integrity problems, and run legacy maintenance tasks.",
"nitroCleanupSubtitle": "Scan the nitro directories for fake bundles (no matching furniture) and broken bundles (unparseable) and remove them.",
"petsSubtitle": "Import pet types, races and their sprite assets.",
"repairIconsTitle": "Repair missing icons",
"repairIconsSubtitle": "Scan all existing furniture and download any missing icon files. Icons are extracted from local .nitro bundles or fetched from configured clone sources.",
"uploadTitle": "Upload .hab furniture",
"uploadSubtitle": "Upload a .hab bundle and optional icon to import furniture into the hotel. The database entry, catalog item, and FurnitureData.json will be updated automatically."
},
"confirm": {
"deleteTitle": "Delete {count} items?",
"deleteDescription": "All {count} selected imported items will be permanently removed from the database and the filesystem.",
"deleteConfirm": "Delete"
},
"batch": {
"ariaProgress": "Import progress",
"ariaProcessed": "Furniture processed",
"headingStopping": "Stopping import…",
"headingImporting": "Importing furniture…",
"headingStopped": "Import stopped",
"headingCompletedErrors": "Import completed with errors",
"headingCompleted": "Import completed",
"imported": "imported",
"failed": "failed",
"notCompleted": "not completed",
"remaining": "remaining",
"retryFailed": "Retry failed ({count})",
"closeSummary": "Close summary",
"stopImport": "Stop import",
"verificationIssues": "{count, plural, one {# catalog data issue still needs attention. Open Catalog audit to review it.} other {# catalog data issues still need attention. Open Catalog audit to review them.}}",
"verificationDone": "Catalog data verification completed.",
"chipFurnidataIds": "furnidata ids fixed",
"chipOfferIds": "offer ids reconciled",
"chipOwnerships": "ownerships fixed",
"chipNitros": "nitros synced",
"chipIcons": "icons synced",
"viewItemDetails": "View item details ({count})",
"showingItems": "Showing {shown} of {total} items",
"status": {
"pending": "Waiting",
"started": "Starting",
"downloading": "Downloading assets",
"converting": "Preparing assets",
"writingDb": "Saving",
"done": "Imported",
"failed": "Failed"
},
"statusNotCompleted": "Not completed"
},
"toasts": {
"allImported": "All items from this source are already imported",
"selectNotImported": "Select furni that isn't imported yet",
"loadMissingFailed": "Could not load missing furniture",
"prepareFailed": "Could not prepare the full selection",
"sourceChanged": "The source changed while preparing the selection. Refresh and retry.",
"batchFailed": "Batch import failed ({status})",
"importCancelled": "Import cancelled",
"importTimeout": "Import timed out. Progress was saved — open Import History to resume failed items.",
"networkUpload": "Network error during upload. Progress was saved — open Import History to resume failed items.",
"unexpectedImport": "Unexpected import error: {message}",
"retryFailed": "Retry failed ({status})",
"importedAgain": "{count} items imported again",
"retryConnection": "Connection failed while retrying",
"deleteFailed": "Delete failed",
"networkError": "Network error",
"deleted": "Deleted {name} ({count} files)",
"regenerated": "Nitro file regenerated for {name}",
"regenerationFailed": "Regeneration failed",
"bulkDeleted": "Deleted: {count}",
"bulkDeletedFailed": "Deleted: {deleted}, failed: {failed}",
"bulkDeleteFailed": "Bulk delete failed",
"regeneratedBulk": "Nitro regenerated: {count}",
"regeneratedBulkFailed": "Nitro regenerated: {succeeded}, failed: {failed}",
"furnidataAdded": "Furnidata added: {count}",
"furnidataAddedFailed": "Furnidata added: {fixed}, failed: {failed}",
"addFurnidataFailed": "Adding furnidata failed",
"languagesTranslated": "Languages translated ({ok}/{total} completed)",
"translationFailed": "Translation failed",
"networkTranslating": "Network error while translating languages",
"saveDefaultFailed": "Could not save the default translation setting"
} }
} }
}, },