diff --git a/.gitignore b/.gitignore index 249e7db..506f80d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ Thumbs.db *.zip .env .claude/ +public/renderer-config.json +public/ui-config.json diff --git a/public/ui-config.json b/public/ui-config.json index ac761b3..b0a4864 100644 --- a/public/ui-config.json +++ b/public/ui-config.json @@ -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": [{ diff --git a/src/components/catalog/CatalogAdminContext.tsx b/src/components/catalog/CatalogAdminContext.tsx index dc64894..5a5fe18 100644 --- a/src/components/catalog/CatalogAdminContext.tsx +++ b/src/components/catalog/CatalogAdminContext.tsx @@ -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; - createPage: (data: IPageEditData) => Promise; - deletePage: (pageId: number) => Promise; - saveOffer: (data: IOfferEditData) => Promise; - createOffer: (data: IOfferEditData) => Promise; - deleteOffer: (offerId: number) => Promise; - reorderOffers: (orders: { id: number; orderNumber: number }[]) => Promise; - togglePageEnabled: (pageId: number) => Promise; - togglePageVisible: (pageId: number) => Promise; + 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(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; 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(null); const [ loading, setLoading ] = useState(false); const [ lastError, setLastError ] = useState(null); + const [ hasPendingChanges, setHasPendingChanges ] = useState(false); + const pendingActionRef = useRef(null); + const { simpleAlert = null } = useNotification(); - const withLoading = useCallback(async (fn: () => Promise): Promise => + // 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 = { + '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 => + 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 => + 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 => + 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 => + 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 => - { - 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 => - { - 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 => - { - 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 => - { - 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 => - { - 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 ( = ({ 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 } diff --git a/src/components/catalog/CatalogClassicView.tsx b/src/components/catalog/CatalogClassicView.tsx new file mode 100644 index 0000000..a5fabd4 --- /dev/null +++ b/src/components/catalog/CatalogClassicView.tsx @@ -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 && + + setIsVisible(false) } /> + { /* Admin banner */ } + { adminMode && +
+ ⚙ Admin Mode + +
} + + { rootNode && (rootNode.children.length > 0) && rootNode.children.map((child, index) => + { + if(!adminMode && !child.isVisible) return null; + + const isHidden = !child.isVisible; + + return ( + + { + if(searchResult) setSearchResult(null); + + activateNode(child); + } } > +
+ { GetConfigurationValue('catalog.tab.icons') && } + { child.localization } + { adminMode && isHidden && } + { adminMode && +
e.stopPropagation() }> + { catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } } /> + catalogAdmin.togglePageVisible(child.pageId) }> + { isHidden ? : } + + { if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId); } } /> +
} +
+
+ ); + }) } + { /* Admin toggle button in tabs bar */ } + { isMod && + setAdminMode(!adminMode) }> + + } +
+ + { /* Admin: add new root category */ } + { adminMode && rootNode && +
+ + +
} + + { !navigationHidden && + + { activeNodes && (activeNodes.length > 0) && + } + } + + { adminMode && } + { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } + + +
+
} + + + + + ); +}; + +export const CatalogClassicView: FC<{}> = () => +{ + return ( + + + + ); +}; diff --git a/src/components/catalog/CatalogModernView.tsx b/src/components/catalog/CatalogModernView.tsx new file mode 100644 index 0000000..958e3ea --- /dev/null +++ b/src/components/catalog/CatalogModernView.tsx @@ -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 && + + setIsVisible(false) } /> + + { /* Admin banner */ } + { adminMode && +
+ ⚙ Admin Mode + +
} + +
+ { /* === LEFT SIDEBAR === */ } +
+ + { /* Favorites toggle */ } +
setShowFavorites(!showFavorites) } + > +
+ 0 ? 'text-danger' : 'text-muted' }` } /> + { totalFavs > 0 && + + { totalFavs } + } +
+ { LocalizeText('catalog.favorites') } +
+ +
+ + { /* Admin: root page actions */ } + { adminMode && rootNode && +
+ + +
} + + { /* Category icons */ } + { rootNode && rootNode.children.length > 0 && rootNode.children.map((child, index) => + { + if(!adminMode && !child.isVisible) return null; + + const isHidden = !child.isVisible; + + return ( +
+ { + if(searchResult) setSearchResult(null); + if(showFavorites) setShowFavorites(false); + activateNode(child); + } } + > +
+ + { isHidden && } +
+ + { child.localization } + + { /* Admin actions on each root category */ } + { adminMode && +
+
+ { + e.stopPropagation(); + catalogAdmin.setEditingPageNode(child); + catalogAdmin.setEditingRootPage(false); + catalogAdmin.setEditingPageData(true); + } } + > + +
+
+ { + e.stopPropagation(); + catalogAdmin.togglePageVisible(child.pageId); + } } + > + { isHidden + ? + : } +
+
+ { + e.stopPropagation(); + if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) + { + catalogAdmin.deletePage(child.pageId); + } + } } + > + +
+
} +
+ ); + }) } +
+ + { /* === MAIN AREA === */ } +
+ { /* Toolbar: search + admin */ } +
+ { /* Breadcrumb */ } +
+ + { activeNodes && activeNodes.length > 0 + ? activeNodes.map((node, i) => ( + + { i > 0 && } + activateNode(node) : undefined }> + { node.localization } + + + )) + : { LocalizeText('catalog.title') } } +
+ +
+ +
+ + { isMod && + } +
+ + { /* Content area */ } +
+ { showFavorites + ?
+ setShowFavorites(false) } /> +
+ : <> + { !navigationHidden && activeNodes && activeNodes.length > 0 && +
+ +
} +
+ { adminMode && } + { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } +
+ } +
+
+
+ + } + + + + + ); +}; + +export const CatalogModernView: FC<{}> = () => +{ + return ( + + + + ); +}; diff --git a/src/components/catalog/CatalogView.tsx b/src/components/catalog/CatalogView.tsx index 9e6ede9..fb63d63 100644 --- a/src/components/catalog/CatalogView.tsx +++ b/src/components/catalog/CatalogView.tsx @@ -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('catalog.style', 'classic'); - const isMod = GetSessionDataManager().isModerator; - const totalFavs = favoriteOfferIds.length + favoritePageIds.length; + if(style === 'new') return ; - 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 && - - setIsVisible(false) } /> - - { /* Admin banner */ } - { adminMode && -
- ⚙ Admin Mode Attivo -
} - -
- { /* === LEFT SIDEBAR === */ } -
- - { /* Favorites toggle */ } -
setShowFavorites(!showFavorites) } - > -
- 0 ? 'text-danger' : 'text-muted' }` } /> - { totalFavs > 0 && - - { totalFavs } - } -
- { LocalizeText('catalog.favorites') } -
- -
- - { /* Admin: root page actions */ } - { adminMode && rootNode && -
- - -
} - - { /* Category icons */ } - { rootNode && rootNode.children.length > 0 && rootNode.children.map((child, index) => - { - if(!adminMode && !child.isVisible) return null; - - const isHidden = !child.isVisible; - - return ( -
- { - if(searchResult) setSearchResult(null); - if(showFavorites) setShowFavorites(false); - activateNode(child); - } } - > -
- - { isHidden && } -
- - { child.localization } - - { /* Admin actions on each root category */ } - { adminMode && -
-
- { - e.stopPropagation(); - catalogAdmin.setEditingPageNode(child); - catalogAdmin.setEditingRootPage(false); - catalogAdmin.setEditingPageData(true); - } } - > - -
-
- { - e.stopPropagation(); - catalogAdmin.togglePageVisible(child.pageId); - } } - > - { isHidden - ? - : } -
-
- { - e.stopPropagation(); - if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) - { - catalogAdmin.deletePage(child.pageId); - } - } } - > - -
-
} -
- ); - }) } -
- - { /* === MAIN AREA === */ } -
- { /* Toolbar: search + admin */ } -
- { /* Breadcrumb */ } -
- - { activeNodes && activeNodes.length > 0 - ? activeNodes.map((node, i) => ( - - { i > 0 && } - activateNode(node) : undefined }> - { node.localization } - - - )) - : { LocalizeText('catalog.title') } } -
- -
- -
- - { isMod && - } -
- - { /* Content area */ } -
- { showFavorites - ?
- setShowFavorites(false) } /> -
- : <> - { !navigationHidden && activeNodes && activeNodes.length > 0 && -
- -
} -
- { adminMode && } - { GetCatalogLayout(currentPage, () => setNavigationHidden(true)) } -
- } -
-
-
- - } - - - - - ); + return ; }; export const CatalogView: FC<{}> = () => diff --git a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx index a0c980f..d31d801 100644 --- a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx +++ b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx @@ -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 = 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(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 (
activateNode(node) } + onDragLeave={ adminMode ? handleDragLeave : undefined } + onDragOver={ adminMode ? handleDragOver : undefined } + onDragStart={ adminMode ? handleDragStart : undefined } + onDrop={ adminMode ? handleDrop : undefined } > + { adminMode && + }
diff --git a/src/components/furni-editor/FurniEditorView.tsx b/src/components/furni-editor/FurniEditorView.tsx index a013554..c9ef3ab 100644 --- a/src/components/furni-editor/FurniEditorView.tsx +++ b/src/components/furni-editor/FurniEditorView.tsx @@ -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 ( @@ -100,6 +118,9 @@ export const FurniEditorView: FC<{}> = () => selectedItem && setActiveTab(TAB_EDIT) }> Edit + setActiveTab(TAB_CREATE) }> + Create + { error && @@ -134,6 +155,15 @@ export const FurniEditorView: FC<{}> = () => /> } + { activeTab === TAB_CREATE && + + } + ); diff --git a/src/components/furni-editor/views/FurniEditorCreateView.tsx b/src/components/furni-editor/views/FurniEditorCreateView.tsx index f47530c..d512a64 100644 --- a/src/components/furni-editor/views/FurniEditorCreateView.tsx +++ b/src/components/furni-editor/views/FurniEditorCreateView.tsx @@ -5,14 +5,13 @@ interface FurniEditorCreateViewProps { interactions: string[]; loading: boolean; - onCreate: (fields: Record) => Promise; - onCreated: (id: number) => void; + onCreate: (fields: Record) => void; + onBack: () => void; } export const FurniEditorCreateView: FC = props => { - const { interactions, loading, onCreate, onCreated } = props; - const [ success, setSuccess ] = useState(null); + const { interactions, loading, onCreate, onBack } = props; const [ form, setForm ] = useState({ itemName: '', @@ -34,37 +33,42 @@ export const FurniEditorCreateView: FC = 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 ( - { success && -
- Item created with ID #{ success }! -
- } + + + Create New Item +
Basic Info @@ -77,6 +81,10 @@ export const FurniEditorCreateView: FC = props => setField('publicName', e.target.value) } placeholder="My Custom Furni" />
+
+ +