"use client"; import { AlertTriangle, CheckCircle2, Database, FileJson, Filter, Loader2, RotateCcw, Save, ShoppingCart, Sparkles, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { translateCatalogItems } from "@/actions/catalog-items"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { useServerAction } from "@/hooks/use-server-action"; import { habboGamedataHotelLabel, habboGamedataHotelShort, normalizeHabboGamedataHotel, } from "@/lib/habbo-gamedata-hotel"; import { FurniIcon } from "./catalog-items-table"; interface BaseItemForTranslation { id: number; publicName: string; itemName: string; spriteId: number; type: string; } interface CatalogTranslateTabProps { baseItems: BaseItemForTranslation[]; catalogNameMap: Record; canEdit: boolean; furniDataIdList: number[]; furniDescriptionMap: Record; furniRevisionMap?: Record; /** CMS setting `habbo_gamedata_hotel` — which official Habbo locale to suggest from */ gamedataHotel?: string; } type FilterMode = "all" | "missing" | "modified"; export function CatalogTranslateTab({ baseItems, catalogNameMap, canEdit, furniDataIdList, furniDescriptionMap, furniRevisionMap = {}, gamedataHotel = "it", }: CatalogTranslateTabProps) { const router = useRouter(); const { run, isPending } = useServerAction(); const [isSuggesting, setIsSuggesting] = useState(false); const [filterMode, setFilterMode] = useState("all"); const [activeHotel, setActiveHotel] = useState(() => normalizeHabboGamedataHotel(gamedataHotel), ); useEffect(() => { setActiveHotel(normalizeHabboGamedataHotel(gamedataHotel)); }, [gamedataHotel]); // Always refresh from CMS settings so the button label tracks hotel changes // without requiring a full page remount after saving Settings. useEffect(() => { let cancelled = false; async function refreshHotel() { try { const res = await fetch("/api/admin/catalog/suggest", { cache: "no-store", }); if (!res.ok) return; const data = (await res.json()) as { hotel?: string }; if (!cancelled && typeof data.hotel === "string") { setActiveHotel(normalizeHabboGamedataHotel(data.hotel)); } } catch { // keep prop/default } } void refreshHotel(); const onFocus = () => void refreshHotel(); const onVisibility = () => { if (document.visibilityState === "visible") void refreshHotel(); }; window.addEventListener("focus", onFocus); document.addEventListener("visibilitychange", onVisibility); return () => { cancelled = true; window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibility); }; }, []); const hotelShort = habboGamedataHotelShort(activeHotel); const hotelLabel = habboGamedataHotelLabel(activeHotel); const furniDataIdSet = useMemo( () => new Set(furniDataIdList), [furniDataIdList], ); const [selected, setSelected] = useState>(new Set()); // ── Editable state: names + descriptions ───────────────────────── const [editedNames, setEditedNames] = useState>( Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])), ); const [editedDescs, setEditedDescs] = useState>( Object.fromEntries( baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]), ), ); // Track which items have name or description changes const isNameChanged = useCallback( (item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName, [editedNames], ); const isDescChanged = useCallback( (item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""), [editedDescs, furniDescriptionMap], ); const changedItems = useMemo( () => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)), [baseItems, isNameChanged, isDescChanged], ); const missingItems = useMemo( () => baseItems.filter((item) => !furniDataIdSet.has(item.id)), [baseItems, furniDataIdSet], ); const filteredItems = useMemo(() => { switch (filterMode) { case "missing": return baseItems.filter((item) => !furniDataIdSet.has(item.id)); case "modified": return baseItems.filter( (item) => isNameChanged(item) || isDescChanged(item), ); default: return baseItems; } }, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]); const toggleOne = useCallback((id: number) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const toggleAll = useCallback(() => { setSelected((prev) => prev.size === filteredItems.length ? new Set() : new Set(filteredItems.map((i) => i.id)), ); }, [filteredItems]); // Items to act on: selected ones if any, otherwise all const targetItems = useMemo(() => { if (selected.size === 0) return baseItems; return baseItems.filter((i) => selected.has(i.id)); }, [baseItems, selected]); function handleNameChange(id: number, value: string) { setEditedNames((prev) => ({ ...prev, [id]: value })); } function handleDescChange(id: number, value: string) { setEditedDescs((prev) => ({ ...prev, [id]: value })); } function handleReset() { setEditedNames( Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])), ); setEditedDescs( Object.fromEntries( baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]), ), ); } async function handleSuggestFromHabboFurni() { if (targetItems.length === 0) return; setIsSuggesting(true); try { // Collect classnames from selected items (or all if none selected) const classnames = targetItems .map((item) => item.itemName) .filter(Boolean); if (classnames.length === 0) { toast.info("No classnames found to look up."); setIsSuggesting(false); return; } // Batch in chunks of 50 to avoid too-long URLs const CHUNK_SIZE = 50; const allSuggestions: Record< string, { name: string; description: string } > = {}; let suggestHotel = activeHotel; for (let i = 0; i < classnames.length; i += CHUNK_SIZE) { const chunk = classnames.slice(i, i + CHUNK_SIZE); const res = await fetch( `/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`, ); if (res.ok) { const data = await res.json(); if (typeof data.hotel === "string") { suggestHotel = normalizeHabboGamedataHotel(data.hotel); setActiveHotel(suggestHotel); } Object.assign(allSuggestions, data.suggestions || {}); } } const suggestLabel = habboGamedataHotelLabel(suggestHotel); // Apply suggestions to items that have a match (names + descriptions) let nameCount = 0; let descCount = 0; const newNames = { ...editedNames }; const newDescs = { ...editedDescs }; for (const item of targetItems) { const suggestion = allSuggestions[item.itemName]; if (!suggestion) continue; if (suggestion.name && suggestion.name !== item.publicName) { newNames[item.id] = suggestion.name; nameCount++; } const currentDesc = furniDescriptionMap[item.id] ?? ""; if (suggestion.description && suggestion.description !== currentDesc) { newDescs[item.id] = suggestion.description; descCount++; } } setEditedNames(newNames); setEditedDescs(newDescs); const total = nameCount + descCount; if (total > 0) { const parts = []; if (nameCount > 0) parts.push(`${nameCount} name(s)`); if (descCount > 0) parts.push(`${descCount} description(s)`); toast.success( `${parts.join(" + ")} suggested from ${suggestLabel}. Review and save.`, ); } else { toast.info( `No new suggestions from ${suggestLabel} (${Object.keys(allSuggestions).length} looked up, all already match).`, ); } } catch (err) { toast.error(`Suggest error: ${(err as Error).message}`); } finally { setIsSuggesting(false); } } async function handleSaveAll() { if (changedItems.length === 0) return; run( () => translateCatalogItems({ items: changedItems.map((item) => ({ id: item.id, publicName: editedNames[item.id], description: editedDescs[item.id], })), }), { successMessage: "Translations saved.", errorMessage: "Failed to save translations.", onSuccess: (data) => { const d = data as Record; const parts = []; if (d.namesUpdated && (d.namesUpdated as number) > 0) parts.push(`${d.namesUpdated} name(s) updated`); if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0) parts.push(`${d.descriptionsUpdated} description(s) updated`); if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`); if (d.furniDataUpdated) parts.push("FurnitureData.json synced"); if (d.furniDataInserted && (d.furniDataInserted as number) > 0) { parts.push( `${d.furniDataInserted} new item(s) added to FurnitureData.json`, ); } parts.push("emulator cache refreshed"); toast.success(`${parts.join(" · ")}.`); router.refresh(); }, }, ); } return (
Translate Item Names

Edit the public name and description of furniture items. Saving will update:

items_base (name) catalogName (auto) FurnitureData.json (name + desc)
{missingItems.length > 0 && (
{missingItems.length} item(s) missing from FurnitureData.json — will be auto-inserted on save
)}
{canEdit && (
)}
{/* Filter bar */} {baseItems.length > 0 && (
)} {baseItems.length === 0 ? (

No items to translate in this page.

) : filteredItems.length === 0 ? (

No items match the current filter.

) : (
0 && selected.size === filteredItems.length } onCheckedChange={toggleAll} aria-label="Select all" /> ID Type Item Name Public Name Description Status {filteredItems.map((item) => { const nameChanged = isNameChanged(item); const descChanged = isDescChanged(item); const isChanged = nameChanged || descChanged; const isMissing = !furniDataIdSet.has(item.id); const currentCatalogName = catalogNameMap[item.id]; return ( toggleOne(item.id)} aria-label={`Select ${item.itemName}`} /> {(() => { const revInfo = furniRevisionMap[item.id]; return revInfo ? ( ) : ( ); })()} {item.id} {item.type === "s" ? "Floor" : item.type === "i" ? "Wall" : item.type} {item.itemName} handleNameChange(item.id, e.target.value) } disabled={!canEdit} className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`} /> {currentCatalogName && currentCatalogName !== item.publicName && (

catalog: {currentCatalogName}

)}
handleDescChange(item.id, e.target.value) } disabled={!canEdit} placeholder="No description" className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`} />
{isChanged ? ( Modified ) : ( — )} {isMissing ? (

Missing from FurnitureData.json

Will be auto-inserted on save

) : ( In FurnitureData.json )}
); })}
)}
); }