'use client' import { AlertTriangle, CheckCircle2, Database, FileJson, Filter, Loader2, RotateCcw, Save, ShoppingCart, Sparkles, } from 'lucide-react' import { useRouter } from 'next/navigation' import { useCallback, 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 { 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 } type FilterMode = 'all' | 'missing' | 'modified' export function CatalogTranslateTab({ baseItems, catalogNameMap, canEdit, furniDataIdList, furniDescriptionMap, furniRevisionMap = {}, }: CatalogTranslateTabProps) { const router = useRouter() const { run, isPending } = useServerAction() const [isSuggesting, setIsSuggesting] = useState(false) const [filterMode, setFilterMode] = useState('all') 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 = {} 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() Object.assign(allSuggestions, data.suggestions || {}) } } // 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(`HabboFurni: ${parts.join(' + ')} suggested from habbo.it. Review and save.`) } else { toast.info( `HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`, ) } } catch (err) { toast.error(`HabboFurni 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-yellow-400 dark:border-yellow-600' : ''}`} /> {currentCatalogName && currentCatalogName !== item.publicName && (

catalog: {currentCatalogName}

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

Missing from FurnitureData.json

Will be auto-inserted on save

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