From 56676effc3ec5989d70fcd7543007624fd6f9383 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 13 Oct 2026 20:31:26 +0200 Subject: [PATCH] feat(studio): localize catalog studio UI and polish responsive layout Route all Catalog Studio strings through the admin.studio namespace (workspace, filters, review, inspector, quality, sync, scale32, organize imports and page titles/subtitles) with English fallback, and add shared StudioPage chrome plus a loading skeleton. Tighten mobile padding on the header and sub-bars. --- src/app/admin/studio/audit/page.tsx | 19 +- src/app/admin/studio/badges/page.tsx | 12 +- src/app/admin/studio/clone/page.tsx | 12 +- src/app/admin/studio/clothing/page.tsx | 12 +- src/app/admin/studio/effects/page.tsx | 12 +- src/app/admin/studio/loading.tsx | 30 ++ src/app/admin/studio/maintenance/page.tsx | 37 +- src/app/admin/studio/nitro-cleanup/page.tsx | 19 +- src/app/admin/studio/pets/page.tsx | 12 +- src/app/admin/studio/repair-icons/page.tsx | 22 +- src/app/admin/studio/studio-layout-client.tsx | 63 ++- .../studio/sync/catalog-git-configuration.tsx | 44 +- src/app/admin/studio/sync/page.tsx | 59 +- src/app/admin/studio/upload/page.tsx | 22 +- src/components/admin/studio/README.md | 4 + .../admin/studio/batch-progress.tsx | 69 +-- src/components/admin/studio/catalog-rail.tsx | 29 +- .../admin/studio/furniture-detail-drawer.tsx | 50 +- .../admin/studio/furniture-inspector.tsx | 129 +++-- src/components/admin/studio/import-review.tsx | 100 ++-- .../admin/studio/nitro-quality-panel.tsx | 152 +++--- .../admin/studio/nitro-scale32-generator.tsx | 40 +- .../admin/studio/organize-imports-dialog.tsx | 6 +- .../studio/organize-imports-launcher.tsx | 4 +- src/components/admin/studio/studio-client.tsx | 507 ++++++++++-------- src/components/admin/studio/studio-page.tsx | 37 ++ .../admin/studio/use-studio-translations.ts | 13 +- src/messages/en.json | 412 ++++++++++++++ 28 files changed, 1293 insertions(+), 634 deletions(-) create mode 100644 src/app/admin/studio/loading.tsx create mode 100644 src/components/admin/studio/studio-page.tsx diff --git a/src/app/admin/studio/audit/page.tsx b/src/app/admin/studio/audit/page.tsx index d655b260..e929fc39 100644 --- a/src/app/admin/studio/audit/page.tsx +++ b/src/app/admin/studio/audit/page.tsx @@ -1,5 +1,8 @@ +import { ClipboardCheck } from "lucide-react"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { AuditClient } from "@/app/admin/import/audit/audit-client"; +import { StudioPage } from "@/components/admin/studio/studio-page"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function StudioAuditPage(_props: { @@ -9,16 +12,14 @@ export default async function StudioAuditPage(_props: { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } + const t = await getTranslations("admin.studio"); return ( -
-
-

Catalog Audit

-

- Scan your entire furniture catalog for missing entries, broken - references, missing files, and items available from clone sources. -

-
+ -
+ ); } diff --git a/src/app/admin/studio/badges/page.tsx b/src/app/admin/studio/badges/page.tsx index de779611..b597aeda 100644 --- a/src/app/admin/studio/badges/page.tsx +++ b/src/app/admin/studio/badges/page.tsx @@ -1,5 +1,8 @@ +import { BadgeCheck } from "lucide-react"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client"; +import { StudioPage } from "@/components/admin/studio/studio-page"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function StudioBadgesPage(_props: { @@ -9,9 +12,14 @@ export default async function StudioBadgesPage(_props: { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } + const t = await getTranslations("admin.studio"); return ( -
+ -
+ ); } diff --git a/src/app/admin/studio/clone/page.tsx b/src/app/admin/studio/clone/page.tsx index a626098e..0c281d98 100644 --- a/src/app/admin/studio/clone/page.tsx +++ b/src/app/admin/studio/clone/page.tsx @@ -1,5 +1,8 @@ +import { Copy } from "lucide-react"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client"; +import { StudioPage } from "@/components/admin/studio/studio-page"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function StudioClonePage(_props: { @@ -9,9 +12,14 @@ export default async function StudioClonePage(_props: { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } + const t = await getTranslations("admin.studio"); return ( -
+ -
+ ); } diff --git a/src/app/admin/studio/clothing/page.tsx b/src/app/admin/studio/clothing/page.tsx index 342bfee7..26544c20 100644 --- a/src/app/admin/studio/clothing/page.tsx +++ b/src/app/admin/studio/clothing/page.tsx @@ -1,5 +1,8 @@ +import { Shirt } from "lucide-react"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client"; +import { StudioPage } from "@/components/admin/studio/studio-page"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function StudioClothingPage(_props: { @@ -9,9 +12,14 @@ export default async function StudioClothingPage(_props: { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } + const t = await getTranslations("admin.studio"); return ( -
+ -
+ ); } diff --git a/src/app/admin/studio/effects/page.tsx b/src/app/admin/studio/effects/page.tsx index 3ef39a6c..db57e65e 100644 --- a/src/app/admin/studio/effects/page.tsx +++ b/src/app/admin/studio/effects/page.tsx @@ -1,5 +1,8 @@ +import { Dumbbell } from "lucide-react"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client"; +import { StudioPage } from "@/components/admin/studio/studio-page"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function StudioEffectsPage(_props: { @@ -9,9 +12,14 @@ export default async function StudioEffectsPage(_props: { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } + const t = await getTranslations("admin.studio"); return ( -
+ -
+ ); } diff --git a/src/app/admin/studio/loading.tsx b/src/app/admin/studio/loading.tsx new file mode 100644 index 00000000..19191038 --- /dev/null +++ b/src/app/admin/studio/loading.tsx @@ -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 ( +
+
+
+
+ +
+ + +
+
+
+
+ + + + +
+
+
+ ); +} diff --git a/src/app/admin/studio/maintenance/page.tsx b/src/app/admin/studio/maintenance/page.tsx index 9cd1e8d5..1828ef3a 100644 --- a/src/app/admin/studio/maintenance/page.tsx +++ b/src/app/admin/studio/maintenance/page.tsx @@ -1,7 +1,9 @@ +import { Wrench } from "lucide-react"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel"; import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel"; +import { StudioPage } from "@/components/admin/studio/studio-page"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function StudioCatalogMaintenancePage() { @@ -16,20 +18,27 @@ export default async function StudioCatalogMaintenancePage() { session.user.rank, ); const t = await getTranslations("pages.admin.catalog.integrity"); + const studioT = await getTranslations("admin.studio"); return ( -
- - {canRepair && ( -
- - {t("legacyTitle")} - -

- {t("legacyHint")} -

- -
- )} -
+ +
+ + {canRepair && ( +
+ + {t("legacyTitle")} + +

+ {t("legacyHint")} +

+ +
+ )} +
+
); } diff --git a/src/app/admin/studio/nitro-cleanup/page.tsx b/src/app/admin/studio/nitro-cleanup/page.tsx index c8915a4e..426ba769 100644 --- a/src/app/admin/studio/nitro-cleanup/page.tsx +++ b/src/app/admin/studio/nitro-cleanup/page.tsx @@ -1,5 +1,8 @@ +import { FileX2 } from "lucide-react"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel"; +import { StudioPage } from "@/components/admin/studio/studio-page"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function StudioNitroCleanupPage() { @@ -7,16 +10,14 @@ export default async function StudioNitroCleanupPage() { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } + const t = await getTranslations("admin.studio"); return ( -
-
-

