nitro-v36-with-renderer-v36
/
src /
components /
catalog /
views /
admin /
CatalogAdminManagerView.tsx
CatalogAdminManagerView.tsx 37.4 Ko
Télécharger le fichier
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import {
FaArrowDown,
FaArrowsAlt,
FaArrowUp,
FaCheckCircle,
FaChevronDown,
FaChevronRight,
FaEdit,
FaExclamationTriangle,
FaEye,
FaEyeSlash,
FaHistory,
FaPlus,
FaSearch,
FaSitemap,
FaTrash,
FaWrench,
} from 'react-icons/fa';
import { GetConfigurationValue, ICatalogNode, IPurchasableOffer, LocalizeText, ProductTypeEnum } from '../../../../api';
import { OctaneCardContentView, OctaneCardHeaderView, OctaneCardView } from '../../../../common';
import { useCatalogActions, useCatalogData, useCatalogUiState } from '../../../../hooks';
import { replaceCatalogPageOffers } from '../../../../hooks/catalog/useCatalog.helpers';
import { getCatalogStudioCommandState, getCatalogStudioWorkspaceTabs } from '../../admin/studio/CatalogStudioCommandCenter';
import { CatalogStudioProblemsHistoryPanel } from '../../admin/studio/CatalogStudioProblemsHistoryPanel';
import { CatalogStudioValidationIssue } from '../../admin/studio/CatalogStudioTypes';
import { CatalogStudioTransferPanel } from '../../admin/studio/CatalogStudioTransferPanel';
import { useCatalogStudio } from '../../admin/studio/useCatalogStudio';
import { useCatalogAdmin } from '../../CatalogAdminContext';
import { parseCatalogTabLabel } from '../../useCatalogWindowWidth';
import { isReadOnlyCatalogAdminLayout } from '../page/layout/catalogLayoutRegistry';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
import {
buildCatalogAdminDraftTree,
CatalogAdminPageDropPosition,
planCatalogAdminPageDrop,
resolveCatalogAdminPageDropPosition
} from './CatalogAdminDraftTree';
import { CatalogAdminOfferPriceView } from './CatalogAdminOfferPriceView';
import { TabBar, TabBarTab } from '../../../../common/TabBar';
import { BaseButton } from '../../../../common/BaseButton';
import { Hint } from '../../../../common/Tooltip';
import { NativeTextTree } from '../../../../common/native-text/NativeTextTree';
type CatalogAdminOffer = Parameters<NonNullable<ReturnType<typeof useCatalogAdmin>>['setEditingOffer']>[0];
type ManagerTab = 'catalog' | 'sql' | 'history';
const stripSwfSuffix = (label: string) => (label || '').replace(/\s*\(\D[^)]*\)\s*$/g, '').trim();
const nodeName = (node: ICatalogNode) => stripSwfSuffix(parseCatalogTabLabel(node.localization).name) || node.pageName;
const findNodeByPageId = (node: ICatalogNode | null, pageId: number): ICatalogNode | null => {
if (!node) return null;
if (node.pageId === pageId) return node;
for (const child of node.children) {
const found = findNodeByPageId(child, pageId);
if (found) return found;
}
return null;
};
const subtreeMatches = (node: ICatalogNode, query: string): boolean => {
if (!query) return true;
if (nodeName(node).toLowerCase().includes(query)) return true;
return node.children.some((child) => subtreeMatches(child, query));
};
const getOfferIconUrl = (offer: IPurchasableOffer): string | null => {
const product = offer.product;
if (!product) return null;
if (product.productType === ProductTypeEnum.FLOOR || product.productType === ProductTypeEnum.WALL) {
const className = product.furnitureData?.className;
if (className?.length) {
let param = '';
if (product.productType === ProductTypeEnum.WALL && product.extraParam?.length) {
param = `_${product.extraParam}`;
} else if (product.productType === ProductTypeEnum.FLOOR && product.furnitureData?.hasIndexedColor && product.furnitureData.colorIndex > 0) {
param = `_${product.furnitureData.colorIndex}`;
}
const configuredIconUrl = GetConfigurationValue<string>('furni.asset.icon.url', '');
if (configuredIconUrl?.length) return configuredIconUrl.replace('%libname%', className).replace('%param%', param);
}
}
return product.getIconUrl(offer) ?? null;
};
export const CatalogAdminManagerView: FC<{}> = () => {
const { rootNode = null, currentPage = null, currentOffer = null } = useCatalogData();
const { setCurrentPage, setCurrentOffer, currentType } = useCatalogUiState();
const { activateNode = null } = useCatalogActions();
const catalogAdmin = useCatalogAdmin();
const studio = useCatalogStudio();
const [activeTab, setActiveTab] = useState<ManagerTab>('catalog');
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [search, setSearch] = useState('');
const [pageDropTarget, setPageDropTarget] = useState<{ pageId: number; position: CatalogAdminPageDropPosition } | null>(null);
const [rootDropActive, setRootDropActive] = useState(false);
const [dragOverOfferIndex, setDragOverOfferIndex] = useState<number | null>(null);
const [selectedPageId, setSelectedPageId] = useState(currentPage?.pageId ?? -1);
const query = search.trim().toLowerCase();
const managerRootNode = useMemo(
() => buildCatalogAdminDraftTree(rootNode, studio.session?.pages ?? [], currentType),
[currentType, rootNode, studio.session?.pages]
);
const selectedNode = findNodeByPageId(managerRootNode, selectedPageId);
const offers = currentPage?.pageId === selectedPageId ? (currentPage.offers ?? []) : [];
const selectedPageLayout = useMemo(
() => (studio.session?.pages ?? []).find((page) => page.pageId === selectedPageId)?.pageLayout ?? null,
[studio.session?.pages, selectedPageId]
);
const isReadOnlyPage = isReadOnlyCatalogAdminLayout(selectedPageLayout);
const categoryCount = managerRootNode?.children.length ?? 0;
const validationCurrent = studio.validation
? studio.validation.current && studio.validation.revision === studio.revision
: studio.session?.validationCurrent ?? false;
const validationIssueCount = studio.validation?.issues.length ?? studio.session?.validationIssueCount ?? 0;
const commandState = getCatalogStudioCommandState({
sessionReady: !!studio.session,
validationCurrent,
validationIssueCount,
loading: studio.loading || !!catalogAdmin?.loading
});
useEffect(() => {
if (!catalogAdmin?.adminMode || !studio.session) return;
studio.loadHistory();
}, [catalogAdmin?.adminMode, studio.session?.draftVersionId, studio.loadHistory]);
useEffect(() => {
if (activeTab !== 'history' || !commandState.canValidate) return;
studio.validate();
}, [activeTab, commandState.canValidate, studio.validate]);
useEffect(() => {
if (currentPage?.pageId != null && currentPage.pageId !== selectedPageId) setSelectedPageId(currentPage.pageId);
}, [currentPage?.pageId, selectedPageId]);
const handleProblemSelect = useCallback((issue: CatalogStudioValidationIssue) => {
if (issue.entityType !== 'PAGE') return;
setSelectedPageId(issue.entityId);
setActiveTab('catalog');
}, []);
const handlePageDragStart = useCallback((event: React.DragEvent, node: ICatalogNode) => {
event.stopPropagation();
event.dataTransfer.setData('text/plain', JSON.stringify({ pageId: node.pageId }));
event.dataTransfer.effectAllowed = 'move';
}, []);
const handlePageDragOver = useCallback((event: React.DragEvent, node: ICatalogNode) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
const bounds = event.currentTarget.getBoundingClientRect();
const position = resolveCatalogAdminPageDropPosition(event.clientY, bounds.top, bounds.height);
setRootDropActive(false);
setPageDropTarget({ pageId: node.pageId, position });
}, []);
const handlePageDragLeave = useCallback(() => {
setPageDropTarget(null);
}, []);
const handlePageDrop = useCallback(
(event: React.DragEvent, node: ICatalogNode) => {
event.preventDefault();
event.stopPropagation();
setPageDropTarget(null);
if (!catalogAdmin || !managerRootNode) return;
try {
const data = JSON.parse(event.dataTransfer.getData('text/plain'));
const dragged = findNodeByPageId(managerRootNode, Number(data.pageId));
const bounds = event.currentTarget.getBoundingClientRect();
const position = resolveCatalogAdminPageDropPosition(event.clientY, bounds.top, bounds.height);
const plan = planCatalogAdminPageDrop(dragged, node, position, managerRootNode);
if (!plan) return;
if (position === 'inside') {
setExpanded((current) => new Set(current).add(node.pageId));
}
catalogAdmin.reorderPage(
plan.pageId,
plan.newParentId,
plan.newIndex,
`Moved page #${plan.pageId} ${position} ${nodeName(node)}`
);
} catch {
}
},
[catalogAdmin, managerRootNode]
);
const handleRootDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
setPageDropTarget(null);
setRootDropActive(true);
}, []);
const handleRootDrop = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setRootDropActive(false);
if (!catalogAdmin || !managerRootNode) return;
try {
const data = JSON.parse(event.dataTransfer.getData('text/plain'));
const dragged = findNodeByPageId(managerRootNode, Number(data.pageId));
const plan = planCatalogAdminPageDrop(dragged, null, 'root', managerRootNode);
if (!plan) return;
catalogAdmin.reorderPage(plan.pageId, plan.newParentId, plan.newIndex, `Moved page #${plan.pageId} to catalog root`);
} catch {
}
}, [catalogAdmin, managerRootNode]);
const reorderOffersToIndex = useCallback(
(fromIndex: number, toIndex: number) => {
if (!catalogAdmin || !currentPage || isReadOnlyPage) return;
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= offers.length || toIndex >= offers.length) return;
const reordered = [...offers];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
setCurrentPage(replaceCatalogPageOffers(currentPage, reordered));
const pageLabel = selectedNode ? nodeName(selectedNode) : 'page';
catalogAdmin.reorderOffers(
reordered.map((offer, i) => ({ id: offer.offerId, orderNumber: i })),
`Reordered offers on "${pageLabel}"`,
currentPage.pageId
);
},
[catalogAdmin, currentPage, isReadOnlyPage, offers, selectedNode, setCurrentPage]
);
const handleOfferDragStart = useCallback((event: React.DragEvent, index: number) => {
if (isReadOnlyPage) {
event.preventDefault();
return;
}
event.stopPropagation();
event.dataTransfer.setData('application/x-catalog-admin-offer', JSON.stringify({ index }));
event.dataTransfer.effectAllowed = 'move';
}, [isReadOnlyPage]);
const handleOfferDragOver = useCallback((event: React.DragEvent, index: number) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
setDragOverOfferIndex(index);
}, []);
const handleOfferDragLeave = useCallback(() => {
setDragOverOfferIndex(null);
}, []);
const handleOfferDrop = useCallback(
(event: React.DragEvent, dropIndex: number) => {
event.preventDefault();
event.stopPropagation();
setDragOverOfferIndex(null);
try {
const data = JSON.parse(event.dataTransfer.getData('application/x-catalog-admin-offer'));
if (typeof data.index !== 'number') return;
reorderOffersToIndex(data.index, dropIndex);
} catch {
}
},
[reorderOffersToIndex]
);
if (!catalogAdmin?.adminMode) return null;
const toggleExpand = (pageId: number) => {
setExpanded((prev) => {
const next = new Set(prev);
next.has(pageId) ? next.delete(pageId) : next.add(pageId);
return next;
});
};
const selectNode = (node: ICatalogNode) => {
if (node.children.length) setExpanded((prev) => new Set(prev).add(node.pageId));
if (node.pageId > -1) {
setSelectedPageId(node.pageId);
const liveNode = findNodeByPageId(rootNode, node.pageId);
if (liveNode) activateNode?.(liveNode);
}
};
const editPage = (node: ICatalogNode | null, isRoot: boolean) => {
catalogAdmin.setCreatingPage(false);
catalogAdmin.setEditingPageNode(isRoot ? null : node);
catalogAdmin.setEditingRootPage(isRoot);
catalogAdmin.setEditingPageData(true);
};
const createCategory = (parent: ICatalogNode) => {
catalogAdmin.setCreatingPage(true);
catalogAdmin.setEditingRootPage(false);
catalogAdmin.setEditingPageNode(parent);
catalogAdmin.setEditingPageData(true);
};
const deletePage = (node: ICatalogNode) => {
if (confirm(LocalizeText('catalog.admin.delete.category.confirm', ['name'], [nodeName(node)]))) {
catalogAdmin.deletePage(node.pageId, `Deleted page: ${nodeName(node)}`);
}
};
const movePage = (node: ICatalogNode, direction: -1 | 1) => {
const parent = node.parent;
if (!parent) return;
const siblings = parent.children;
const index = siblings.indexOf(node);
const target = index + direction;
if (target < 0 || target >= siblings.length) return;
catalogAdmin.reorderPage(node.pageId, parent.pageId, target, `Moved page: ${nodeName(node)}`);
};
const newOffer = () => {
if (!currentPage || isReadOnlyPage) return;
catalogAdmin.setEditingOffer({
offerId: -1,
product: { productClassId: 0, productType: 'i', productCount: 1, extraParam: '' }
} as CatalogAdminOffer);
};
const deleteOffer = (offer: IPurchasableOffer) => {
if (isReadOnlyPage) return;
const label = offer.localizationName || `#${offer.offerId}`;
if (confirm(`Delete offer "${label}"?`)) catalogAdmin.deleteOffer(offer.offerId, `Deleted offer: ${label}`);
};
const moveOffer = (index: number, direction: -1 | 1) => {
reorderOffersToIndex(index, index + direction);
};
const renderNode = (node: ICatalogNode, depth: number) => {
if (!subtreeMatches(node, query)) return null;
const isOpen = query ? true : expanded.has(node.pageId);
const isSelected = node.pageId === selectedPageId && selectedPageId > -1;
const isHidden = !node.isVisible;
const hasChildren = node.children.length > 0;
const dropPosition = pageDropTarget?.pageId === node.pageId ? pageDropTarget.position : null;
return (
<div key={node.pageId} className="octane-catalog-admin-tree-branch">
<div
className={`octane-catalog-admin-tree-row ${isSelected ? 'is-selected' : ''} ${isHidden ? 'is-hidden' : ''} ${dropPosition ? `is-drop-${dropPosition}` : ''}`}
draggable
role="treeitem"
tabIndex={0}
aria-expanded={hasChildren ? isOpen : undefined}
aria-selected={isSelected}
style={{ paddingLeft: `${4 + depth * 14}px` }}
onClick={() => selectNode(node)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
selectNode(node);
}}
onDragLeave={handlePageDragLeave}
onDragOver={(event) => handlePageDragOver(event, node)}
onDragStart={(event) => handlePageDragStart(event, node)}
onDragEnd={() => {
setPageDropTarget(null);
setRootDropActive(false);
}}
onDrop={(event) => handlePageDrop(event, node)}
>
<FaArrowsAlt className="octane-catalog-admin-tree-drag" title="Drag to reorder or reparent" />
<span className="octane-catalog-admin-tree-caret">
{hasChildren ? (
<BaseButton
aria-label={`${isOpen ? 'Collapse' : 'Expand'} ${nodeName(node)}`}
onClick={(event) => {
event.stopPropagation();
toggleExpand(node.pageId);
}}
>
{isOpen ? <FaChevronDown /> : <FaChevronRight />}
</BaseButton>
) : (
<span className="octane-catalog-admin-tree-caret-spacer" />
)}
</span>
<span className="octane-catalog-admin-tree-icon">
{node.iconId > 0 ? <CatalogIconView icon={node.iconId} /> : <span className="octane-catalog-admin-tree-icon-empty" />}
</span>
<span className="octane-catalog-admin-tree-label">{nodeName(node)}</span>
<span className="octane-catalog-admin-tree-count">{node.pageId}</span>
</div>
{isOpen && hasChildren && <div className="octane-catalog-admin-tree-children">{node.children.map((child) => renderNode(child, depth + 1))}</div>}
</div>
);
};
const renderDetail = () => {
if (!selectedNode) {
return <div className="octane-catalog-admin-placeholder">Select a page from the tree to edit</div>;
}
const siblings = selectedNode.parent?.children ?? [];
const index = siblings.indexOf(selectedNode);
const isHidden = !selectedNode.isVisible;
return (
<NativeTextTree>
<div className="octane-catalog-admin-detail-inner">
<div className="octane-catalog-admin-detail-head">
<span className="octane-catalog-admin-detail-icon">
{selectedNode.iconId > 0 ? <CatalogIconView icon={selectedNode.iconId} /> : <span className="octane-catalog-admin-tree-icon-empty" />}
</span>
<div className="octane-catalog-admin-detail-titles">
<span className="octane-catalog-admin-detail-title">{nodeName(selectedNode)}</span>
<span className="octane-catalog-admin-detail-sub">
Page ID {selectedNode.pageId} · {selectedNode.children.length} sub-page(s) · {offers.length} offer(s)
</span>
</div>
</div>
<div className="octane-catalog-admin-detail-actions">
<BaseButton className="octane-catalog-admin-btn is-primary" onClick={() => editPage(selectedNode, false)}>
<FaEdit /> <span>Edit page</span>
</BaseButton>
<BaseButton className="octane-catalog-admin-btn" onClick={() => createCategory(selectedNode)}>
<FaPlus /> <span>Add sub-page</span>
</BaseButton>
<BaseButton
className="octane-catalog-admin-btn"
onClick={() =>
catalogAdmin.togglePageVisible(selectedNode.pageId, isHidden, `${isHidden ? 'Showed' : 'Hidden'} page: ${nodeName(selectedNode)}`)
}
>
{isHidden ? <FaEye /> : <FaEyeSlash />} <span>{isHidden ? 'Show' : 'Hide'}</span>
</BaseButton>
<BaseButton className="octane-catalog-admin-btn" disabled={index <= 0} onClick={() => movePage(selectedNode, -1)}>
<FaArrowUp /> <span>Move up</span>
</BaseButton>
<BaseButton className="octane-catalog-admin-btn" disabled={index < 0 || index >= siblings.length - 1} onClick={() => movePage(selectedNode, 1)}>
<FaArrowDown /> <span>Move down</span>
</BaseButton>
<BaseButton className="octane-catalog-admin-btn is-danger" onClick={() => deletePage(selectedNode)}>
<FaTrash /> <span>Delete</span>
</BaseButton>
</div>
<div className="octane-catalog-admin-offers">
{isReadOnlyPage ? (
<div className="octane-catalog-admin-placeholder is-small">
This page lists each user's own recent purchases ({selectedPageLayout}), so it has no editable
offers. You can still edit the page settings.
</div>
) : (
<>
<div className="octane-catalog-admin-offers-head">
<span className="octane-catalog-admin-offers-title">Offers ({offers.length})</span>
<BaseButton className="octane-catalog-admin-btn is-primary" disabled={!currentPage} onClick={newOffer}>
<FaPlus /> <span>New offer</span>
</BaseButton>
</div>
<div className="octane-catalog-admin-offers-list">
{!currentPage && <div className="octane-catalog-admin-placeholder is-small">Loading offers…</div>}
{currentPage && offers.length === 0 && <div className="octane-catalog-admin-placeholder is-small">No offers on this page</div>}
{offers.map((offer, index) => {
const iconUrl = getOfferIconUrl(offer);
const isSelected = currentOffer?.offerId === offer.offerId;
const editableProducts = (offer.products?.length ? offer.products : [ offer.product ]).filter(
(product) =>
product?.productClassId > 0 &&
(product.productType === ProductTypeEnum.FLOOR || product.productType === ProductTypeEnum.WALL)
);
return (
<div
key={offer.offerId}
className={`octane-catalog-admin-offer-row ${isSelected ? 'is-selected' : ''} ${dragOverOfferIndex === index ? 'is-drag-over' : ''}`}
draggable
role="option"
tabIndex={0}
aria-selected={isSelected}
onClick={() => setCurrentOffer(offer)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
setCurrentOffer(offer);
}}
onDragLeave={handleOfferDragLeave}
onDragOver={(event) => handleOfferDragOver(event, index)}
onDragStart={(event) => handleOfferDragStart(event, index)}
onDrop={(event) => handleOfferDrop(event, index)}
>
<Hint content="Drag to reorder">
<span aria-label="Drag to reorder" className="octane-catalog-admin-offer-drag" role="img">
<FaArrowsAlt />
</span>
</Hint>
<div className="octane-catalog-admin-manager-reorder">
<Hint content="Move up"><BaseButton aria-label="Move up" disabled={index === 0} onClick={() => moveOffer(index, -1)}>
<FaArrowUp />
</BaseButton></Hint>
<Hint content="Move down"><BaseButton aria-label="Move down" disabled={index === offers.length - 1} onClick={() => moveOffer(index, 1)}>
<FaArrowDown />
</BaseButton></Hint>
</div>
<span className="octane-catalog-admin-offer-icon">
{iconUrl ? (
<img
alt=""
draggable={false}
src={iconUrl}
onError={(event) => {
const fallback = offer.product?.getIconUrl(offer);
if (fallback && event.currentTarget.src !== fallback) event.currentTarget.src = fallback;
else event.currentTarget.style.visibility = 'hidden';
}}
/>
) : (
<span className="octane-catalog-admin-offer-icon-empty" />
)}
</span>
<span className="octane-catalog-admin-offer-name" title={offer.localizationName}>
{offer.localizationName || `#${offer.offerId}`}
</span>
<CatalogAdminOfferPriceView
credits={offer.priceInCredits}
points={offer.priceInActivityPoints}
pointsType={offer.activityPointType}
/>
<div className="octane-catalog-admin-manager-controls">
{editableProducts.map((product, productIndex) => {
const productLabel = product.furnitureData?.className || `#${product.productClassId}`;
return <Hint key={`${product.productType}-${product.productClassId}-${productIndex}`} content={`Edit Furni: ${productLabel}`}>
<BaseButton
aria-label={`Edit Furni ${productLabel}`}
onClick={() => window.dispatchEvent(new CustomEvent('furni-editor:open', {
detail: { spriteId: product.productClassId }
}))}
>
<FaWrench />
</BaseButton>
</Hint>;
})}
<Hint content="Edit offer">
<BaseButton aria-label="Edit offer" onClick={() => {
catalogAdmin.setEditingOffer(offer);
}}>
<FaEdit />
</BaseButton>
</Hint>
<Hint content="Delete offer">
<BaseButton aria-label="Delete offer" className="danger" onClick={() => deleteOffer(offer)}>
<FaTrash />
</BaseButton>
</Hint>
</div>
</div>
);
})}
</div>
</>
)}
</div>
</div>
</NativeTextTree>
);
};
const renderInspector = () => {
if (!selectedNode) {
return <aside className="octane-catalog-admin-inspector"><div className="octane-catalog-admin-placeholder is-small">No page selected</div></aside>;
}
return (
<NativeTextTree>
<aside className="octane-catalog-admin-inspector">
<div className="octane-catalog-admin-inspector-head">
<strong>Inspector</strong>
<span>Page #{selectedNode.pageId}</span>
</div>
<dl className="octane-catalog-admin-inspector-data">
<div><dt>Name</dt><dd title={nodeName(selectedNode)}>{nodeName(selectedNode)}</dd></div>
<div><dt>Sub-pages</dt><dd>{selectedNode.children.length}</dd></div>
<div><dt>Offers</dt><dd>{offers.length}</dd></div>
<div><dt>Visibility</dt><dd className={selectedNode.isVisible ? 'is-positive' : 'is-muted'}>{selectedNode.isVisible ? 'Visible' : 'Hidden'}</dd></div>
<div><dt>Revision</dt><dd>{studio.revision}</dd></div>
</dl>
<div className="octane-catalog-admin-inspector-actions">
<BaseButton className="octane-catalog-admin-btn is-primary" onClick={() => editPage(selectedNode, false)}>
<FaEdit /> Edit
</BaseButton>
<BaseButton className="octane-catalog-admin-btn" onClick={() => createCategory(selectedNode)}>
<FaPlus /> Sub-page
</BaseButton>
<BaseButton className="octane-catalog-admin-btn" disabled={isReadOnlyPage} onClick={newOffer}>
<FaPlus /> Offer
</BaseButton>
</div>
</aside>
</NativeTextTree>
);
};
const renderPagesTab = () => (
<div className="octane-catalog-admin-pages">
<div className="octane-catalog-admin-sidebar">
<div className="octane-catalog-admin-search-row">
<span className="octane-catalog-admin-search">
<FaSearch />
<input aria-label="Search catalog pages" placeholder="Search pages..." value={search} onChange={(event) => setSearch(event.target.value)} />
</span>
<Hint content="New root category"><BaseButton aria-label="New root category"
className="octane-catalog-admin-add"
disabled={!managerRootNode}
onClick={() => managerRootNode && createCategory(managerRootNode)}
>
<FaPlus />
</BaseButton></Hint>
</div>
<div className="octane-catalog-admin-tree" role="tree" aria-label="Catalog pages">
<div
className={`octane-catalog-admin-root-drop ${rootDropActive ? 'is-active' : ''}`}
onDragLeave={() => setRootDropActive(false)}
onDragOver={handleRootDragOver}
onDrop={handleRootDrop}
>
<FaSitemap />
<span>Drop here to move to catalog root</span>
</div>
{!managerRootNode || managerRootNode.children.length === 0 ? (
<div className="octane-catalog-admin-placeholder is-small">No categories</div>
) : (
managerRootNode.children.map((child) => renderNode(child, 0))
)}
</div>
</div>
<div className="octane-catalog-admin-detail">{renderDetail()}</div>
{renderInspector()}
</div>
);
const renderHistoryTab = () => (
<div>
<CatalogStudioProblemsHistoryPanel
issues={studio.validation?.issues ?? []}
history={studio.history}
loading={studio.loading}
undo={studio.undo}
revalidate={studio.validate}
checkedAt={studio.validation?.receivedAt ?? null}
onSelectEntity={handleProblemSelect}
/>
</div>
);
const tabPresentation = {
catalog: { label: 'Catalog', icon: <FaSitemap />, count: categoryCount },
sql: { label: 'SQL tools', icon: <FaEdit />, count: 0 },
history: { label: 'Problems & History', icon: <FaHistory />, count: validationIssueCount }
};
const tabs = getCatalogStudioWorkspaceTabs().map(id => ({ id, ...tabPresentation[id] }));
return (
<NativeTextTree>
<OctaneCardView classNames={['octane-catalog-admin-manager']} uniqueKey="catalog-admin-manager">
<OctaneCardHeaderView headerText="Catalog Admin Editor" onCloseClick={() => catalogAdmin.setAdminMode(false)} />
<OctaneCardContentView classNames={['octane-catalog-admin-manager-body']}>
<div aria-live="polite" className={`octane-catalog-admin-command-bar is-${commandState.phase}`}>
<div className="octane-catalog-admin-command-title">
<strong>Catalog Manager</strong>
<span>Direct live catalog controls</span>
</div>
<div className="octane-catalog-admin-command-stats">
<span className={validationIssueCount > 0 ? 'has-error' : validationCurrent ? 'is-valid' : 'has-warning'}>
{validationCurrent && validationIssueCount === 0 ? <FaCheckCircle /> : <FaExclamationTriangle />}
{commandState.validationLabel}
</span>
</div>
</div>
{catalogAdmin.lastError && (
<div className="octane-catalog-admin-operation-error" role="alert">
<FaExclamationTriangle />
<span>{catalogAdmin.lastError}</span>
</div>
)}
<TabBar className="octane-catalog-admin-tabs" aria-label="Catalog Studio sections" value={activeTab}>
{tabs.map((tab) => (
<TabBarTab
key={tab.id}
id={`catalog-studio-tab-${tab.id}`}
value={tab.id}
aria-controls="catalog-studio-active-panel"
className={`octane-catalog-admin-tab ${activeTab === tab.id ? 'is-active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.icon}
<span>{tab.label}</span>
{tab.count > 0 && <span className="octane-catalog-admin-tab-count">{tab.count}</span>}
</TabBarTab>
))}
</TabBar>
<div id="catalog-studio-active-panel" className="octane-catalog-admin-panel" role="tabpanel" aria-labelledby={`catalog-studio-tab-${activeTab}`}>
{activeTab === 'catalog' && renderPagesTab()}
{activeTab === 'sql' && <CatalogStudioTransferPanel />}
{activeTab === 'history' && renderHistoryTab()}
</div>
</OctaneCardContentView>
</OctaneCardView>
</NativeTextTree>
);
};
