mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
59d6c4cab3
Completes the useCatalog decomposition. After the previous commit
extracted the pure helpers, this one splits the singleton-via-useBetween
store into three slice-specific entry points and migrates a handful of
consumers as proof.
`src/hooks/catalog/useCatalog.ts`
- Internal `useCatalogState` → renamed to `useCatalogStore` and is no
longer exported. The full return shape is unchanged so callers that
still go through the shim see the exact same object.
- Three new exports built on top of the same `useBetween` instance:
- `useCatalogData()` — server-driven read-only slice (rootNode,
offersToNodes, currentPage, currentOffer, frontPageItems,
searchResult, roomPreviewer, isBusy, catalog localization
version, Builders Club counters + timers).
- `useCatalogUiState()` — UI ephemeral state + writers
(isVisible, pageId, previousPageId, currentType, activeNodes,
navigationHidden, purchaseOptions, catalogPlaceMultipleObjects,
plus every `set*` writer including the ones that mutate the
data slice on user-driven selection).
- `useCatalogActions()` — imperative operations only
(openCatalogByType, toggleCatalogByType, activateNode,
openPageBy{Id,Name,OfferId}, requestOfferToMover,
selectCatalogOffer, getNodeBy{Id,Name},
getBuilderFurniPlaceableStatus).
- `useCatalog` is kept as a deprecated shim that returns the full
historical surface, so the 48 existing consumers compile and run
unchanged.
Pilot consumer migrations (3 of 48):
- `CatalogBuildersClubStatusView` — Data (furni counters, seconds
timers) + UiState (currentType).
- `CatalogBreadcrumbView` — UiState (activeNodes) + Actions
(activateNode).
- `CatalogNavigationItemView` — UiState (currentType) + Actions
(activateNode).
Tests: `tests/useCatalog.filters.test.tsx` (5 cases).
`useBetween` is mocked via `vi.hoisted` so the four hooks share one
deterministic fake store — rendering the real `useCatalogStore`
would mount ~30 useState calls + open a fresh RoomPreviewer +
subscribe to a dozen renderer events, which is more than these
contract tests need.
- `useCatalogData` exposes exactly its read-only keys.
- `useCatalogUiState` exposes exactly its UI keys + setters.
- `useCatalogActions` exposes exactly its imperative ops (and
explicitly NOT data fields — proves no leak across slices).
- Singleton identity: callbacks read through the shim are `===` to
the ones read through the slices.
- Shim surface: the historical key set is still present so
un-migrated consumers don't silently break.
Suite: 163/163 (was 158/158). `yarn typecheck` green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
145 lines
6.6 KiB
TypeScript
145 lines
6.6 KiB
TypeScript
import { FC, useCallback, useRef, useState } from 'react';
|
|
import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
|
|
import { CatalogType, ICatalogNode, LocalizeText } from '../../../../api';
|
|
import { useCatalogActions, useCatalogFavorites, useCatalogUiState } from '../../../../hooks';
|
|
import { useCatalogAdmin } from '../../CatalogAdminContext';
|
|
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
|
|
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
|
|
|
|
export interface CatalogNavigationItemViewProps
|
|
{
|
|
node: ICatalogNode;
|
|
child?: boolean;
|
|
}
|
|
|
|
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
|
|
{
|
|
const { node = null, child = false } = props;
|
|
const { activateNode = null } = useCatalogActions();
|
|
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
|
|
const catalogAdmin = useCatalogAdmin();
|
|
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
|
|
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-5 h-5 flex items-center justify-center shrink-0">
|
|
<CatalogIconView icon={ node.iconId } />
|
|
</div>
|
|
<span className="flex-1 truncate" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span>
|
|
{ adminMode &&
|
|
<div className="flex items-center gap-1 opacity-0 group-hover/nav:opacity-100 transition-opacity">
|
|
<FaPlus
|
|
className="text-[8px] text-success hover:text-green-800"
|
|
title={ LocalizeText('catalog.admin.create.subpage') }
|
|
onClick={ e =>
|
|
{
|
|
e.stopPropagation();
|
|
catalogAdmin.createPage({
|
|
caption: 'New Page',
|
|
catalogMode: currentType,
|
|
pageLayout: 'default_3x3',
|
|
minRank: 1,
|
|
visible: '1',
|
|
enabled: '1',
|
|
orderNum: 0,
|
|
parentId: node.pageId,
|
|
});
|
|
} }
|
|
/>
|
|
<FaTrash
|
|
className="text-[8px] text-danger hover:text-red-700"
|
|
title={ LocalizeText('catalog.admin.delete.page') }
|
|
onClick={ e =>
|
|
{
|
|
e.stopPropagation();
|
|
if(confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ node.localization ])))
|
|
{
|
|
catalogAdmin.deletePage(node.pageId);
|
|
}
|
|
} }
|
|
/>
|
|
</div> }
|
|
{ !adminMode && node.pageId > 0 &&
|
|
<FaStar
|
|
className={ `text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
|
|
onClick={ e =>
|
|
{
|
|
e.stopPropagation(); toggleFavoritePage(node.pageId);
|
|
} }
|
|
/> }
|
|
{ node.isBranch &&
|
|
<span className="text-[9px] text-muted shrink-0">
|
|
{ node.isOpen ? <FaCaretUp /> : <FaCaretDown /> }
|
|
</span> }
|
|
</div>
|
|
{ node.isOpen && node.isBranch &&
|
|
<CatalogNavigationSetView child={ true } node={ node } /> }
|
|
</div>
|
|
);
|
|
};
|