From da791cd0d05c210c0980a26b9283b2e4fd431308 Mon Sep 17 00:00:00 2001 From: Life Date: Sun, 22 Mar 2026 20:40:05 +0100 Subject: [PATCH] 0 --- .../furni-editor/FurniEditorView.tsx | 33 ++- .../views/FurniEditorCreateView.tsx | 148 ++++++---- .../views/FurniEditorEditView.tsx | 255 +++++++++--------- .../views/FurniEditorSearchView.tsx | 173 +++++++----- src/hooks/furni-editor/useFurniEditor.ts | 216 ++++++--------- 5 files changed, 411 insertions(+), 414 deletions(-) diff --git a/src/components/furni-editor/FurniEditorView.tsx b/src/components/furni-editor/FurniEditorView.tsx index a013554..4ad02e6 100644 --- a/src/components/furni-editor/FurniEditorView.tsx +++ b/src/components/furni-editor/FurniEditorView.tsx @@ -1,12 +1,14 @@ -import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; 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'; import { FurniEditorEditView } from './views/FurniEditorEditView'; import { FurniEditorSearchView } from './views/FurniEditorSearchView'; const TAB_SEARCH = 0; const TAB_EDIT = 1; +const TAB_CREATE = 2; export const FurniEditorView: FC<{}> = () => { @@ -16,8 +18,8 @@ export const FurniEditorView: FC<{}> = () => const { items, total, page, loading, error, clearError, selectedItem, catalogItems, furniDataEntry, - interactions, - searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions + interactions, lastResult, + searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, createItem, loadInteractions } = useFurniEditor(); useEffect(() => @@ -57,13 +59,14 @@ export const FurniEditorView: FC<{}> = () => useEffect(() => { - const handler = async (e: CustomEvent<{ spriteId: number }>) => + const handler = (e: CustomEvent<{ spriteId: number }>) => { const { spriteId } = e.detail; - const ok = await loadBySpriteId(spriteId); + if(!spriteId || spriteId <= 0) return; - if(ok) setActiveTab(TAB_EDIT); + loadBySpriteId(spriteId); + setActiveTab(TAB_EDIT); }; window.addEventListener('furni-editor:open', handler as EventListener); @@ -71,11 +74,10 @@ export const FurniEditorView: FC<{}> = () => return () => window.removeEventListener('furni-editor:open', handler as EventListener); }, [ loadBySpriteId ]); - const handleSelect = useCallback(async (id: number) => + const handleSelect = useCallback((id: number) => { - const ok = await loadDetail(id); - - if(ok) setActiveTab(TAB_EDIT); + loadDetail(id); + setActiveTab(TAB_EDIT); }, [ loadDetail ]); const handleBack = useCallback(() => @@ -88,10 +90,17 @@ export const FurniEditorView: FC<{}> = () => setIsVisible(false); }, []); + const handleCreated = useCallback((id: number) => + { + loadDetail(id); + setActiveTab(TAB_EDIT); + }, [ loadDetail ]); + + if(!GetSessionDataManager()?.isModerator) return null; if(!isVisible) return null; return ( - + setActiveTab(TAB_SEARCH) }> @@ -127,6 +136,7 @@ export const FurniEditorView: FC<{}> = () => furniDataEntry={ furniDataEntry } interactions={ interactions } loading={ loading } + lastResult={ lastResult } onUpdate={ updateItem } onDelete={ deleteItem } onBack={ handleBack } @@ -134,6 +144,7 @@ export const FurniEditorView: FC<{}> = () => /> } + ); diff --git a/src/components/furni-editor/views/FurniEditorCreateView.tsx b/src/components/furni-editor/views/FurniEditorCreateView.tsx index f47530c..d7461e0 100644 --- a/src/components/furni-editor/views/FurniEditorCreateView.tsx +++ b/src/components/furni-editor/views/FurniEditorCreateView.tsx @@ -1,18 +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; - onCreate: (fields: Record) => Promise; + lastResult: { success: boolean; message: string; id: number } | null; + onCreate: (fields: Record) => 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 = props => { - const { interactions, loading, onCreate, onCreated } = props; - const [ success, setSuccess ] = useState(null); + 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: '', @@ -36,39 +41,50 @@ export const FurniEditorCreateView: FC = props => customparams: '', }); + 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 })); - setSuccess(null); }, []); - const handleCreate = useCallback(async () => + const handleCreate = useCallback(() => { 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'; + onCreate(form); + }, [ form, onCreate ]); return ( - { success && -
- Item created with ID #{ success }! + { /* Toast */ } + { toast && +
+ { toast.message }
} -
- Basic Info -
+ { /* Basic Info */ } +
+
+ Basic Info +
+
setField('itemName', e.target.value) } placeholder="my_custom_furni" /> @@ -83,7 +99,7 @@ export const FurniEditorCreateView: FC = props =>
- setField('type', e.target.value) }> @@ -91,9 +107,12 @@ export const FurniEditorCreateView: FC = props =>
-
- Dimensions -
+ { /* Dimensions */ } +
+
+ Dimensions +
+
setField('width', Number(e.target.value)) } /> @@ -109,14 +128,17 @@ export const FurniEditorCreateView: FC = props =>
-
- Permissions -
+ { /* Permissions */ } +
+
+ Permissions +
+
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => ( -
-
- Interaction -
-
- - -
-
- - setField('interactionModesCount', Number(e.target.value)) } /> -
+ { /* Interaction */ } +
+
+ Interaction
-
- - setField('customparams', e.target.value) } /> +
+
+
+ + +
+
+ + setField('interactionModesCount', Number(e.target.value)) } /> +
+
+
+ + setField('customparams', e.target.value) } /> +
- - - + { /* Create Button */ } +
+ +
); }; diff --git a/src/components/furni-editor/views/FurniEditorEditView.tsx b/src/components/furni-editor/views/FurniEditorEditView.tsx index dc648ef..bb50765 100644 --- a/src/components/furni-editor/views/FurniEditorEditView.tsx +++ b/src/components/furni-editor/views/FurniEditorEditView.tsx @@ -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 | null; interactions: string[]; loading: boolean; - onUpdate: (id: number, fields: Record) => Promise; - onDelete: (id: number) => Promise; + lastResult: { success: boolean; message: string; id: number } | null; + onUpdate: (id: number, fields: Record) => 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 = 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, @@ -42,15 +48,14 @@ export const FurniEditorEditView: FC = 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, @@ -72,105 +77,119 @@ export const FurniEditorEditView: FC = 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 })); }, []); - const handleSave = useCallback(async () => + const handleSave = useCallback(() => { - const ok = await onUpdate(item.id, form); + onUpdate(item.id, form); + }, [ item, form, onUpdate ]); - if(ok) onRefresh(item.id); - }, [ item, form, onUpdate, onRefresh ]); - - const handleDelete = useCallback(async () => + const handleDelete = useCallback(() => { 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'; + onDelete(item.id); + }, [ confirmDelete, item, onDelete ]); return ( - - - - - - - - { item.id } - | - - - - { item.spriteId } - - ({ item.usageCount } in use) - + + { toast && +
+ { toast.message } +
+ } + + { /* Header */ } +
+
+ +
+
+
{ item.publicName }
+
+ { navigator.clipboard.writeText(String(item.id)); setToast({ type: 'success', message: `ID ${item.id} copied!` }); } }>#{item.id} + | + sprite:{ item.spriteId } + | + { item.itemName } + + { item.type === 's' ? 'FLOOR' : 'WALL' } + + { item.usageCount > 0 && { item.usageCount } in use } +
+
+
+ + +
+
{ /* Basic Info */ } -
- Basic Info -
-
- - setField('itemName', e.target.value) } /> -
-
- - setField('publicName', e.target.value) } /> -
-
- - setField('spriteId', Number(e.target.value)) } /> -
-
- - -
+
+
+ + +
+
+ + setField('publicName', e.target.value) } /> +
+
+ + +
+
+ +
{ /* Dimensions */ } -
- Dimensions -
+
+
Dimensions
+
- - setField('width', Number(e.target.value)) } /> + + setField('width', Number(e.target.value)) } />
- - setField('length', Number(e.target.value)) } /> + + setField('length', Number(e.target.value)) } />
- - setField('stackHeight', Number(e.target.value)) } /> + + setField('stackHeight', Number(e.target.value)) } />
{ /* Permissions */ } -
- Permissions -
+
+
Permissions
+
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => ( -
{ /* Interaction */ } -
- Interaction -
+
+
Interaction
+
- - setField('interactionType', e.target.value) }> - { interactions.map(i => ( - - )) } + { interactions.map(i => ) }
- - setField('interactionModesCount', Number(e.target.value)) } /> + + setField('interactionModesCount', Number(e.target.value)) } /> +
+
+ + setField('customparams', e.target.value) } />
-
-
- - setField('customparams', e.target.value) } />
- { /* Catalog References */ } - { catalogItems.length > 0 && -
- Catalog ({ catalogItems.length }) -
- { catalogItems.map(ci => ( -
- { ci.catalogName } (page: { ci.pageName }) - { ci.costCredits }c + { ci.costPoints }p -
- )) } -
-
- } - - { /* FurniData.json Entry */ } - { furniDataEntry && -
- FurniData.json -
- { Object.entries(furniDataEntry).map(([ key, value ]) => ( -
- { key } - { String(value ?? '') } -
- )) } -
-
- } - { /* Actions */ } - - - + - + { confirmDelete ? 'Confirm' : 'Delete' } + +
); }; diff --git a/src/components/furni-editor/views/FurniEditorSearchView.tsx b/src/components/furni-editor/views/FurniEditorSearchView.tsx index 7b3f8c6..1327b45 100644 --- a/src/components/furni-editor/views/FurniEditorSearchView.tsx +++ b/src/components/furni-editor/views/FurniEditorSearchView.tsx @@ -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 = props => { const { items, total, page, loading, onSearch, onSelect } = props; @@ -37,97 +41,122 @@ export const FurniEditorSearchView: FC = props => return ( - - - Search + { /* Search Bar */ } +
+
+ setQuery(e.target.value) } onKeyDown={ handleKeyDown } /> - - - Type - setTypeFilter(e.target.value) }> - - + + - - - +
+ +
- - - - - - - - - - - - - - { items.map(item => ( - onSelect(item.id) } - > - - - - - - - - )) } - { items.length === 0 && !loading && - - - - } - -
IDSpriteNamePublic NameTypeInteraction
{ item.id }{ item.spriteId }{ item.itemName }{ item.publicName } - - { item.type === 's' ? 'Floor' : 'Wall' } - - { item.interactionType || '-' }
No items found
-
+ { /* Results counter */ } + { total > 0 && +
+ { total } items found { totalPages > 1 && - Page { page }/{ totalPages } } +
+ } + { /* Results Table */ } +
+ { loading && +
+
Loading...
+
+ } + + { !loading && items.length === 0 && +
+ No items found +
+ } + + { !loading && items.length > 0 && + + + + + + + + + + + + + + { items.map(item => ( + onSelect(item.id) } + > + + + + + + + + + )) } + +
IDSpriteNamePublic NameTypeInteraction
+
+ +
+
{ item.id }{ item.spriteId }{ item.itemName }{ item.publicName } + + { item.type === 's' ? 'Floor' : 'Wall' } + + { item.interactionType || '-' }
+ } +
+ + { /* Pagination */ } { totalPages > 1 && - - - { total } items - Page { page }/{ totalPages } - - - - - - + +
+
} ); diff --git a/src/hooks/furni-editor/useFurniEditor.ts b/src/hooks/furni-editor/useFurniEditor.ts index e4258e5..5d2c95a 100644 --- a/src/hooks/furni-editor/useFurniEditor.ts +++ b/src/hooks/furni-editor/useFurniEditor.ts @@ -1,4 +1,7 @@ +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'; export interface FurniItem { @@ -46,18 +49,6 @@ export interface CatalogRef pageName: string; } -const API_BASE = '/api/admin/furni-editor'; - -async function apiFetch(url: string, options?: RequestInit): Promise -{ - const res = await fetch(url, { credentials: 'include', ...options }); - const data = await res.json(); - - if(!res.ok || data.error) throw new Error(data.error || 'API error'); - - return data; -} - export const useFurniEditor = () => { const [ items, setItems ] = useState([]); @@ -69,171 +60,114 @@ export const useFurniEditor = () => const [ catalogItems, setCatalogItems ] = useState([]); const [ interactions, setInteractions ] = useState([]); const [ furniDataEntry, setFurniDataEntry ] = useState | null>(null); + const [ lastResult, setLastResult ] = useState<{ success: boolean; message: string; id: number } | null>(null); const clearError = useCallback(() => setError(null), []); - const searchItems = useCallback(async (query: string, type: string, pg: number) => + // Listen for search results + useMessageEvent(FurniEditorSearchMsgEvent, (event: any) => + { + const parser = event.getParser(); + + setItems(parser.items); + setTotal(parser.total); + setPage(parser.page); + setLoading(false); + }); + + // Listen for detail results + useMessageEvent(FurniEditorDetailMsgEvent, (event: any) => + { + const parser = event.getParser(); + + setSelectedItem(parser.item as FurniDetail); + setCatalogItems(parser.catalogItems as CatalogRef[]); + + try + { + setFurniDataEntry(parser.furniDataJson ? JSON.parse(parser.furniDataJson) : null); + } + catch + { + setFurniDataEntry(null); + } + + setLoading(false); + }); + + // 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); + } + }); + + const searchItems = useCallback((query: string, type: string, pg: number) => { setLoading(true); setError(null); - - try - { - const params = new URLSearchParams({ q: query, limit: '20', page: String(pg) }); - - if(type) params.set('type', type); - - const data = await apiFetch<{ items: FurniItem[]; total: number; page: number }>(`${ API_BASE }?${ params }`); - - setItems(data.items); - setTotal(data.total); - setPage(data.page); - } - catch(e: any) - { - setError(e.message); - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorSearchComposer(query, type, pg)); }, []); - const loadDetail = useCallback(async (id: number): Promise => + const loadDetail = useCallback((id: number) => { setLoading(true); setError(null); - - try - { - const data = await apiFetch<{ item: FurniDetail; catalogItems: CatalogRef[]; furniDataEntry: Record | null }>(`${ API_BASE }/detail?id=${ id }`); - - setSelectedItem(data.item); - setCatalogItems(data.catalogItems); - setFurniDataEntry(data.furniDataEntry); - - return true; - } - catch(e: any) - { - setError(e.message); - - return false; - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorDetailComposer(id)); }, []); - const updateItem = useCallback(async (id: number, fields: Record) => + const loadBySpriteId = useCallback((spriteId: number) => { setLoading(true); setError(null); - - try - { - await apiFetch(`${ API_BASE }/update?id=${ id }`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(fields) - }); - - return true; - } - catch(e: any) - { - setError(e.message); - - return false; - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorBySpriteComposer(spriteId)); }, []); - const createItem = useCallback(async (fields: Record) => + const updateItem = useCallback((id: number, fields: Record) => { setLoading(true); setError(null); - - try - { - const data = await apiFetch<{ id: number }>(`${ API_BASE }`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(fields) - }); - - return data.id; - } - catch(e: any) - { - setError(e.message); - - return null; - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorUpdateComposer(id, JSON.stringify(fields))); }, []); - const deleteItem = useCallback(async (id: number) => + const createItem = useCallback((fields: Record) => { setLoading(true); setError(null); - - try - { - await apiFetch(`${ API_BASE }/delete?id=${ id }`, { method: 'POST' }); - - return true; - } - catch(e: any) - { - setError(e.message); - - return false; - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorCreateComposer(JSON.stringify(fields))); }, []); - const loadInteractions = useCallback(async () => + const deleteItem = useCallback((id: number) => { - try - { - const data = await apiFetch<{ interactions: Array }>(`${ API_BASE }/interactions`); - - setInteractions(data.interactions.map(i => typeof i === 'string' ? i : i.name)); - } - catch {} + setLoading(true); + setError(null); + SendMessageComposer(new FurniEditorDeleteComposer(id)); }, []); - const loadBySpriteId = useCallback(async (spriteId: number): Promise => + const loadInteractions = useCallback(() => { - try - { - const data = await apiFetch<{ id: number }>(`${ API_BASE }/by-sprite?spriteId=${ spriteId }`); - - return await loadDetail(data.id); - } - catch(e: any) - { - setError(e.message); - - return false; - } - }, [ loadDetail ]); + SendMessageComposer(new FurniEditorInteractionsComposer()); + }, []); return { items, total, page, loading, error, clearError, selectedItem, setSelectedItem, catalogItems, furniDataEntry, - interactions, + interactions, lastResult, searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions }; };