From 6dced0fb54244d21d8294055a7e9da8354ef9bfd Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 18:16:42 +0200 Subject: [PATCH] feat: per-import translation toggle and language picker in studio Furni imports can now choose whether to translate (per import) and which languages to build, instead of always rebuilding all 13 FurnitureData_ files. The studio header also has a global switch that persists the furnidata_translate_enabled setting, seeding the per-import default. --- src/actions/import-furni.ts | 18 ++ src/app/admin/studio/furni/page.tsx | 5 +- src/app/api/admin/import/furni/route.ts | 35 +++- src/components/admin/studio/studio-client.tsx | 160 +++++++++++++++++- src/lib/services/furni-data-i18n.ts | 15 +- 5 files changed, 223 insertions(+), 10 deletions(-) diff --git a/src/actions/import-furni.ts b/src/actions/import-furni.ts index f91fc20de3..b7f31f3003 100644 --- a/src/actions/import-furni.ts +++ b/src/actions/import-furni.ts @@ -1,6 +1,7 @@ "use server"; import { z } from "zod"; +import { db, WebsiteSetting } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { actionOk } from "@/lib/safe-action-shared"; @@ -8,6 +9,7 @@ import { cleanupConvertedSwfs, deleteImportedItem, } from "@/lib/services/furni-import"; +import { siteSettings } from "@/lib/services/site-settings"; export const cleanSwfFiles = adminAction( { permission: PERMS.ASSETS_IMPORT }, @@ -29,3 +31,19 @@ export const deleteImportedFurni = adminAction( >, ), ); + +const translateToggleSchema = z.object({ enabled: z.boolean() }); + +/** Persist the global "translate furniture names" setting from the studio. */ +export const setFurnidataTranslateEnabled = adminAction( + { permission: PERMS.ASSETS_IMPORT, schema: translateToggleSchema }, + async (ctx) => { + const value = ctx.data.enabled ? "1" : "0"; + await db + .insert(WebsiteSetting) + .values({ key: "furnidata_translate_enabled", value }) + .onDuplicateKeyUpdate({ set: { value } }); + await siteSettings.reload(); + return actionOk({ enabled: ctx.data.enabled }); + }, +); diff --git a/src/app/admin/studio/furni/page.tsx b/src/app/admin/studio/furni/page.tsx index 2bc64d618c..944cb99bf4 100644 --- a/src/app/admin/studio/furni/page.tsx +++ b/src/app/admin/studio/furni/page.tsx @@ -4,6 +4,7 @@ import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { getTreeFlat } from "@/lib/services/catalog-tree"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; +import { siteSettings } from "@/lib/services/site-settings"; export default async function StudioFurniPage(_props: { params: Promise<{ locale: string }>; @@ -13,15 +14,17 @@ export default async function StudioFurniPage(_props: { redirect("/admin"); } - const [hotel, catalogTree] = await Promise.all([ + const [hotel, catalogTree, translateEnabled] = await Promise.all([ getHabboGamedataHotel(), getTreeFlat(), + siteSettings.getBool("furnidata_translate_enabled", true), ]); return ( ); } diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 86a9c8246c..3443210d89 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -527,11 +527,24 @@ export const POST = withAdmin( const { catalogNameFixed, haveOfferFixed, costCreditsFixed } = await fixDatabaseConsistencyAfterImport(); - // Rebuild all localized furnidata files (best effort) + // Rebuild localized furnidata files (best effort). `translate: false` + // skips entirely; `translate: true` forces a build even when the global + // setting is off; undefined falls back to the global setting. An optional + // `langs` array restricts which languages are rebuilt. let languages: Awaited> = []; try { - languages = await buildLocalizedFurniDataFiles(); + if (body.translate !== false) { + const langs = Array.isArray(body.langs) + ? (body.langs as unknown[]).filter( + (l): l is string => typeof l === "string", + ) + : undefined; + languages = await buildLocalizedFurniDataFiles( + body.translate === true, + langs, + ); + } } catch (err) { console.warn( "[import-furni] Localized furnidata build failed:", @@ -638,14 +651,24 @@ export const POST = withAdmin( // Verify + fix interaction_modes_count (single consolidated pass) const verification = await verifyAndFixInteractionModesCount(); - // Patch localized furnidata files for this single furni (best effort) + // Patch localized furnidata files for this single furni (best effort). + // Same `translate` / `langs` contract as the batch path above. let languagesPatch: Awaited< ReturnType > = []; try { - languagesPatch = await patchLocalizedFurniDataEntries([ - { classname, name, description: description ?? "" }, - ]); + if (body.translate !== false) { + const langs = Array.isArray(body.langs) + ? (body.langs as unknown[]).filter( + (l): l is string => typeof l === "string", + ) + : undefined; + languagesPatch = await patchLocalizedFurniDataEntries( + [{ classname, name, description: description ?? "" }], + body.translate === true, + langs, + ); + } } catch (err) { console.warn( "[import-furni] Localized patch failed:", diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index a8aca4e0e9..a2884330ae 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -6,6 +6,8 @@ import { CircleAlert, CloudDownload, Download, + Globe, + Languages, Layers, LayoutGrid, List, @@ -23,7 +25,10 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { deleteImportedFurni } from "@/actions/import-furni"; +import { + deleteImportedFurni, + setFurnidataTranslateEnabled, +} from "@/actions/import-furni"; import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -36,6 +41,11 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Select, SelectContent, @@ -43,6 +53,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { adminFetch } from "@/lib/admin-fetch"; import { type AutoCatalogPreview, @@ -67,12 +78,33 @@ import type { VerificationSummary, } from "./studio-types"; +// Language codes that get a FurnitureData_.json file. Mirrors +// FURNIDATA_LANGUAGES in furni-data-i18n (kept client-safe here so we don't pull +// server-only fs modules into the browser bundle). +const TRANSLATE_LANGS = [ + "nl", + "en", + "de", + "fr", + "es", + "tr", + "it", + "pt", + "fi", + "pl", + "ru", + "ar", + "ja", +] as const; + export function StudioClient({ source, initialTree, + defaultTranslate, }: { source: FurniImportSource; initialTree: TreeNode[]; + defaultTranslate: boolean; }) { // Catalog tree const [tree] = useState(initialTree); @@ -137,6 +169,32 @@ export function StudioClient({ new Set(), ); + // 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[]; + const [translate, setTranslate] = useState(defaultTranslate); + const [translateLangs, setTranslateLangs] = useState([ + ...ALL_LANGS, + ]); + const [langPickerOpen, setLangPickerOpen] = useState(false); + const [globalTranslate, setGlobalTranslate] = + useState(defaultTranslate); + + const toggleLang = (lang: string) => + setTranslateLangs((prev) => + prev.includes(lang) ? prev.filter((l) => l !== lang) : [...prev, lang], + ); + + const onGlobalTranslateChange = async (next: boolean) => { + setGlobalTranslate(next); + setTranslate(next); + try { + await setFurnidataTranslateEnabled({ enabled: next }); + } catch { + toast.error("Kon de globale vertaal-instelling niet opslaan"); + } + }; + const categories = useMemo(() => { const cats = new Set(items.map((i) => i.category).filter(Boolean)); return ["all", ...Array.from(cats).sort()]; @@ -353,6 +411,9 @@ export function StudioClient({ revision: item.revision, category: item.category, sourceId: activeSource || undefined, + translate, + langs: + translate && translateLangs.length > 0 ? translateLangs : undefined, }), }); const data = await res.json(); @@ -428,6 +489,9 @@ export function StudioClient({ category: i.category, })), concurrency: 3, + translate, + langs: + translate && translateLangs.length > 0 ? translateLangs : undefined, }), signal: abort.signal, }); @@ -675,6 +739,18 @@ export function StudioClient({ )} + +
+ + + + Vertaling (globaal) + +
{/* ── Filters + actions ─────────────────────────────── */} @@ -839,6 +915,88 @@ export function StudioClient({ )} + {/* ── Translate control (per-import) ─────────────── */} +
+
+ + + Vertalen + +
+ + + + + + +
+ + Talen + +
+ + +
+
+
+ {ALL_LANGS.map((lang) => { + const on = translateLangs.includes(lang); + return ( + + ); + })} +
+
+
+
+
{selectedNonImported > 0 && ( diff --git a/src/lib/services/furni-data-i18n.ts b/src/lib/services/furni-data-i18n.ts index 357430a253..8f8f2cd32f 100644 --- a/src/lib/services/furni-data-i18n.ts +++ b/src/lib/services/furni-data-i18n.ts @@ -433,6 +433,7 @@ function cloneFurniData( */ export async function buildLocalizedFurniDataFiles( force = false, + langs?: readonly string[], ): Promise { if (!force && !(await translationEnabled())) { logger.debug( @@ -440,6 +441,10 @@ export async function buildLocalizedFurniDataFiles( ); return []; } + const langsToBuild = + langs && langs.length > 0 + ? FURNIDATA_LANGUAGES.filter((l) => langs.includes(l.lang)) + : FURNIDATA_LANGUAGES; return withFurniDataLock(async () => { const master = (await readFurniData()) as Record< string, @@ -448,7 +453,7 @@ export async function buildLocalizedFurniDataFiles( // Fetch all language maps in parallel (uses per-hotel memory cache). const maps = await Promise.all( - FURNIDATA_LANGUAGES.map(async ({ lang, hotel }) => { + langsToBuild.map(async ({ lang, hotel }) => { const map = await fetchTranslationsForHotel(hotel); return { lang, hotel, map }; }), @@ -577,6 +582,7 @@ export async function buildLocalizedFurniDataFiles( export async function patchLocalizedFurniDataEntries( entries: Array<{ classname: string; name?: string; description?: string }>, force = false, + langs?: readonly string[], ): Promise { if (entries.length === 0) return []; if (!force && !(await translationEnabled())) { @@ -586,9 +592,14 @@ export async function patchLocalizedFurniDataEntries( return []; } + const langsToBuild = + langs && langs.length > 0 + ? FURNIDATA_LANGUAGES.filter((l) => langs.includes(l.lang)) + : FURNIDATA_LANGUAGES; + return withFurniDataLock(async () => { const maps = await Promise.all( - FURNIDATA_LANGUAGES.map(async ({ lang, hotel }) => { + langsToBuild.map(async ({ lang, hotel }) => { const map = await fetchTranslationsForHotel(hotel); return { lang, hotel, map }; }),