Add toolbox spaces and recover from stale asset bundles
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-29 21:31:49 +02:00
parent 0959c6740b
commit 0acf5ecf21
30 changed files with 1252 additions and 179 deletions

View file

@ -1,5 +1,5 @@
// Rôle : affiche les pages toolbox, le panneau latéral et leurs contrôles.
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx";
import { Icon } from "../../components/Icon.jsx";
import { ImportButton } from "../../components/ImportButton.jsx";
@ -11,6 +11,16 @@ import { useInlineEdit } from "../../hooks/useInlineEdit.js";
import { compressImage } from "../../utils/imageCompression.js";
import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx";
import { AddToolControls, ToolboxModules } from "./modules/index.jsx";
import { ToolboxSpaces } from "./ToolboxSpaces.jsx";
import {
addModuleToToolboxSpace,
decodeSpaceColumn,
encodeSpaceColumn,
getActiveToolboxSpace,
getModuleSpace,
moveToolboxModule,
removeModuleFromToolboxSpaces
} from "./toolboxSpaceUtils.js";
import {
clampQty,
hostnameFromUrl,
@ -24,7 +34,6 @@ import {
normalizeImageAnnotationData,
normalizeLinksData,
normalizeMemosData,
normalizeModuleOrder,
normalizeNotepadData,
normalizeRandomizerData,
normalizeRngSimulatorData,
@ -42,31 +51,6 @@ function getDrawerMaxWidth() {
return Math.max(DRAWER_INITIAL_WIDTH, Math.floor(window.innerWidth * 0.94));
}
function addModuleToOrder(moduleOrder, modules, moduleId) {
const order = normalizeModuleOrder(moduleOrder, modules);
const two = [
order.two[0].filter((id) => id !== moduleId),
order.two[1].filter((id) => id !== moduleId)
];
const targetColumnIndex = two[0].length <= two[1].length ? 0 : 1;
two[targetColumnIndex].push(moduleId);
return {
one: [...order.one.filter((id) => id !== moduleId), moduleId],
two
};
}
function removeModuleFromOrder(moduleOrder, modules, moduleId) {
const order = normalizeModuleOrder(moduleOrder, modules);
return {
one: order.one.filter((id) => id !== moduleId),
two: [
order.two[0].filter((id) => id !== moduleId),
order.two[1].filter((id) => id !== moduleId)
]
};
}
async function copyText(value) {
try {
await navigator.clipboard.writeText(value);
@ -187,10 +171,16 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId, guideChecklist }) {
const toolboxId = toolbox.id;
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const activeSpace = getActiveToolboxSpace(toolbox);
const spacesText = siteContent.toolboxes.spaces;
const toolboxGameCover = getGameCardCover(toolboxGame);
const moduleText = siteContent.toolboxes.modules;
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
const [pendingModuleId, setPendingModuleId] = useState("");
const dragOriginSpaceRef = useRef("");
const dragSucceededRef = useRef(false);
const hoverActivatedRef = useRef(false);
const previousDraggingIdRef = useRef("");
const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId);
const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested });
// Consommer la demande de lancement ne doit pas interrompre la visite en cours.
@ -237,15 +227,99 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
refresh: () => {}
};
const moduleLocations = useMemo(() => {
const locations = new Map();
toolbox.spaces.forEach((space) => {
space.moduleOrder.one.forEach((moduleId) => {
const column = space.moduleOrder.two[1].includes(moduleId) ? "right" : "left";
locations.set(moduleId, { spaceId: space.id, column });
});
});
return locations;
}, [toolbox.spaces]);
const rawModuleReorder = useGroupedReorder({
namespace: `toolbox-modules-${toolbox.id}`,
items: toolbox.modules,
getItemId: (module) => module.id,
getItemGroup: (module) => {
const location = moduleLocations.get(module.id);
return encodeSpaceColumn(location?.spaceId || toolbox.defaultSpaceId, moduleColumns === 1 ? "one" : location?.column || "left");
},
getEffectiveGroup: (module) => {
const location = moduleLocations.get(module.id);
return encodeSpaceColumn(location?.spaceId || toolbox.defaultSpaceId, moduleColumns === 1 ? "one" : location?.column || "left");
},
getParentId: () => toolbox.id,
reorderFeatures: { item: { groupChange: true, boundaryDrop: true } },
canMoveItem: (operation) => operation.targetType !== "group" || decodeSpaceColumn(operation.sourceGroup).spaceId !== operation.targetGroup,
onItemMove: (operation) => {
let targetSpaceId = "";
let targetColumn = "";
let targetModuleId = "";
if (operation.targetType === "group") {
targetSpaceId = operation.targetGroup;
} else if (operation.targetType === "item") {
targetModuleId = operation.targetId;
targetSpaceId = getModuleSpace(toolbox, targetModuleId)?.id || "";
} else if (operation.targetType === "boundary") {
const target = decodeSpaceColumn(operation.targetGroup);
targetSpaceId = target.spaceId;
targetColumn = target.column;
}
if (!targetSpaceId) return;
const sourceSpace = getModuleSpace(toolbox, operation.sourceId);
dragSucceededRef.current = true;
updateToolbox(moveToolboxModule(toolbox, operation.sourceId, {
targetSpaceId,
targetModuleId,
targetColumn,
placement: operation.placement,
moduleColumns
}));
if (sourceSpace?.id !== targetSpaceId) {
const destination = toolbox.spaces.find((space) => space.id === targetSpaceId);
actions.notify(spacesText.moveSuccess.replace("{space}", destination?.name || ""));
}
},
hierarchy: { enabled: true, stickyParents: true }
});
const moduleReorder = {
...rawModuleReorder,
itemReorder: {
...rawModuleReorder.itemReorder,
startDrag: (event, moduleId) => {
dragOriginSpaceRef.current = getModuleSpace(toolbox, moduleId)?.id || activeSpace.id;
dragSucceededRef.current = false;
hoverActivatedRef.current = false;
rawModuleReorder.itemReorder.startDrag(event, moduleId);
}
}
};
const activateHoveredSpace = useCallback((spaceId) => {
if (spaceId === toolbox.activeSpaceId) return;
hoverActivatedRef.current = true;
updateToolbox({ ...toolbox, activeSpaceId: spaceId });
}, [toolbox, updateToolbox]);
useEffect(() => {
const draggingId = rawModuleReorder.itemReorder.draggingId;
if (previousDraggingIdRef.current && !draggingId) {
if (!dragSucceededRef.current && hoverActivatedRef.current && toolbox.spaces.some((space) => space.id === dragOriginSpaceRef.current)) {
updateToolbox({ ...toolbox, activeSpaceId: dragOriginSpaceRef.current });
}
dragOriginSpaceRef.current = "";
dragSucceededRef.current = false;
hoverActivatedRef.current = false;
}
previousDraggingIdRef.current = draggingId;
}, [rawModuleReorder.itemReorder.draggingId, toolbox, updateToolbox]);
function addModule(type, initialData = null) {
if (!type) return;
const module = { id: uid("mod"), type };
const modules = [...toolbox.modules, module];
updateToolbox({
...toolbox,
modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
});
updateToolbox(addModuleToToolboxSpace(toolbox, module, activeSpace.id));
setPendingModuleId(module.id);
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
}
@ -253,12 +327,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
function createImageAnnotationModule(dataUrl) {
if (!dataUrl) return;
const moduleId = uid("mod");
const modules = [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }];
updateToolbox({
...toolbox,
modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId)
});
updateToolbox(addModuleToToolboxSpace(toolbox, { id: moduleId, type: "imageAnnotation" }, activeSpace.id));
setPendingModuleId(moduleId);
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
}
@ -291,6 +360,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
<AddToolControls onAdd={addModule} />
</div>
</section>
<ToolboxSpaces
toolbox={toolbox}
text={spacesText}
moduleReorder={moduleReorder}
onUpdateToolbox={updateToolbox}
onHoverActivate={activateHoveredSpace}
onConfirm={actions.setConfirmModal}
/>
{!embedded && showTutorial && (
<GuidedTooltipTour
key={toolboxId}
@ -321,13 +398,20 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
)}
<ToolboxModules
toolbox={toolbox}
space={activeSpace}
moduleColumns={moduleColumns}
context={moduleContext}
reorder={moduleReorder}
spacesText={spacesText}
scrollToModuleId={pendingModuleId}
onModuleScrolled={() => setPendingModuleId("")}
onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })}
onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })}
onModuleOrderChange={(moduleOrder) => updateToolbox({ ...toolbox, moduleOrder })}
onMoveToSpace={(moduleId, targetSpaceId) => {
const destination = toolbox.spaces.find((space) => space.id === targetSpaceId);
updateToolbox(moveToolboxModule(toolbox, moduleId, { targetSpaceId, moduleColumns }));
actions.notify(spacesText.moveSuccess.replace("{space}", destination?.name || ""));
}}
onDelete={(moduleId) => actions.setConfirmModal({
title: "Retirer l'outil",
message: "Retirer cet outil de la toolbox ?",
@ -336,12 +420,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
onResolve: (confirmed) => {
if (!confirmed) return;
actions.removeModuleData(toolbox.id, moduleId);
const modules = toolbox.modules.filter((module) => module.id !== moduleId);
updateToolbox({
...toolbox,
modules,
moduleOrder: removeModuleFromOrder(toolbox.moduleOrder, modules, moduleId)
});
updateToolbox(removeModuleFromToolboxSpaces(toolbox, moduleId));
}
})}
/>

