mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
Binary file not shown.
|
After Width: | Height: | Size: 629 B |
@@ -9,6 +9,7 @@ import { CampaignView } from './campaign/CampaignView';
|
|||||||
import { CatalogView } from './catalog/CatalogView';
|
import { CatalogView } from './catalog/CatalogView';
|
||||||
import { ChatHistoryView } from './chat-history/ChatHistoryView';
|
import { ChatHistoryView } from './chat-history/ChatHistoryView';
|
||||||
import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
|
import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
|
||||||
|
import { FurniEditorView } from './furni-editor/FurniEditorView';
|
||||||
import { FriendsView } from './friends/FriendsView';
|
import { FriendsView } from './friends/FriendsView';
|
||||||
import { GameCenterView } from './game-center/GameCenterView';
|
import { GameCenterView } from './game-center/GameCenterView';
|
||||||
import { GroupsView } from './groups/GroupsView';
|
import { GroupsView } from './groups/GroupsView';
|
||||||
@@ -122,6 +123,7 @@ export const MainView: FC<{}> = props =>
|
|||||||
<CampaignView />
|
<CampaignView />
|
||||||
<GameCenterView />
|
<GameCenterView />
|
||||||
<FloorplanEditorView />
|
<FloorplanEditorView />
|
||||||
|
<FurniEditorView />
|
||||||
<YoutubeTvView />
|
<YoutubeTvView />
|
||||||
<ExternalPluginLoader />
|
<ExternalPluginLoader />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useCallback, useEffect, useState } from 'react';
|
import { FC, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { GetSessionDataManager } from '../../api';
|
||||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||||
import { useFurniEditor } from '../../hooks/furni-editor';
|
import { useFurniEditor } from '../../hooks/furni-editor';
|
||||||
import { FurniEditorEditView } from './views/FurniEditorEditView';
|
import { FurniEditorEditView } from './views/FurniEditorEditView';
|
||||||
@@ -15,13 +16,23 @@ export const FurniEditorView: FC<{}> = () =>
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
items, total, page, loading, error, clearError,
|
items, total, page, loading, error, clearError,
|
||||||
selectedItem, catalogItems, furniDataEntry,
|
selectedItem, setSelectedItem, furniDataEntry,
|
||||||
interactions,
|
interactions,
|
||||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
|
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
|
||||||
} = useFurniEditor();
|
} = useFurniEditor();
|
||||||
|
|
||||||
|
const isMod = GetSessionDataManager()?.isModerator;
|
||||||
|
|
||||||
|
// Auto-switch to edit tab when an item is selected
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
if(selectedItem) setActiveTab(TAB_EDIT);
|
||||||
|
}, [ selectedItem ]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(!isMod) return;
|
||||||
|
|
||||||
const linkTracker: ILinkEventTracker = {
|
const linkTracker: ILinkEventTracker = {
|
||||||
linkReceived: (url: string) =>
|
linkReceived: (url: string) =>
|
||||||
{
|
{
|
||||||
@@ -48,47 +59,62 @@ export const FurniEditorView: FC<{}> = () =>
|
|||||||
AddLinkEventTracker(linkTracker);
|
AddLinkEventTracker(linkTracker);
|
||||||
|
|
||||||
return () => RemoveLinkEventTracker(linkTracker);
|
return () => RemoveLinkEventTracker(linkTracker);
|
||||||
}, []);
|
}, [ isMod ]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if(isVisible) loadInteractions();
|
if(isVisible) loadInteractions();
|
||||||
}, [ isVisible ]);
|
}, [ isVisible ]);
|
||||||
|
|
||||||
|
// Escape to close
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
const handler = async (e: CustomEvent<{ spriteId: number }>) =>
|
if(!isVisible) return;
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) =>
|
||||||
|
{
|
||||||
|
if(e.key === 'Escape') setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [ isVisible ]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(!isMod) return;
|
||||||
|
|
||||||
|
const handler = (e: CustomEvent<{ spriteId: number }>) =>
|
||||||
{
|
{
|
||||||
const { spriteId } = e.detail;
|
const { spriteId } = e.detail;
|
||||||
|
|
||||||
const ok = await loadBySpriteId(spriteId);
|
setIsVisible(true);
|
||||||
|
loadBySpriteId(spriteId);
|
||||||
if(ok) setActiveTab(TAB_EDIT);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('furni-editor:open', handler as EventListener);
|
window.addEventListener('furni-editor:open', handler as EventListener);
|
||||||
|
|
||||||
return () => window.removeEventListener('furni-editor:open', handler as EventListener);
|
return () => window.removeEventListener('furni-editor:open', handler as EventListener);
|
||||||
}, [ loadBySpriteId ]);
|
}, [ isMod, loadBySpriteId ]);
|
||||||
|
|
||||||
const handleSelect = useCallback(async (id: number) =>
|
const handleSelect = useCallback((id: number) =>
|
||||||
{
|
{
|
||||||
const ok = await loadDetail(id);
|
loadDetail(id);
|
||||||
|
|
||||||
if(ok) setActiveTab(TAB_EDIT);
|
|
||||||
}, [ loadDetail ]);
|
}, [ loadDetail ]);
|
||||||
|
|
||||||
const handleBack = useCallback(() =>
|
const handleBack = useCallback(() =>
|
||||||
{
|
{
|
||||||
|
setSelectedItem(null);
|
||||||
setActiveTab(TAB_SEARCH);
|
setActiveTab(TAB_SEARCH);
|
||||||
}, []);
|
}, [ setSelectedItem ]);
|
||||||
|
|
||||||
const handleClose = useCallback(() =>
|
const handleClose = useCallback(() =>
|
||||||
{
|
{
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if(!isVisible) return null;
|
if(!isVisible || !isMod) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]">
|
<NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]">
|
||||||
@@ -123,14 +149,12 @@ export const FurniEditorView: FC<{}> = () =>
|
|||||||
{ activeTab === TAB_EDIT && selectedItem &&
|
{ activeTab === TAB_EDIT && selectedItem &&
|
||||||
<FurniEditorEditView
|
<FurniEditorEditView
|
||||||
item={ selectedItem }
|
item={ selectedItem }
|
||||||
catalogItems={ catalogItems }
|
|
||||||
furniDataEntry={ furniDataEntry }
|
furniDataEntry={ furniDataEntry }
|
||||||
interactions={ interactions }
|
interactions={ interactions }
|
||||||
loading={ loading }
|
loading={ loading }
|
||||||
onUpdate={ updateItem }
|
onUpdate={ updateItem }
|
||||||
onDelete={ deleteItem }
|
onDelete={ deleteItem }
|
||||||
onBack={ handleBack }
|
onBack={ handleBack }
|
||||||
onRefresh={ loadDetail }
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,72 @@
|
|||||||
import { FC, useCallback, useEffect, useState } from 'react';
|
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Button, Column, Flex, Text } from '../../../common';
|
import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
|
||||||
import { CatalogRef, FurniDetail } from '../../../hooks/furni-editor';
|
import { FurniDetail } from '../../../hooks/furni-editor';
|
||||||
|
|
||||||
interface FurniEditorEditViewProps
|
interface FurniEditorEditViewProps
|
||||||
{
|
{
|
||||||
item: FurniDetail;
|
item: FurniDetail;
|
||||||
catalogItems: CatalogRef[];
|
|
||||||
furniDataEntry: Record<string, unknown> | null;
|
furniDataEntry: Record<string, unknown> | null;
|
||||||
interactions: string[];
|
interactions: string[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onUpdate: (id: number, fields: Record<string, unknown>) => Promise<boolean>;
|
onUpdate: (id: number, fields: Record<string, unknown>) => void;
|
||||||
onDelete: (id: number) => Promise<boolean>;
|
onDelete: (id: number) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onRefresh: (id: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FIELD_TIPS: Record<string, string> = {
|
||||||
|
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' ] },
|
||||||
|
{ label: 'Trading', keys: [ 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell' ] },
|
||||||
|
{ label: 'Inventory', keys: [ 'allowInventoryStack' ] },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SectionProps { title: string; children: React.ReactNode; defaultOpen?: boolean }
|
||||||
|
|
||||||
|
const Section: FC<SectionProps> = ({ title, children, defaultOpen = true }) =>
|
||||||
|
{
|
||||||
|
const [ open, setOpen ] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded border border-[#ccc]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center justify-between px-2 py-1.5 cursor-pointer hover:bg-[#f5f5f5] transition-colors"
|
||||||
|
onClick={ () => setOpen(p => !p) }
|
||||||
|
>
|
||||||
|
<Text small bold variant="primary">{ title }</Text>
|
||||||
|
<span className="text-[10px] text-[#999]">{ open ? '▼' : '▶' }</span>
|
||||||
|
</button>
|
||||||
|
{ open && <div className="px-2 pb-2">{ children }</div> }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Tip: FC<{ field: string }> = ({ field }) =>
|
||||||
|
{
|
||||||
|
const tip = FIELD_TIPS[field];
|
||||||
|
|
||||||
|
if(!tip) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative group ml-0.5 inline-flex">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-[#1e7295] text-white text-[8px] flex items-center justify-center cursor-help font-bold">?</span>
|
||||||
|
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-[#333] text-white text-[10px] rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
|
||||||
|
{ tip }
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { item, catalogItems, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onRefresh } = props;
|
const { item, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack } = props;
|
||||||
|
const saveRef = useRef<() => void>(null);
|
||||||
|
|
||||||
const [ form, setForm ] = useState({
|
const [ form, setForm ] = useState({
|
||||||
itemName: '',
|
itemName: '',
|
||||||
@@ -41,7 +90,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
customparams: '',
|
customparams: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [ confirmDelete, setConfirmDelete ] = useState(false);
|
const [ showDeleteDialog, setShowDeleteDialog ] = useState(false);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@@ -69,7 +118,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
customparams: item.customparams || '',
|
customparams: item.customparams || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
setConfirmDelete(false);
|
setShowDeleteDialog(false);
|
||||||
}, [ item ]);
|
}, [ item ]);
|
||||||
|
|
||||||
const setField = useCallback((key: string, value: unknown) =>
|
const setField = useCallback((key: string, value: unknown) =>
|
||||||
@@ -77,97 +126,166 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
setForm(prev => ({ ...prev, [key]: value }));
|
setForm(prev => ({ ...prev, [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(async () =>
|
const isDirty = useMemo(() =>
|
||||||
{
|
{
|
||||||
const ok = await onUpdate(item.id, form);
|
if(!item) return false;
|
||||||
|
|
||||||
if(ok) onRefresh(item.id);
|
return form.itemName !== (item.itemName || '') ||
|
||||||
}, [ item, form, onUpdate, onRefresh ]);
|
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 handleDelete = useCallback(async () =>
|
const validation = useMemo(() =>
|
||||||
{
|
{
|
||||||
if(!confirmDelete) return setConfirmDelete(true);
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
const ok = await onDelete(item.id);
|
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';
|
||||||
|
|
||||||
if(ok) onBack();
|
return errors;
|
||||||
}, [ confirmDelete, item, onDelete, onBack ]);
|
}, [ form ]);
|
||||||
|
|
||||||
const inputClass = 'form-control form-control-sm';
|
const isValid = useMemo(() => Object.keys(validation).length === 0, [ validation ]);
|
||||||
const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
|
|
||||||
|
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-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] min-h-[calc(1.5em+0.5rem+2px)] ${ field && validation[field] ? 'border-red-500 bg-red-50' : '' }`;
|
||||||
|
const labelClass = 'text-[11px] font-bold text-[#333] mb-0 flex items-center gap-0.5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap={ 1 } className="h-full overflow-auto">
|
<Column gap={ 1 } className="h-full overflow-auto">
|
||||||
<Flex gap={ 1 } alignItems="center" className="mb-1">
|
{ /* Header */ }
|
||||||
<Button variant="secondary" onClick={ onBack }>Back</Button>
|
<Flex gap={ 2 } alignItems="center" className="mb-1">
|
||||||
<Flex alignItems="center" gap={ 1 } className="bg-[#e9ecef] px-2 py-0.5 rounded">
|
<Button variant="secondary" onClick={ handleBack }>Back</Button>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
|
<div className="bg-[#e9ecef] rounded border border-[#ccc] flex items-center justify-center w-[48px] h-[48px]">
|
||||||
<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" />
|
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } className="scale-150" />
|
||||||
</svg>
|
</div>
|
||||||
<Text bold className="text-[12px]">{ item.id }</Text>
|
<Flex column gap={ 0 }>
|
||||||
<span className="text-[#999] mx-0.5">|</span>
|
<Flex alignItems="center" gap={ 1 }>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
|
<Text bold className="text-[12px]">ID: { item.id }</Text>
|
||||||
<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" />
|
<span className="text-[#999]">|</span>
|
||||||
</svg>
|
<Text bold className="text-[12px]">Sprite: { item.spriteId }</Text>
|
||||||
<Text bold className="text-[12px]">{ item.spriteId }</Text>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
<Text small variant="gray">({ item.usageCount } in use)</Text>
|
<Text small variant="gray">({ item.usageCount } in use)</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
{ isDirty && <span className="text-[10px] text-orange-500 font-bold ml-auto">Unsaved changes</span> }
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{ /* Basic Info */ }
|
<Section title="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 className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Item Name</label>
|
<label className={ labelClass }>Item Name</label>
|
||||||
<input className={ inputClass } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } />
|
<input className={ inputClass('itemName') } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } />
|
||||||
|
{ validation.itemName && <span className="text-[9px] text-red-500">{ validation.itemName }</span> }
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Public Name</label>
|
<label className={ labelClass }>Public Name</label>
|
||||||
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
|
<input className={ inputClass('publicName') } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
|
||||||
|
{ validation.publicName && <span className="text-[9px] text-red-500">{ validation.publicName }</span> }
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Sprite ID</label>
|
<label className={ labelClass }>Sprite ID</label>
|
||||||
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
|
<input type="number" className={ inputClass() } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Type</label>
|
<label className={ labelClass }>Type</label>
|
||||||
<select className="form-select form-select-sm" value={ form.type } onChange={ e => setField('type', e.target.value) }>
|
<select className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] pr-8" value={ form.type } onChange={ e => setField('type', e.target.value) }>
|
||||||
<option value="s">Floor (s)</option>
|
<option value="s">Floor (s)</option>
|
||||||
<option value="i">Wall (i)</option>
|
<option value="i">Wall (i)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{ /* Dimensions */ }
|
<Section title="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 className="grid grid-cols-3 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Width</label>
|
<label className={ labelClass }>Width</label>
|
||||||
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
|
<input type="number" className={ inputClass('width') } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
|
||||||
|
{ validation.width && <span className="text-[9px] text-red-500">{ validation.width }</span> }
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Length</label>
|
<label className={ labelClass }>Length</label>
|
||||||
<input type="number" className={ inputClass } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
|
<input type="number" className={ inputClass('length') } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
|
||||||
|
{ validation.length && <span className="text-[9px] text-red-500">{ validation.length }</span> }
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Stack Height</label>
|
<label className={ labelClass }>Stack Height<Tip field="stackHeight" /></label>
|
||||||
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
|
<input type="number" step="0.01" className={ inputClass('stackHeight') } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
|
||||||
</div>
|
{ validation.stackHeight && <span className="text-[9px] text-red-500">{ validation.stackHeight }</span> }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{ /* Permissions */ }
|
<Section title="Permissions">
|
||||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Text small bold variant="primary" className="mb-1 block">Permissions</Text>
|
{ PERM_GROUPS.map(group => (
|
||||||
<div className="grid grid-cols-3 gap-x-3 gap-y-1">
|
<div key={ group.label }>
|
||||||
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => (
|
<Text className="text-[10px] font-bold text-[#666] uppercase tracking-wider mb-0.5 block">{ group.label }</Text>
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-1">
|
||||||
|
{ group.keys.map(key => (
|
||||||
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
|
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="form-check-input"
|
className="mt-1"
|
||||||
checked={ (form as any)[key] }
|
checked={ (form as any)[key] }
|
||||||
onChange={ e => setField(key, e.target.checked) }
|
onChange={ e => setField(key, e.target.checked) }
|
||||||
/>
|
/>
|
||||||
@@ -176,14 +294,15 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
)) }
|
)) }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)) }
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{ /* Interaction */ }
|
<Section title="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="grid grid-cols-3 gap-2">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className={ labelClass }>Type</label>
|
<label className={ labelClass }>Type<Tip field="interactionType" /></label>
|
||||||
<select className="form-select form-select-sm" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
|
<select className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] pr-8" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
|
||||||
<option value="">none</option>
|
<option value="">none</option>
|
||||||
{ interactions.map(i => (
|
{ interactions.map(i => (
|
||||||
<option key={ i } value={ i }>{ i }</option>
|
<option key={ i } value={ i }>{ i }</option>
|
||||||
@@ -191,35 +310,18 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={ labelClass }>Modes</label>
|
<label className={ labelClass }>Modes<Tip field="interactionModesCount" /></label>
|
||||||
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
|
<input type="number" className={ inputClass() } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<label className={ labelClass }>Custom Params</label>
|
<label className={ labelClass }>Custom Params<Tip field="customparams" /></label>
|
||||||
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
|
<input className={ inputClass() } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{ /* 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 &&
|
{ furniDataEntry &&
|
||||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
<Section title="FurniData.json" defaultOpen={ false }>
|
||||||
<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]">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[10px]">
|
||||||
{ Object.entries(furniDataEntry).map(([ key, value ]) => (
|
{ Object.entries(furniDataEntry).map(([ key, value ]) => (
|
||||||
<div key={ key } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
|
<div key={ key } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
|
||||||
@@ -228,22 +330,42 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
|||||||
</div>
|
</div>
|
||||||
)) }
|
)) }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
}
|
}
|
||||||
|
|
||||||
{ /* Actions */ }
|
{ /* Actions */ }
|
||||||
<Flex gap={ 1 } justifyContent="between" className="mt-1">
|
<Flex gap={ 1 } justifyContent="between" alignItems="center" className="mt-1">
|
||||||
<Button variant="success" disabled={ loading } onClick={ handleSave }>
|
<Flex gap={ 1 } alignItems="center">
|
||||||
|
<Button variant="success" disabled={ loading || !isValid || !isDirty } onClick={ handleSave }>
|
||||||
{ loading ? 'Saving...' : 'Save' }
|
{ loading ? 'Saving...' : 'Save' }
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="text-[9px] text-[#999]">Ctrl+S</span>
|
||||||
|
</Flex>
|
||||||
<Button
|
<Button
|
||||||
variant={ confirmDelete ? 'danger' : 'warning' }
|
variant="danger"
|
||||||
disabled={ loading || item.usageCount > 0 }
|
disabled={ loading || item.usageCount > 0 }
|
||||||
onClick={ handleDelete }
|
onClick={ () => setShowDeleteDialog(true) }
|
||||||
>
|
>
|
||||||
{ confirmDelete ? 'Confirm Delete' : 'Delete' }
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{ /* Delete Confirmation Dialog */ }
|
||||||
|
{ showDeleteDialog &&
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={ () => setShowDeleteDialog(false) }>
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-4 w-[320px]" onClick={ e => e.stopPropagation() }>
|
||||||
|
<Text bold className="text-[14px] mb-2 block">Delete Item?</Text>
|
||||||
|
<Text small className="mb-3 block text-[#666]">
|
||||||
|
Are you sure you want to delete <strong>{ item.publicName || item.itemName }</strong> (ID: { item.id })?
|
||||||
|
This action cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Flex gap={ 1 } justifyContent="end">
|
||||||
|
<Button variant="secondary" onClick={ () => setShowDeleteDialog(false) }>Cancel</Button>
|
||||||
|
<Button variant="danger" onClick={ handleDeleteConfirm }>Delete</Button>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, useCallback, useEffect, useState } from 'react';
|
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Column, Flex, Text } from '../../../common';
|
import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
|
||||||
import { FurniItem } from '../../../hooks/furni-editor';
|
import { FurniItem } from '../../../hooks/furni-editor';
|
||||||
|
|
||||||
interface FurniEditorSearchViewProps
|
interface FurniEditorSearchViewProps
|
||||||
@@ -12,11 +12,23 @@ interface FurniEditorSearchViewProps
|
|||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortField = 'id' | 'spriteId' | 'itemName' | 'publicName' | 'type' | 'interactionType';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const SortArrow: FC<{ field: SortField; active: SortField; dir: SortDir }> = ({ field, active, dir }) =>
|
||||||
|
{
|
||||||
|
if(field !== active) return <span className="ml-0.5 opacity-30">↕</span>;
|
||||||
|
|
||||||
|
return <span className="ml-0.5">{ dir === 'asc' ? '▲' : '▼' }</span>;
|
||||||
|
};
|
||||||
|
|
||||||
export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { items, total, page, loading, onSearch, onSelect } = props;
|
const { items, total, page, loading, onSearch, onSelect } = props;
|
||||||
const [ query, setQuery ] = useState('');
|
const [ query, setQuery ] = useState('');
|
||||||
const [ typeFilter, setTypeFilter ] = useState('');
|
const [ typeFilter, setTypeFilter ] = useState('');
|
||||||
|
const [ sortField, setSortField ] = useState<SortField>('id');
|
||||||
|
const [ sortDir, setSortDir ] = useState<SortDir>('asc');
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@@ -33,6 +45,45 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
|||||||
if(e.key === 'Enter') handleSearch();
|
if(e.key === 'Enter') handleSearch();
|
||||||
}, [ handleSearch ]);
|
}, [ handleSearch ]);
|
||||||
|
|
||||||
|
const handleSort = useCallback((field: SortField) =>
|
||||||
|
{
|
||||||
|
setSortDir(prev => (sortField === field ? (prev === 'asc' ? 'desc' : 'asc') : 'asc'));
|
||||||
|
setSortField(field);
|
||||||
|
}, [ sortField ]);
|
||||||
|
|
||||||
|
const handleTypeToggle = useCallback((type: string) =>
|
||||||
|
{
|
||||||
|
setTypeFilter(prev =>
|
||||||
|
{
|
||||||
|
const next = prev === type ? '' : type;
|
||||||
|
|
||||||
|
onSearch(query, next, 1);
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [ query, onSearch ]);
|
||||||
|
|
||||||
|
const sortedItems = useMemo(() =>
|
||||||
|
{
|
||||||
|
const sorted = [ ...items ];
|
||||||
|
|
||||||
|
sorted.sort((a, b) =>
|
||||||
|
{
|
||||||
|
let va: string | number = a[sortField] ?? '';
|
||||||
|
let vb: string | number = b[sortField] ?? '';
|
||||||
|
|
||||||
|
if(typeof va === 'string') va = va.toLowerCase();
|
||||||
|
if(typeof vb === 'string') vb = vb.toLowerCase();
|
||||||
|
|
||||||
|
if(va < vb) return sortDir === 'asc' ? -1 : 1;
|
||||||
|
if(va > vb) return sortDir === 'asc' ? 1 : -1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}, [ items, sortField, sortDir ]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / 20);
|
const totalPages = Math.ceil(total / 20);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,55 +93,80 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
|||||||
<Text small bold>Search</Text>
|
<Text small bold>Search</Text>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control form-control-sm"
|
className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] min-h-[calc(1.5em+0.5rem+2px)]"
|
||||||
placeholder="ID, name or sprite ID..."
|
placeholder="ID, name or sprite ID..."
|
||||||
value={ query }
|
value={ query }
|
||||||
onChange={ e => setQuery(e.target.value) }
|
onChange={ e => setQuery(e.target.value) }
|
||||||
onKeyDown={ handleKeyDown }
|
onKeyDown={ handleKeyDown }
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
<Column gap={ 0 } className="w-[80px]">
|
<Flex gap={ 1 }>
|
||||||
<Text small bold>Type</Text>
|
{ [ '', 's', 'i' ].map(t => (
|
||||||
<select
|
<button
|
||||||
className="form-select form-select-sm"
|
key={ t || 'all' }
|
||||||
value={ typeFilter }
|
className={ `px-2 py-1 text-[11px] rounded border cursor-pointer transition-colors ${
|
||||||
onChange={ e => setTypeFilter(e.target.value) }
|
typeFilter === t
|
||||||
|
? 'bg-[#1e7295] text-white border-[#1e7295]'
|
||||||
|
: 'bg-white text-[#333] border-[#ccc] hover:bg-[#f0f0f0]'
|
||||||
|
}` }
|
||||||
|
onClick={ () => handleTypeToggle(t) }
|
||||||
>
|
>
|
||||||
<option value="">All</option>
|
{ t === '' ? 'All' : t === 's' ? 'Floor' : 'Wall' }
|
||||||
<option value="s">Floor (s)</option>
|
</button>
|
||||||
<option value="i">Wall (i)</option>
|
)) }
|
||||||
</select>
|
</Flex>
|
||||||
</Column>
|
|
||||||
<Button variant="primary" disabled={ loading } onClick={ handleSearch }>
|
<Button variant="primary" disabled={ loading } onClick={ handleSearch }>
|
||||||
{ loading ? '...' : 'Search' }
|
{ loading ? '...' : 'Search' }
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{ total > 0 &&
|
||||||
|
<Text small variant="gray" className="text-[10px]">
|
||||||
|
{ total } items found
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
|
||||||
<Column gap={ 0 } className="flex-1 overflow-auto border border-[#ccc] rounded bg-white">
|
<Column gap={ 0 } className="flex-1 overflow-auto border border-[#ccc] rounded bg-white">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-[#e8e8e8] sticky top-0">
|
<tr className="bg-[#e8e8e8] sticky top-0 select-none">
|
||||||
<th className="px-2 py-1 text-left">ID</th>
|
<th className="px-1 py-1 text-center w-[50px]"></th>
|
||||||
<th className="px-2 py-1 text-left">Sprite</th>
|
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('id') }>
|
||||||
<th className="px-2 py-1 text-left">Name</th>
|
ID<SortArrow field="id" active={ sortField } dir={ sortDir } />
|
||||||
<th className="px-2 py-1 text-left">Public Name</th>
|
</th>
|
||||||
<th className="px-2 py-1 text-center">Type</th>
|
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('spriteId') }>
|
||||||
<th className="px-2 py-1 text-left">Interaction</th>
|
Sprite<SortArrow field="spriteId" active={ sortField } dir={ sortDir } />
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('itemName') }>
|
||||||
|
Name<SortArrow field="itemName" active={ sortField } dir={ sortDir } />
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('publicName') }>
|
||||||
|
Public Name<SortArrow field="publicName" active={ sortField } dir={ sortDir } />
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-1 text-center cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('type') }>
|
||||||
|
Type<SortArrow field="type" active={ sortField } dir={ sortDir } />
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('interactionType') }>
|
||||||
|
Interaction<SortArrow field="interactionType" active={ sortField } dir={ sortDir } />
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ items.map(item => (
|
{ sortedItems.map(item => (
|
||||||
<tr
|
<tr
|
||||||
key={ item.id }
|
key={ item.id }
|
||||||
className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors"
|
className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors"
|
||||||
onClick={ () => onSelect(item.id) }
|
onClick={ () => onSelect(item.id) }
|
||||||
>
|
>
|
||||||
|
<td className="px-1 py-1 text-center">
|
||||||
|
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } className="inline-block scale-125" />
|
||||||
|
</td>
|
||||||
<td className="px-2 py-1 font-mono">{ item.id }</td>
|
<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 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]" title={ item.itemName }>{ item.itemName }</td>
|
||||||
<td className="px-2 py-1 truncate max-w-[120px]">{ item.publicName }</td>
|
<td className="px-2 py-1 truncate max-w-[120px]" title={ item.publicName }>{ item.publicName }</td>
|
||||||
<td className="px-2 py-1 text-center">
|
<td className="px-2 py-1 text-center">
|
||||||
<span className={ `px-1 rounded text-white text-[10px] ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#6b7280]' }` }>
|
<span className={ `px-1.5 py-0.5 rounded text-white text-[10px] font-medium ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#6b7280]' }` }>
|
||||||
{ item.type === 's' ? 'Floor' : 'Wall' }
|
{ item.type === 's' ? 'Floor' : 'Wall' }
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -99,7 +175,7 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
|||||||
)) }
|
)) }
|
||||||
{ items.length === 0 && !loading &&
|
{ items.length === 0 && !loading &&
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={ 6 } className="px-2 py-4 text-center text-[#999]">No items found</td>
|
<td colSpan={ 7 } className="px-2 py-4 text-center text-[#999]">No items found</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -109,7 +185,7 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
|||||||
{ totalPages > 1 &&
|
{ totalPages > 1 &&
|
||||||
<Flex gap={ 1 } justifyContent="between" alignItems="center">
|
<Flex gap={ 1 } justifyContent="between" alignItems="center">
|
||||||
<Text small variant="gray">
|
<Text small variant="gray">
|
||||||
{ total } items - Page { page }/{ totalPages }
|
Page { page }/{ totalPages }
|
||||||
</Text>
|
</Text>
|
||||||
<Flex gap={ 1 }>
|
<Flex gap={ 1 }>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -574,6 +574,19 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
|
|||||||
onClick={ () => setDropdownOpen(!dropdownOpen) }>
|
onClick={ () => setDropdownOpen(!dropdownOpen) }>
|
||||||
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
|
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
|
||||||
|
onClick={ () =>
|
||||||
|
{
|
||||||
|
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
|
||||||
|
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
|
||||||
|
|
||||||
|
CreateLinkEvent('furni-editor/show');
|
||||||
|
|
||||||
|
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
|
||||||
|
} }>
|
||||||
|
Edit Furni
|
||||||
|
</button>
|
||||||
{ dropdownOpen &&
|
{ dropdownOpen &&
|
||||||
<div className="flex gap-[4px] w-full">
|
<div className="flex gap-[4px] w-full">
|
||||||
{ /* Left panel: position + rotation */ }
|
{ /* Left panel: position + rotation */ }
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
|||||||
<ToolbarItemView icon="camera" onClick={ event => CreateLinkEvent('camera/toggle') } /> }
|
<ToolbarItemView icon="camera" onClick={ event => CreateLinkEvent('camera/toggle') } /> }
|
||||||
{ isMod &&
|
{ isMod &&
|
||||||
<ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> }
|
<ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> }
|
||||||
|
{ isMod &&
|
||||||
|
<ToolbarItemView icon="furnieditor" onClick={ event => CreateLinkEvent('furni-editor/toggle') } /> }
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex alignItems="center" justifyContent="center" className="flex-1 min-w-0 max-w-[600px] mx-auto" id="toolbar-chat-input-container" />
|
<Flex alignItems="center" justifyContent="center" className="flex-1 min-w-0 max-w-[600px] mx-auto" id="toolbar-chat-input-container" />
|
||||||
<Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
|
<Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export const UserContainerView: FC<{
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-sm leading-none" dangerouslySetInnerHTML={{ __html: LocalizeText('extendedprofile.created', ['created'], [userProfile.registration]) }} />
|
<p className="text-sm leading-none" dangerouslySetInnerHTML={{ __html: LocalizeText('extendedprofile.created', ['created'], [userProfile.registration]) }} />
|
||||||
<p className="text-sm leading-none" dangerouslySetInnerHTML={{ __html: LocalizeText('extendedprofile.last.login', ['lastlogin'], [FriendlyTime.format(userProfile.secondsSinceLastVisit, '.ago', 2)]) }} />
|
<p className="text-sm leading-none" dangerouslySetInnerHTML={{ __html: LocalizeText('extendedprofile.last.login', ['lastlogin'], [FriendlyTime.format(userProfile.secondsSinceLastVisit, '.ago', 2)]) }} />
|
||||||
|
<p className="text-sm leading-none">
|
||||||
|
<b>{ LocalizeText('extendedprofile.friends.count') }</b> { userProfile.friendsCount }
|
||||||
|
</p>
|
||||||
<p className="text-sm leading-none">
|
<p className="text-sm leading-none">
|
||||||
<b>{ LocalizeText('extendedprofile.achievementscore') }</b> { userProfile.achievementPoints }
|
<b>{ LocalizeText('extendedprofile.achievementscore') }</b> { userProfile.achievementPoints }
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -145,16 +145,16 @@ export const UserProfileView: FC<{}> = props =>
|
|||||||
</div>
|
</div>
|
||||||
<NitroCard.Tabs>
|
<NitroCard.Tabs>
|
||||||
<NitroCard.TabItem isActive={ activeTab === 'badge' } count={ userBadges.length } onClick={ () => onTabClick('badge') }>
|
<NitroCard.TabItem isActive={ activeTab === 'badge' } count={ userBadges.length } onClick={ () => onTabClick('badge') }>
|
||||||
Badge
|
{ LocalizeText('extendedprofile.tab.badge') }
|
||||||
</NitroCard.TabItem>
|
</NitroCard.TabItem>
|
||||||
<NitroCard.TabItem isActive={ activeTab === 'amici' } count={ userProfile.friendsCount } onClick={ () => onTabClick('amici') }>
|
<NitroCard.TabItem isActive={ activeTab === 'amici' } count={ userProfile.friendsCount } onClick={ () => onTabClick('amici') }>
|
||||||
Amici
|
{ LocalizeText('extendedprofile.tab.friends') }
|
||||||
</NitroCard.TabItem>
|
</NitroCard.TabItem>
|
||||||
<NitroCard.TabItem isActive={ activeTab === 'stanze' } onClick={ () => onTabClick('stanze') }>
|
<NitroCard.TabItem isActive={ activeTab === 'stanze' } onClick={ () => onTabClick('stanze') }>
|
||||||
Stanze
|
{ LocalizeText('extendedprofile.tab.rooms') }
|
||||||
</NitroCard.TabItem>
|
</NitroCard.TabItem>
|
||||||
<NitroCard.TabItem isActive={ activeTab === 'gruppi' } count={ userProfile.groups?.length } onClick={ () => onTabClick('gruppi') }>
|
<NitroCard.TabItem isActive={ activeTab === 'gruppi' } count={ userProfile.groups?.length } onClick={ () => onTabClick('gruppi') }>
|
||||||
Gruppi
|
{ LocalizeText('extendedprofile.tab.groups') }
|
||||||
</NitroCard.TabItem>
|
</NitroCard.TabItem>
|
||||||
</NitroCard.Tabs>
|
</NitroCard.Tabs>
|
||||||
<div className="flex-1 overflow-auto p-2">
|
<div className="flex-1 overflow-auto p-2">
|
||||||
@@ -166,7 +166,7 @@ export const UserProfileView: FC<{}> = props =>
|
|||||||
))
|
))
|
||||||
: (
|
: (
|
||||||
<Flex center fullWidth className="h-full">
|
<Flex center fullWidth className="h-full">
|
||||||
<Text small variant="muted">Nessun badge da mostrare</Text>
|
<Text small variant="muted">{ LocalizeText('extendedprofile.badge.empty') }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ export const UserProfileView: FC<{}> = props =>
|
|||||||
<FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } />
|
<FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } />
|
||||||
) : (
|
) : (
|
||||||
<Flex center className="h-full">
|
<Flex center className="h-full">
|
||||||
<Text small variant="muted">Caricamento...</Text>
|
<Text small variant="muted">{ LocalizeText('generic.loading') }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
) }
|
) }
|
||||||
</div>
|
</div>
|
||||||
@@ -187,12 +187,12 @@ export const UserProfileView: FC<{}> = props =>
|
|||||||
<div className="flex flex-col gap-1 h-full">
|
<div className="flex flex-col gap-1 h-full">
|
||||||
{ !userRooms && (
|
{ !userRooms && (
|
||||||
<Flex center className="h-full">
|
<Flex center className="h-full">
|
||||||
<Text small variant="muted">Caricamento stanze...</Text>
|
<Text small variant="muted">{ LocalizeText('extendedprofile.rooms.loading') }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
) }
|
) }
|
||||||
{ userRooms && userRooms.length === 0 && (
|
{ userRooms && userRooms.length === 0 && (
|
||||||
<Flex center className="h-full">
|
<Flex center className="h-full">
|
||||||
<Text small variant="muted">Nessuna stanza trovata</Text>
|
<Text small variant="muted">{ LocalizeText('extendedprofile.rooms.empty') }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
) }
|
) }
|
||||||
{ userRooms && userRooms.length > 0 && userRooms.map(room => (
|
{ userRooms && userRooms.length > 0 && userRooms.map(room => (
|
||||||
|
|||||||
@@ -70,6 +70,12 @@
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nitro-icon.icon-furnieditor {
|
||||||
|
background-image: url("@/assets/images/toolbar/icons/furnieditor.png");
|
||||||
|
width: 30px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
.nitro-icon.icon-friendall {
|
.nitro-icon.icon-friendall {
|
||||||
background-image: url("@/assets/images/toolbar/icons/friend_all.png");
|
background-image: url("@/assets/images/toolbar/icons/friend_all.png");
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './useFurniEditor';
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { FurniEditorBySpriteComposer, FurniEditorDeleteComposer, FurniEditorDetailComposer, FurniEditorDetailResultEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsResultEvent, FurniEditorResultEvent, FurniEditorSearchComposer, FurniEditorSearchResultEvent, FurniEditorUpdateComposer } from '@nitrots/nitro-renderer';
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { NotificationAlertType, SendMessageComposer } from '../../api';
|
||||||
|
import { useMessageEvent, useNotification } from '../../hooks';
|
||||||
|
|
||||||
|
export interface FurniItem
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
spriteId: number;
|
||||||
|
itemName: string;
|
||||||
|
publicName: string;
|
||||||
|
type: string;
|
||||||
|
width: number;
|
||||||
|
length: number;
|
||||||
|
stackHeight: number;
|
||||||
|
allowStack: boolean;
|
||||||
|
allowWalk: boolean;
|
||||||
|
allowSit: boolean;
|
||||||
|
allowLay: boolean;
|
||||||
|
interactionType: string;
|
||||||
|
interactionModesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FurniDetail extends FurniItem
|
||||||
|
{
|
||||||
|
allowGift: boolean;
|
||||||
|
allowTrade: boolean;
|
||||||
|
allowRecycle: boolean;
|
||||||
|
allowMarketplaceSell: boolean;
|
||||||
|
allowInventoryStack: boolean;
|
||||||
|
vendingIds: string;
|
||||||
|
customparams: string;
|
||||||
|
effectIdMale: number;
|
||||||
|
effectIdFemale: number;
|
||||||
|
clothingOnWalk: string;
|
||||||
|
multiheight: string;
|
||||||
|
description: string;
|
||||||
|
usageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogRef
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
catalogName: string;
|
||||||
|
costCredits: number;
|
||||||
|
costPoints: number;
|
||||||
|
pointsType: number;
|
||||||
|
pageId: number;
|
||||||
|
pageName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFurniEditor = () =>
|
||||||
|
{
|
||||||
|
const [ items, setItems ] = useState<FurniItem[]>([]);
|
||||||
|
const [ total, setTotal ] = useState(0);
|
||||||
|
const [ page, setPage ] = useState(1);
|
||||||
|
const [ loading, setLoading ] = useState(false);
|
||||||
|
const [ error, setError ] = useState<string | null>(null);
|
||||||
|
const [ selectedItem, setSelectedItem ] = useState<FurniDetail | null>(null);
|
||||||
|
const [ catalogItems, setCatalogItems ] = useState<CatalogRef[]>([]);
|
||||||
|
const [ interactions, setInteractions ] = useState<string[]>([]);
|
||||||
|
const [ furniDataEntry, setFurniDataEntry ] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const pendingActionRef = useRef<string | null>(null);
|
||||||
|
const { simpleAlert = null } = useNotification();
|
||||||
|
|
||||||
|
const clearError = useCallback(() => setError(null), []);
|
||||||
|
|
||||||
|
// Handle search results
|
||||||
|
useMessageEvent(FurniEditorSearchResultEvent, (event: FurniEditorSearchResultEvent) =>
|
||||||
|
{
|
||||||
|
const parser = event.getParser();
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setItems(parser.items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
spriteId: item.spriteId,
|
||||||
|
itemName: item.itemName,
|
||||||
|
publicName: item.publicName,
|
||||||
|
type: item.type,
|
||||||
|
width: item.width,
|
||||||
|
length: item.length,
|
||||||
|
stackHeight: item.stackHeight,
|
||||||
|
allowStack: item.allowStack,
|
||||||
|
allowWalk: item.allowWalk,
|
||||||
|
allowSit: item.allowSit,
|
||||||
|
allowLay: item.allowLay,
|
||||||
|
interactionType: item.interactionType,
|
||||||
|
interactionModesCount: item.interactionModesCount
|
||||||
|
})));
|
||||||
|
setTotal(parser.total);
|
||||||
|
setPage(parser.page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle detail results (for both detail and by-sprite lookups)
|
||||||
|
useMessageEvent(FurniEditorDetailResultEvent, (event: FurniEditorDetailResultEvent) =>
|
||||||
|
{
|
||||||
|
const parser = event.getParser();
|
||||||
|
const item = parser.item;
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setSelectedItem({
|
||||||
|
id: item.id,
|
||||||
|
spriteId: item.spriteId,
|
||||||
|
itemName: item.itemName,
|
||||||
|
publicName: item.publicName,
|
||||||
|
type: item.type,
|
||||||
|
width: item.width,
|
||||||
|
length: item.length,
|
||||||
|
stackHeight: item.stackHeight,
|
||||||
|
allowStack: item.allowStack,
|
||||||
|
allowWalk: item.allowWalk,
|
||||||
|
allowSit: item.allowSit,
|
||||||
|
allowLay: item.allowLay,
|
||||||
|
interactionType: item.interactionType,
|
||||||
|
interactionModesCount: item.interactionModesCount,
|
||||||
|
allowGift: item.allowGift,
|
||||||
|
allowTrade: item.allowTrade,
|
||||||
|
allowRecycle: item.allowRecycle,
|
||||||
|
allowMarketplaceSell: item.allowMarketplaceSell,
|
||||||
|
allowInventoryStack: item.allowInventoryStack,
|
||||||
|
vendingIds: item.vendingIds,
|
||||||
|
customparams: item.customparams,
|
||||||
|
effectIdMale: item.effectIdMale,
|
||||||
|
effectIdFemale: item.effectIdFemale,
|
||||||
|
clothingOnWalk: item.clothingOnWalk,
|
||||||
|
multiheight: item.multiheight,
|
||||||
|
description: item.description,
|
||||||
|
usageCount: item.usageCount
|
||||||
|
});
|
||||||
|
setCatalogItems(parser.catalogItems.map(ref => ({
|
||||||
|
id: ref.id,
|
||||||
|
catalogName: ref.catalogName,
|
||||||
|
costCredits: ref.costCredits,
|
||||||
|
costPoints: ref.costPoints,
|
||||||
|
pointsType: ref.pointsType,
|
||||||
|
pageId: ref.pageId,
|
||||||
|
pageName: ref.pageName
|
||||||
|
})));
|
||||||
|
|
||||||
|
let furniData: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if(parser.furniDataJson && parser.furniDataJson !== '{}' && parser.furniDataJson !== '')
|
||||||
|
{
|
||||||
|
furniData = JSON.parse(parser.furniDataJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e) {}
|
||||||
|
|
||||||
|
setFurniDataEntry(furniData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle interaction types list
|
||||||
|
useMessageEvent(FurniEditorInteractionsResultEvent, (event: FurniEditorInteractionsResultEvent) =>
|
||||||
|
{
|
||||||
|
setInteractions(event.getParser().interactions);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle operation results (update, create, delete)
|
||||||
|
useMessageEvent(FurniEditorResultEvent, (event: FurniEditorResultEvent) =>
|
||||||
|
{
|
||||||
|
const parser = event.getParser();
|
||||||
|
const action = pendingActionRef.current;
|
||||||
|
|
||||||
|
pendingActionRef.current = null;
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if(!parser.success)
|
||||||
|
{
|
||||||
|
setError(parser.message || 'Operation failed');
|
||||||
|
|
||||||
|
if(simpleAlert)
|
||||||
|
{
|
||||||
|
simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Furni Editor Error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if(action === 'update')
|
||||||
|
{
|
||||||
|
// Auto-reload detail after update
|
||||||
|
if(selectedItem)
|
||||||
|
{
|
||||||
|
SendMessageComposer(new FurniEditorDetailComposer(selectedItem.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(simpleAlert)
|
||||||
|
{
|
||||||
|
simpleAlert('Item updated successfully', NotificationAlertType.DEFAULT, null, null, 'Furni Editor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(action === 'delete')
|
||||||
|
{
|
||||||
|
setSelectedItem(null);
|
||||||
|
setCatalogItems([]);
|
||||||
|
setFurniDataEntry(null);
|
||||||
|
|
||||||
|
if(simpleAlert)
|
||||||
|
{
|
||||||
|
simpleAlert('Item deleted successfully', NotificationAlertType.DEFAULT, null, null, 'Furni Editor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchItems = useCallback((query: string, type: string, pg: number) =>
|
||||||
|
{
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
SendMessageComposer(new FurniEditorSearchComposer(query, type, pg));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDetail = useCallback((id: number) =>
|
||||||
|
{
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
SendMessageComposer(new FurniEditorDetailComposer(id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadBySpriteId = useCallback((spriteId: number) =>
|
||||||
|
{
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
SendMessageComposer(new FurniEditorBySpriteComposer(spriteId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateItem = useCallback((id: number, fields: Record<string, unknown>) =>
|
||||||
|
{
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
pendingActionRef.current = 'update';
|
||||||
|
SendMessageComposer(new FurniEditorUpdateComposer(id, JSON.stringify(fields)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteItem = useCallback((id: number) =>
|
||||||
|
{
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
pendingActionRef.current = 'delete';
|
||||||
|
SendMessageComposer(new FurniEditorDeleteComposer(id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadInteractions = useCallback(() =>
|
||||||
|
{
|
||||||
|
SendMessageComposer(new FurniEditorInteractionsComposer());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items, total, page, loading, error, clearError,
|
||||||
|
selectedItem, setSelectedItem, catalogItems, furniDataEntry,
|
||||||
|
interactions,
|
||||||
|
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user