Merge pull request #38 from simoleo89/catalog-redesign

feat: catalog style toggle (classic/new) with admin mode
This commit is contained in:
DuckieTM
2026-03-23 09:27:44 +01:00
committed by GitHub
12 changed files with 1267 additions and 629 deletions
+2
View File
@@ -28,3 +28,5 @@ Thumbs.db
*.zip
.env
.claude/
public/renderer-config.json
public/ui-config.json
+2 -1
View File
@@ -242,8 +242,9 @@
"catalog.asset.url": "${image.library.url}catalogue",
"catalog.asset.image.url": "${catalog.asset.url}/%name%.gif",
"catalog.asset.icon.url": "${catalog.asset.url}/icon_%name%.png",
"catalog.tab.icons": false,
"catalog.tab.icons": true,
"catalog.headers": false,
"catalog.style": "old",
"chat.input.maxlength": 100,
"chat.styles.disabled": [],
"chat.styles": [{
+197 -150
View File
@@ -1,5 +1,7 @@
import { createContext, FC, ReactNode, useCallback, useContext, useState } from 'react';
import { ICatalogNode, IPurchasableOffer } from '../../api';
import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer';
import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api';
import { useMessageEvent, useNotification } from '../../hooks';
export interface IPageEditData
{
@@ -53,49 +55,24 @@ interface ICatalogAdminContext
setEditingPageNode: (node: ICatalogNode | null) => void;
loading: boolean;
lastError: string | null;
savePage: (data: IPageEditData) => Promise<boolean>;
createPage: (data: IPageEditData) => Promise<boolean>;
deletePage: (pageId: number) => Promise<boolean>;
saveOffer: (data: IOfferEditData) => Promise<boolean>;
createOffer: (data: IOfferEditData) => Promise<boolean>;
deleteOffer: (offerId: number) => Promise<boolean>;
reorderOffers: (orders: { id: number; orderNumber: number }[]) => Promise<boolean>;
togglePageEnabled: (pageId: number) => Promise<boolean>;
togglePageVisible: (pageId: number) => Promise<boolean>;
savePage: (data: IPageEditData) => void;
createPage: (data: IPageEditData) => void;
deletePage: (pageId: number) => void;
saveOffer: (data: IOfferEditData) => void;
createOffer: (data: IOfferEditData) => void;
deleteOffer: (offerId: number) => void;
reorderOffers: (orders: { id: number; orderNumber: number }[]) => void;
reorderPage: (pageId: number, newParentId: number, newIndex: number) => void;
togglePageEnabled: (pageId: number) => void;
togglePageVisible: (pageId: number) => void;
publishCatalog: () => void;
hasPendingChanges: boolean;
}
const CatalogAdminContext = createContext<ICatalogAdminContext>(null);
export const useCatalogAdmin = () => useContext(CatalogAdminContext);
const API_BASE = '/api/admin/catalog';
async function apiCall(url: string, method: string, body?: unknown): Promise<{ ok: boolean; data?: Record<string, unknown>; error?: string }>
{
try
{
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
...(body !== undefined ? { body: JSON.stringify(body) } : {})
});
const json = await res.json();
if(!res.ok || json.error)
{
return { ok: false, error: json.error || `HTTP ${ res.status }` };
}
return { ok: true, data: json };
}
catch(err)
{
return { ok: false, error: (err as Error).message };
}
}
export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) =>
{
const [ adminMode, setAdminMode ] = useState(false);
@@ -105,131 +82,200 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
const [ editingPageNode, setEditingPageNode ] = useState<ICatalogNode | null>(null);
const [ loading, setLoading ] = useState(false);
const [ lastError, setLastError ] = useState<string | null>(null);
const [ hasPendingChanges, setHasPendingChanges ] = useState(false);
const pendingActionRef = useRef<string | null>(null);
const { simpleAlert = null } = useNotification();
const withLoading = useCallback(async (fn: () => Promise<boolean>): Promise<boolean> =>
// Keyboard shortcuts: Esc to close edit panels
useEffect(() =>
{
if(!adminMode) return;
const handleKeyDown = (e: KeyboardEvent) =>
{
if(e.key === 'Escape')
{
if(editingOffer) { setEditingOffer(null); e.preventDefault(); return; }
if(editingPageData || editingRootPage || editingPageNode)
{
setEditingPageData(false);
setEditingRootPage(false);
setEditingPageNode(null);
e.preventDefault();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [ adminMode, editingOffer, editingPageData, editingRootPage, editingPageNode ]);
useMessageEvent(CatalogAdminResultEvent, (event: CatalogAdminResultEvent) =>
{
const parser = event.getParser();
const action = pendingActionRef.current;
pendingActionRef.current = null;
setLoading(false);
if(!parser.success)
{
setLastError(parser.message || 'Operation failed');
if(simpleAlert)
{
simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Admin Error');
}
}
else
{
setLastError(null);
setEditingOffer(null);
setEditingPageData(false);
setEditingRootPage(false);
setEditingPageNode(null);
if(action === 'publish')
{
setHasPendingChanges(false);
}
else
{
setHasPendingChanges(true);
}
if(simpleAlert && action)
{
const messages: Record<string, string> = {
'savePage': 'Page saved (publish to apply)',
'createPage': 'Page created (publish to apply)',
'deletePage': 'Page deleted (publish to apply)',
'saveOffer': 'Offer saved (publish to apply)',
'createOffer': 'Offer created (publish to apply)',
'deleteOffer': 'Offer deleted (publish to apply)',
'reorder': 'Order updated (publish to apply)',
'toggleEnabled': 'Page toggled (publish to apply)',
'toggleVisible': 'Visibility toggled (publish to apply)',
'movePage': 'Page moved (publish to apply)',
'publish': 'Catalog published! All users updated.',
};
simpleAlert(messages[action] || 'Operation completed', NotificationAlertType.DEFAULT, null, null, 'Catalog Admin');
}
}
});
const savePage = useCallback((data: IPageEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'savePage';
SendMessageComposer(new CatalogAdminSavePageComposer(
data.pageId || 0, data.caption, data.caption, data.pageLayout, 0,
data.minRank, data.visible === '1', data.enabled === '1',
data.orderNum, data.parentId,
data.pageHeadline || '', data.pageTeaser || '', data.pageTextDetails || ''
));
}, []);
try
const createPage = useCallback((data: IPageEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'createPage';
SendMessageComposer(new CatalogAdminCreatePageComposer(
data.caption, data.caption, data.pageLayout, 0,
data.minRank, data.visible === '1', data.enabled === '1',
data.orderNum, data.parentId
));
}, []);
const deletePage = useCallback((pageId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'deletePage';
SendMessageComposer(new CatalogAdminDeletePageComposer(pageId));
}, []);
const saveOffer = useCallback((data: IOfferEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'saveOffer';
SendMessageComposer(new CatalogAdminSaveOfferComposer(
data.offerId || 0, data.pageId, parseInt(data.itemIds) || 0,
data.catalogName, data.costCredits, data.costPoints, data.pointsType,
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber
));
}, []);
const createOffer = useCallback((data: IOfferEditData) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'createOffer';
SendMessageComposer(new CatalogAdminCreateOfferComposer(
data.pageId, parseInt(data.itemIds) || 0,
data.catalogName, data.costCredits, data.costPoints, data.pointsType,
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber
));
}, []);
const deleteOffer = useCallback((offerId: number) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'deleteOffer';
SendMessageComposer(new CatalogAdminDeleteOfferComposer(offerId));
}, []);
const reorderOffers = useCallback((orders: { id: number; orderNumber: number }[]) =>
{
setLoading(true);
setLastError(null);
pendingActionRef.current = 'reorder';
for(const order of orders)
{
return await fn();
}
finally
{
setLoading(false);
SendMessageComposer(new CatalogAdminMoveOfferComposer(order.id, order.orderNumber));
}
}, []);
const savePage = useCallback((data: IPageEditData): Promise<boolean> =>
const reorderPage = useCallback((pageId: number, newParentId: number, newIndex: number) =>
{
return withLoading(async () =>
{
const { pageId, ...fields } = data;
const result = await apiCall(`${ API_BASE }?id=${ pageId }`, 'PUT', fields);
setLoading(true);
setLastError(null);
pendingActionRef.current = 'movePage';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, newParentId, newIndex));
}, []);
if(!result.ok) { setLastError(result.error); return false; }
return true;
});
}, [ withLoading ]);
const createPage = useCallback((data: IPageEditData): Promise<boolean> =>
const togglePageEnabled = useCallback((pageId: number) =>
{
return withLoading(async () =>
{
const result = await apiCall(API_BASE, 'POST', data);
setLoading(true);
setLastError(null);
pendingActionRef.current = 'toggleEnabled';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -1, -1));
}, []);
if(!result.ok) { setLastError(result.error); return false; }
return true;
});
}, [ withLoading ]);
const deletePage = useCallback((pageId: number): Promise<boolean> =>
const togglePageVisible = useCallback((pageId: number) =>
{
return withLoading(async () =>
{
const result = await apiCall(API_BASE, 'DELETE', { id: pageId });
setLoading(true);
setLastError(null);
pendingActionRef.current = 'toggleVisible';
SendMessageComposer(new CatalogAdminMovePageComposer(pageId, -2, -1));
}, []);
if(!result.ok) { setLastError(result.error); return false; }
return true;
});
}, [ withLoading ]);
const saveOffer = useCallback((data: IOfferEditData): Promise<boolean> =>
const publishCatalog = useCallback(() =>
{
return withLoading(async () =>
{
const { offerId, ...fields } = data;
const result = await apiCall(`${ API_BASE }/items?id=${ offerId }`, 'PUT', fields);
if(!result.ok) { setLastError(result.error); return false; }
return true;
});
}, [ withLoading ]);
const createOffer = useCallback((data: IOfferEditData): Promise<boolean> =>
{
return withLoading(async () =>
{
const result = await apiCall(`${ API_BASE }/items`, 'POST', data);
if(!result.ok) { setLastError(result.error); return false; }
return true;
});
}, [ withLoading ]);
const deleteOffer = useCallback((offerId: number): Promise<boolean> =>
{
return withLoading(async () =>
{
const result = await apiCall(`${ API_BASE }/items`, 'DELETE', { id: offerId });
if(!result.ok) { setLastError(result.error); return false; }
return true;
});
}, [ withLoading ]);
const reorderOffers = useCallback((orders: { id: number; orderNumber: number }[]): Promise<boolean> =>
{
return withLoading(async () =>
{
const result = await apiCall(`${ API_BASE }/items`, 'PATCH', { action: 'reorder', orders });
if(!result.ok) { setLastError(result.error); return false; }
return true;
});
}, [ withLoading ]);
const togglePageEnabled = useCallback((pageId: number): Promise<boolean> =>
{
return withLoading(async () =>
{
const result = await apiCall(API_BASE, 'PATCH', { action: 'toggleEnabled', id: pageId });
if(!result.ok) { setLastError(result.error); return false; }
return true;
});
}, [ withLoading ]);
const togglePageVisible = useCallback((pageId: number): Promise<boolean> =>
{
return withLoading(async () =>
{
const result = await apiCall(API_BASE, 'PATCH', { action: 'toggleVisible', id: pageId });
if(!result.ok) { setLastError(result.error); return false; }
return true;
});
}, [ withLoading ]);
setLoading(true);
setLastError(null);
pendingActionRef.current = 'publish';
SendMessageComposer(new CatalogAdminPublishComposer());
}, []);
return (
<CatalogAdminContext.Provider value={ {
@@ -238,10 +284,11 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
editingPageData, setEditingPageData,
editingRootPage, setEditingRootPage,
editingPageNode, setEditingPageNode,
loading, lastError,
loading, lastError, hasPendingChanges,
savePage, createPage, deletePage,
saveOffer, createOffer, deleteOffer,
reorderOffers, togglePageEnabled, togglePageVisible
reorderOffers, reorderPage, togglePageEnabled, togglePageVisible,
publishCatalog
} }>
{ children }
</CatalogAdminContext.Provider>
@@ -0,0 +1,183 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react';
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
import { GetConfigurationValue, LocalizeText } from '../../api';
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useCatalog } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout';
import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView';
const CatalogClassicViewInner: FC<{}> = () =>
{
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {});
const loading = catalogAdmin?.loading ?? false;
const isMod = GetSessionDataManager().isModerator;
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
case 'open':
if(parts.length > 2)
{
if(parts.length === 4)
{
switch(parts[2])
{
case 'offerId':
openPageByOfferId(parseInt(parts[3]));
return;
}
}
else
{
openPageByName(parts[2]);
}
}
else
{
setIsVisible(true);
}
return;
}
},
eventUrlPrefix: 'catalog/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ setIsVisible, openPageByOfferId, openPageByName ]);
return (
<>
{ isVisible &&
<NitroCardView className="w-[630px] h-[400px]" style={ GetConfigurationValue('catalog.headers') ? { width: 710 } : {} } uniqueKey="catalog">
<NitroCardHeaderView headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } />
{ /* Admin banner */ }
{ adminMode &&
<div className="flex items-center justify-between bg-warning text-dark text-[10px] font-bold px-3 py-0.5 uppercase tracking-wider" style={ { textShadow: '0 1px 0 rgba(255,255,255,0.3)' } }>
<span> Admin Mode</span>
<button
className={ `px-3 py-0.5 rounded text-[10px] font-bold uppercase cursor-pointer transition-all ${ hasPendingChanges ? 'bg-success text-white animate-pulse shadow-md' : 'bg-white/50 text-dark hover:bg-success hover:text-white' }` }
disabled={ loading }
onClick={ () => publishCatalog() }
>
{ loading ? '...' : '⬆ Publish' }
</button>
</div> }
<NitroCardTabsView>
{ rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) =>
{
if(!adminMode && !child.isVisible) return null;
const isHidden = !child.isVisible;
return (
<NitroCardTabsItemView key={ `${ child.pageId }-${ child.pageName }-${ index }` } isActive={ child.isActive } onClick={ () =>
{
if(searchResult) setSearchResult(null);
activateNode(child);
} } >
<div className={ `flex items-center gap-${ GetConfigurationValue('catalog.tab.icons') ? 1 : 0 } ${ isHidden ? 'opacity-40' : '' }` }>
{ GetConfigurationValue('catalog.tab.icons') && <CatalogIconView icon={ child.iconId } /> }
{ child.localization }
{ adminMode && isHidden && <FaEyeSlash className="text-[8px] text-danger ml-1" /> }
{ adminMode &&
<div className="flex items-center gap-0.5 ml-1" onClick={ e => e.stopPropagation() }>
<FaEdit className="text-[8px] text-primary cursor-pointer hover:text-dark" title={ LocalizeText('catalog.admin.edit.title') }
onClick={ () => { catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } } />
<span className="cursor-pointer" title={ isHidden ? LocalizeText('catalog.admin.show') : LocalizeText('catalog.admin.hide') }
onClick={ () => catalogAdmin.togglePageVisible(child.pageId) }>
{ isHidden ? <FaEye className="text-[8px] text-success" /> : <FaEyeSlash className="text-[8px] text-muted" /> }
</span>
<FaTrash className="text-[8px] text-danger cursor-pointer hover:text-red-800" title={ LocalizeText('catalog.admin.delete.title') }
onClick={ () => { if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId); } } />
</div> }
</div>
</NitroCardTabsItemView>
);
}) }
{ /* Admin toggle button in tabs bar */ }
{ isMod &&
<NitroCardTabsItemView isActive={ adminMode } onClick={ () => setAdminMode(!adminMode) }>
<FaCog className={ `text-[10px] ${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
</NitroCardTabsItemView> }
</NitroCardTabsView>
<NitroCardContentView>
{ /* Admin: add new root category */ }
{ adminMode && rootNode &&
<div className="flex items-center gap-2 mb-1">
<button
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
>
<FaPlus className="text-[8px]" />
<span>{ LocalizeText('catalog.admin.new') }</span>
</button>
<button
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
>
<FaEdit className="text-[8px]" />
<span>{ LocalizeText('catalog.admin.root') }</span>
</button>
</div> }
<Grid>
{ !navigationHidden &&
<Column overflow="hidden" size={ 3 }>
{ activeNodes && (activeNodes.length > 0) &&
<CatalogNavigationView node={ activeNodes[0] } /> }
</Column> }
<Column overflow="hidden" size={ !navigationHidden ? 9 : 12 }>
{ adminMode && <CatalogAdminPageEditView /> }
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
</Column>
</Grid>
</NitroCardContentView>
</NitroCardView> }
<CatalogAdminOfferEditView />
<CatalogGiftView />
<MarketplacePostOfferView />
</>
);
};
export const CatalogClassicView: FC<{}> = () =>
{
return (
<CatalogAdminProvider>
<CatalogClassicViewInner />
</CatalogAdminProvider>
);
};
@@ -0,0 +1,291 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { LocalizeText } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useCatalog, useCatalogFavorites } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogFavoritesView } from './views/favorites/CatalogFavoritesView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
import { CatalogSearchView } from './views/page/common/CatalogSearchView';
import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout';
import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView';
const CatalogModernViewInner: FC<{}> = () =>
{
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {});
const loading = catalogAdmin?.loading ?? false;
const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites();
const [ showFavorites, setShowFavorites ] = useState(false);
const isMod = GetSessionDataManager().isModerator;
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
case 'open':
if(parts.length > 2)
{
if(parts.length === 4)
{
switch(parts[2])
{
case 'offerId':
openPageByOfferId(parseInt(parts[3]));
return;
}
}
else
{
openPageByName(parts[2]);
}
}
else
{
setIsVisible(true);
}
return;
}
},
eventUrlPrefix: 'catalog/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ setIsVisible, openPageByOfferId, openPageByName ]);
return (
<>
{ isVisible &&
<NitroCardView className="nitro-catalog w-[780px] h-[520px]" uniqueKey="catalog">
<NitroCardHeaderView headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCardContentView classNames={ [ 'p-0!', 'overflow-hidden!' ] }>
{ /* Admin banner */ }
{ adminMode &&
<div className="flex items-center justify-between bg-warning text-dark text-[10px] font-bold px-3 py-0.5 uppercase tracking-wider" style={ { textShadow: '0 1px 0 rgba(255,255,255,0.3)' } }>
<span> Admin Mode</span>
<button
className={ `px-3 py-0.5 rounded text-[10px] font-bold uppercase cursor-pointer transition-all ${ hasPendingChanges ? 'bg-success text-white animate-pulse shadow-md' : 'bg-white/50 text-dark hover:bg-success hover:text-white' }` }
disabled={ loading }
onClick={ () => publishCatalog() }
>
{ loading ? '...' : hasPendingChanges ? '⬆ Publish' : '⬆ Publish' }
</button>
</div> }
<div className="flex h-full">
{ /* === LEFT SIDEBAR === */ }
<div className="group/rail flex flex-col w-[52px] hover:w-[175px] min-w-[52px] bg-card-grid-item border-r-2 border-card-grid-item-border py-1.5 gap-px overflow-y-auto overflow-x-hidden transition-[width] duration-200 ease-in-out">
{ /* Favorites toggle */ }
<div
className={ `flex items-center gap-2 mx-1 px-1.5 py-1.5 rounded cursor-pointer transition-all duration-150 ${ showFavorites ? 'bg-primary text-white' : 'hover:bg-card-grid-item-active' }` }
onClick={ () => setShowFavorites(!showFavorites) }
>
<div className="w-[28px] h-[24px] flex items-center justify-center shrink-0 relative">
<FaHeart className={ `text-xs ${ showFavorites ? 'text-white' : totalFavs > 0 ? 'text-danger' : 'text-muted' }` } />
{ totalFavs > 0 &&
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] bg-danger text-white text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
{ totalFavs }
</span> }
</div>
<span className={ `text-[11px] font-bold whitespace-nowrap opacity-0 group-hover/rail:opacity-100 transition-opacity duration-200 ${ showFavorites ? 'text-white' : '' }` }>{ LocalizeText('catalog.favorites') }</span>
</div>
<div className="border-b border-card-grid-item-border mx-2 my-0.5" />
{ /* Admin: root page actions */ }
{ adminMode && rootNode &&
<div className="flex items-center gap-1 mx-1 px-1.5 py-1 opacity-0 group-hover/rail:opacity-100 transition-opacity">
<button
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
title={ LocalizeText('catalog.admin.new.root.category') }
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
>
<FaPlus className="text-[8px]" />
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.new') }</span>
</button>
<button
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
title={ LocalizeText('catalog.admin.edit.root') }
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
>
<FaEdit className="text-[8px]" />
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.root') }</span>
</button>
</div> }
{ /* Category icons */ }
{ rootNode && rootNode.children.length > 0 && rootNode.children.map((child, index) =>
{
if(!adminMode && !child.isVisible) return null;
const isHidden = !child.isVisible;
return (
<div
key={ `${ child.pageId }-${ index }` }
className={ `group/cat flex items-center gap-2 mx-1 px-1.5 py-1 rounded cursor-pointer transition-all duration-150 ${ isHidden ? 'opacity-40' : '' } ${ child.isActive ? 'bg-card-grid-item-active border border-card-grid-item-border-active shadow-inner1px' : 'border border-transparent hover:bg-card-grid-item-active' }` }
title={ adminMode ? `${ child.localization } [ID: ${ child.pageId }]${ isHidden ? ` (${ LocalizeText('catalog.admin.hidden') })` : '' }` : child.localization }
onClick={ () =>
{
if(searchResult) setSearchResult(null);
if(showFavorites) setShowFavorites(false);
activateNode(child);
} }
>
<div className="w-[28px] h-[24px] flex items-center justify-center shrink-0 relative">
<CatalogIconView icon={ child.iconId } />
{ isHidden && <FaEyeSlash className="absolute -bottom-0.5 -right-0.5 text-[7px] text-danger" /> }
</div>
<span className={ `text-[11px] whitespace-nowrap overflow-hidden truncate opacity-0 group-hover/rail:opacity-100 transition-opacity duration-200 flex-1 ${ child.isActive ? 'font-bold text-dark' : 'text-gray-700' }` }>
{ child.localization }
</span>
{ /* Admin actions on each root category */ }
{ adminMode &&
<div className="flex items-center gap-1 opacity-0 group-hover/rail:opacity-100 transition-opacity shrink-0">
<div
className="w-[18px] h-[18px] rounded flex items-center justify-center hover:bg-primary/20 cursor-pointer transition-colors"
title={ `${ LocalizeText('catalog.admin.edit.title') } "${ child.localization }"` }
onClick={ e =>
{
e.stopPropagation();
catalogAdmin.setEditingPageNode(child);
catalogAdmin.setEditingRootPage(false);
catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[9px] text-primary" />
</div>
<div
className="w-[18px] h-[18px] rounded flex items-center justify-center hover:bg-warning/20 cursor-pointer transition-colors"
title={ isHidden ? LocalizeText('catalog.admin.show') : LocalizeText('catalog.admin.hide') }
onClick={ e =>
{
e.stopPropagation();
catalogAdmin.togglePageVisible(child.pageId);
} }
>
{ isHidden
? <FaEye className="text-[9px] text-success" />
: <FaEyeSlash className="text-[9px] text-muted" /> }
</div>
<div
className="w-[18px] h-[18px] rounded flex items-center justify-center hover:bg-danger/20 cursor-pointer transition-colors"
title={ `${ LocalizeText('catalog.admin.delete.title') } "${ child.localization }"` }
onClick={ e =>
{
e.stopPropagation();
if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ])))
{
catalogAdmin.deletePage(child.pageId);
}
} }
>
<FaTrash className="text-[9px] text-danger" />
</div>
</div> }
</div>
);
}) }
</div>
{ /* === MAIN AREA === */ }
<div className="flex flex-col flex-1 overflow-hidden bg-light">
{ /* Toolbar: search + admin */ }
<div className="flex items-center gap-2 px-2 py-1.5 bg-card-tab-item border-b border-card-grid-item-border">
{ /* Breadcrumb */ }
<div className="flex items-center gap-1 text-[11px] text-gray-600 min-w-0 flex-1">
<FaStar className="text-[9px] text-primary shrink-0" />
{ activeNodes && activeNodes.length > 0
? activeNodes.map((node, i) => (
<span key={ node.pageId } className="flex items-center gap-1 min-w-0">
{ i > 0 && <span className="text-[8px] opacity-30"></span> }
<span className={ `truncate ${ i === activeNodes.length - 1 ? 'font-bold text-dark' : 'cursor-pointer hover:text-primary' }` }
onClick={ i < activeNodes.length - 1 ? () => activateNode(node) : undefined }>
{ node.localization }
</span>
</span>
))
: <span className="text-muted">{ LocalizeText('catalog.title') }</span> }
</div>
<div className="w-[180px] shrink-0">
<CatalogSearchView />
</div>
{ isMod &&
<button
className={ `flex items-center gap-1 px-2 py-1 rounded text-[10px] font-bold cursor-pointer transition-all border ${ adminMode ? 'bg-warning text-dark border-warning shadow-inner1px' : 'bg-card-grid-item text-gray-600 border-card-grid-item-border hover:bg-primary hover:text-white hover:border-primary' }` }
onClick={ () => setAdminMode(!adminMode) }
>
<FaCog className={ `${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
{ LocalizeText('catalog.admin') }
</button> }
</div>
{ /* Content area */ }
<div className="flex flex-1 overflow-hidden">
{ showFavorites
? <div className="flex-1 overflow-auto bg-card-content-area">
<CatalogFavoritesView onClose={ () => setShowFavorites(false) } />
</div>
: <>
{ !navigationHidden && activeNodes && activeNodes.length > 0 &&
<div className="w-[170px] min-w-[170px] border-r-2 border-card-grid-item-border bg-card-grid-item overflow-y-auto py-1">
<CatalogNavigationView node={ activeNodes[0] } />
</div> }
<div className="flex-1 overflow-auto p-2 bg-card-content-area">
{ adminMode && <CatalogAdminPageEditView /> }
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
</div>
</> }
</div>
</div>
</div>
</NitroCardContentView>
</NitroCardView> }
<CatalogAdminOfferEditView />
<CatalogGiftView />
<MarketplacePostOfferView />
</>
);
};
export const CatalogModernView: FC<{}> = () =>
{
return (
<CatalogAdminProvider>
<CatalogModernViewInner />
</CatalogAdminProvider>
);
};
+8 -267
View File
@@ -1,274 +1,15 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { LocalizeText } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useCatalog, useCatalogFavorites } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogFavoritesView } from './views/favorites/CatalogFavoritesView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
import { CatalogSearchView } from './views/page/common/CatalogSearchView';
import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout';
import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView';
import { FC } from 'react';
import { GetConfigurationValue } from '../../api';
import { CatalogClassicView } from './CatalogClassicView';
import { CatalogModernView } from './CatalogModernView';
const CatalogViewInner: FC<{}> = () =>
export const CatalogView: FC<{}> = () =>
{
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites();
const [ showFavorites, setShowFavorites ] = useState(false);
const style = GetConfigurationValue<string>('catalog.style', 'classic');
const isMod = GetSessionDataManager().isModerator;
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
if(style === 'new') return <CatalogModernView />;
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
case 'open':
if(parts.length > 2)
{
if(parts.length === 4)
{
switch(parts[2])
{
case 'offerId':
openPageByOfferId(parseInt(parts[3]));
return;
}
}
else
{
openPageByName(parts[2]);
}
}
else
{
setIsVisible(true);
}
return;
}
},
eventUrlPrefix: 'catalog/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ setIsVisible, openPageByOfferId, openPageByName ]);
return (
<>
{ isVisible &&
<NitroCardView className="nitro-catalog w-[780px] h-[520px]" uniqueKey="catalog">
<NitroCardHeaderView headerText={ LocalizeText('catalog.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCardContentView classNames={ [ 'p-0!', 'overflow-hidden!' ] }>
{ /* Admin banner */ }
{ adminMode &&
<div className="bg-warning text-dark text-[10px] font-bold text-center py-0.5 uppercase tracking-wider" style={ { textShadow: '0 1px 0 rgba(255,255,255,0.3)' } }>
Admin Mode Attivo
</div> }
<div className="flex h-full">
{ /* === LEFT SIDEBAR === */ }
<div className="group/rail flex flex-col w-[52px] hover:w-[175px] min-w-[52px] bg-card-grid-item border-r-2 border-card-grid-item-border py-1.5 gap-px overflow-y-auto overflow-x-hidden transition-[width] duration-200 ease-in-out">
{ /* Favorites toggle */ }
<div
className={ `flex items-center gap-2 mx-1 px-1.5 py-1.5 rounded cursor-pointer transition-all duration-150 ${ showFavorites ? 'bg-primary text-white' : 'hover:bg-card-grid-item-active' }` }
onClick={ () => setShowFavorites(!showFavorites) }
>
<div className="w-[28px] h-[24px] flex items-center justify-center shrink-0 relative">
<FaHeart className={ `text-xs ${ showFavorites ? 'text-white' : totalFavs > 0 ? 'text-danger' : 'text-muted' }` } />
{ totalFavs > 0 &&
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] bg-danger text-white text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
{ totalFavs }
</span> }
</div>
<span className={ `text-[11px] font-bold whitespace-nowrap opacity-0 group-hover/rail:opacity-100 transition-opacity duration-200 ${ showFavorites ? 'text-white' : '' }` }>{ LocalizeText('catalog.favorites') }</span>
</div>
<div className="border-b border-card-grid-item-border mx-2 my-0.5" />
{ /* Admin: root page actions */ }
{ adminMode && rootNode &&
<div className="flex items-center gap-1 mx-1 px-1.5 py-1 opacity-0 group-hover/rail:opacity-100 transition-opacity">
<button
className="flex items-center gap-1 text-[9px] text-success hover:text-green-800 cursor-pointer transition-colors"
title={ LocalizeText('catalog.admin.new.root.category') }
onClick={ () => catalogAdmin.createPage({ caption: 'New Category', pageLayout: 'default_3x3', minRank: 1, visible: '1', enabled: '1', orderNum: 99, parentId: rootNode.pageId }) }
>
<FaPlus className="text-[8px]" />
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.new') }</span>
</button>
<button
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
title={ LocalizeText('catalog.admin.edit.root') }
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
>
<FaEdit className="text-[8px]" />
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.root') }</span>
</button>
</div> }
{ /* Category icons */ }
{ rootNode && rootNode.children.length > 0 && rootNode.children.map((child, index) =>
{
if(!adminMode && !child.isVisible) return null;
const isHidden = !child.isVisible;
return (
<div
key={ `${ child.pageId }-${ index }` }
className={ `group/cat flex items-center gap-2 mx-1 px-1.5 py-1 rounded cursor-pointer transition-all duration-150 ${ isHidden ? 'opacity-40' : '' } ${ child.isActive ? 'bg-card-grid-item-active border border-card-grid-item-border-active shadow-inner1px' : 'border border-transparent hover:bg-card-grid-item-active' }` }
title={ adminMode ? `${ child.localization } [ID: ${ child.pageId }]${ isHidden ? ` (${ LocalizeText('catalog.admin.hidden') })` : '' }` : child.localization }
onClick={ () =>
{
if(searchResult) setSearchResult(null);
if(showFavorites) setShowFavorites(false);
activateNode(child);
} }
>
<div className="w-[28px] h-[24px] flex items-center justify-center shrink-0 relative">
<CatalogIconView icon={ child.iconId } />
{ isHidden && <FaEyeSlash className="absolute -bottom-0.5 -right-0.5 text-[7px] text-danger" /> }
</div>
<span className={ `text-[11px] whitespace-nowrap overflow-hidden truncate opacity-0 group-hover/rail:opacity-100 transition-opacity duration-200 flex-1 ${ child.isActive ? 'font-bold text-dark' : 'text-gray-700' }` }>
{ child.localization }
</span>
{ /* Admin actions on each root category */ }
{ adminMode &&
<div className="flex items-center gap-1 opacity-0 group-hover/rail:opacity-100 transition-opacity shrink-0">
<div
className="w-[18px] h-[18px] rounded flex items-center justify-center hover:bg-primary/20 cursor-pointer transition-colors"
title={ `${ LocalizeText('catalog.admin.edit.title') } "${ child.localization }"` }
onClick={ e =>
{
e.stopPropagation();
catalogAdmin.setEditingPageNode(child);
catalogAdmin.setEditingRootPage(false);
catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[9px] text-primary" />
</div>
<div
className="w-[18px] h-[18px] rounded flex items-center justify-center hover:bg-warning/20 cursor-pointer transition-colors"
title={ isHidden ? LocalizeText('catalog.admin.show') : LocalizeText('catalog.admin.hide') }
onClick={ e =>
{
e.stopPropagation();
catalogAdmin.togglePageVisible(child.pageId);
} }
>
{ isHidden
? <FaEye className="text-[9px] text-success" />
: <FaEyeSlash className="text-[9px] text-muted" /> }
</div>
<div
className="w-[18px] h-[18px] rounded flex items-center justify-center hover:bg-danger/20 cursor-pointer transition-colors"
title={ `${ LocalizeText('catalog.admin.delete.title') } "${ child.localization }"` }
onClick={ e =>
{
e.stopPropagation();
if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ])))
{
catalogAdmin.deletePage(child.pageId);
}
} }
>
<FaTrash className="text-[9px] text-danger" />
</div>
</div> }
</div>
);
}) }
</div>
{ /* === MAIN AREA === */ }
<div className="flex flex-col flex-1 overflow-hidden bg-light">
{ /* Toolbar: search + admin */ }
<div className="flex items-center gap-2 px-2 py-1.5 bg-card-tab-item border-b border-card-grid-item-border">
{ /* Breadcrumb */ }
<div className="flex items-center gap-1 text-[11px] text-gray-600 min-w-0 flex-1">
<FaStar className="text-[9px] text-primary shrink-0" />
{ activeNodes && activeNodes.length > 0
? activeNodes.map((node, i) => (
<span key={ node.pageId } className="flex items-center gap-1 min-w-0">
{ i > 0 && <span className="text-[8px] opacity-30"></span> }
<span className={ `truncate ${ i === activeNodes.length - 1 ? 'font-bold text-dark' : 'cursor-pointer hover:text-primary' }` }
onClick={ i < activeNodes.length - 1 ? () => activateNode(node) : undefined }>
{ node.localization }
</span>
</span>
))
: <span className="text-muted">{ LocalizeText('catalog.title') }</span> }
</div>
<div className="w-[180px] shrink-0">
<CatalogSearchView />
</div>
{ isMod &&
<button
className={ `flex items-center gap-1 px-2 py-1 rounded text-[10px] font-bold cursor-pointer transition-all border ${ adminMode ? 'bg-warning text-dark border-warning shadow-inner1px' : 'bg-card-grid-item text-gray-600 border-card-grid-item-border hover:bg-primary hover:text-white hover:border-primary' }` }
onClick={ () => setAdminMode(!adminMode) }
>
<FaCog className={ `${ adminMode ? 'animate-spin' : '' }` } style={ adminMode ? { animationDuration: '3s' } : {} } />
{ LocalizeText('catalog.admin') }
</button> }
</div>
{ /* Content area */ }
<div className="flex flex-1 overflow-hidden">
{ showFavorites
? <div className="flex-1 overflow-auto bg-card-content-area">
<CatalogFavoritesView onClose={ () => setShowFavorites(false) } />
</div>
: <>
{ !navigationHidden && activeNodes && activeNodes.length > 0 &&
<div className="w-[170px] min-w-[170px] border-r-2 border-card-grid-item-border bg-card-grid-item overflow-y-auto py-1">
<CatalogNavigationView node={ activeNodes[0] } />
</div> }
<div className="flex-1 overflow-auto p-2 bg-card-content-area">
{ adminMode && <CatalogAdminPageEditView /> }
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
</div>
</> }
</div>
</div>
</div>
</NitroCardContentView>
</NitroCardView> }
<CatalogAdminOfferEditView />
<CatalogGiftView />
<MarketplacePostOfferView />
</>
);
return <CatalogClassicView />;
};
export const CatalogView: FC<{}> = () =>
@@ -1,5 +1,5 @@
import { FC } from 'react';
import { FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { FC, useCallback, useRef, useState } from 'react';
import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { ICatalogNode, LocalizeText } from '../../../../api';
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
import { useCatalogAdmin } from '../../CatalogAdminContext';
@@ -20,13 +20,72 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
const adminMode = catalogAdmin?.adminMode ?? false;
const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites();
const isFav = node ? isFavoritePage(node.pageId) : false;
const [ isDragOver, setIsDragOver ] = useState(false);
const dragRef = useRef<HTMLDivElement>(null);
const handleDragStart = useCallback((e: React.DragEvent) =>
{
if(!adminMode) return;
e.dataTransfer.setData('text/plain', JSON.stringify({ pageId: node.pageId, parentId: node.parent?.pageId ?? -1 }));
e.dataTransfer.effectAllowed = 'move';
}, [ adminMode, node ]);
const handleDragOver = useCallback((e: React.DragEvent) =>
{
if(!adminMode) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
}, [ adminMode ]);
const handleDragLeave = useCallback(() =>
{
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) =>
{
if(!adminMode) return;
e.preventDefault();
setIsDragOver(false);
try
{
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if(data.pageId && data.pageId !== node.pageId)
{
// Drop onto a branch = reparent under this node
// Drop onto a leaf = reorder as sibling
const targetParentId = node.isBranch ? node.pageId : (node.parent?.pageId ?? -1);
const targetIndex = node.isBranch ? 0 : (node.parent?.children?.indexOf(node) ?? 0);
catalogAdmin?.reorderPage(data.pageId, targetParentId, targetIndex);
}
}
catch(err)
{
// Invalid drag data
}
}, [ adminMode, node, catalogAdmin ]);
return (
<div className={ child ? 'pl-1.5 ml-1.5 border-l-2 border-card-grid-item-border' : '' }>
<div
className={ `group/nav flex items-center gap-1.5 px-1.5 py-[3px] mx-0.5 rounded cursor-pointer transition-all duration-100 text-[11px] ${ node.isActive ? 'bg-card-grid-item-active border border-card-grid-item-border-active shadow-inner1px font-bold' : 'border border-transparent hover:bg-card-grid-item-active' }` }
ref={ dragRef }
className={ `group/nav flex items-center gap-1.5 px-1.5 py-[3px] mx-0.5 rounded cursor-pointer transition-all duration-100 text-[11px] ${ node.isActive ? 'bg-card-grid-item-active border border-card-grid-item-border-active shadow-inner1px font-bold' : 'border border-transparent hover:bg-card-grid-item-active' } ${ isDragOver ? 'ring-2 ring-primary ring-offset-1 bg-primary/10' : '' }` }
draggable={ adminMode }
onClick={ () => activateNode(node) }
onDragLeave={ adminMode ? handleDragLeave : undefined }
onDragOver={ adminMode ? handleDragOver : undefined }
onDragStart={ adminMode ? handleDragStart : undefined }
onDrop={ adminMode ? handleDrop : undefined }
>
{ adminMode &&
<FaArrowsAlt className="text-[7px] text-muted cursor-grab shrink-0 opacity-0 group-hover/nav:opacity-60" /> }
<div className="w-[20px] h-[20px] flex items-center justify-center shrink-0">
<CatalogIconView icon={ node.iconId } />
</div>
+41 -11
View File
@@ -1,30 +1,44 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useRef, 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<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ activeTab, setActiveTab ] = useState(TAB_SEARCH);
const pendingEditRef = useRef(false);
const {
items, total, page, loading, error, clearError,
selectedItem, catalogItems, furniDataEntry,
interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions
} = useFurniEditor();
useEffect(() =>
{
if(selectedItem && pendingEditRef.current)
{
pendingEditRef.current = false;
setActiveTab(TAB_EDIT);
}
}, [ selectedItem ]);
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
if(!GetSessionDataManager().isModerator) return;
const parts = url.split('/');
if(parts.length < 2) return;
@@ -57,13 +71,16 @@ export const FurniEditorView: FC<{}> = () =>
useEffect(() =>
{
const handler = async (e: CustomEvent<{ spriteId: number }>) =>
const handler = (e: CustomEvent<{ spriteId: number }>) =>
{
if(!GetSessionDataManager().isModerator) return;
const { spriteId } = e.detail;
const ok = await loadBySpriteId(spriteId);
if(!Number.isFinite(spriteId) || spriteId < 0) return;
if(ok) setActiveTab(TAB_EDIT);
pendingEditRef.current = true;
loadBySpriteId(spriteId);
};
window.addEventListener('furni-editor:open', handler as EventListener);
@@ -71,11 +88,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);
pendingEditRef.current = true;
loadDetail(id);
}, [ loadDetail ]);
const handleBack = useCallback(() =>
@@ -88,7 +104,9 @@ export const FurniEditorView: FC<{}> = () =>
setIsVisible(false);
}, []);
if(!isVisible) return null;
const isMod = useMemo(() => GetSessionDataManager().isModerator, []);
if(!isVisible || !isMod) return null;
return (
<NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]">
@@ -100,6 +118,9 @@ export const FurniEditorView: FC<{}> = () =>
<NitroCardTabsItemView isActive={ activeTab === TAB_EDIT } onClick={ () => selectedItem && setActiveTab(TAB_EDIT) }>
Edit
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === TAB_CREATE } onClick={ () => setActiveTab(TAB_CREATE) }>
Create
</NitroCardTabsItemView>
</NitroCardTabsView>
<NitroCardContentView>
{ error &&
@@ -134,6 +155,15 @@ export const FurniEditorView: FC<{}> = () =>
/>
}
{ activeTab === TAB_CREATE &&
<FurniEditorCreateView
interactions={ interactions }
loading={ loading }
onCreate={ createItem }
onBack={ handleBack }
/>
}
</NitroCardContentView>
</NitroCardView>
);
@@ -5,14 +5,13 @@ interface FurniEditorCreateViewProps
{
interactions: string[];
loading: boolean;
onCreate: (fields: Record<string, unknown>) => Promise<number | null>;
onCreated: (id: number) => void;
onCreate: (fields: Record<string, unknown>) => void;
onBack: () => void;
}
export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
{
const { interactions, loading, onCreate, onCreated } = props;
const [ success, setSuccess ] = useState<number | null>(null);
const { interactions, loading, onCreate, onBack } = props;
const [ form, setForm ] = useState({
itemName: '',
@@ -34,37 +33,42 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
interactionType: '',
interactionModesCount: 1,
customparams: '',
description: '',
revision: 0,
category: '',
defaultdir: 0,
offerid: 0,
buyout: false,
rentofferid: 0,
rentbuyout: false,
bc: false,
excludeddynamic: false,
furniline: '',
environment: '',
rare: false,
});
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 ]);
onCreate(form);
}, [ form, onCreate ]);
const inputClass = 'form-control form-control-sm';
const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
return (
<Column gap={ 1 } className="h-full overflow-auto">
{ success &&
<div className="bg-[#d4edda] border border-[#c3e6cb] rounded p-2 text-[#155724] text-xs">
Item created with ID #{ success }!
</div>
}
<Flex gap={ 1 } alignItems="center" className="mb-1">
<Button variant="secondary" onClick={ onBack }>Back</Button>
<Text bold className="text-[14px]">Create New Item</Text>
</Flex>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
@@ -77,6 +81,10 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
<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 className="col-span-2">
<label className={ labelClass }>Description</label>
<textarea className={ inputClass } rows={ 2 } value={ form.description } onChange={ e => setField('description', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Sprite ID</label>
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
@@ -93,7 +101,7 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
<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-4 gap-2">
<div>
<label className={ labelClass }>Width</label>
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
@@ -106,6 +114,10 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
<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>
<label className={ labelClass }>Default Dir</label>
<input type="number" className={ inputClass } value={ form.defaultdir } onChange={ e => setField('defaultdir', Number(e.target.value)) } />
</div>
</div>
</div>
@@ -149,6 +161,55 @@ export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
</div>
</div>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
<div className="grid grid-cols-3 gap-2">
<div>
<label className={ labelClass }>Revision</label>
<input type="number" className={ inputClass } value={ form.revision } onChange={ e => setField('revision', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Category</label>
<input className={ inputClass } value={ form.category } onChange={ e => setField('category', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Offer ID</label>
<input type="number" className={ inputClass } value={ form.offerid } onChange={ e => setField('offerid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Rent Offer ID</label>
<input type="number" className={ inputClass } value={ form.rentofferid } onChange={ e => setField('rentofferid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Furniline</label>
<input className={ inputClass } value={ form.furniline } onChange={ e => setField('furniline', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Environment</label>
<input className={ inputClass } value={ form.environment } onChange={ e => setField('environment', e.target.value) } />
</div>
</div>
<div className="grid grid-cols-4 gap-x-3 gap-y-1 mt-1">
{ [
['buyout', 'Buyout'],
['rentbuyout', 'Rent Buyout'],
['bc', 'BC'],
['excludeddynamic', 'Excl. Dynamic'],
['rare', 'Rare']
].map(([ key, label ]) => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="form-check-input"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
{ label }
</label>
)) }
</div>
</div>
<Flex className="mt-1">
<Button variant="success" disabled={ loading || !form.itemName || !form.publicName } onClick={ handleCreate }>
{ loading ? 'Creating...' : 'Create Item' }
@@ -9,8 +9,8 @@ interface FurniEditorEditViewProps
furniDataEntry: Record<string, unknown> | null;
interactions: string[];
loading: boolean;
onUpdate: (id: number, fields: Record<string, unknown>) => Promise<boolean>;
onDelete: (id: number) => Promise<boolean>;
onUpdate: (id: number, fields: Record<string, unknown>) => void;
onDelete: (id: number) => void;
onBack: () => void;
onRefresh: (id: number) => void;
}
@@ -39,6 +39,19 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
interactionType: '',
interactionModesCount: 0,
customparams: '',
description: '',
revision: 0,
category: '',
defaultdir: 0,
offerid: 0,
buyout: false,
rentofferid: 0,
rentbuyout: false,
bc: false,
excludeddynamic: false,
furniline: '',
environment: '',
rare: false,
});
const [ confirmDelete, setConfirmDelete ] = useState(false);
@@ -67,6 +80,19 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
interactionType: item.interactionType || '',
interactionModesCount: item.interactionModesCount || 0,
customparams: item.customparams || '',
description: item.description || '',
revision: item.revision || 0,
category: item.category || '',
defaultdir: item.defaultdir || 0,
offerid: item.offerid || 0,
buyout: !!item.buyout,
rentofferid: item.rentofferid || 0,
rentbuyout: !!item.rentbuyout,
bc: !!item.bc,
excludeddynamic: !!item.excludeddynamic,
furniline: item.furniline || '',
environment: item.environment || '',
rare: !!item.rare,
});
setConfirmDelete(false);
@@ -77,20 +103,17 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
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();
onDelete(item.id);
onBack();
}, [ confirmDelete, item, onDelete, onBack ]);
const inputClass = 'form-control form-control-sm';
@@ -101,15 +124,9 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
<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>
<Text bold className="text-[12px]">ID: { 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>
<Text bold className="text-[12px]">Sprite: { item.spriteId }</Text>
</Flex>
<Text small variant="gray">({ item.usageCount } in use)</Text>
</Flex>
@@ -126,6 +143,10 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
<label className={ labelClass }>Public Name</label>
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
</div>
<div className="col-span-2">
<label className={ labelClass }>Description</label>
<textarea className={ inputClass } rows={ 2 } value={ form.description } onChange={ e => setField('description', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Sprite ID</label>
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
@@ -143,7 +164,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
{ /* 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-4 gap-2">
<div>
<label className={ labelClass }>Width</label>
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
@@ -156,6 +177,10 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
<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>
<label className={ labelClass }>Default Dir</label>
<input type="number" className={ inputClass } value={ form.defaultdir } onChange={ e => setField('defaultdir', Number(e.target.value)) } />
</div>
</div>
</div>
@@ -201,6 +226,56 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</div>
</div>
{ /* FurniData JSON */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
<div className="grid grid-cols-3 gap-2">
<div>
<label className={ labelClass }>Revision</label>
<input type="number" className={ inputClass } value={ form.revision } onChange={ e => setField('revision', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Category</label>
<input className={ inputClass } value={ form.category } onChange={ e => setField('category', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Offer ID</label>
<input type="number" className={ inputClass } value={ form.offerid } onChange={ e => setField('offerid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Rent Offer ID</label>
<input type="number" className={ inputClass } value={ form.rentofferid } onChange={ e => setField('rentofferid', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Furniline</label>
<input className={ inputClass } value={ form.furniline } onChange={ e => setField('furniline', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Environment</label>
<input className={ inputClass } value={ form.environment } onChange={ e => setField('environment', e.target.value) } />
</div>
</div>
<div className="grid grid-cols-4 gap-x-3 gap-y-1 mt-1">
{ [
['buyout', 'Buyout'],
['rentbuyout', 'Rent Buyout'],
['bc', 'BC'],
['excludeddynamic', 'Excl. Dynamic'],
['rare', 'Rare']
].map(([ key, label ]) => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="form-check-input"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
{ label }
</label>
)) }
</div>
</div>
{ /* Catalog References */ }
{ catalogItems.length > 0 &&
<div className="bg-white rounded border border-[#ccc] p-2">
@@ -216,21 +291,6 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</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 }>
+303 -142
View File
@@ -1,4 +1,7 @@
import { FurniEditorBySpriteComposer, FurniEditorCreateComposer, FurniEditorCreateResultEvent, FurniEditorDeleteComposer, FurniEditorDeleteResultEvent, FurniEditorDetailComposer, FurniEditorDetailResultEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsResultEvent, FurniEditorSearchComposer, FurniEditorSearchResultEvent, FurniEditorUpdateComposer, FurniEditorUpdateResultEvent } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
export interface FurniItem
{
@@ -33,6 +36,18 @@ export interface FurniDetail extends FurniItem
multiheight: string;
description: string;
usageCount: number;
revision: number;
category: string;
defaultdir: number;
offerid: number;
buyout: boolean;
rentofferid: number;
rentbuyout: boolean;
bc: boolean;
excludeddynamic: boolean;
furniline: string;
environment: string;
rare: boolean;
}
export interface CatalogRef
@@ -46,16 +61,57 @@ export interface CatalogRef
pageName: string;
}
const API_BASE = '/api/admin/furni-editor';
export const MAX_STRING_LENGTH = 255;
export const MAX_CUSTOM_PARAMS_LENGTH = 1000;
export const MAX_DIMENSION = 100;
export const MAX_STACK_HEIGHT = 100;
export const MAX_MODES_COUNT = 100;
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T>
export interface FurniFormErrors
{
const res = await fetch(url, { credentials: 'include', ...options });
const data = await res.json();
itemName?: string;
publicName?: string;
spriteId?: string;
width?: string;
length?: string;
stackHeight?: string;
interactionModesCount?: string;
customparams?: string;
}
if(!res.ok || data.error) throw new Error(data.error || 'API error');
export function validateFurniForm(fields: Record<string, unknown>): FurniFormErrors
{
const errors: FurniFormErrors = {};
return data;
const itemName = String(fields.itemName ?? '').trim();
const publicName = String(fields.publicName ?? '').trim();
if(!itemName) errors.itemName = 'Item name is required';
else if(itemName.length > MAX_STRING_LENGTH) errors.itemName = `Max ${ MAX_STRING_LENGTH } characters`;
else if(!/^[a-zA-Z0-9_\- ]+$/.test(itemName)) errors.itemName = 'Only letters, numbers, _, - and spaces';
if(!publicName) errors.publicName = 'Public name is required';
else if(publicName.length > MAX_STRING_LENGTH) errors.publicName = `Max ${ MAX_STRING_LENGTH } characters`;
const spriteId = Number(fields.spriteId);
if(!Number.isFinite(spriteId) || spriteId < 0) errors.spriteId = 'Must be a positive number';
const width = Number(fields.width);
const length = Number(fields.length);
const stackHeight = Number(fields.stackHeight);
const modes = Number(fields.interactionModesCount);
if(!Number.isFinite(width) || width < 1 || width > MAX_DIMENSION) errors.width = `1-${ MAX_DIMENSION }`;
if(!Number.isFinite(length) || length < 1 || length > MAX_DIMENSION) errors.length = `1-${ MAX_DIMENSION }`;
if(!Number.isFinite(stackHeight) || stackHeight < 0 || stackHeight > MAX_STACK_HEIGHT) errors.stackHeight = `0-${ MAX_STACK_HEIGHT }`;
if(!Number.isFinite(modes) || modes < 0 || modes > MAX_MODES_COUNT) errors.interactionModesCount = `0-${ MAX_MODES_COUNT }`;
const customparams = String(fields.customparams ?? '');
if(customparams.length > MAX_CUSTOM_PARAMS_LENGTH) errors.customparams = `Max ${ MAX_CUSTOM_PARAMS_LENGTH } characters`;
return errors;
}
export const useFurniEditor = () =>
@@ -72,164 +128,269 @@ export const useFurniEditor = () =>
const clearError = useCallback(() => setError(null), []);
const searchItems = useCallback(async (query: string, type: string, pg: number) =>
// --- Message event handlers (incoming from server) ---
useMessageEvent<FurniEditorSearchResultEvent>(FurniEditorSearchResultEvent, useCallback(event =>
{
const parser = event.getParser();
setItems(parser.items.map(i => ({
id: i.id,
spriteId: i.spriteId,
itemName: i.itemName,
publicName: i.publicName,
type: i.type,
width: i.width,
length: i.length,
stackHeight: i.stackHeight,
allowStack: i.allowStack,
allowWalk: i.allowWalk,
allowSit: i.allowSit,
allowLay: i.allowLay,
interactionType: i.interactionType,
interactionModesCount: i.interactionModesCount
})));
setTotal(parser.total);
setPage(parser.page);
setLoading(false);
}, []));
useMessageEvent<FurniEditorDetailResultEvent>(FurniEditorDetailResultEvent, useCallback(event =>
{
const parser = event.getParser();
const i = parser.item;
setSelectedItem({
id: i.id,
spriteId: i.spriteId,
itemName: i.itemName,
publicName: i.publicName,
type: i.type,
width: i.width,
length: i.length,
stackHeight: i.stackHeight,
allowStack: i.allowStack,
allowWalk: i.allowWalk,
allowSit: i.allowSit,
allowLay: i.allowLay,
allowGift: i.allowGift,
allowTrade: i.allowTrade,
allowRecycle: i.allowRecycle,
allowMarketplaceSell: i.allowMarketplaceSell,
allowInventoryStack: i.allowInventoryStack,
interactionType: i.interactionType,
interactionModesCount: i.interactionModesCount,
customparams: i.customparams,
effectIdMale: i.effectIdMale,
effectIdFemale: i.effectIdFemale,
clothingOnWalk: i.clothingOnWalk,
vendingIds: i.vendingIds,
multiheight: i.multiheight,
description: i.description,
usageCount: i.usageCount,
revision: parser.revision,
category: parser.category,
defaultdir: parser.defaultdir,
offerid: parser.offerid,
buyout: parser.buyout,
rentofferid: parser.rentofferid,
rentbuyout: parser.rentbuyout,
bc: parser.bc,
excludeddynamic: parser.excludeddynamic,
furniline: parser.furniline,
environment: parser.environment,
rare: parser.rare
});
setCatalogItems(parser.catalogItems.map(ci => ({
id: ci.id,
catalogName: ci.catalogName,
costCredits: ci.costCredits,
costPoints: ci.costPoints,
pointsType: ci.pointsType,
pageId: ci.pageId,
pageName: ci.pageName
})));
let furniData: Record<string, unknown> | null = null;
if(parser.furniDataEntry)
{
try { furniData = JSON.parse(parser.furniDataEntry); }
catch { furniData = null; }
}
setFurniDataEntry(furniData);
setLoading(false);
}, []));
useMessageEvent<FurniEditorInteractionsResultEvent>(FurniEditorInteractionsResultEvent, useCallback(event =>
{
setInteractions(event.getParser().interactions);
}, []));
useMessageEvent<FurniEditorUpdateResultEvent>(FurniEditorUpdateResultEvent, useCallback(event =>
{
const parser = event.getParser();
setLoading(false);
if(!parser.success)
{
setError(parser.message);
}
else if(parser.id > 0)
{
SendMessageComposer(new FurniEditorDetailComposer(parser.id));
}
}, []));
useMessageEvent<FurniEditorCreateResultEvent>(FurniEditorCreateResultEvent, useCallback(event =>
{
const parser = event.getParser();
setLoading(false);
if(!parser.success)
{
setError(parser.message);
}
}, []));
useMessageEvent<FurniEditorDeleteResultEvent>(FurniEditorDeleteResultEvent, useCallback(event =>
{
const parser = event.getParser();
setLoading(false);
if(!parser.success)
{
setError(parser.message);
}
}, []));
// --- Outgoing commands (client to server) ---
const searchItems = useCallback((query: string, type: string, pg: number) =>
{
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);
try
{
const params = new URLSearchParams({ q: query, limit: '20', page: String(pg) });
const f = fields;
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 FurniEditorUpdateComposer(
id,
String(f.itemName ?? ''),
String(f.publicName ?? ''),
Number(f.spriteId ?? 0),
String(f.type ?? 's'),
Number(f.width ?? 1),
Number(f.length ?? 1),
Number(f.stackHeight ?? 0),
!!f.allowStack,
!!f.allowWalk,
!!f.allowSit,
!!f.allowLay,
!!f.allowGift,
!!f.allowTrade,
!!f.allowRecycle,
!!f.allowMarketplaceSell,
!!f.allowInventoryStack,
String(f.interactionType ?? ''),
Number(f.interactionModesCount ?? 0),
String(f.customparams ?? ''),
String(f.description ?? ''),
Number(f.revision ?? 0),
String(f.category ?? ''),
Number(f.defaultdir ?? 0),
Number(f.offerid ?? 0),
!!f.buyout,
Number(f.rentofferid ?? 0),
!!f.rentbuyout,
!!f.bc,
!!f.excludeddynamic,
String(f.furniline ?? ''),
String(f.environment ?? ''),
!!f.rare
));
}, []);
const loadDetail = useCallback(async (id: number): Promise<boolean> =>
const createItem = useCallback((fields: Record<string, unknown>) =>
{
setLoading(true);
setError(null);
try
{
const data = await apiFetch<{ item: FurniDetail; catalogItems: CatalogRef[]; furniDataEntry: Record<string, unknown> | null }>(`${ API_BASE }/detail?id=${ id }`);
const f = fields;
setSelectedItem(data.item);
setCatalogItems(data.catalogItems);
setFurniDataEntry(data.furniDataEntry);
return true;
}
catch(e: any)
{
setError(e.message);
return false;
}
finally
{
setLoading(false);
}
SendMessageComposer(new FurniEditorCreateComposer(
String(f.itemName ?? ''),
String(f.publicName ?? ''),
Number(f.spriteId ?? 0),
String(f.type ?? 's'),
Number(f.width ?? 1),
Number(f.length ?? 1),
Number(f.stackHeight ?? 0),
!!f.allowStack,
!!f.allowWalk,
!!f.allowSit,
!!f.allowLay,
!!f.allowGift,
!!f.allowTrade,
!!f.allowRecycle,
!!f.allowMarketplaceSell,
!!f.allowInventoryStack,
String(f.interactionType ?? ''),
Number(f.interactionModesCount ?? 0),
String(f.customparams ?? ''),
String(f.description ?? ''),
Number(f.revision ?? 0),
String(f.category ?? ''),
Number(f.defaultdir ?? 0),
Number(f.offerid ?? 0),
!!f.buyout,
Number(f.rentofferid ?? 0),
!!f.rentbuyout,
!!f.bc,
!!f.excludeddynamic,
String(f.furniline ?? ''),
String(f.environment ?? ''),
!!f.rare
));
}, []);
const updateItem = useCallback(async (id: number, fields: Record<string, unknown>) =>
const deleteItem = useCallback((id: 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 FurniEditorDeleteComposer(id));
}, []);
const createItem = useCallback(async (fields: Record<string, unknown>) =>
const loadInteractions = useCallback(() =>
{
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 FurniEditorInteractionsComposer());
}, []);
const deleteItem = useCallback(async (id: number) =>
{
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);
}
}, []);
const loadInteractions = useCallback(async () =>
{
try
{
const data = await apiFetch<{ interactions: Array<string | { name: string }> }>(`${ API_BASE }/interactions`);
setInteractions(data.interactions.map(i => typeof i === 'string' ? i : i.name));
}
catch {}
}, []);
const loadBySpriteId = useCallback(async (spriteId: number): Promise<boolean> =>
{
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 ]);
return {
items, total, page, loading, error, clearError,
selectedItem, setSelectedItem, catalogItems, furniDataEntry,
+2
View File
@@ -31,6 +31,8 @@ export default defineConfig({
alias: {
'@': resolve(__dirname, 'src'),
'~': resolve(__dirname, 'node_modules'),
// Renderer3 root → resolve through its src/index.ts
'@nitrots/nitro-renderer': resolve(renderer3, 'src/index.ts'),
// Renderer3 workspace packages → point to their src/index.ts
'@nitrots/api': resolve(renderer3, 'packages/api/src/index.ts'),
'@nitrots/assets': resolve(renderer3, 'packages/assets/src/index.ts'),