import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common'; import { FurniDetail } from '../../../hooks/furni-editor'; interface FurniEditorEditViewProps { item: FurniDetail; furniDataEntry: Record | null; furniDataDiagnostic: Record | null; interactions: string[]; loading: boolean; onUpdate: (id: number, fields: Record) => void; onDelete: (id: number) => void; onBack: () => void; onUpdateFurnidata: (id: number, name: string, description: string) => void; onRevertFurnidata: (id: number) => void; onImportText: (id: number) => void; importResult: { found: boolean; name: string; description: string; classname: string; nonce: number } | null; } const FIELD_TIPS: Record = { stackHeight: 'Visual height when items are stacked on top of this furniture', interactionType: 'Defines behavior when user interacts (e.g. default, gate, teleport, vendingmachine)', customparams: 'Extra parameters for the interaction type (format depends on interaction)', interactionModesCount: 'Number of visual states/animations this furniture has', }; const PERM_GROUPS = [ { label: 'Gameplay', keys: [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowInventoryStack' ] }, { label: 'Trading', keys: [ 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell' ] }, ]; interface SectionProps { title: string; children: React.ReactNode; defaultOpen?: boolean } const Section: FC = ({ title, children, defaultOpen = true }) => { const [ open, setOpen ] = useState(defaultOpen); return (
{ open &&
{ children }
}
); }; const Tip: FC<{ field: string }> = ({ field }) => { const tip = FIELD_TIPS[field]; const ref = useRef(null); const [ pos, setPos ] = useState<{ left: number; top: number } | null>(null); const show = useCallback(() => { const r = ref.current?.getBoundingClientRect(); if(r) setPos({ left: r.left + (r.width / 2), top: r.top - 6 }); }, []); const hide = useCallback(() => setPos(null), []); if(!tip) return null; return ( ? { pos && createPortal( { tip } , document.body) } ); }; const CopyValue: FC<{ value: string | number }> = ({ value }) => { const [ copied, setCopied ] = useState(false); const copy = useCallback(() => { const text = String(value); if(navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => setCopied(true)).catch(() => setCopied(true)); else setCopied(true); }, [ value ]); // Reset the "copied!" flag after 1s, with cleanup so the timer never fires after unmount. useEffect(() => { if(!copied) return; const handle = window.setTimeout(() => setCopied(false), 1000); return () => window.clearTimeout(handle); }, [ copied ]); return (
{ String(value) } { copied ? 'copied!' : 'copy' }
); }; export const FurniEditorEditView: FC = props => { const { item, furniDataEntry, furniDataDiagnostic, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props; const saveRef = useRef<() => void>(null); const [ form, setForm ] = useState({ itemName: '', publicName: '', spriteId: 0, type: 's', width: 1, length: 1, stackHeight: 0, allowStack: true, allowWalk: false, allowSit: false, allowLay: false, allowGift: true, allowTrade: true, allowRecycle: true, allowMarketplaceSell: true, allowInventoryStack: true, interactionType: '', interactionModesCount: 0, customparams: '', }); const [ showDeleteDialog, setShowDeleteDialog ] = useState(false); const [ furniName, setFurniName ] = useState(''); const [ furniDescription, setFurniDescription ] = useState(''); const [ confirmFurnidata, setConfirmFurnidata ] = useState(false); const [ importNote, setImportNote ] = useState(''); const appliedImportNonce = useRef(0); useEffect(() => { if(!item) return; setForm({ itemName: item.itemName || '', publicName: item.publicName || '', spriteId: item.spriteId || 0, type: item.type || 's', width: item.width || 1, length: item.length || 1, stackHeight: item.stackHeight || 0, allowStack: !!item.allowStack, allowWalk: !!item.allowWalk, allowSit: !!item.allowSit, allowLay: !!item.allowLay, allowGift: !!item.allowGift, allowTrade: !!item.allowTrade, allowRecycle: !!item.allowRecycle, allowMarketplaceSell: !!item.allowMarketplaceSell, allowInventoryStack: !!item.allowInventoryStack, interactionType: item.interactionType || '', interactionModesCount: item.interactionModesCount || 0, customparams: item.customparams || '', }); setShowDeleteDialog(false); setFurniName(String(furniDataEntry?.name ?? '')); setFurniDescription(String(furniDataEntry?.description ?? '')); setConfirmFurnidata(false); setImportNote(''); }, [ item, furniDataEntry ]); const setField = useCallback((key: string, value: unknown) => { setForm(prev => ({ ...prev, [key]: value })); }, []); const isDirty = useMemo(() => { if(!item) return false; return form.itemName !== (item.itemName || '') || form.publicName !== (item.publicName || '') || form.spriteId !== (item.spriteId || 0) || form.type !== (item.type || 's') || form.width !== (item.width || 1) || form.length !== (item.length || 1) || form.stackHeight !== (item.stackHeight || 0) || form.allowStack !== !!item.allowStack || form.allowWalk !== !!item.allowWalk || form.allowSit !== !!item.allowSit || form.allowLay !== !!item.allowLay || form.allowGift !== !!item.allowGift || form.allowTrade !== !!item.allowTrade || form.allowRecycle !== !!item.allowRecycle || form.allowMarketplaceSell !== !!item.allowMarketplaceSell || form.allowInventoryStack !== !!item.allowInventoryStack || form.interactionType !== (item.interactionType || '') || form.interactionModesCount !== (item.interactionModesCount || 0) || form.customparams !== (item.customparams || ''); }, [ form, item ]); const validation = useMemo(() => { const errors: Record = {}; if(!form.itemName.trim()) errors.itemName = 'Required'; if(!form.publicName.trim()) errors.publicName = 'Required'; if(form.width < 1) errors.width = 'Min 1'; if(form.length < 1) errors.length = 'Min 1'; if(form.stackHeight < 0) errors.stackHeight = 'Min 0'; return errors; }, [ form ]); const isValid = useMemo(() => Object.keys(validation).length === 0, [ validation ]); // Furnidata name editing only works when the furni has a matching furnidata // entry: the server writer is edit-only and refuses classnames absent from // furnidata (pets, custom items, …). furniDataEntry is the entry resolved by // the server (by id); guard on it + a classname match so we never trigger the // cryptic "Classname not found in furnidata" error on save. const furnidataEditable = useMemo(() => { if(!furniDataEntry) return false; const cn = String((furniDataEntry as { classname?: unknown }).classname ?? '').trim().toLowerCase(); const itemCn = String(item?.itemName ?? '').trim().toLowerCase(); return cn ? (cn === itemCn) : true; }, [ furniDataEntry, item ]); // True only when the name/description actually differ from the stored furnidata // entry. Used to gate the Save button: saving an unchanged value makes the // server writer return false, which the handler misreports as "Classname not // found in furnidata" — so we never let an unchanged save fire. const furnidataDirty = useMemo(() => furniName !== String(furniDataEntry?.name ?? '') || furniDescription !== String(furniDataEntry?.description ?? ''), [ furniName, furniDescription, furniDataEntry ]); const furnidataMissReason = useMemo(() => { const reason = String(furniDataDiagnostic?.reason ?? ''); return reason || 'not_found'; }, [ furniDataDiagnostic ]); const furnidataSourcePath = String(furniDataDiagnostic?.sourcePath ?? ''); // Apply an "Import from Habbo" result into the editable fields (review then Save). useEffect(() => { if(!importResult || importResult.nonce === appliedImportNonce.current) return; appliedImportNonce.current = importResult.nonce; // Ignore a result that belongs to a different furni (user navigated away). if(importResult.classname && importResult.classname.trim().toLowerCase() !== String(item?.itemName ?? '').trim().toLowerCase()) return; if(importResult.found) { setFurniName(importResult.name); setFurniDescription(importResult.description); setImportNote('Imported from Habbo — review and Save'); } else { setImportNote('Not found on Habbo for this classname'); } }, [ importResult, item ]); const handleSave = useCallback(() => { if(!isValid) return; onUpdate(item.id, form); }, [ item, form, isValid, onUpdate ]); // Expose save for keyboard shortcut saveRef.current = handleSave; const handleBack = useCallback(() => { if(isDirty && !window.confirm('You have unsaved changes. Discard and go back?')) return; onBack(); }, [ isDirty, onBack ]); const handleDeleteConfirm = useCallback(() => { onDelete(item.id); setShowDeleteDialog(false); }, [ item, onDelete ]); // Keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { if(e.ctrlKey && e.key === 's') { e.preventDefault(); saveRef.current?.(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); const inputClass = (field?: string) => `w-full px-3 py-1.5 text-sm leading-normal rounded-lg border border-slate-300 bg-[#ffffff] focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/15 transition${ field && validation[field] ? ' border-red-400 bg-red-50' : '' }`; const labelClass = 'text-[11px] font-medium text-slate-500 mb-1 flex items-center gap-0.5'; return ( { /* Header */ }
{ furniName || form.publicName || form.itemName } { form.itemName } ID { item.id } Sprite { item.spriteId } 0 ? 'border-[#a7f3d0] bg-[#ecfdf5] text-[#047857]' : 'border-slate-200 bg-slate-50 text-slate-500' }` }> 0 ? 'bg-[#10b981]' : 'bg-slate-300' }` } /> { item.usageCount } in use { isDirty && Unsaved }
{ /* Primary edit surface: furnidata display name + description (server-authoritative, live) */ }
Display name & description { furnidataEditable ? LIVE : NO FURNIDATA } { furnidataEditable && furnidataDirty && Unsaved }
{ furnidataEditable ? ( <>
setFurniName(e.target.value) } maxLength={ 256 } />
setFurniDescription(e.target.value) } maxLength={ 256 } />
{ importNote && { importNote } } ) : (
This furni has no matching furnidata entry ({ furnidataMissReason.replace(/_/g, ' ') }), so its display name can't be edited here. Clients fall back to the DB Public Name below.
) }
{ furniDataEntry &&
Read-only — how this furni resolves from the furnidata JSON (source of truth for the display name).
{ JSON.stringify(furniDataEntry, null, 2) }
}
{ JSON.stringify(furniDataDiagnostic ?? {}, null, 2) }
setField('width', Number(e.target.value)) } /> { validation.width && { validation.width } }
setField('length', Number(e.target.value)) } /> { validation.length && { validation.length } }
setField('stackHeight', Number(e.target.value)) } /> { validation.stackHeight && { validation.stackHeight } }
{ PERM_GROUPS.map(group => (
{ group.label }
{ group.keys.map(key => { const on = (form as any)[key]; return ( ); }) }
)) }
setField('interactionModesCount', Number(e.target.value)) } />
setField('customparams', e.target.value) } />
{ /* Actions */ } Ctrl+S { /* Delete Confirmation Dialog */ } { showDeleteDialog &&
setShowDeleteDialog(false) }>
e.stopPropagation() }> Delete Item? Are you sure you want to delete { item.publicName || item.itemName } (ID: { item.id })? This action cannot be undone.
} { /* Furnidata Confirmation Dialog */ } { confirmFurnidata &&
setConfirmFurnidata(false) }>
e.stopPropagation() }> Apply furnidata change to ALL clients?
Name: { String(furniDataEntry?.name ?? '') } → { furniName }
Desc: { String(furniDataEntry?.description ?? '') } → { furniDescription }
}
); };