Nitro cleanup

-

- Scan the nitro directories for fake bundles (no matching furniture) - and broken bundles (unparseable) and remove them. -

-
+ -
+ ); } diff --git a/src/app/admin/studio/pets/page.tsx b/src/app/admin/studio/pets/page.tsx index b95d3925..03571bc7 100644 --- a/src/app/admin/studio/pets/page.tsx +++ b/src/app/admin/studio/pets/page.tsx @@ -1,5 +1,8 @@ +import { Cat } from "lucide-react"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client"; +import { StudioPage } from "@/components/admin/studio/studio-page"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function StudioPetsPage(_props: { @@ -9,9 +12,14 @@ export default async function StudioPetsPage(_props: { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } + const t = await getTranslations("admin.studio"); return ( -
+ -
+ ); } diff --git a/src/app/admin/studio/repair-icons/page.tsx b/src/app/admin/studio/repair-icons/page.tsx index c4b428ec..f52bd10c 100644 --- a/src/app/admin/studio/repair-icons/page.tsx +++ b/src/app/admin/studio/repair-icons/page.tsx @@ -1,5 +1,8 @@ +import { Image as ImageIcon } from "lucide-react"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client"; +import { StudioPage } from "@/components/admin/studio/studio-page"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function StudioRepairIconsPage(_props: { @@ -9,19 +12,14 @@ export default async function StudioRepairIconsPage(_props: { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } + const t = await getTranslations("admin.studio"); return ( -
-
-

- Repair Missing Icons -

-

- Scan all existing furniture and download any missing icon files. Icons - are extracted from local .nitro bundles or fetched from configured - clone sources. -

-
+ -
+ ); } diff --git a/src/app/admin/studio/studio-layout-client.tsx b/src/app/admin/studio/studio-layout-client.tsx index fff510bd..6c691a0c 100644 --- a/src/app/admin/studio/studio-layout-client.tsx +++ b/src/app/admin/studio/studio-layout-client.tsx @@ -17,6 +17,7 @@ import { Wrench, } from "lucide-react"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper"; import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher"; @@ -28,34 +29,38 @@ import { } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; -type StudioLink = { href: string; label: string; icon: LucideIcon }; +type StudioLink = { href: string; labelKey: string; icon: LucideIcon }; const assets: StudioLink[] = [ - { href: "/admin/studio/furni", label: "Furniture", icon: Puzzle }, - { href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck }, - { href: "/admin/studio/clothing", label: "Clothing", icon: Shirt }, - { href: "/admin/studio/effects", label: "Effects", icon: Dumbbell }, - { href: "/admin/studio/pets", label: "Pets", icon: Cat }, + { href: "/admin/studio/furni", labelKey: "furniture", icon: Puzzle }, + { href: "/admin/studio/badges", labelKey: "badges", icon: BadgeCheck }, + { href: "/admin/studio/clothing", labelKey: "clothing", icon: Shirt }, + { href: "/admin/studio/effects", labelKey: "effects", icon: Dumbbell }, + { href: "/admin/studio/pets", labelKey: "pets", icon: Cat }, ]; const imports: StudioLink[] = [ - { href: "/admin/studio/upload", label: "Upload files", icon: Upload }, - { href: "/admin/studio/clone", label: "Import from a source", icon: Copy }, - { href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw }, + { href: "/admin/studio/upload", labelKey: "uploadFiles", icon: Upload }, + { href: "/admin/studio/clone", labelKey: "importFromSource", icon: Copy }, + { href: "/admin/studio/sync", labelKey: "syncRepository", icon: RefreshCw }, ]; const maintenance: StudioLink[] = [ - { href: "/admin/studio/audit", label: "Catalog audit", icon: ClipboardCheck }, + { + href: "/admin/studio/audit", + labelKey: "catalogAudit", + icon: ClipboardCheck, + }, { href: "/admin/studio/repair-icons", - label: "Repair icons", + labelKey: "repairIcons", icon: ImageIcon, }, { href: "/admin/studio/maintenance", - label: "Catalog maintenance", + labelKey: "catalogMaintenance", icon: Wrench, }, { href: "/admin/studio/nitro-cleanup", - label: "Nitro cleanup", + labelKey: "nitroCleanup", icon: FileX2, }, ]; @@ -71,6 +76,7 @@ function StudioMenu({ links: StudioLink[]; pathname: string; }) { + const t = useTranslations("admin.studio"); const [open, setOpen] = useState(false); const active = links.find((link) => activePath(pathname, link.href)); return ( @@ -90,7 +96,7 @@ function StudioMenu({ align="end" className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]" > - {links.map(({ href, label: title, icon: Icon }) => ( + {links.map(({ href, labelKey, icon: Icon }) => ( - - {title} + + {t(`nav.${labelKey}`)} ))} @@ -120,14 +126,15 @@ export function StudioLayoutClient({ children: React.ReactNode; }) { const pathname = usePathname() ?? ""; + const t = useTranslations("admin.studio"); return ( -
+
) : ( @@ -2164,7 +2237,7 @@ export function StudioClient({
- {TYPE_LABELS[item.type] ?? item.type} + {typeLabel(item.type)} {item.alreadyImported ? ( <> @@ -2173,7 +2246,7 @@ export function StudioClient({ variant="secondary" className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40" > - No nitro + {t("items.noNitro")} )} {item.hasFurnidata === false && ( @@ -2181,7 +2254,7 @@ export function StudioClient({ variant="secondary" className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40" > - No furnidata + {t("items.noFurnidata")} )} {item.hasCatalogEntry === false && ( @@ -2189,7 +2262,7 @@ export function StudioClient({ variant="secondary" className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40" > - No catalog + {t("items.noCatalog")} )} {item.nitroExists && @@ -2199,13 +2272,13 @@ export function StudioClient({ variant="secondary" className="h-4 text-[var(--admin-success)] border-[var(--admin-success)]/40" > - Imported + {t("items.imported")} )} ) : ( - New + {t("items.new")} )}
@@ -2222,7 +2295,15 @@ export function StudioClient({ ? "scale-105 border-[var(--admin-accent)] bg-[var(--admin-accent)] shadow-md shadow-[var(--admin-accent)]/30" : "border-[var(--admin-border)] bg-[var(--admin-surface)] opacity-70 hover:border-[var(--admin-accent)]/60 hover:opacity-100", )} - aria-label={`${isSel ? "Deselect" : "Select"} ${item.name}`} + aria-label={ + isSel + ? t("aria.deselectItem", { + name: item.name, + }) + : t("aria.selectItem", { + name: item.name, + }) + } aria-pressed={isSel} > {isSel ? ( @@ -2242,7 +2323,7 @@ export function StudioClient({ ) : ( @@ -2261,7 +2342,7 @@ export function StudioClient({ className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]" checked={allSelected} onCheckedChange={toggleAll} - aria-label="Select all" + aria-label={t("aria.selectAll")} /> @@ -2344,7 +2425,9 @@ export function StudioClient({ onCheckedChange={() => toggleSelect(item.classname) } - aria-label={`Select ${item.classname}`} + aria-label={t("aria.selectItem", { + name: item.classname, + })} /> @@ -2439,7 +2524,9 @@ export function StudioClient({ }) } disabled={importingId !== null} - aria-label={`Import ${item.classname}`} + aria-label={t("aria.importItem", { + name: item.classname, + })} className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50" > {importingId === item.classname ? ( @@ -2458,7 +2545,9 @@ export function StudioClient({ onClick={() => setEditingClassname(item.classname) } - aria-label={`Edit nitro ${item.classname}`} + aria-label={t("aria.editNitro", { + name: item.classname, + })} className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]" > @@ -2471,7 +2560,9 @@ export function StudioClient({ disabled={regeneratingNitro.has( item.classname, )} - aria-label={`Regenerate nitro ${item.classname}`} + aria-label={t("aria.regenerateNitro", { + name: item.classname, + })} className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-warning)] transition-colors hover:bg-[var(--admin-warning)]/10 disabled:opacity-50" > {regeneratingNitro.has(item.classname) ? ( @@ -2504,18 +2595,18 @@ export function StudioClient({ size={14} className="animate-spin text-[var(--admin-accent)]" /> - Loading more… + {t("items.loadingMore")} ) : ( - Scroll for more + {t("items.scrollForMore")} ) ) : ( - End of results + {t("items.endOfResults")} )} @@ -2612,8 +2703,8 @@ export function StudioClient({ translate ? translateLangs.length ? translateLangs.join(", ") - : "Default languages" - : "Off" + : t("items.defaultLanguages") + : t("items.off") } onCancel={() => setReview(null)} busy={jobs.busy} @@ -2652,18 +2743,14 @@ export function StudioClient({ - Delete imported furni + {t("deleteDialog.title")}

- This permanently removes{" "} - - {deleting} - {" "} - from items_base and the catalog, plus its asset files. + {t("deleteDialog.body", { name: deleting ?? "" })}

diff --git a/src/components/admin/studio/studio-page.tsx b/src/components/admin/studio/studio-page.tsx new file mode 100644 index 00000000..a28e63ab --- /dev/null +++ b/src/components/admin/studio/studio-page.tsx @@ -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 ( +
+ + {children} + +
+ ); +} diff --git a/src/components/admin/studio/use-studio-translations.ts b/src/components/admin/studio/use-studio-translations.ts index ec05afa8..51cb13d5 100644 --- a/src/components/admin/studio/use-studio-translations.ts +++ b/src/components/admin/studio/use-studio-translations.ts @@ -1,4 +1,5 @@ "use client"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; import { setFurnidataTranslateEnabled } from "@/actions/import-furni"; @@ -24,6 +25,7 @@ const TRANSLATE_LANGS = [ ] as const; export function useStudioTranslations(defaultTranslate: boolean) { + const t = useTranslations("admin.studio"); // Translation controls (per-import toggle + language selection, plus a global // default that persists to the site setting and seeds the per-import toggle). const ALL_LANGS = TRANSLATE_LANGS as readonly string[]; @@ -52,13 +54,16 @@ export function useStudioTranslations(defaultTranslate: boolean) { const langs = data.languages ?? []; const okCount = langs.filter((l) => l.ok).length; toast.success( - `Languages translated (${okCount}/${langs.length} completed)`, + t("toasts.languagesTranslated", { + ok: okCount, + total: langs.length, + }), ); } else { - toast.error(data.error ?? "Translation failed"); + toast.error(data.error ?? t("toasts.translationFailed")); } } catch { - toast.error("Network error while translating languages"); + toast.error(t("toasts.networkTranslating")); } finally { setBuildingLanguages(false); } @@ -75,7 +80,7 @@ export function useStudioTranslations(defaultTranslate: boolean) { try { await setFurnidataTranslateEnabled({ enabled: next }); } catch { - toast.error("Could not save the default translation setting"); + toast.error(t("toasts.saveDefaultFailed")); } }; diff --git a/src/messages/en.json b/src/messages/en.json index b1f30c69..b1eb7e9e 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2570,6 +2570,8 @@ "credits": "Credits", "points": "Points", "pointsType": "Type", + "pointsTypeDuckets": "Duckets", + "pointsTypeDiamonds": "Diamonds", "newPriceHint": "Applies to new offers only; moved offers keep their current price.", "parent": "Place pages under", "root": "Catalog root", @@ -6801,6 +6803,416 @@ }, "missing": "Missing: {parts}", "unknown": "Not verified: {parts}" + }, + "rail": { + "title": "Categories", + "all": "All categories", + "noMatches": "No matching categories.", + "refreshFailed": "Catalog could not refresh.", + "retry": "Retry", + "updating": "Updating catalog…" + }, + "nav": { + "ariaLabel": "Catalog Studio", + "furniture": "Furniture", + "badges": "Badges", + "clothing": "Clothing", + "effects": "Effects", + "pets": "Pets", + "uploadFiles": "Upload files", + "importFromSource": "Import from a source", + "syncRepository": "Sync & repository", + "catalogAudit": "Catalog audit", + "repairIcons": "Repair icons", + "catalogMaintenance": "Catalog maintenance", + "nitroCleanup": "Nitro cleanup", + "catalogManager": "Catalog manager", + "importSync": "Import & sync", + "maintenance": "Maintenance" + }, + "aria": { + "search": "Search furniture", + "searchPlaceholder": "Search furni, classnames, keywords… (/)", + "clearSearch": "Clear search", + "hideCatalogTree": "Hide catalog tree", + "showCatalogTree": "Show catalog tree", + "source": "Furniture source", + "type": "Furniture type", + "category": "Furniture category", + "status": "Import status", + "sort": "Sort furniture", + "importSpeed": "Import speed", + "translateImport": "Translate this import", + "defaultTranslation": "Default translation", + "selection": "Furniture selection", + "results": "Furniture results", + "gridView": "Grid view", + "listView": "List view", + "selectAll": "Select all", + "languageLabel": "Language {lang}", + "selectItem": "Select {name}", + "deselectItem": "Deselect {name}", + "viewItem": "View {name}", + "importItem": "Import {name}", + "editNitro": "Edit nitro {name}", + "regenerateNitro": "Regenerate nitro {name}", + "scrollToTop": "Scroll to top", + "closeCategoryPanel": "Close category panel", + "catalogCategories": "Catalog categories", + "closeCategories": "Close categories", + "findCategory": "Find a category", + "expandCategory": "Expand {name}", + "collapseCategory": "Collapse {name}", + "closeDetails": "Close furniture details" + }, + "workspace": { + "title": "Studio", + "furnidata": "furnidata", + "catalog": "Catalog", + "results": "{count} results", + "statInDb": "{count} in DB", + "statInCatalog": "{count} in catalog", + "statMissingNitro": "{count} missing nitro", + "statTotal": "{count} total", + "statPresent": "{count} present", + "statToImport": "{count} to import", + "official": "(official)" + }, + "types": { + "floor": "Floor", + "wall": "Wall" + }, + "filters": { + "allTypes": "All types", + "floorItems": "Floor items", + "wallItems": "Wall items", + "allCategories": "All categories", + "allStatuses": "All statuses", + "allStatusesCount": "All statuses ({count} available to import)", + "statuses": { + "notImported": "Not imported", + "imported": "Imported", + "inCatalog": "In catalog", + "missingNitro": "Missing nitro", + "missingFurnidata": "Missing furnidata", + "missingCatalog": "Missing catalog entry" + }, + "sorts": { + "default": "Default order", + "name": "Name A–Z", + "classname": "Classname A–Z", + "category": "Category", + "importedFirst": "Imported first", + "notImportedFirst": "Not imported first" + }, + "reset": "Reset filters", + "noNitro": "{count} no nitro", + "noFurnidata": "{count} no furnidata" + }, + "importOptions": { + "summary": "Import options", + "translationOn": "Translation on", + "translationOff": "Translation off", + "speed": "Speed", + "parallel": "{count} parallel", + "gentle": "1 (gentle)", + "recommended": "4 (recommended)", + "turbo": "12 (turbo)", + "translate": "Translate", + "noLanguages": "No languages", + "allLanguages": "All languages", + "languagesCount": "{count} languages", + "languages": "Languages", + "all": "All", + "none": "None", + "translateAllTitle": "Translate the entire catalog into all languages", + "translateAll": "Translate entire catalog", + "defaultFuture": "Default for future imports" + }, + "selection": { + "selectVisible": "Select visible", + "deselectVisible": "Deselect visible", + "loadingAll": "Loading all…", + "selectAllResults": "Select all {count} results", + "selectedInView": "{count} selected in this view", + "clear": "Clear", + "preparingReview": "Preparing review…", + "importAllMissing": "Import all missing ({count})", + "importSelected": "Import selected ({count})", + "rebuildNitro": "Rebuild Nitro ({count})", + "addMissingFurnidata": "Add missing furnidata ({count})", + "deleteSelected": "Delete ({count})", + "hiddenWarning": "Some selected furniture is hidden by your filters. Actions apply to the visible selection." + }, + "items": { + "tryAgain": "Try again", + "noFound": "No furni found for “{query}”", + "noFurni": "No furni to show yet", + "noFoundHint": "Try a different search or clear your filters.", + "noFurniHint": "Search a furni library to get started.", + "noNitro": "No nitro", + "noFurnidata": "No furnidata", + "noCatalog": "No catalog", + "imported": "Imported", + "new": "New", + "colName": "Name", + "colClassname": "Classname", + "colType": "Type", + "colCategory": "Category", + "colStatus": "Status", + "colActions": "Actions", + "loadingMore": "Loading more…", + "scrollForMore": "Scroll for more", + "endOfResults": "End of results", + "defaultLanguages": "Default languages", + "off": "Off" + }, + "deleteDialog": { + "title": "Delete imported furni", + "body": "This permanently removes {name} from items_base and the catalog, plus its asset files.", + "cancel": "Cancel", + "delete": "Delete" + }, + "drawer": { + "title": "Furni detail", + "revision": "rev {revision}", + "autoImportReady": "Auto-import ready", + "automaticPlacement": "Automatic placement", + "category": "Category", + "price": "Price", + "priceCredits": "{count} credits", + "priceCreditsPoints": "{credits} credits + {points} points", + "importAdd": "Import & add to catalog", + "bundleMissing": "This furni is in the DB but its bundle file is missing.", + "regenerate": "Regenerate .nitro", + "editNitro": "Edit nitro", + "deleteImported": "Delete imported furni" + }, + "review": { + "titleReview": "Review furniture import", + "titleConfirm": "Confirm furniture import", + "steps": "1. Select → 2. Review → 3. Import", + "source": "Source:", + "destination": "Destination:", + "destinationValue": "local catalog, furnidata, bundle and icons", + "translation": "Translation:", + "selection": "Selection:", + "selectionCount": "{count} furniture items", + "checkingSource": "Checking source files… {count} checked", + "retrySource": "Retry source check", + "unknownSources": "Some sources could not be verified. The import will still try to download their files.", + "sourceNotFound": "Source files not found by preliminary check: {name}", + "checkingLocal": "Checking local data and assets…", + "retryInspection": "Retry inspection", + "incomplete": "Inspection is incomplete. Close and retry before importing.", + "summary": "{ready} ready to import · {existing} already in SQL (complete missing data) · {blocked} conflicts · {warnings} source file warnings", + "localUnverified": "Some local files could not be verified. Review the item details before continuing.", + "statusNew": "New", + "statusChanged": "Differences", + "statusConflict": "Conflict", + "statusUnknown": "Not fully verified", + "statusPresent": "Present", + "validatingFile": "Validating file…", + "confirmIntro": "Only the {count} eligible items below will be submitted. Existing furniture keeps its local IDs, SQL settings and catalog offers. Missing assets and catalog entries are completed; furnidata is refreshed. Conflicts are excluded.", + "confirmEstimates": "Automatic categories and prices below are estimates. Source rules may override them; final results appear in the import summary.", + "placement": "{category} · {credits} credits + {points} points", + "previous": "Previous items", + "page": "Page {page} / {total}", + "next": "Next items", + "backToReview": "Back to review", + "cancel": "Cancel", + "continue": "Continue to confirmation", + "startImport": "Start import ({count})", + "uploadFailed": "Upload failed" + }, + "inspector": { + "furnidataUnreadable": "Local furnidata could not be read. Its values are not verified.", + "field": "Field", + "localNow": "Local now", + "source": "Source", + "fieldName": "Name", + "fieldDescription": "Description", + "fieldRevision": "Revision", + "fieldType": "Type", + "changed": "Changed", + "notSupplied": "Not supplied", + "compareNote": "Source values are compared with local data. Import may remap IDs and translate names. Existing furniture keeps its local IDs and SQL settings; new furniture receives a free local ID when the source ID is occupied.", + "sqlRecords": "SQL records and catalog offers", + "noSqlItem": "No local SQL item.", + "itemRow": "Item #{id} · Sprite #{sprite} · {name}", + "offerRow": "Offer #{id} · Page #{page} · {credits} credits + {points} points", + "healthComplete": "Complete", + "healthIncomplete": "Needs repair", + "healthNew": "Not imported", + "healthConflict": "Local conflict", + "healthUnknown": "Not verified", + "missing": "Missing: {parts}", + "conflictNote": "Duplicate records or mismatched IDs/types require review before repair.", + "unknownNote": "Some local data could not be checked. Retry verification before repair.", + "repairNote": "Repair adds missing parts and preserves existing IDs, settings, furnidata and catalog offers. Missing offers use the automatic category and price shown below.", + "repairQueued": "Repair queued or running…", + "repair": "Repair furni", + "recheck": "Recheck local state", + "checking": "Checking local data and assets…", + "retry": "Retry inspection", + "unavailable": "Local inspection unavailable." + }, + "quality": { + "compressionTitle": "Compression · {format}", + "losslessVp8l": "Lossless (VP8L)", + "lossyVp8": "Lossy (VP8)", + "bundle": "Bundle", + "texture": "Texture", + "metadata": "Metadata", + "container": "Container", + "textureDetail": "Texture {raw} decoded · deflate ×{ratio} · sheet {width}×{height} · {frames} frames", + "legacyPngSaving": "Legacy PNG texture. Re-importing as WebP Lossless would store {size}, saving {saving} ({percent}% smaller)", + "legacyPngNoGain": "Legacy PNG texture. Re-importing as WebP Lossless would store {size} (no gain on this bundle)", + "webpSaving": "WebP Lossless stores {raw} against {png} for the same artwork as PNG, saving {saving} ({percent}% smaller)", + "webpNoGain": "WebP Lossless stores {raw} against {png} for the same artwork as PNG (PNG is not larger on this bundle)", + "pixelExact": "Pixel-exact — 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" } } },
@@ -2269,37 +2350,37 @@ export function StudioClient({ scope="col" className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]" > - Name + {t("items.colName")} - Classname + {t("items.colClassname")} - Type + {t("items.colType")} - Category + {t("items.colCategory")} - Status + {t("items.colStatus")} - Actions + {t("items.colActions")}
@@ -2352,7 +2435,9 @@ export function StudioClient({ type="button" onClick={() => openDetail(item)} className="rounded outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]" - aria-label={`View ${item.name}`} + aria-label={t("aria.viewItem", { + name: item.name, + })} > - {TYPE_LABELS[item.type] ?? item.type} + {typeLabel(item.type)} {item.category || "—"} @@ -2391,7 +2476,7 @@ export function StudioClient({ variant="secondary" className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40" > - No nitro + {t("items.noNitro")} )} {item.hasFurnidata === false && ( @@ -2399,7 +2484,7 @@ export function StudioClient({ variant="secondary" className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40" > - No furnidata + {t("items.noFurnidata")} )} {item.hasCatalogEntry === false && ( @@ -2407,7 +2492,7 @@ export function StudioClient({ variant="secondary" className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40" > - No catalog + {t("items.noCatalog")} )} {item.nitroExists && @@ -2417,13 +2502,13 @@ export function StudioClient({ variant="secondary" className="h-4 text-[0.6rem] text-[var(--admin-success)] border-[var(--admin-success)]/40" > - Imported + {t("items.imported")} )} ) : ( - New + {t("items.new")} )}