Merge pull request #41 from simoleo89/furnisettingeditor-pr

feat(furni-editor): migrate to WebSocket and improve UI
This commit is contained in:
DuckieTM
2026-03-23 11:18:57 +01:00
committed by GitHub
6 changed files with 462 additions and 588 deletions
+110 -1
View File
@@ -1,4 +1,113 @@
{
"socket.url": "ws://localhost:2097",
"asset.url": "http://localhost:3000/nitro-assets",
"image.library.url": "http://localhost:3000/swf/c_images/",
"hof.furni.url": "http://localhost:3000/swf/dcr/hof_furni",
"images.url": "${asset.url}/images",
"gamedata.url": "${asset.url}/gamedata",
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
"external.texts.url": [ "${gamedata.url}/ExternalTexts.json", "${gamedata.url}/UITexts.json" ],
"external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3",
"furnidata.url": "${gamedata.url}/FurnitureData.json",
"productdata.url": "${gamedata.url}/ProductData.json",
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json",
"avatar.figuredata.url": "${gamedata.url}/FigureData.json",
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json",
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json",
"avatar.asset.url": "${asset.url}/bundled/figure/%libname%.nitro",
"avatar.asset.effect.url": "${asset.url}/bundled/effect/%libname%.nitro",
"furni.asset.url": "${asset.url}/bundled/furniture/%libname%.nitro",
"furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png",
"pet.asset.url": "${asset.url}/bundled/pet/%libname%.nitro",
"generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro",
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
"furni.rotation.bounce.steps": 20,
"furni.rotation.bounce.height": 0.0625,
"enable.avatar.arrow": false,
"system.log.debug": true,
"system.log.warn": true,
"system.log.error": true,
"system.log.events": true,
"system.log.packets": true,
"system.fps.animation": 24,
"system.fps.max": 60,
"system.pong.manually": true,
"system.pong.interval.ms": 20000,
"room.color.skip.transition": true,
"room.landscapes.enabled": true,
"avatar.mandatory.libraries": [
"bd:1",
"li:0"
],
"avatar.mandatory.effect.libraries": [
"dance.1",
"dance.2",
"dance.3",
"dance.4"
],
"avatar.default.figuredata": {"palettes":[{"id":1,"colors":[{"id":99999,"index":1001,"club":0,"selectable":false,"hexCode":"DDDDDD"},{"id":99998,"index":1001,"club":0,"selectable":false,"hexCode":"FAFAFA"}]},{"id":3,"colors":[{"id":10001,"index":1001,"club":0,"selectable":false,"hexCode":"EEEEEE"},{"id":10002,"index":1002,"club":0,"selectable":false,"hexCode":"FA3831"},{"id":10003,"index":1003,"club":0,"selectable":false,"hexCode":"FD92A0"},{"id":10004,"index":1004,"club":0,"selectable":false,"hexCode":"2AC7D2"},{"id":10005,"index":1005,"club":0,"selectable":false,"hexCode":"35332C"},{"id":10006,"index":1006,"club":0,"selectable":false,"hexCode":"EFFF92"},{"id":10007,"index":1007,"club":0,"selectable":false,"hexCode":"C6FF98"},{"id":10008,"index":1008,"club":0,"selectable":false,"hexCode":"FF925A"},{"id":10009,"index":1009,"club":0,"selectable":false,"hexCode":"9D597E"},{"id":10010,"index":1010,"club":0,"selectable":false,"hexCode":"B6F3FF"},{"id":10011,"index":1011,"club":0,"selectable":false,"hexCode":"6DFF33"},{"id":10012,"index":1012,"club":0,"selectable":false,"hexCode":"3378C9"},{"id":10013,"index":1013,"club":0,"selectable":false,"hexCode":"FFB631"},{"id":10014,"index":1014,"club":0,"selectable":false,"hexCode":"DFA1E9"},{"id":10015,"index":1015,"club":0,"selectable":false,"hexCode":"F9FB32"},{"id":10016,"index":1016,"club":0,"selectable":false,"hexCode":"CAAF8F"},{"id":10017,"index":1017,"club":0,"selectable":false,"hexCode":"C5C6C5"},{"id":10018,"index":1018,"club":0,"selectable":false,"hexCode":"47623D"},{"id":10019,"index":1019,"club":0,"selectable":false,"hexCode":"8A8361"},{"id":10020,"index":1020,"club":0,"selectable":false,"hexCode":"FF8C33"},{"id":10021,"index":1021,"club":0,"selectable":false,"hexCode":"54C627"},{"id":10022,"index":1022,"club":0,"selectable":false,"hexCode":"1E6C99"},{"id":10023,"index":1023,"club":0,"selectable":false,"hexCode":"984F88"},{"id":10024,"index":1024,"club":0,"selectable":false,"hexCode":"77C8FF"},{"id":10025,"index":1025,"club":0,"selectable":false,"hexCode":"FFC08E"},{"id":10026,"index":1026,"club":0,"selectable":false,"hexCode":"3C4B87"},{"id":10027,"index":1027,"club":0,"selectable":false,"hexCode":"7C2C47"},{"id":10028,"index":1028,"club":0,"selectable":false,"hexCode":"D7FFE3"},{"id":10029,"index":1029,"club":0,"selectable":false,"hexCode":"8F3F1C"},{"id":10030,"index":1030,"club":0,"selectable":false,"hexCode":"FF6393"},{"id":10031,"index":1031,"club":0,"selectable":false,"hexCode":"1F9B79"},{"id":10032,"index":1032,"club":0,"selectable":false,"hexCode":"FDFF33"}]}],"setTypes":[{"type":"hd","paletteId":1,"mandatory_f_0":true,"mandatory_f_1":true,"mandatory_m_0":true,"mandatory_m_1":true,"sets":[{"id":99999,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":1,"type":"bd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"hd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"lh","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"rh","colorable":true,"index":0,"colorindex":1}]}]},{"type":"bds","paletteId":1,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10001,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"bds","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"lhs","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"rhs","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"bd"},{"partType":"rh"},{"partType":"lh"}]}]},{"type":"ss","paletteId":3,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10010,"gender":"F","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]},{"id":10011,"gender":"M","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10002,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]}]}]},
"avatar.default.actions": {
"actions": [
{
"id": "Default",
"state": "std",
"precedence": 1000,
"main": true,
"isDefault": true,
"geometryType": "vertical",
"activePartSet": "figure",
"assetPartDefinition": "std"
}
]
},
"pet.types": [
"dog",
"cat",
"croco",
"terrier",
"bear",
"pig",
"lion",
"rhino",
"spider",
"turtle",
"chicken",
"frog",
"dragon",
"monster",
"monkey",
"horse",
"monsterplant",
"bunnyeaster",
"bunnyevil",
"bunnydepressed",
"bunnylove",
"pigeongood",
"pigeonevil",
"demonmonkey",
"bearbaby",
"terrierbaby",
"gnome",
"leprechaun",
"kittenbaby",
"puppybaby",
"pigletbaby",
"haloompa",
"fools",
"pterosaur",
"velociraptor",
"cow",
"dragondog"
],
"preload.assets.urls": [
"${asset.url}/bundled/generic/avatar_additions.nitro",
"${asset.url}/bundled/generic/group_badge.nitro",
"${asset.url}/bundled/generic/floor_editor.nitro",
"${images.url}/loading_icon.png",
"${images.url}/clear_icon.png",
"${images.url}/big_arrow.png"
]
}
"socket.url": "ws://192.168.1.52:2096",
"asset.url": "https://client.paxxo.online/nitro/bundled",
"image.library.url": "https://client.paxxo.online/c_images/",
@@ -584,4 +693,4 @@
"${images.url}/clear_icon.png",
"${images.url}/big_arrow.png"
]
}
}
+15 -17
View File
@@ -1,5 +1,5 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FC, useCallback, useEffect, useState } from 'react';
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useFurniEditor } from '../../hooks/furni-editor';
import { FurniEditorCreateView } from './views/FurniEditorCreateView';
@@ -19,8 +19,8 @@ export const FurniEditorView: FC<{}> = () =>
const {
items, total, page, loading, error, clearError,
selectedItem, catalogItems, furniDataEntry,
interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions
interactions, lastResult,
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, createItem, loadInteractions
} = useFurniEditor();
useEffect(() =>
@@ -77,10 +77,10 @@ export const FurniEditorView: FC<{}> = () =>
const { spriteId } = e.detail;
if(!Number.isFinite(spriteId) || spriteId < 0) return;
if(!spriteId || spriteId <= 0) return;
pendingEditRef.current = true;
loadBySpriteId(spriteId);
setActiveTab(TAB_EDIT);
};
window.addEventListener('furni-editor:open', handler as EventListener);
@@ -90,8 +90,8 @@ export const FurniEditorView: FC<{}> = () =>
const handleSelect = useCallback((id: number) =>
{
pendingEditRef.current = true;
loadDetail(id);
setActiveTab(TAB_EDIT);
}, [ loadDetail ]);
const handleBack = useCallback(() =>
@@ -104,12 +104,17 @@ export const FurniEditorView: FC<{}> = () =>
setIsVisible(false);
}, []);
const isMod = useMemo(() => GetSessionDataManager().isModerator, []);
const handleCreated = useCallback((id: number) =>
{
loadDetail(id);
setActiveTab(TAB_EDIT);
}, [ loadDetail ]);
if(!isVisible || !isMod) return null;
if(!GetSessionDataManager()?.isModerator) return null;
if(!isVisible) return null;
return (
<NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]">
<NitroCardView uniqueKey="furni-editor" className="min-w-[550px] w-[680px] min-h-[400px] h-[600px]">
<NitroCardHeaderView headerText="Furni Editor" onCloseClick={ handleClose } />
<NitroCardTabsView>
<NitroCardTabsItemView isActive={ activeTab === TAB_SEARCH } onClick={ () => setActiveTab(TAB_SEARCH) }>
@@ -148,6 +153,7 @@ export const FurniEditorView: FC<{}> = () =>
furniDataEntry={ furniDataEntry }
interactions={ interactions }
loading={ loading }
lastResult={ lastResult }
onUpdate={ updateItem }
onDelete={ deleteItem }
onBack={ handleBack }
@@ -155,14 +161,6 @@ export const FurniEditorView: FC<{}> = () =>
/>
}
{ activeTab === TAB_CREATE &&
<FurniEditorCreateView
interactions={ interactions }
loading={ loading }
onCreate={ createItem }
onBack={ handleBack }
/>
}
</NitroCardContentView>
</NitroCardView>
@@ -1,17 +1,23 @@
import { FC, useCallback, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common';
import { FC, useCallback, useEffect, useState } from 'react';
import { FaPlus } from 'react-icons/fa';
import { Column } from '../../../common';
interface FurniEditorCreateViewProps
{
interactions: string[];
loading: boolean;
lastResult: { success: boolean; message: string; id: number } | null;
onCreate: (fields: Record<string, unknown>) => void;
onBack: () => void;
onCreated: (id: number) => void;
}
const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors w-full';
const labelClass = 'text-[9px] text-[#666] uppercase font-bold mb-0.5 block';
export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
{
const { interactions, loading, onCreate, onBack } = props;
const { interactions, loading, lastResult, onCreate, onCreated } = props;
const [ toast, setToast ] = useState<{ type: 'success' | 'error'; message: string; id?: number } | null>(null);
const [ form, setForm ] = useState({
itemName: '',
@@ -48,6 +54,24 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
rare: false,
});
useEffect(() =>
{
if(!lastResult) return;
if(lastResult.success && lastResult.id > 0)
{
setToast({ type: 'success', message: `Item created with ID #${ lastResult.id }`, id: lastResult.id });
setTimeout(() => onCreated(lastResult.id), 1500);
}
else if(!lastResult.success)
{
setToast({ type: 'error', message: lastResult.message });
}
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}, [ lastResult ]);
const setField = useCallback((key: string, value: unknown) =>
{
setForm(prev => ({ ...prev, [key]: value }));
@@ -56,23 +80,24 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
const handleCreate = useCallback(() =>
{
if(!form.itemName || !form.publicName) return;
onCreate(form);
}, [ form, onCreate ]);
const inputClass = 'form-control form-control-sm';
const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
return (
<Column gap={ 1 } className="h-full overflow-auto">
<Flex gap={ 1 } alignItems="center" className="mb-1">
<Button variant="secondary" onClick={ onBack }>Back</Button>
<Text bold className="text-[14px]">Create New Item</Text>
</Flex>
{ /* Toast */ }
{ toast &&
<div className={ `rounded px-3 py-1.5 text-[11px] font-bold text-white ${ toast.type === 'success' ? 'bg-[#28a745]' : 'bg-[#dc3545]' }` }>
{ toast.message }
</div>
}
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
<div className="grid grid-cols-2 gap-2">
{ /* Basic Info */ }
<div className="border-2 border-card-grid-item-border rounded overflow-hidden">
<div className="px-2.5 py-1.5 bg-[#f0f4f7]">
<span className="text-[9px] text-primary uppercase font-bold tracking-wide">Basic Info</span>
</div>
<div className="p-2.5 bg-white grid grid-cols-2 gap-2">
<div>
<label className={ labelClass }>Item Name *</label>
<input className={ inputClass } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } placeholder="my_custom_furni" />
@@ -91,7 +116,7 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
</div>
<div>
<label className={ labelClass }>Type</label>
<select className="form-select form-select-sm" value={ form.type } onChange={ e => setField('type', e.target.value) }>
<select className={ inputClass } value={ form.type } onChange={ e => setField('type', e.target.value) }>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
@@ -99,9 +124,12 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
</div>
</div>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
<div className="grid grid-cols-4 gap-2">
{ /* Dimensions */ }
<div className="border-2 border-card-grid-item-border rounded overflow-hidden">
<div className="px-2.5 py-1.5 bg-[#f0f4f7]">
<span className="text-[9px] text-primary uppercase font-bold tracking-wide">Dimensions</span>
</div>
<div className="p-2.5 bg-white grid grid-cols-3 gap-2">
<div>
<label className={ labelClass }>Width</label>
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
@@ -121,14 +149,17 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
</div>
</div>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Permissions</Text>
<div className="grid grid-cols-3 gap-x-3 gap-y-1">
{ /* Permissions */ }
<div className="border-2 border-card-grid-item-border rounded overflow-hidden">
<div className="px-2.5 py-1.5 bg-[#f0f4f7]">
<span className="text-[9px] text-primary uppercase font-bold tracking-wide">Permissions</span>
</div>
<div className="p-2.5 bg-white grid grid-cols-3 gap-x-3 gap-y-1.5">
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<label key={ key } className="flex items-center gap-1.5 text-[11px] cursor-pointer hover:text-primary transition-colors">
<input
type="checkbox"
className="form-check-input"
className="accent-primary"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
@@ -138,83 +169,44 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
</div>
</div>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Interaction</Text>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<label className={ labelClass }>Type</label>
<select className="form-select form-select-sm" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
<option value="">none</option>
{ interactions.map(i => (
<option key={ i } value={ i }>{ i }</option>
)) }
</select>
</div>
<div>
<label className={ labelClass }>Modes</label>
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
</div>
{ /* Interaction */ }
<div className="border-2 border-card-grid-item-border rounded overflow-hidden">
<div className="px-2.5 py-1.5 bg-[#f0f4f7]">
<span className="text-[9px] text-primary uppercase font-bold tracking-wide">Interaction</span>
</div>
<div className="mt-1">
<label className={ labelClass }>Custom Params</label>
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
<div className="p-2.5 bg-white">
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<label className={ labelClass }>Type</label>
<select className={ inputClass } value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
<option value="">none</option>
{ interactions.map(i => (
<option key={ i } value={ i }>{ i }</option>
)) }
</select>
</div>
<div>
<label className={ labelClass }>Modes</label>
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
</div>
</div>
<div className="mt-2">
<label className={ labelClass }>Custom Params</label>
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
</div>
</div>
</div>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
<div className="grid grid-cols-3 gap-2">
<div>
<label className={ labelClass }>Revision</label>
<input type="number" className={ inputClass } value={ form.revision } onChange={ e => setField('revision', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Category</label>
<input className={ inputClass } value={ form.category } onChange={ e => setField('category', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Offer ID</label>
<input type="number" className={ inputClass } value={ form.offerid } onChange={ e => setField('offerid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Rent Offer ID</label>
<input type="number" className={ inputClass } value={ form.rentofferid } onChange={ e => setField('rentofferid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Furniline</label>
<input className={ inputClass } value={ form.furniline } onChange={ e => setField('furniline', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Environment</label>
<input className={ inputClass } value={ form.environment } onChange={ e => setField('environment', e.target.value) } />
</div>
</div>
<div className="grid grid-cols-4 gap-x-3 gap-y-1 mt-1">
{ [
['buyout', 'Buyout'],
['rentbuyout', 'Rent Buyout'],
['bc', 'BC'],
['excludeddynamic', 'Excl. Dynamic'],
['rare', 'Rare']
].map(([ key, label ]) => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="form-check-input"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
{ label }
</label>
)) }
</div>
{ /* Create Button */ }
<div className="pt-1">
<button
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-[11px] font-bold bg-[#28a745] text-white hover:bg-[#218838] transition-colors cursor-pointer disabled:opacity-50"
disabled={ loading || !form.itemName || !form.publicName }
onClick={ handleCreate }
>
<FaPlus className="text-[9px]" /> { loading ? 'Creating...' : 'Create Item' }
</button>
</div>
<Flex className="mt-1">
<Button variant="success" disabled={ loading || !form.itemName || !form.publicName } onClick={ handleCreate }>
{ loading ? 'Creating...' : 'Create Item' }
</Button>
</Flex>
</Column>
);
};
@@ -1,5 +1,7 @@
import { FC, useCallback, useEffect, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common';
import { FaSave, FaSync, FaTrash, FaArrowLeft } from 'react-icons/fa';
import { Column } from '../../../common';
import { LayoutFurniIconImageView } from '../../../common/layout/LayoutFurniIconImageView';
import { CatalogRef, FurniDetail } from '../../../hooks/furni-editor';
interface FurniEditorEditViewProps
@@ -9,20 +11,24 @@ interface FurniEditorEditViewProps
furniDataEntry: Record<string, unknown> | null;
interactions: string[];
loading: boolean;
lastResult: { success: boolean; message: string; id: number } | null;
onUpdate: (id: number, fields: Record<string, unknown>) => void;
onDelete: (id: number) => void;
onBack: () => void;
onRefresh: (id: number) => void;
}
const ic = 'text-[13px] border border-[#c5cdd6] rounded px-2 py-1 bg-white focus:outline-none focus:border-[#1e7295] focus:shadow-[0_0_0_1px_rgba(30,114,149,0.15)] transition-all w-full';
const ro = 'text-[13px] border border-[#d5dbe0] rounded px-2 py-1 bg-[#f0f2f4] text-[#777] w-full cursor-not-allowed';
const lb = 'text-[11px] text-[#1e7295] uppercase font-bold tracking-wider leading-none';
const sectionTitle = 'text-[12px] text-[#1e7295] uppercase font-bold tracking-wider';
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
{
const { item, catalogItems, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onRefresh } = props;
const { item, catalogItems, furniDataEntry, interactions, loading, lastResult, onUpdate, onDelete, onBack, onRefresh } = props;
const [ form, setForm ] = useState({
itemName: '',
publicName: '',
spriteId: 0,
type: 's',
width: 1,
length: 1,
@@ -55,15 +61,14 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
});
const [ confirmDelete, setConfirmDelete ] = useState(false);
const [ toast, setToast ] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
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,
@@ -98,6 +103,17 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
setConfirmDelete(false);
}, [ item ]);
useEffect(() =>
{
if(!lastResult) return;
setToast({ type: lastResult.success ? 'success' : 'error', message: lastResult.message });
if(lastResult.success && lastResult.id > 0) onRefresh(lastResult.id);
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}, [ lastResult ]);
const setField = useCallback((key: string, value: unknown) =>
{
setForm(prev => ({ ...prev, [key]: value }));
@@ -111,71 +127,84 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
const handleDelete = useCallback(() =>
{
if(!confirmDelete) return setConfirmDelete(true);
onDelete(item.id);
onBack();
}, [ confirmDelete, item, onDelete, onBack ]);
const inputClass = 'form-control form-control-sm';
const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
}, [ confirmDelete, item, onDelete ]);
return (
<Column gap={ 1 } className="h-full overflow-auto">
<Flex gap={ 1 } alignItems="center" className="mb-1">
<Button variant="secondary" onClick={ onBack }>Back</Button>
<Flex alignItems="center" gap={ 1 } className="bg-[#e9ecef] px-2 py-0.5 rounded">
<Text bold className="text-[12px]">ID: { item.id }</Text>
<span className="text-[#999] mx-0.5">|</span>
<Text bold className="text-[12px]">Sprite: { item.spriteId }</Text>
</Flex>
<Text small variant="gray">({ item.usageCount } in use)</Text>
</Flex>
<Column gap={ 0 } className="h-full overflow-auto">
{ toast &&
<div className={ `rounded px-2 py-1 text-[12px] font-bold text-white mb-1.5 shadow-sm ${ toast.type === 'success' ? 'bg-[#28a745]' : 'bg-[#dc3545]' }` }>
{ toast.message }
</div>
}
{ /* Header */ }
<div className="flex items-center gap-3 mb-2 pb-2 border-b-2 border-[#c5cdd6]">
<div className="w-[46px] h-[46px] flex items-center justify-center bg-white rounded-md border border-[#c5cdd6] flex-shrink-0 shadow-sm overflow-hidden">
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } style={ { transform: 'scale(1.2)' } } />
</div>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-bold text-[#2d3748] truncate leading-tight">{ item.publicName }</div>
<div className="flex items-center gap-1.5 text-[11px] mt-0.5">
<span className="text-[#1e7295] font-bold cursor-pointer hover:underline" title="Click to copy ID" onClick={ () => { navigator.clipboard.writeText(String(item.id)); setToast({ type: 'success', message: `ID ${item.id} copied!` }); } }>#{item.id}</span>
<span className="text-[#d0d5db]">|</span>
<span className="text-[#4a5568]">sprite:<b>{ item.spriteId }</b></span>
<span className="text-[#d0d5db]">|</span>
<span className="truncate max-w-[140px] text-[#4a5568]">{ item.itemName }</span>
<span className={ `px-2 py-[2px] rounded text-white text-[11px] font-bold ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#718096]' }` }>
{ item.type === 's' ? 'FLOOR' : 'WALL' }
</span>
{ item.usageCount > 0 && <span className="text-[#e53e3e] font-bold">{ item.usageCount } in use</span> }
</div>
</div>
<div className="flex gap-1 flex-shrink-0">
<button className="p-1.5 rounded-md bg-[#edf2f7] hover:bg-[#e2e8f0] cursor-pointer transition-colors" onClick={ () => onRefresh(item.id) } title="Refresh">
<FaSync className="text-[9px] text-[#718096]" />
</button>
<button className="flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-bold bg-[#edf2f7] hover:bg-[#e2e8f0] text-[#4a5568] cursor-pointer transition-colors" onClick={ onBack }>
<FaArrowLeft className="text-[7px]" /> Back
</button>
</div>
</div>
{ /* Basic Info */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={ labelClass }>Item Name</label>
<input className={ inputClass } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Public Name</label>
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
</div>
<div className="col-span-2">
<label className={ labelClass }>Description</label>
<textarea className={ inputClass } rows={ 2 } value={ form.description } onChange={ e => setField('description', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Sprite ID</label>
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Type</label>
<select className="form-select form-select-sm" value={ form.type } onChange={ e => setField('type', e.target.value) }>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</div>
<div className="grid grid-cols-4 gap-x-2 gap-y-1.5 pb-2 border-b-2 border-[#c5cdd6]">
<div>
<label className={ lb }>Item Name</label>
<input className={ ro } value={ item.itemName } readOnly />
</div>
<div>
<label className={ lb }>Public Name</label>
<input className={ ic } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
</div>
<div>
<label className={ lb }>Sprite ID</label>
<input className={ ro } value={ item.spriteId } readOnly />
</div>
<div>
<label className={ lb }>Type</label>
<select className={ ic } value={ form.type } onChange={ e => setField('type', e.target.value) }>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</div>
</div>
{ /* Dimensions */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
<div className="grid grid-cols-4 gap-2">
<div className="pt-2 pb-2 border-b-2 border-[#c5cdd6]">
<div className={ sectionTitle + ' mb-1' }>Dimensions</div>
<div className="grid grid-cols-3 gap-x-2">
<div>
<label className={ labelClass }>Width</label>
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
<label className={ lb }>Width</label>
<input type="number" className={ ic } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Length</label>
<input type="number" className={ inputClass } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
<label className={ lb }>Length</label>
<input type="number" className={ ic } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Stack Height</label>
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
<label className={ lb }>Stack Height</label>
<input type="number" step="0.01" className={ ic } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Default Dir</label>
@@ -185,17 +214,12 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</div>
{ /* Permissions */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Permissions</Text>
<div className="grid grid-cols-3 gap-x-3 gap-y-1">
<div className="pt-2 pb-2 border-b-2 border-[#c5cdd6]">
<div className={ sectionTitle + ' mb-1' }>Permissions</div>
<div className="grid grid-cols-3 gap-x-2 gap-y-[3px]">
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="form-check-input"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
<label key={ key } className="flex items-center gap-1 text-[12px] text-[#4a5568] cursor-pointer hover:text-[#1e7295] transition-colors">
<input type="checkbox" className="accent-[#1e7295] w-3 h-3" checked={ (form as any)[key] } onChange={ e => setField(key, e.target.checked) } />
{ key.replace('allow', '') }
</label>
)) }
@@ -203,107 +227,44 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</div>
{ /* Interaction */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Interaction</Text>
<div className="grid grid-cols-3 gap-2">
<div className="pt-2">
<div className={ sectionTitle + ' mb-1' }>Interaction</div>
<div className="grid grid-cols-4 gap-x-2">
<div className="col-span-2">
<label className={ labelClass }>Type</label>
<select className="form-select form-select-sm" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
<label className={ lb }>Type</label>
<select className={ ic } value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
<option value="">none</option>
{ interactions.map(i => (
<option key={ i } value={ i }>{ i }</option>
)) }
{ interactions.map(i => <option key={ i } value={ i }>{ i }</option>) }
</select>
</div>
<div>
<label className={ labelClass }>Modes</label>
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
<label className={ lb }>Modes</label>
<input type="number" className={ ic } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
</div>
<div>
<label className={ lb }>Custom Params</label>
<input className={ ic } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
</div>
</div>
<div className="mt-1">
<label className={ labelClass }>Custom Params</label>
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
</div>
</div>
{ /* FurniData JSON */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
<div className="grid grid-cols-3 gap-2">
<div>
<label className={ labelClass }>Revision</label>
<input type="number" className={ inputClass } value={ form.revision } onChange={ e => setField('revision', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Category</label>
<input className={ inputClass } value={ form.category } onChange={ e => setField('category', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Offer ID</label>
<input type="number" className={ inputClass } value={ form.offerid } onChange={ e => setField('offerid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Rent Offer ID</label>
<input type="number" className={ inputClass } value={ form.rentofferid } onChange={ e => setField('rentofferid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Furniline</label>
<input className={ inputClass } value={ form.furniline } onChange={ e => setField('furniline', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Environment</label>
<input className={ inputClass } value={ form.environment } onChange={ e => setField('environment', e.target.value) } />
</div>
</div>
<div className="grid grid-cols-4 gap-x-3 gap-y-1 mt-1">
{ [
['buyout', 'Buyout'],
['rentbuyout', 'Rent Buyout'],
['bc', 'BC'],
['excludeddynamic', 'Excl. Dynamic'],
['rare', 'Rare']
].map(([ key, label ]) => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="form-check-input"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
{ label }
</label>
)) }
</div>
</div>
{ /* Catalog References */ }
{ catalogItems.length > 0 &&
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Catalog ({ catalogItems.length })</Text>
<div className="text-[10px] space-y-0.5">
{ catalogItems.map(ci => (
<div key={ ci.id } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
<span>{ ci.catalogName } (page: { ci.pageName })</span>
<span>{ ci.costCredits }c + { ci.costPoints }p</span>
</div>
)) }
</div>
</div>
}
{ /* Actions */ }
<Flex gap={ 1 } justifyContent="between" className="mt-1">
<Button variant="success" disabled={ loading } onClick={ handleSave }>
{ loading ? 'Saving...' : 'Save' }
</Button>
<Button
variant={ confirmDelete ? 'danger' : 'warning' }
<div className="flex justify-between items-center mt-auto pt-2 border-t border-[#e2e8f0]">
<button
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[12px] font-bold bg-[#28a745] text-white hover:bg-[#218838] shadow-sm transition-all cursor-pointer disabled:opacity-50"
disabled={ loading }
onClick={ handleSave }
>
<FaSave className="text-[8px]" /> { loading ? 'Saving...' : 'Save' }
</button>
<button
className={ `flex items-center gap-1 px-3 py-1.5 rounded-md text-[12px] font-bold text-white shadow-sm transition-all cursor-pointer disabled:opacity-50 ${ confirmDelete ? 'bg-[#dc3545] hover:bg-[#c82333]' : 'bg-[#e8993e] hover:bg-[#d98a30]' }` }
disabled={ loading || item.usageCount > 0 }
onClick={ handleDelete }
>
{ confirmDelete ? 'Confirm Delete' : 'Delete' }
</Button>
</Flex>
<FaTrash className="text-[8px]" /> { confirmDelete ? 'Confirm' : 'Delete' }
</button>
</div>
</Column>
);
};
@@ -1,5 +1,7 @@
import { FC, useCallback, useEffect, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common';
import { FaSearch } from 'react-icons/fa';
import { Column, Text } from '../../../common';
import { LayoutFurniIconImageView } from '../../../common/layout/LayoutFurniIconImageView';
import { FurniItem } from '../../../hooks/furni-editor';
interface FurniEditorSearchViewProps
@@ -12,6 +14,8 @@ interface FurniEditorSearchViewProps
onSelect: (id: number) => void;
}
const inputClass = 'text-[14px] border border-[#c5cdd6] rounded px-2 py-1.5 bg-white focus:outline-none focus:border-[#1e7295] transition-colors w-full';
export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
{
const { items, total, page, loading, onSearch, onSelect } = props;
@@ -37,97 +41,122 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
return (
<Column gap={ 1 } className="h-full">
<Flex gap={ 1 } alignItems="end">
<Column gap={ 0 } className="flex-1">
<Text small bold>Search</Text>
{ /* Search Bar */ }
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="text-[12px] text-[#1e7295] uppercase font-bold mb-0.5 block">Search</label>
<input
type="text"
className="form-control form-control-sm"
className={ inputClass }
placeholder="ID, name or sprite ID..."
value={ query }
onChange={ e => setQuery(e.target.value) }
onKeyDown={ handleKeyDown }
/>
</Column>
<Column gap={ 0 } className="w-[80px]">
<Text small bold>Type</Text>
<select
className="form-select form-select-sm"
value={ typeFilter }
onChange={ e => setTypeFilter(e.target.value) }
>
</div>
<div className="w-[100px]">
<label className="text-[12px] text-[#1e7295] uppercase font-bold mb-0.5 block">Type</label>
<select className={ inputClass } value={ typeFilter } onChange={ e => setTypeFilter(e.target.value) }>
<option value="">All</option>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
<option value="s">Floor</option>
<option value="i">Wall</option>
</select>
</Column>
<Button variant="primary" disabled={ loading } onClick={ handleSearch }>
{ loading ? '...' : 'Search' }
</Button>
</Flex>
</div>
<button
className="flex items-center gap-1.5 px-4 py-1.5 rounded text-[13px] font-bold bg-[#1e7295] text-white hover:bg-[#185d79] transition-colors cursor-pointer disabled:opacity-50"
disabled={ loading }
onClick={ handleSearch }
>
<FaSearch className="text-[11px]" /> { loading ? '...' : 'Search' }
</button>
</div>
<Column gap={ 0 } className="flex-1 overflow-auto border border-[#ccc] rounded bg-white">
<table className="w-full text-xs">
<thead>
<tr className="bg-[#e8e8e8] sticky top-0">
<th className="px-2 py-1 text-left">ID</th>
<th className="px-2 py-1 text-left">Sprite</th>
<th className="px-2 py-1 text-left">Name</th>
<th className="px-2 py-1 text-left">Public Name</th>
<th className="px-2 py-1 text-center">Type</th>
<th className="px-2 py-1 text-left">Interaction</th>
</tr>
</thead>
<tbody>
{ items.map(item => (
<tr
key={ item.id }
className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors"
onClick={ () => onSelect(item.id) }
>
<td className="px-2 py-1 font-mono">{ item.id }</td>
<td className="px-2 py-1 font-mono">{ item.spriteId }</td>
<td className="px-2 py-1 truncate max-w-[120px]">{ item.itemName }</td>
<td className="px-2 py-1 truncate max-w-[120px]">{ item.publicName }</td>
<td className="px-2 py-1 text-center">
<span className={ `px-1 rounded text-white text-[10px] ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#6b7280]' }` }>
{ item.type === 's' ? 'Floor' : 'Wall' }
</span>
</td>
<td className="px-2 py-1 text-[10px]">{ item.interactionType || '-' }</td>
</tr>
)) }
{ items.length === 0 && !loading &&
<tr>
<td colSpan={ 6 } className="px-2 py-4 text-center text-[#999]">No items found</td>
</tr>
}
</tbody>
</table>
</Column>
{ /* Results counter */ }
{ total > 0 &&
<div className="text-[13px] text-[#4a5568]">
<b className="text-[#1e7295]">{ total }</b> items found { totalPages > 1 && <span>- Page <b>{ page }</b>/{ totalPages }</span> }
</div>
}
{ /* Results Table */ }
<div className="flex-1 overflow-auto border border-[#c5cdd6] rounded bg-white">
{ loading &&
<div className="flex items-center justify-center py-8">
<div className="text-[14px] text-[#4a5568] animate-pulse">Loading...</div>
</div>
}
{ !loading && items.length === 0 &&
<div className="flex items-center justify-center py-8 text-[14px] text-[#4a5568]">
No items found
</div>
}
{ !loading && items.length > 0 &&
<table className="w-full text-[14px]">
<thead>
<tr className="bg-[#f0f4f7] sticky top-0 text-[12px] text-[#1e7295] uppercase font-bold">
<th className="px-2 py-2 text-center w-[44px]"></th>
<th className="px-2 py-2 text-left w-[55px]">ID</th>
<th className="px-2 py-2 text-left w-[60px]">Sprite</th>
<th className="px-2 py-2 text-left">Name</th>
<th className="px-2 py-2 text-left">Public Name</th>
<th className="px-2 py-2 text-center w-[60px]">Type</th>
<th className="px-2 py-2 text-left">Interaction</th>
</tr>
</thead>
<tbody>
{ items.map(item => (
<tr
key={ item.id }
className="cursor-pointer hover:bg-[#e8f4fb] border-b border-[#f0f0f0] transition-colors"
onClick={ () => onSelect(item.id) }
>
<td className="px-2 py-1 text-center">
<div className="w-[34px] h-[34px] flex items-center justify-center mx-auto">
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } />
</div>
</td>
<td className="px-2 py-1 font-mono text-[14px] text-[#1e7295] font-bold">{ item.id }</td>
<td className="px-2 py-1 font-mono text-[14px] text-[#4a5568]">{ item.spriteId }</td>
<td className="px-2 py-1 text-[14px] text-[#2d3748] truncate max-w-[130px]">{ item.itemName }</td>
<td className="px-2 py-1 text-[14px] text-[#2d3748] truncate max-w-[130px]">{ item.publicName }</td>
<td className="px-2 py-1 text-center">
<span className={ `px-2 py-0.5 rounded text-white text-[11px] font-bold ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#718096]' }` }>
{ item.type === 's' ? 'Floor' : 'Wall' }
</span>
</td>
<td className="px-2 py-1 text-[14px] text-[#4a5568]">{ item.interactionType || '-' }</td>
</tr>
)) }
</tbody>
</table>
}
</div>
{ /* Pagination */ }
{ totalPages > 1 &&
<Flex gap={ 1 } justifyContent="between" alignItems="center">
<Text small variant="gray">
{ total } items - Page { page }/{ totalPages }
</Text>
<Flex gap={ 1 }>
<Button
variant="secondary"
<div className="flex justify-between items-center">
<div className="text-[13px] text-[#4a5568]">
Page <b>{ page }</b> of <b>{ totalPages }</b>
</div>
<div className="flex gap-1.5">
<button
className="px-3 py-1.5 rounded text-[13px] font-bold bg-[#edf2f7] hover:bg-[#e2e8f0] text-[#4a5568] transition-colors cursor-pointer disabled:opacity-40"
disabled={ page <= 1 }
onClick={ () => onSearch(query, typeFilter, page - 1) }
>
Prev
</Button>
<Button
variant="secondary"
</button>
<button
className="px-3 py-1.5 rounded text-[13px] font-bold bg-[#edf2f7] hover:bg-[#e2e8f0] text-[#4a5568] transition-colors cursor-pointer disabled:opacity-40"
disabled={ page >= totalPages }
onClick={ () => onSearch(query, typeFilter, page + 1) }
>
Next
</Button>
</Flex>
</Flex>
</button>
</div>
</div>
}
</Column>
);
+32 -247
View File
@@ -1,4 +1,4 @@
import { FurniEditorBySpriteComposer, FurniEditorCreateComposer, FurniEditorCreateResultEvent, FurniEditorDeleteComposer, FurniEditorDeleteResultEvent, FurniEditorDetailComposer, FurniEditorDetailResultEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsResultEvent, FurniEditorSearchComposer, FurniEditorSearchResultEvent, FurniEditorUpdateComposer, FurniEditorUpdateResultEvent } from '@nitrots/nitro-renderer';
import { FurniEditorBySpriteComposer, FurniEditorCreateComposer, FurniEditorDeleteComposer, FurniEditorDetailComposer, FurniEditorDetailEvent as FurniEditorDetailMsgEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsEvent as FurniEditorInteractionsMsgEvent, FurniEditorResultEvent as FurniEditorResultMsgEvent, FurniEditorSearchComposer, FurniEditorSearchEvent as FurniEditorSearchMsgEvent, FurniEditorUpdateComposer } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
@@ -61,59 +61,6 @@ export interface CatalogRef
pageName: string;
}
export const MAX_STRING_LENGTH = 255;
export const MAX_CUSTOM_PARAMS_LENGTH = 1000;
export const MAX_DIMENSION = 100;
export const MAX_STACK_HEIGHT = 100;
export const MAX_MODES_COUNT = 100;
export interface FurniFormErrors
{
itemName?: string;
publicName?: string;
spriteId?: string;
width?: string;
length?: string;
stackHeight?: string;
interactionModesCount?: string;
customparams?: string;
}
export function validateFurniForm(fields: Record<string, unknown>): FurniFormErrors
{
const errors: FurniFormErrors = {};
const itemName = String(fields.itemName ?? '').trim();
const publicName = String(fields.publicName ?? '').trim();
if(!itemName) errors.itemName = 'Item name is required';
else if(itemName.length > MAX_STRING_LENGTH) errors.itemName = `Max ${ MAX_STRING_LENGTH } characters`;
else if(!/^[a-zA-Z0-9_\- ]+$/.test(itemName)) errors.itemName = 'Only letters, numbers, _, - and spaces';
if(!publicName) errors.publicName = 'Public name is required';
else if(publicName.length > MAX_STRING_LENGTH) errors.publicName = `Max ${ MAX_STRING_LENGTH } characters`;
const spriteId = Number(fields.spriteId);
if(!Number.isFinite(spriteId) || spriteId < 0) errors.spriteId = 'Must be a positive number';
const width = Number(fields.width);
const length = Number(fields.length);
const stackHeight = Number(fields.stackHeight);
const modes = Number(fields.interactionModesCount);
if(!Number.isFinite(width) || width < 1 || width > MAX_DIMENSION) errors.width = `1-${ MAX_DIMENSION }`;
if(!Number.isFinite(length) || length < 1 || length > MAX_DIMENSION) errors.length = `1-${ MAX_DIMENSION }`;
if(!Number.isFinite(stackHeight) || stackHeight < 0 || stackHeight > MAX_STACK_HEIGHT) errors.stackHeight = `0-${ MAX_STACK_HEIGHT }`;
if(!Number.isFinite(modes) || modes < 0 || modes > MAX_MODES_COUNT) errors.interactionModesCount = `0-${ MAX_MODES_COUNT }`;
const customparams = String(fields.customparams ?? '');
if(customparams.length > MAX_CUSTOM_PARAMS_LENGTH) errors.customparams = `Max ${ MAX_CUSTOM_PARAMS_LENGTH } characters`;
return errors;
}
export const useFurniEditor = () =>
{
const [ items, setItems ] = useState<FurniItem[]>([]);
@@ -125,151 +72,62 @@ export const useFurniEditor = () =>
const [ catalogItems, setCatalogItems ] = useState<CatalogRef[]>([]);
const [ interactions, setInteractions ] = useState<string[]>([]);
const [ furniDataEntry, setFurniDataEntry ] = useState<Record<string, unknown> | null>(null);
const [ lastResult, setLastResult ] = useState<{ success: boolean; message: string; id: number } | null>(null);
const clearError = useCallback(() => setError(null), []);
// --- Message event handlers (incoming from server) ---
useMessageEvent<FurniEditorSearchResultEvent>(FurniEditorSearchResultEvent, useCallback(event =>
// Listen for search results
useMessageEvent(FurniEditorSearchMsgEvent, (event: any) =>
{
const parser = event.getParser();
setItems(parser.items.map(i => ({
id: i.id,
spriteId: i.spriteId,
itemName: i.itemName,
publicName: i.publicName,
type: i.type,
width: i.width,
length: i.length,
stackHeight: i.stackHeight,
allowStack: i.allowStack,
allowWalk: i.allowWalk,
allowSit: i.allowSit,
allowLay: i.allowLay,
interactionType: i.interactionType,
interactionModesCount: i.interactionModesCount
})));
setItems(parser.items);
setTotal(parser.total);
setPage(parser.page);
setLoading(false);
}, []));
});
useMessageEvent<FurniEditorDetailResultEvent>(FurniEditorDetailResultEvent, useCallback(event =>
// Listen for detail results
useMessageEvent(FurniEditorDetailMsgEvent, (event: any) =>
{
const parser = event.getParser();
const i = parser.item;
setSelectedItem({
id: i.id,
spriteId: i.spriteId,
itemName: i.itemName,
publicName: i.publicName,
type: i.type,
width: i.width,
length: i.length,
stackHeight: i.stackHeight,
allowStack: i.allowStack,
allowWalk: i.allowWalk,
allowSit: i.allowSit,
allowLay: i.allowLay,
allowGift: i.allowGift,
allowTrade: i.allowTrade,
allowRecycle: i.allowRecycle,
allowMarketplaceSell: i.allowMarketplaceSell,
allowInventoryStack: i.allowInventoryStack,
interactionType: i.interactionType,
interactionModesCount: i.interactionModesCount,
customparams: i.customparams,
effectIdMale: i.effectIdMale,
effectIdFemale: i.effectIdFemale,
clothingOnWalk: i.clothingOnWalk,
vendingIds: i.vendingIds,
multiheight: i.multiheight,
description: i.description,
usageCount: i.usageCount,
revision: parser.revision,
category: parser.category,
defaultdir: parser.defaultdir,
offerid: parser.offerid,
buyout: parser.buyout,
rentofferid: parser.rentofferid,
rentbuyout: parser.rentbuyout,
bc: parser.bc,
excludeddynamic: parser.excludeddynamic,
furniline: parser.furniline,
environment: parser.environment,
rare: parser.rare
});
setSelectedItem(parser.item as FurniDetail);
setCatalogItems(parser.catalogItems as CatalogRef[]);
setCatalogItems(parser.catalogItems.map(ci => ({
id: ci.id,
catalogName: ci.catalogName,
costCredits: ci.costCredits,
costPoints: ci.costPoints,
pointsType: ci.pointsType,
pageId: ci.pageId,
pageName: ci.pageName
})));
let furniData: Record<string, unknown> | null = null;
if(parser.furniDataEntry)
try
{
try { furniData = JSON.parse(parser.furniDataEntry); }
catch { furniData = null; }
setFurniDataEntry(parser.furniDataJson ? JSON.parse(parser.furniDataJson) : null);
}
catch
{
setFurniDataEntry(null);
}
setFurniDataEntry(furniData);
setLoading(false);
}, []));
});
useMessageEvent<FurniEditorInteractionsResultEvent>(FurniEditorInteractionsResultEvent, useCallback(event =>
{
setInteractions(event.getParser().interactions);
}, []));
useMessageEvent<FurniEditorUpdateResultEvent>(FurniEditorUpdateResultEvent, useCallback(event =>
// Listen for interactions results
useMessageEvent(FurniEditorInteractionsMsgEvent, (event: any) =>
{
const parser = event.getParser();
setInteractions(parser.interactions);
});
// Listen for operation results (update/create/delete)
useMessageEvent(FurniEditorResultMsgEvent, (event: any) =>
{
const parser = event.getParser();
setLastResult({ success: parser.success, message: parser.message, id: parser.id });
setLoading(false);
if(!parser.success)
{
setError(parser.message);
}
else if(parser.id > 0)
{
SendMessageComposer(new FurniEditorDetailComposer(parser.id));
}
}, []));
useMessageEvent<FurniEditorCreateResultEvent>(FurniEditorCreateResultEvent, useCallback(event =>
{
const parser = event.getParser();
setLoading(false);
if(!parser.success)
{
setError(parser.message);
}
}, []));
useMessageEvent<FurniEditorDeleteResultEvent>(FurniEditorDeleteResultEvent, useCallback(event =>
{
const parser = event.getParser();
setLoading(false);
if(!parser.success)
{
setError(parser.message);
}
}, []));
// --- Outgoing commands (client to server) ---
});
const searchItems = useCallback((query: string, type: string, pg: number) =>
{
@@ -296,87 +154,14 @@ export const useFurniEditor = () =>
{
setLoading(true);
setError(null);
const f = fields;
SendMessageComposer(new FurniEditorUpdateComposer(
id,
String(f.itemName ?? ''),
String(f.publicName ?? ''),
Number(f.spriteId ?? 0),
String(f.type ?? 's'),
Number(f.width ?? 1),
Number(f.length ?? 1),
Number(f.stackHeight ?? 0),
!!f.allowStack,
!!f.allowWalk,
!!f.allowSit,
!!f.allowLay,
!!f.allowGift,
!!f.allowTrade,
!!f.allowRecycle,
!!f.allowMarketplaceSell,
!!f.allowInventoryStack,
String(f.interactionType ?? ''),
Number(f.interactionModesCount ?? 0),
String(f.customparams ?? ''),
String(f.description ?? ''),
Number(f.revision ?? 0),
String(f.category ?? ''),
Number(f.defaultdir ?? 0),
Number(f.offerid ?? 0),
!!f.buyout,
Number(f.rentofferid ?? 0),
!!f.rentbuyout,
!!f.bc,
!!f.excludeddynamic,
String(f.furniline ?? ''),
String(f.environment ?? ''),
!!f.rare
));
SendMessageComposer(new FurniEditorUpdateComposer(id, JSON.stringify(fields)));
}, []);
const createItem = useCallback((fields: Record<string, unknown>) =>
{
setLoading(true);
setError(null);
const f = fields;
SendMessageComposer(new FurniEditorCreateComposer(
String(f.itemName ?? ''),
String(f.publicName ?? ''),
Number(f.spriteId ?? 0),
String(f.type ?? 's'),
Number(f.width ?? 1),
Number(f.length ?? 1),
Number(f.stackHeight ?? 0),
!!f.allowStack,
!!f.allowWalk,
!!f.allowSit,
!!f.allowLay,
!!f.allowGift,
!!f.allowTrade,
!!f.allowRecycle,
!!f.allowMarketplaceSell,
!!f.allowInventoryStack,
String(f.interactionType ?? ''),
Number(f.interactionModesCount ?? 0),
String(f.customparams ?? ''),
String(f.description ?? ''),
Number(f.revision ?? 0),
String(f.category ?? ''),
Number(f.defaultdir ?? 0),
Number(f.offerid ?? 0),
!!f.buyout,
Number(f.rentofferid ?? 0),
!!f.rentbuyout,
!!f.bc,
!!f.excludeddynamic,
String(f.furniline ?? ''),
String(f.environment ?? ''),
!!f.rare
));
SendMessageComposer(new FurniEditorCreateComposer(JSON.stringify(fields)));
}, []);
const deleteItem = useCallback((id: number) =>
@@ -394,7 +179,7 @@ export const useFurniEditor = () =>
return {
items, total, page, loading, error, clearError,
selectedItem, setSelectedItem, catalogItems, furniDataEntry,
interactions,
interactions, lastResult,
searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions
};
};