View file

@ -0,0 +1,134 @@
// Rôle : affiche et gère les onglets d'espaces persistants d'une toolbox.
import { useEffect, useState } from "react";
import { Icon } from "../../components/Icon.jsx";
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
import { lockBodyScroll } from "../../utils/bodyScrollLock.js";
import { MAX_TOOLBOX_SPACES } from "./storage/toolboxStorage.js";
import { createToolboxSpace, deleteToolboxSpace, reorderToolboxSpaces } from "./toolboxSpaceUtils.js";
const SPACE_HOVER_DELAY_MS = 600;
function SpaceNameModal({ initialName = "", title, text, onClose }) {
const [name, setName] = useState(initialName);
useEffect(() => lockBodyScroll(), []);
return (
<div className="confirm-modal-root">
<div className="confirm-backdrop nds-modal-backdrop" onClick={() => onClose("")} />
<section className="confirm-modal nds-modal toolbox-space-modal" role="dialog" aria-modal="true" aria-labelledby="toolbox-space-modal-title">
<header><h2 id="toolbox-space-modal-title">{title}</h2></header>
<form onSubmit={(event) => {
event.preventDefault();
if (name.trim()) onClose(name.trim());
}}>
<label className="field"><span>{text.nameLabel}</span><input className="nds-input" data-size="sm" value={name} onChange={(event) => setName(event.target.value)} autoComplete="off" autoFocus required /></label>
<footer className="nds-modal-footer">
<button className="nds-button" data-size="sm" type="button" onClick={() => onClose("")}>{text.cancelLabel}</button>
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{text.saveLabel}</button>
</footer>
</form>
</section>
</div>
);
}
export function ToolboxSpaces({ toolbox, text, moduleReorder, onUpdateToolbox, onHoverActivate, onConfirm }) {
const [nameModal, setNameModal] = useState(null);
const activeSpace = toolbox.spaces.find((space) => space.id === toolbox.activeSpaceId) || toolbox.spaces[0];
const atLimit = toolbox.spaces.length >= MAX_TOOLBOX_SPACES;
const hoveredSpaceId = toolbox.spaces.find((space) => space.id !== activeSpace.id && moduleReorder.isGroupDropTarget(space.id, toolbox.id))?.id || "";
const spaceReorder = useGroupedReorder({
namespace: `toolbox-spaces-${toolbox.id}`,
items: toolbox.spaces,
getItemId: (space) => space.id,
orientation: "horizontal",
canMoveItem: (operation) => operation.sourceId !== toolbox.defaultSpaceId,
onItemMove: (operation) => onUpdateToolbox(reorderToolboxSpaces(toolbox, operation.sourceId, operation.targetId, operation.placement))
});
useEffect(() => {
if (!hoveredSpaceId || !moduleReorder.itemReorder.draggingId) return undefined;
const timeoutId = window.setTimeout(() => onHoverActivate(hoveredSpaceId), SPACE_HOVER_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [hoveredSpaceId, moduleReorder.itemReorder.draggingId, onHoverActivate]);
function saveName(name) {
if (!name || !nameModal) return setNameModal(null);
if (nameModal.mode === "create") onUpdateToolbox(createToolboxSpace(toolbox, name));
else onUpdateToolbox({
...toolbox,
spaces: toolbox.spaces.map((space) => space.id === nameModal.spaceId ? { ...space, name } : space)
});
setNameModal(null);
}
function requestDelete() {
const count = activeSpace.moduleOrder.one.length;
onConfirm({
title: text.deleteTitle,
message: count ? text.deleteWithTools.replace("{count}", count) : text.deleteEmpty,
confirmLabel: text.deleteLabel,
danger: true,
onResolve: (confirmed) => {
if (confirmed) onUpdateToolbox(deleteToolboxSpace(toolbox, activeSpace.id));
}
});
}
return (
<>
<section className={`toolbox-spaces-toolbar ${toolbox.spaces.length === 1 ? "is-single" : ""}`} aria-label={text.toolbarLabel}>
{toolbox.spaces.length > 1 && (
<div className="toolbox-space-tabs-row legacy-scrollbar">
<div className="toolbox-space-tabs" role="tablist" aria-label={text.tabsLabel}>
{toolbox.spaces.map((space) => {
const active = space.id === activeSpace.id;
const isDefault = space.id === toolbox.defaultSpaceId;
const isModuleDropTarget = moduleReorder.isGroupDropTarget(space.id, toolbox.id);
const dropAfter = spaceReorder.getDropPlacement("item", space.id) === "after";
return (
<div
className={`toolbox-space-tab-wrap ${active ? "active" : ""} ${isModuleDropTarget ? "is-module-drop-target" : ""} ${spaceReorder.isItemDragging(space.id) ? "is-dragging" : ""} ${spaceReorder.isItemDropTarget(space.id) ? "is-drop-target" : ""} ${dropAfter ? "drop-after" : ""}`}
key={space.id}
role="presentation"
{...spaceReorder.getItemProps({ itemId: space.id })}
>
{!isDefault && <button className="toolbox-space-drag-handle nds-icon-button" data-size="xs" type="button" onPointerDown={(event) => spaceReorder.itemReorder.startDrag(event, space.id)} aria-label={`${text.reorderLabel} ${space.name}`} title={text.reorderLabel}><Icon name="drag" /></button>}
<button
className={`toolbox-space-tab ${active ? "active" : ""}`}
type="button"
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={() => onUpdateToolbox({ ...toolbox, activeSpaceId: space.id })}
onKeyDown={(event) => {
const index = toolbox.spaces.findIndex((item) => item.id === space.id);
let nextIndex;
if (event.key === "ArrowRight") nextIndex = (index + 1) % toolbox.spaces.length;
else if (event.key === "ArrowLeft") nextIndex = (index - 1 + toolbox.spaces.length) % toolbox.spaces.length;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = toolbox.spaces.length - 1;
else return;
event.preventDefault();
const nextSpace = toolbox.spaces[nextIndex];
onUpdateToolbox({ ...toolbox, activeSpaceId: nextSpace.id });
event.currentTarget.parentElement?.parentElement?.querySelectorAll('[role="tab"]')[nextIndex]?.focus();
}}
{...moduleReorder.getGroupProps({ groupId: space.id, parentId: toolbox.id })}
>{space.name}</button>
</div>
);
})}
</div>
<button className="toolbox-space-add-compact nds-icon-button" data-size="sm" type="button" disabled={atLimit} onClick={() => !atLimit && setNameModal({ mode: "create" })} aria-label={atLimit ? text.limitReached : text.addLabel} title={atLimit ? text.limitReached : text.addLabel}><Icon name="add" /></button>
</div>
)}
<div className="toolbox-space-actions nds-toolbar">
{toolbox.spaces.length > 1 && <button className="nds-icon-button" data-size="sm" type="button" onClick={() => setNameModal({ mode: "rename", spaceId: activeSpace.id })} aria-label={text.renameLabel} title={text.renameLabel}><Icon name="edit" /></button>}
{toolbox.spaces.length > 1 && activeSpace.id !== toolbox.defaultSpaceId && <button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={requestDelete} aria-label={text.deleteLabel} title={text.deleteLabel}><span className="ui-icon ui-icon-trash" aria-hidden="true" /></button>}
{toolbox.spaces.length === 1 && <button className="nds-button toolbox-space-add" data-size="sm" data-variant="dashed" type="button" disabled={atLimit} onClick={() => !atLimit && setNameModal({ mode: "create" })} aria-label={atLimit ? text.limitReached : text.addLabel} title={atLimit ? text.limitReached : text.addLabel}><Icon name="add" />{text.addLabel}</button>}
</div>
</section>
{nameModal && <SpaceNameModal initialName={nameModal.mode === "rename" ? activeSpace.name : ""} title={nameModal.mode === "create" ? text.createTitle : text.renameTitle} text={text} onClose={saveName} />}
</>
);
}

View file

@ -8,7 +8,7 @@ import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete }) {
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", spaces = [], activeSpaceId = "", moveText = "Déplacer vers", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const label = definition.label || module.type;
@ -93,6 +93,19 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "memos" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
</div>
<div>
{spaces.length > 1 && (
<select
className="module-space-select nds-select"
data-size="sm"
value=""
aria-label={`${moveText} — ${module.title || label}`}
title={moveText}
onChange={(event) => onMoveToSpace(module.id, event.target.value)}
>
<option value="" disabled>{moveText}</option>
{spaces.filter((space) => space.id !== activeSpaceId).map((space) => <option key={space.id} value={space.id}>{space.name}</option>)}
</select>
)}
{definition.scrollable && (
<button
className={`module-scroll-button nds-switch ${scrollable ? "active" : ""}`}

View file

@ -1,6 +1,6 @@
// Rôle : répartit et réorganise les modules d'une toolbox dans leurs colonnes.
// Rôle : répartit les modules de l'espace actif dans leurs colonnes et cibles de dépôt.
import { useMemo } from "react";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { encodeSpaceColumn } from "../toolboxSpaceUtils.js";
import { ModuleShell } from "./ModuleShell.jsx";
const MODULE_COLUMN_IDS = ["left", "right"];
@ -10,97 +10,29 @@ function getModulesByIds(modules, ids) {
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
}
function getModuleColumnId(moduleId, twoColumnOrder) {
return twoColumnOrder?.[1]?.includes(moduleId) ? MODULE_COLUMN_IDS[1] : MODULE_COLUMN_IDS[0];
}
function moveId(ids, sourceId, targetId, placement = "before") {
const nextIds = ids.filter((id) => id !== sourceId);
const targetIndex = nextIds.indexOf(targetId);
if (targetIndex < 0) return ids;
nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceId);
return nextIds;
}
function moveModuleInOneColumnOrder(order, operation) {
if (!operation.sourceId || !operation.targetId || operation.sourceId === operation.targetId) return order;
return {
...order,
one: moveId(order.one, operation.sourceId, operation.targetId, operation.placement)
};
}
function moveModuleInTwoColumnOrder(order, operation) {
const columns = [
[...(order.two?.[0] || [])],
[...(order.two?.[1] || [])]
];
const sourceColumnIndex = columns.findIndex((column) => column.includes(operation.sourceId));
if (sourceColumnIndex < 0) return order;
const targetColumnIndex = operation.targetType === "item"
? columns.findIndex((column) => column.includes(operation.targetId))
: MODULE_COLUMN_IDS.indexOf(operation.targetGroup);
if (targetColumnIndex < 0) return order;
const [movedId] = columns[sourceColumnIndex].splice(columns[sourceColumnIndex].indexOf(operation.sourceId), 1);
if (operation.targetType === "item") {
const targetIndex = columns[targetColumnIndex].indexOf(operation.targetId);
if (targetIndex < 0) return order;
columns[targetColumnIndex].splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, movedId);
} else {
columns[targetColumnIndex].splice(operation.placement === "after" ? columns[targetColumnIndex].length : 0, 0, movedId);
}
return { ...order, two: columns };
}
export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder, spacesText, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
const twoColumnOrder = useMemo(() => space.moduleOrder?.two || [[], []], [space.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, space.moduleOrder?.one || []), [toolbox.modules, space.moduleOrder?.one]);
const columns = useMemo(() => [
getModulesByIds(toolbox.modules, twoColumnOrder[0]),
getModulesByIds(toolbox.modules, twoColumnOrder[1])
], [toolbox.modules, twoColumnOrder]);
const {
itemReorder,
getItemProps,
getGroupBoundaryProps,
isItemDragging,
isItemDropTarget,
isBoundaryDropTarget,
getDropPlacement
} = useGroupedReorder({
namespace: "toolbox-modules",
items: toolbox.modules,
getItemId: (module) => module.id,
getItemGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
getEffectiveGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
getParentId: () => toolbox.id,
reorderFeatures: {
item: {
groupChange: moduleColumns === 2,
boundaryDrop: moduleColumns === 2
}
},
onItemMove: (operation) => {
const nextOrder = moduleColumns === 2
? moveModuleInTwoColumnOrder(toolbox.moduleOrder, operation)
: moveModuleInOneColumnOrder(toolbox.moduleOrder, operation);
onModuleOrderChange(nextOrder);
},
hierarchy: { enabled: true, stickyParents: true }
});
if (moduleColumns === 1) {
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{!orderedModules.length && <div className="empty toolbox-space-empty nds-empty-state"><p>{spacesText.emptyText}</p></div>}
{orderedModules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup=""
reorder={reorder}
itemGroup={encodeSpaceColumn(space.id, "one")}
spaces={toolbox.spaces}
activeSpaceId={space.id}
moveText={spacesText.moveLabel}
onMoveToSpace={onMoveToSpace}
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}
onRename={onRename}
@ -112,15 +44,15 @@ export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModule
);
}
const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0);
const hasEmptyColumn = orderedModules.length > 1 && columns.some((modules) => modules.length === 0);
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{columns.map((modules, index) => (
<div className={`module-column ${hasEmptyColumn && !modules.length ? "is-empty-warning" : ""}`} key={MODULE_COLUMN_IDS[index]}>
<div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" })}
className={`module-column-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
{...reorder.getGroupBoundaryProps({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "before" })}
/>
{modules.map((module) => (
<ModuleShell
@ -128,8 +60,12 @@ export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModule
toolbox={toolbox}
module={module}
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup={MODULE_COLUMN_IDS[index]}
reorder={reorder}
itemGroup={encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index])}
spaces={toolbox.spaces}
activeSpaceId={space.id}
moveText={spacesText.moveLabel}
onMoveToSpace={onMoveToSpace}
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}
onRename={onRename}
@ -138,11 +74,12 @@ export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModule
/>
))}
<div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" })}
className={`module-column-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
{...reorder.getGroupBoundaryProps({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "after" })}
/>
</div>
))}
{!orderedModules.length && <div className="empty toolbox-space-empty nds-empty-state"><p>{spacesText.emptyText}</p></div>}
</section>
);
}

