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