Add FurniEditor tool with Next.js API integration

- FurniEditor component with Search/Edit tabs (NitroCard UI)
- useFurniEditor hook connecting to Next.js API routes via Vite proxy
- Edit Furni button in room infostand (godMode) with sprite ID lookup
- Toolbar: 3-column flex layout (icons | chat | friends)
- Heroicons SVG for ID/Sprite display in infostand and edit view
- Vite config: proxy /api to Next.js, aliases for renderer3 packages
This commit is contained in:
simoleo89
2026-03-15 00:25:57 +01:00
parent bdae069003
commit a11987e1e0
11 changed files with 1019 additions and 34 deletions
@@ -0,0 +1,159 @@
import { FC, useCallback, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common';
interface FurniEditorCreateViewProps
{
interactions: string[];
loading: boolean;
onCreate: (fields: Record<string, unknown>) => Promise<number | null>;
onCreated: (id: number) => void;
}
export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
{
const { interactions, loading, onCreate, onCreated } = props;
const [ success, setSuccess ] = useState<number | null>(null);
const [ form, setForm ] = useState({
itemName: '',
publicName: '',
spriteId: 0,
type: 's' as 's' | 'i',
width: 1,
length: 1,
stackHeight: 0,
allowStack: true,
allowSit: false,
allowLay: false,
allowWalk: false,
allowGift: true,
allowTrade: true,
allowRecycle: true,
allowMarketplaceSell: true,
allowInventoryStack: true,
interactionType: '',
interactionModesCount: 1,
customparams: '',
});
const setField = useCallback((key: string, value: unknown) =>
{
setForm(prev => ({ ...prev, [key]: value }));
setSuccess(null);
}, []);
const handleCreate = useCallback(async () =>
{
if(!form.itemName || !form.publicName) return;
const id = await onCreate(form);
if(id)
{
setSuccess(id);
setTimeout(() => onCreated(id), 1000);
}
}, [ form, onCreate, onCreated ]);
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">
{ success &&
<div className="bg-[#d4edda] border border-[#c3e6cb] rounded p-2 text-[#155724] text-xs">
Item created with ID #{ success }!
</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">
<div>
<label className={ labelClass }>Item Name *</label>
<input className={ inputClass } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } placeholder="my_custom_furni" />
</div>
<div>
<label className={ labelClass }>Public Name *</label>
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } placeholder="My Custom Furni" />
</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>
</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-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)) } />
</div>
<div>
<label className={ labelClass }>Length</label>
<input type="number" className={ inputClass } 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)) } />
</div>
</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">
{ [ '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) }
/>
{ key.replace('allow', '') }
</label>
)) }
</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>
</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>
<Flex className="mt-1">
<Button variant="success" disabled={ loading || !form.itemName || !form.publicName } onClick={ handleCreate }>
{ loading ? 'Creating...' : 'Create Item' }
</Button>
</Flex>
</Column>
);
};
@@ -0,0 +1,249 @@
import { FC, useCallback, useEffect, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common';
import { CatalogRef, FurniDetail } from '../../../hooks/furni-editor';
interface FurniEditorEditViewProps
{
item: FurniDetail;
catalogItems: CatalogRef[];
furniDataEntry: Record<string, unknown> | null;
interactions: string[];
loading: boolean;
onUpdate: (id: number, fields: Record<string, unknown>) => Promise<boolean>;
onDelete: (id: number) => Promise<boolean>;
onBack: () => void;
onRefresh: (id: number) => void;
}
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
{
const { item, catalogItems, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onRefresh } = props;
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 [ confirmDelete, setConfirmDelete ] = useState(false);
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 || '',
});
setConfirmDelete(false);
}, [ item ]);
const setField = useCallback((key: string, value: unknown) =>
{
setForm(prev => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(async () =>
{
const ok = await onUpdate(item.id, form);
if(ok) onRefresh(item.id);
}, [ item, form, onUpdate, onRefresh ]);
const handleDelete = useCallback(async () =>
{
if(!confirmDelete) return setConfirmDelete(true);
const ok = await onDelete(item.id);
if(ok) onBack();
}, [ confirmDelete, item, onDelete, onBack ]);
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>
<Flex alignItems="center" gap={ 1 } className="bg-[#e9ecef] px-2 py-0.5 rounded">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
<path fillRule="evenodd" d="M4.93 1.31a41.401 41.401 0 0 1 10.14 0C16.194 1.45 17 2.414 17 3.517V18.25a.75.75 0 0 1-1.075.676l-2.8-1.344-2.8 1.344a.75.75 0 0 1-.65 0l-2.8-1.344-2.8 1.344A.75.75 0 0 1 3 18.25V3.517c0-1.103.806-2.068 1.93-2.207Z" clipRule="evenodd" />
</svg>
<Text bold className="text-[12px]">{ item.id }</Text>
<span className="text-[#999] mx-0.5">|</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
<path d="M12.586 2.586a2 2 0 1 1 2.828 2.828l-3 3a2 2 0 0 1-2.828 0 1 1 0 0 0-1.414 1.414 4 4 0 0 0 5.656 0l3-3a4 4 0 0 0-5.656-5.656l-1.5 1.5a1 1 0 1 0 1.414 1.414l1.5-1.5ZM7.414 17.414a2 2 0 1 1-2.828-2.828l3-3a2 2 0 0 1 2.828 0 1 1 0 0 0 1.414-1.414 4 4 0 0 0-5.656 0l-3 3a4 4 0 0 0 5.656 5.656l1.5-1.5a1 1 0 1 0-1.414-1.414l-1.5 1.5Z" />
</svg>
<Text bold className="text-[12px]">{ item.spriteId }</Text>
</Flex>
<Text small variant="gray">({ item.usageCount } in use)</Text>
</Flex>
{ /* 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>
<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>
</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-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)) } />
</div>
<div>
<label className={ labelClass }>Length</label>
<input type="number" className={ inputClass } 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)) } />
</div>
</div>
</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">
{ [ '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) }
/>
{ key.replace('allow', '') }
</label>
)) }
</div>
</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="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>
</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>
{ /* 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>
}
{ /* FurniData.json Entry */ }
{ furniDataEntry &&
<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-2 gap-x-3 gap-y-0.5 text-[10px]">
{ Object.entries(furniDataEntry).map(([ key, value ]) => (
<div key={ key } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
<span className="font-bold text-[#555]">{ key }</span>
<span className="text-[#333] truncate ml-1 max-w-[120px] text-right">{ String(value ?? '') }</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' }
disabled={ loading || item.usageCount > 0 }
onClick={ handleDelete }
>
{ confirmDelete ? 'Confirm Delete' : 'Delete' }
</Button>
</Flex>
</Column>
);
};
@@ -0,0 +1,134 @@
import { FC, useCallback, useEffect, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common';
import { FurniItem } from '../../../hooks/furni-editor';
interface FurniEditorSearchViewProps
{
items: FurniItem[];
total: number;
page: number;
loading: boolean;
onSearch: (query: string, type: string, page: number) => void;
onSelect: (id: number) => void;
}
export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
{
const { items, total, page, loading, onSearch, onSelect } = props;
const [ query, setQuery ] = useState('');
const [ typeFilter, setTypeFilter ] = useState('');
useEffect(() =>
{
onSearch('', '', 1);
}, []);
const handleSearch = useCallback(() =>
{
onSearch(query, typeFilter, 1);
}, [ query, typeFilter, onSearch ]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) =>
{
if(e.key === 'Enter') handleSearch();
}, [ handleSearch ]);
const totalPages = Math.ceil(total / 20);
return (
<Column gap={ 1 } className="h-full">
<Flex gap={ 1 } alignItems="end">
<Column gap={ 0 } className="flex-1">
<Text small bold>Search</Text>
<input
type="text"
className="form-control form-control-sm"
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) }
>
<option value="">All</option>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</Column>
<Button variant="primary" disabled={ loading } onClick={ handleSearch }>
{ loading ? '...' : 'Search' }
</Button>
</Flex>
<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>
{ 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"
disabled={ page <= 1 }
onClick={ () => onSearch(query, typeFilter, page - 1) }
>
Prev
</Button>
<Button
variant="secondary"
disabled={ page >= totalPages }
onClick={ () => onSearch(query, typeFilter, page + 1) }
>
Next
</Button>
</Flex>
</Flex>
}
</Column>
);
};