View file

@ -1,6 +1,7 @@
// Rôle : fournit les primitives communes de normalisation et d'identifiants du stockage toolbox.
export const ID_PREFIXES = {
tbx: "t",
space: "sp",
mod: "m",
item: "i",
section: "g",

View file

@ -47,6 +47,7 @@ const TOOLBOX_ICON_FILES = [
];
export const TOOLBOX_ICONS = TOOLBOX_ICON_FILES.map((file) => `${TOOLBOX_ICON_BASE}${file}`);
export const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`;
export const MAX_TOOLBOX_SPACES = 5;
export function normalizeToolboxIcon(icon) {
const value = String(icon || "").trim();
@ -123,9 +124,74 @@ export function remapModuleOrderIds(moduleOrder, moduleIdMap) {
};
}
function getSpaceModuleIds(space, knownModuleIds) {
const candidates = [
...(Array.isArray(space?.moduleOrder?.one) ? space.moduleOrder.one : []),
...(Array.isArray(space?.moduleOrder?.two?.[0]) ? space.moduleOrder.two[0] : []),
...(Array.isArray(space?.moduleOrder?.two?.[1]) ? space.moduleOrder.two[1] : [])
];
return uniqueKnownIds(candidates, knownModuleIds);
}
export function normalizeToolboxSpaces(toolbox, modules) {
const moduleIds = modules.map((module) => module.id);
const rawSpaces = Array.isArray(toolbox?.spaces) ? toolbox.spaces.filter((space) => space && typeof space === "object") : [];
if (!rawSpaces.length) {
const id = uid("space");
return {
spaces: [{ id, name: "Défaut", moduleOrder: normalizeModuleOrder(toolbox?.moduleOrder, modules) }],
defaultSpaceId: id,
activeSpaceId: id
};
}
const seenSpaceIds = new Set();
const prepared = rawSpaces.slice(0, MAX_TOOLBOX_SPACES).map((space, index) => {
let id = String(space.id || "").trim() || uid("space");
while (seenSpaceIds.has(id)) id = uid("space");
seenSpaceIds.add(id);
return {
id,
name: String(space.name || "").trim() || (index === 0 ? "Défaut" : `Espace ${index + 1}`),
source: space
};
});
const requestedDefaultId = String(toolbox?.defaultSpaceId || "");
const defaultIndex = prepared.findIndex((space) => space.id === requestedDefaultId);
if (defaultIndex > 0) prepared.unshift(prepared.splice(defaultIndex, 1)[0]);
const assigned = new Set();
const memberships = new Map();
prepared.forEach((space) => {
const ids = getSpaceModuleIds(space.source, moduleIds).filter((id) => {
if (assigned.has(id)) return false;
assigned.add(id);
return true;
});
memberships.set(space.id, ids);
});
const defaultId = prepared[0].id;
memberships.set(defaultId, [...memberships.get(defaultId), ...moduleIds.filter((id) => !assigned.has(id))]);
const spaces = prepared.map((space) => {
const memberIds = memberships.get(space.id);
const memberSet = new Set(memberIds);
const spaceModules = modules.filter((module) => memberSet.has(module.id));
return {
id: space.id,
name: space.name,
moduleOrder: normalizeModuleOrder(space.source.moduleOrder, spaceModules)
};
});
const requestedActiveId = String(toolbox?.activeSpaceId || "");
const activeSpaceId = spaces.some((space) => space.id === requestedActiveId) ? requestedActiveId : defaultId;
return { spaces, defaultSpaceId: defaultId, activeSpaceId };
}
export function normalizeToolbox(toolbox) {
if (!toolbox || typeof toolbox !== "object") return null;
const modules = (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean);
const spaceState = normalizeToolboxSpaces(toolbox, modules);
const normalized = {
id: toolbox.id || uid("tbx"),
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
@ -133,7 +199,7 @@ export function normalizeToolbox(toolbox) {
customCover: normalizeToolboxCustomCover(toolbox.customCover),
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
modules,
moduleOrder: normalizeModuleOrder(toolbox.moduleOrder, modules),
...spaceState,
updatedAt: toolbox.updatedAt || new Date().toISOString()
};
if (toolbox.tutorial === true) normalized.tutorial = true;
@ -147,7 +213,9 @@ export function compactToolboxForStorage(toolbox) {
id: normalized.id,
name: normalized.name,
modules: normalized.modules,
moduleOrder: normalized.moduleOrder,
spaces: normalized.spaces,
defaultSpaceId: normalized.defaultSpaceId,
activeSpaceId: normalized.activeSpaceId,
updatedAt: normalized.updatedAt
};
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
@ -402,6 +470,7 @@ export function createToolboxExportPayload(toolbox, moduleData) {
const source = normalizeToolbox(toolbox);
const nextId = createExportIdFactory();
const moduleIdMap = new Map();
const spaceIdMap = new Map(source.spaces.map((space) => [space.id, nextId("space")]));
const exportedToolbox = compactToolboxForStorage({
...source,
id: nextId("tbx"),
@ -410,7 +479,13 @@ export function createToolboxExportPayload(toolbox, moduleData) {
moduleIdMap.set(module.id, id);
return { ...module, id };
}),
moduleOrder: remapModuleOrderIds(source.moduleOrder, moduleIdMap)
spaces: source.spaces.map((space) => ({
...space,
id: spaceIdMap.get(space.id),
moduleOrder: remapModuleOrderIds(space.moduleOrder, moduleIdMap)
})),
defaultSpaceId: spaceIdMap.get(source.defaultSpaceId),
activeSpaceId: spaceIdMap.get(source.activeSpaceId)
});
const modules = Object.fromEntries(source.modules
.map((module) => [
@ -418,7 +493,7 @@ export function createToolboxExportPayload(toolbox, moduleData) {
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId)
])
.filter(([, data]) => data != null));
return { toolbox: exportedToolbox, modules };
return { version: 2, toolbox: exportedToolbox, modules };
}
export function createGlobalExportPayload(toolboxes, links, moduleData) {
@ -430,7 +505,7 @@ export function createGlobalExportPayload(toolboxes, links, moduleData) {
});
});
return {
version: 1,
version: 2,
exportedAt: new Date().toISOString(),
toolboxes: compactToolboxesForStorage(toolboxes),
modules,

View file

@ -0,0 +1,163 @@
// Rôle : applique les créations, suppressions et déplacements d'outils entre espaces toolbox.
import { MAX_TOOLBOX_SPACES, normalizeModuleOrder, uid } from "./storage/toolboxStorage.js";
export const SPACE_GROUP_SEPARATOR = "::";
export function getActiveToolboxSpace(toolbox) {
return toolbox.spaces.find((space) => space.id === toolbox.activeSpaceId) || toolbox.spaces[0];
}
export function getModuleSpace(toolbox, moduleId) {
return toolbox.spaces.find((space) => space.moduleOrder.one.includes(moduleId)) || toolbox.spaces[0];
}
export function encodeSpaceColumn(spaceId, column = "one") {
return `${spaceId}${SPACE_GROUP_SEPARATOR}${column}`;
}
export function decodeSpaceColumn(value) {
const [spaceId = "", column = "one"] = String(value || "").split(SPACE_GROUP_SEPARATOR);
return { spaceId, column };
}
function removeId(order, moduleId) {
return {
one: order.one.filter((id) => id !== moduleId),
two: order.two.map((column) => column.filter((id) => id !== moduleId))
};
}
function insertAround(ids, moduleId, targetId, placement) {
const next = ids.filter((id) => id !== moduleId);
const targetIndex = next.indexOf(targetId);
if (targetIndex < 0) return [...next, moduleId];
next.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moduleId);
return next;
}
export function addModuleToToolboxSpace(toolbox, module, spaceId = toolbox.activeSpaceId) {
const modules = [...toolbox.modules, module];
const targetId = toolbox.spaces.some((space) => space.id === spaceId) ? spaceId : toolbox.defaultSpaceId;
return {
...toolbox,
modules,
spaces: toolbox.spaces.map((space) => {
if (space.id !== targetId) return space;
const order = normalizeModuleOrder(space.moduleOrder, modules.filter((item) => space.moduleOrder.one.includes(item.id)));
const two = order.two.map((column) => column.filter((id) => id !== module.id));
const targetColumn = two[0].length <= two[1].length ? 0 : 1;
two[targetColumn].push(module.id);
return { ...space, moduleOrder: { one: [...order.one.filter((id) => id !== module.id), module.id], two } };
})
};
}
export function removeModuleFromToolboxSpaces(toolbox, moduleId) {
return {
...toolbox,
modules: toolbox.modules.filter((module) => module.id !== moduleId),
spaces: toolbox.spaces.map((space) => ({ ...space, moduleOrder: removeId(space.moduleOrder, moduleId) }))
};
}
export function createToolboxSpace(toolbox, name) {
if (toolbox.spaces.length >= MAX_TOOLBOX_SPACES || !String(name || "").trim()) return toolbox;
const id = uid("space");
return {
...toolbox,
spaces: [...toolbox.spaces, { id, name: String(name || "").trim(), moduleOrder: { one: [], two: [[], []] } }],
activeSpaceId: id
};
}
export function deleteToolboxSpace(toolbox, spaceId) {
if (spaceId === toolbox.defaultSpaceId) return toolbox;
const removed = toolbox.spaces.find((space) => space.id === spaceId);
const fallback = toolbox.spaces.find((space) => space.id === toolbox.defaultSpaceId);
if (!removed || !fallback) return toolbox;
const mergedOrder = {
one: [...fallback.moduleOrder.one, ...removed.moduleOrder.one],
two: [
[...fallback.moduleOrder.two[0], ...removed.moduleOrder.two[0]],
[...fallback.moduleOrder.two[1], ...removed.moduleOrder.two[1]]
]
};
return {
...toolbox,
spaces: toolbox.spaces.filter((space) => space.id !== spaceId).map((space) => (
space.id === fallback.id ? { ...space, moduleOrder: mergedOrder } : space
)),
activeSpaceId: fallback.id
};
}
export function reorderToolboxSpaces(toolbox, sourceId, targetId, placement = "before") {
if (sourceId === toolbox.defaultSpaceId || sourceId === targetId) return toolbox;
const secondary = toolbox.spaces.filter((space) => space.id !== toolbox.defaultSpaceId && space.id !== sourceId);
const moving = toolbox.spaces.find((space) => space.id === sourceId);
if (!moving) return toolbox;
let targetIndex = secondary.findIndex((space) => space.id === targetId);
if (targetId === toolbox.defaultSpaceId) targetIndex = 0;
else if (placement === "after" && targetIndex >= 0) targetIndex += 1;
if (targetIndex < 0) targetIndex = secondary.length;
secondary.splice(targetIndex, 0, moving);
const fallback = toolbox.spaces.find((space) => space.id === toolbox.defaultSpaceId);
return { ...toolbox, spaces: [fallback, ...secondary] };
}
export function moveToolboxModule(toolbox, moduleId, {
targetSpaceId,
targetModuleId = "",
targetColumn = "",
placement = "after",
moduleColumns = toolbox.moduleColumns
}) {
const sourceSpace = getModuleSpace(toolbox, moduleId);
const destination = toolbox.spaces.find((space) => space.id === targetSpaceId);
if (!sourceSpace || !destination) return toolbox;
const sourceColumnIndex = sourceSpace.moduleOrder.two[1].includes(moduleId) ? 1 : 0;
if (sourceSpace.id === destination.id) {
return {
...toolbox,
spaces: toolbox.spaces.map((space) => {
if (space.id !== destination.id) return space;
const next = { one: [...space.moduleOrder.one], two: space.moduleOrder.two.map((column) => [...column]) };
if (Number(moduleColumns) === 1) {
next.one = insertAround(next.one, moduleId, targetModuleId, placement);
} else {
next.two = next.two.map((column) => column.filter((id) => id !== moduleId));
const foundColumnIndex = next.two.findIndex((column) => column.includes(targetModuleId));
const columnIndex = targetColumn === "right" ? 1 : targetColumn === "left" ? 0 : foundColumnIndex >= 0 ? foundColumnIndex : sourceColumnIndex;
next.two[columnIndex] = targetModuleId
? insertAround(next.two[columnIndex], moduleId, targetModuleId, placement)
: placement === "before" ? [moduleId, ...next.two[columnIndex]] : [...next.two[columnIndex], moduleId];
}
return { ...space, moduleOrder: next };
})
};
}
const spaces = toolbox.spaces.map((space) => ({ ...space, moduleOrder: removeId(space.moduleOrder, moduleId) }));
return {
...toolbox,
spaces: spaces.map((space) => {
if (space.id !== destination.id) return space;
const next = { one: [...space.moduleOrder.one], two: space.moduleOrder.two.map((column) => [...column]) };
const targetColumnIndex = targetColumn === "right"
? 1
: targetColumn === "left"
? 0
: Number(moduleColumns) === 2 ? next.two.findIndex((column) => column.includes(targetModuleId)) : -1;
next.one = targetModuleId && Number(moduleColumns) === 1
? insertAround(next.one, moduleId, targetModuleId, placement)
: [...next.one, moduleId];
const columnIndex = targetColumnIndex >= 0 ? targetColumnIndex : sourceColumnIndex;
next.two[columnIndex] = targetModuleId && Number(moduleColumns) === 2
? insertAround(next.two[columnIndex], moduleId, targetModuleId, placement)
: targetColumn && placement === "before" ? [moduleId, ...next.two[columnIndex]] : [...next.two[columnIndex], moduleId];
return { ...space, moduleOrder: next };
})
};
}

View file

@ -5,12 +5,14 @@ import {
createToolboxExportPayload,
globalModuleKey,
labelFromFileName,
MAX_TOOLBOX_SPACES,
normalizeToolbox,
remapModuleOrderIds,
uid
} from "./storage/toolboxStorage.js";
import { compressImage } from "../../utils/imageCompression.js";
import { getBackupFileName } from "./storage/autoBackup.js";
import { addModuleToToolboxSpace } from "./toolboxSpaceUtils.js";
function downloadJson(payload, filename) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
@ -38,7 +40,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
title: creationTemplate?.links?.title || "Librairie des outils",
url: getLibraryUrl()
};
const toolbox = {
const toolbox = normalizeToolbox({
id: uid("tbx"),
name: name.trim() || "Nouvelle toolbox",
updatedAt: now,
@ -46,7 +48,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
{ id: memosModuleId, type: "memos", title: creationTemplate?.memos?.title || "Mémos" },
{ id: linksModuleId, type: "links", title: creationTemplate?.links?.moduleTitle || "Liens utiles" }
]
};
});
if (options.tutorial === true) toolbox.tutorial = true;
const memos = compactModuleDataForStorage("memos", { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] });
const links = compactModuleDataForStorage("links", { links: [libraryLink] });
@ -141,7 +143,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
if (!confirmed) return;
const updatedAt = new Date().toISOString();
store.setToolboxes((toolboxes) => toolboxes.map((item) => item.id === toolboxId
? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt }
? { ...addModuleToToolboxSpace(item, { id: moduleId, type: "checklist", title: list.title || "Checklist" }, item.activeSpaceId), updatedAt }
: item));
store.updateModuleData(toolboxId, moduleId, { sections }, "checklist");
notify("Checklist créée dans la toolbox associée.");
@ -153,6 +155,9 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
async function importToolboxPayload(file, gameId = "") {
const payload = JSON.parse(await file.text());
if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide");
if (Array.isArray(payload.toolbox.spaces) && payload.toolbox.spaces.length > MAX_TOOLBOX_SPACES) {
throw new Error(`Une toolbox ne peut pas contenir plus de ${MAX_TOOLBOX_SPACES} espaces.`);
}
const imported = normalizeToolbox({ ...payload.toolbox, id: uid("tbx"), updatedAt: new Date().toISOString() });
const moduleIdMap = new Map();
imported.modules = imported.modules.map((module) => {
@ -160,7 +165,14 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
moduleIdMap.set(module.id, nextId);
return { ...module, id: nextId };
});
imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap);
const spaceIdMap = new Map(imported.spaces.map((space) => [space.id, uid("space")]));
imported.spaces = imported.spaces.map((space) => ({
...space,
id: spaceIdMap.get(space.id),
moduleOrder: remapModuleOrderIds(space.moduleOrder, moduleIdMap)
}));
imported.defaultSpaceId = spaceIdMap.get(imported.defaultSpaceId);
imported.activeSpaceId = spaceIdMap.get(imported.activeSpaceId);
const normalizedImport = normalizeToolbox(imported);
const nextModuleData = {};
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
@ -185,6 +197,9 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
if (!Array.isArray(payload.toolboxes) || !payload.modules || typeof payload.modules !== "object") {
throw new Error("Format d'import global invalide");
}
if (payload.toolboxes.some((toolbox) => Array.isArray(toolbox.spaces) && toolbox.spaces.length > MAX_TOOLBOX_SPACES)) {
throw new Error(`Une toolbox ne peut pas contenir plus de ${MAX_TOOLBOX_SPACES} espaces.`);
}
const toolboxIdMap = new Map();
const moduleIdMap = new Map();
const importedToolboxes = payload.toolboxes.map((toolbox) => {
@ -197,8 +212,23 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
localModuleIdMap.set(module.id, nextModuleId);
return { ...module, id: nextModuleId };
});
const moduleOrder = remapModuleOrderIds(toolbox.moduleOrder, localModuleIdMap);
return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, moduleOrder, updatedAt: new Date().toISOString() });
const normalizedSource = normalizeToolbox({ ...toolbox, modules: toolbox.modules || [] });
const spaceIdMap = new Map(normalizedSource.spaces.map((space) => [space.id, uid("space")]));
const spaces = normalizedSource.spaces.map((space) => ({
...space,
id: spaceIdMap.get(space.id),
moduleOrder: remapModuleOrderIds(space.moduleOrder, localModuleIdMap)
}));
return normalizeToolbox({
...toolbox,
id: nextToolboxId,
name: `${toolbox.name || "Toolbox"} (import)`,
modules,
spaces,
defaultSpaceId: spaceIdMap.get(normalizedSource.defaultSpaceId),
activeSpaceId: spaceIdMap.get(normalizedSource.activeSpaceId),
updatedAt: new Date().toISOString()
});
});
const nextLinks = {};
Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => {