diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md
index 42d4dd6..2e4ba2c 100644
--- a/DESIGN_SYSTEM.md
+++ b/DESIGN_SYSTEM.md
@@ -57,6 +57,18 @@ Rayons :
- `--radius-xl: 18px`
- `--radius-pill: 999px`
+Espacements :
+
+- `--space-1: 0.25rem`
+- `--space-2: 0.5rem`
+- `--space-3: 0.75rem`
+- `--space-4: 1rem`
+- `--space-5: 1.25rem`
+- `--space-6: 1.5rem`
+- `--space-8: 2rem`
+- `--space-10: 2.5rem`
+- `--space-12: 3rem`
+
Animations :
- `--duration-fast: 120ms`
@@ -594,6 +606,13 @@ Outil Tableau :
## 14. Toolboxes
+### Espaces d'outils
+
+- La barre d'espaces se place entre le hero de la toolbox et les contrôles de disposition, dans la page complète comme dans le drawer lié à un jeu.
+- Quand seul l'espace par défaut existe, son onglet est masqué et le bouton `Nouvel espace` reste aligné à droite. À partir de deux espaces, la liste devient un `tablist` horizontal scrollable et l'ajout prend la forme d'un bouton `+` placé immédiatement après le dernier onglet.
+- L'espace actif reprend les surfaces et focus des radio-button groups. Les onglets ciblés par un outil affichent un soulignement doré ; les espaces secondaires disposent d'une poignée de réordonnancement distincte avec un retrait à gauche.
+- Le réordonnancement des onglets reprend l'indicateur d'insertion doré des outils, positionné à gauche ou à droite selon la destination. Les actions de renommage et suppression restent groupées à droite et passent sous les onglets sur mobile.
+
Les toolboxes sont locales et stockees dans le navigateur.
Regles UI :
diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md
index 3e5f2a8..36e4eca 100644
--- a/docs/FEATURE_CHECKLIST.md
+++ b/docs/FEATURE_CHECKLIST.md
@@ -38,7 +38,9 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier qu'une notification ou une mise à jour sans rapport ne remplace pas un brouillon Mémos avant sa validation au blur.
- Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu.
- Vérifier le mode une colonne et deux colonnes.
-- Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`.
+- Vérifier que l'ajout, la suppression et la réorganisation respectent les `moduleOrder.one` et `moduleOrder.two` de l'espace actif.
+- Pour les espaces toolbox, vérifier la conversion de l'ancien ordre vers « Défaut », la limite de cinq, l'espace actif partagé avec le panneau jeu, l'ajout dans l'espace actif, le renommage, le réordonnancement hors défaut et la suppression avec rapatriement des outils.
+- Vérifier le déplacement d'un outil par sélecteur et par dépôt sur onglet : dépôt rapide sans changement d'espace, activation après 600 ms, dépôt précis après activation et `pointercancel` sans déplacement.
- Pour les déplacements par pointeur, vérifier les dépôts avant/après, le rejet des cibles interdites et l'annulation navigateur (`pointercancel`) sans modification de l'ordre ; le démontage doit retirer les listeners actifs.
- Vérifier le quota de stockage si l'outil manipule des données lourdes.
- Pour la sauvegarde locale, vérifier la feature detection, les permissions sans popup au démarrage, le nommage en date locale, la déduplication quotidienne, le remplacement manuel, la rétention de dix fichiers et la conservation des fichiers étrangers.
diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md
index e3d21b6..ed310fe 100644
--- a/docs/STORAGE_SCHEMA.md
+++ b/docs/STORAGE_SCHEMA.md
@@ -111,8 +111,8 @@ rechargement de l'application ; il n'y a pas d'éviction à la fermeture.
Les exports attendent les écritures précédentes dans la même file et lisent les
médias manquants depuis l'état confirmé, sans les ajouter au cache de l'interface.
-Si cette lecture échoue, aucun fichier partiel n'est téléchargé. Les formats
-IndexedDB et import/export sont inchangés.
+Si cette lecture échoue, aucun fichier partiel n'est téléchargé. Les snapshots
+IndexedDB et les exports utilisent le même registre d'espaces décrit ci-dessous.
Cette coordination est locale à l'instance React ; elle ne synchronise pas
l'édition entre plusieurs onglets.
@@ -141,10 +141,18 @@ Les toolboxes sont stockées dans `kv/toolboxes`.
}
],
"moduleColumns": 2,
- "moduleOrder": {
- "one": ["mod1"],
- "two": [["mod1"], []]
- },
+ "spaces": [
+ {
+ "id": "space1",
+ "name": "Défaut",
+ "moduleOrder": {
+ "one": ["mod1"],
+ "two": [["mod1"], []]
+ }
+ }
+ ],
+ "defaultSpaceId": "space1",
+ "activeSpaceId": "space1",
"tutorial": true,
"updatedAt": "2026-07-25T12:00:00.000Z"
}
@@ -156,10 +164,12 @@ Notes :
- `icon` est utilisé uniquement pour les toolboxes libres.
- `customCover` est une image rectangulaire optionnelle utilisée comme cover de card pour les toolboxes libres. Elle est stockée en data URL image après recadrage, sans conserver le fichier original.
- `moduleColumns` vaut `1` ou `2` et contrôle l'affichage de la page toolbox complète ; le panneau latéral utilise toujours l'ordre une colonne.
-- `moduleOrder.one` stocke l'ordre du mode une colonne.
-- `moduleOrder.two` stocke l'ordre explicite des colonnes gauche et droite en mode deux colonnes.
-- Chaque identifiant d'outil présent dans `modules` doit apparaître exactement une fois dans `moduleOrder.one` et exactement une fois dans l'une des deux colonnes de `moduleOrder.two`.
-- Si une ancienne toolbox n'a pas encore `moduleOrder.two`, les outils sont répartis automatiquement depuis l'ordre une colonne avec une moitié en colonne gauche et une moitié en colonne droite.
+- `spaces` contient de un à cinq espaces dans l'ordre de leurs onglets. L'espace identifié par `defaultSpaceId` reste toujours premier et ne peut pas être supprimé, même après renommage.
+- `activeSpaceId` conserve l'espace affiché dans la page complète et le panneau latéral.
+- Chaque `spaces[].moduleOrder.one` stocke l'ordre du mode une colonne de cet espace ; `two` stocke ses colonnes gauche et droite.
+- Chaque identifiant d'outil présent dans `modules` apparaît exactement une fois dans l'ordre `one` d'un espace et exactement une fois dans l'une de ses colonnes `two`.
+- Les anciennes toolboxes sans `spaces` sont normalisées vers un espace « Défaut » qui reprend leur `moduleOrder`. Le format canonique compact ne conserve ensuite plus le champ racine `moduleOrder`.
+- Les exports individuels et globaux utilisent la version 2. Un import v2 de plus de cinq espaces est rejeté avant toute écriture.
- Les données lourdes des outils ne sont pas stockées dans la toolbox, mais dans `modules`.
- `modules[].collapsed` vaut `true` lorsque seul l'en-tête de l'outil est affiché ; il est omis lorsque l'outil est déplié.
- `tutorial` est un indicateur temporaire réservé aux toolboxes créées depuis la prise en main. Il déclenche les premiers repères à l'ouverture, puis il est retiré automatiquement du stockage.
@@ -1152,6 +1162,7 @@ Exemple :
```json
{
+ "version": 2,
"toolbox": {
"id": "t1",
"name": "MHW",
@@ -1162,6 +1173,18 @@ Exemple :
"title": "Armures"
}
],
+ "spaces": [
+ {
+ "id": "sp1",
+ "name": "Défaut",
+ "moduleOrder": {
+ "one": ["m1"],
+ "two": [["m1"], []]
+ }
+ }
+ ],
+ "defaultSpaceId": "sp1",
+ "activeSpaceId": "sp1",
"updatedAt": "2026-07-25T12:00:00.000Z"
},
"modules": {
diff --git a/e2e/fixtures/toolbox-tutorial-probe.js b/e2e/fixtures/toolbox-tutorial-probe.js
index a43c3fe..b4f4793 100644
--- a/e2e/fixtures/toolbox-tutorial-probe.js
+++ b/e2e/fixtures/toolbox-tutorial-probe.js
@@ -9,10 +9,22 @@ export function mountToolboxTutorialProbe(element, siteContent) {
const writes = [];
const pendingChanges = [];
let setInputs;
+ function createToolbox(id) {
+ const defaultSpaceId = `space-${id}`;
+ return {
+ id,
+ name: id,
+ modules: [],
+ spaces: [{ id: defaultSpaceId, name: "Défaut", moduleOrder: { one: [], two: [[], []] } }],
+ defaultSpaceId,
+ activeSpaceId: defaultSpaceId,
+ tutorial: id !== "c"
+ };
+ }
function Probe() {
const [inputs, updateInputs] = useState({
toolboxId: "a", pendingTutorialToolboxId: "",
- toolboxes: ["a", "b", "c"].map((id) => ({ id, name: id, modules: [], tutorial: id !== "c" }))
+ toolboxes: ["a", "b", "c"].map(createToolbox)
});
setInputs = updateInputs;
return createElement(ToolboxPage, {
diff --git a/e2e/module-order.spec.js b/e2e/module-order.spec.js
index 09a000d..b971a0b 100644
--- a/e2e/module-order.spec.js
+++ b/e2e/module-order.spec.js
@@ -20,7 +20,7 @@ async function setup(page, columns) {
async function savedOrder(page) {
return page.evaluate(async () => {
const db = await import("/src/utils/indexedDbStorage.js");
- return (await db.getToolboxes())[0].moduleOrder;
+ return (await db.getToolboxes())[0].spaces[0].moduleOrder;
});
}
diff --git a/e2e/production/asset-recovery.spec.js b/e2e/production/asset-recovery.spec.js
new file mode 100644
index 0000000..52fa57e
--- /dev/null
+++ b/e2e/production/asset-recovery.spec.js
@@ -0,0 +1,37 @@
+// Rôle : vérifie qu'un ancien HTML rétablit l'application quand son bundle Vite a disparu.
+import { test, expect } from "@playwright/test";
+import { readFile } from "node:fs/promises";
+
+const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
+
+test("a stale entry bundle triggers one fresh document load", async ({ page }) => {
+ let staleDocumentServed = false;
+ let documentRequestCount = 0;
+
+ await page.route("**/*", async (route) => {
+ const request = route.request();
+ const requestUrl = new globalThis.URL(request.url());
+ const isRootDocument = request.resourceType() === "document" && requestUrl.pathname === "/";
+ if (isRootDocument) documentRequestCount += 1;
+
+ if (staleDocumentServed || !isRootDocument) {
+ await route.continue();
+ return;
+ }
+
+ staleDocumentServed = true;
+ const response = await route.fetch();
+ const html = await response.text();
+ await route.fulfill({
+ response,
+ body: html.replace(/(
diff --git a/website/public/data/library-toolbox-export.json b/website/public/data/library-toolbox-export.json
index 94fd6b7..5e867b7 100644
--- a/website/public/data/library-toolbox-export.json
+++ b/website/public/data/library-toolbox-export.json
@@ -1,4 +1,5 @@
{
+ "version": 2,
"toolbox": {
"id": "t1",
"name": "Librairie",
@@ -78,8 +79,12 @@
"type": "equipmentPlanner"
}
],
- "moduleOrder": {
- "one": [
+ "spaces": [
+ {
+ "id": "library-space",
+ "name": "Défaut",
+ "moduleOrder": {
+ "one": [
"m1",
"m2",
"m3",
@@ -99,7 +104,7 @@
"mh",
"mi"
],
- "two": [
+ "two": [
[
"m1",
"m2",
@@ -122,8 +127,12 @@
"mh",
"mi"
]
- ]
- },
+ ]
+ }
+ }
+ ],
+ "defaultSpaceId": "library-space",
+ "activeSpaceId": "library-space",
"updatedAt": "2026-08-09T07:34:31.425Z",
"moduleColumns": 1
},
diff --git a/website/public/data/library.json b/website/public/data/library.json
index aafbbf1..033fbc9 100644
--- a/website/public/data/library.json
+++ b/website/public/data/library.json
@@ -229,8 +229,12 @@
"type": "equipmentPlanner"
}
],
- "moduleOrder": {
- "one": [
+ "spaces": [
+ {
+ "id": "library-space",
+ "name": "Défaut",
+ "moduleOrder": {
+ "one": [
"m0",
"m1",
"m2",
@@ -250,7 +254,7 @@
"mb",
"mc"
],
- "two": [
+ "two": [
[
"m0",
"m1",
@@ -273,8 +277,12 @@
"mb",
"mc"
]
- ]
- },
+ ]
+ }
+ }
+ ],
+ "defaultSpaceId": "library-space",
+ "activeSpaceId": "library-space",
"updatedAt": "2026-08-09T07:34:31.425Z",
"moduleColumns": 1
},
diff --git a/website/public/data/site.json b/website/public/data/site.json
index 1c8b89b..d2d62c3 100644
--- a/website/public/data/site.json
+++ b/website/public/data/site.json
@@ -1803,6 +1803,26 @@
"retryLabel": "Réessayer",
"writeErrorTitle": "Erreur de stockage local"
},
+ "spaces": {
+ "toolbarLabel": "Espaces de la toolbox",
+ "tabsLabel": "Changer d’espace",
+ "addLabel": "Nouvel espace",
+ "createTitle": "Créer un espace",
+ "renameTitle": "Renommer l’espace",
+ "renameLabel": "Renommer l’espace actif",
+ "deleteTitle": "Supprimer l’espace",
+ "deleteLabel": "Supprimer l’espace actif",
+ "deleteEmpty": "Supprimer cet espace vide ?",
+ "deleteWithTools": "Supprimer cet espace et déplacer ses {count} outil(s) vers l’espace par défaut ?",
+ "nameLabel": "Nom",
+ "cancelLabel": "Annuler",
+ "saveLabel": "Enregistrer",
+ "reorderLabel": "Réordonner l’espace",
+ "moveLabel": "Déplacer vers…",
+ "moveSuccess": "Outil déplacé vers {space}.",
+ "emptyText": "Cet espace est vide. Ajoutez-y un outil pour commencer.",
+ "limitReached": "Limite de 5 espaces atteinte"
+ },
"eyebrow": "Données locales",
"title": "Toolboxes",
"newButton": "Nouvelle toolbox",
diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx
index 89c355e..056a612 100644
--- a/website/src/features/toolboxes/ToolboxPages.jsx
+++ b/website/src/features/toolboxes/ToolboxPages.jsx
@@ -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
+
{!embedded && showTutorial && (
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));
}
})}
/>
diff --git a/website/src/features/toolboxes/ToolboxSpaces.jsx b/website/src/features/toolboxes/ToolboxSpaces.jsx
new file mode 100644
index 0000000..dcce7d6
--- /dev/null
+++ b/website/src/features/toolboxes/ToolboxSpaces.jsx
@@ -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 (
+
+
onClose("")} />
+
+
+ );
+}
+
+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 (
+ <>
+
+ {toolbox.spaces.length > 1 && (
+
+
+ {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 (
+
+ {!isDefault && spaceReorder.itemReorder.startDrag(event, space.id)} aria-label={`${text.reorderLabel} ${space.name}`} title={text.reorderLabel}> }
+ 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}
+
+ );
+ })}
+
+
!atLimit && setNameModal({ mode: "create" })} aria-label={atLimit ? text.limitReached : text.addLabel} title={atLimit ? text.limitReached : text.addLabel}>
+
+ )}
+
+ {toolbox.spaces.length > 1 && setNameModal({ mode: "rename", spaceId: activeSpace.id })} aria-label={text.renameLabel} title={text.renameLabel}> }
+ {toolbox.spaces.length > 1 && activeSpace.id !== toolbox.defaultSpaceId && }
+ {toolbox.spaces.length === 1 && !atLimit && setNameModal({ mode: "create" })} aria-label={atLimit ? text.limitReached : text.addLabel} title={atLimit ? text.limitReached : text.addLabel}> {text.addLabel} }
+
+
+ {nameModal &&
}
+ >
+ );
+}
diff --git a/website/src/features/toolboxes/modules/ModuleShell.jsx b/website/src/features/toolboxes/modules/ModuleShell.jsx
index 245c858..e0b6c8b 100644
--- a/website/src/features/toolboxes/modules/ModuleShell.jsx
+++ b/website/src/features/toolboxes/modules/ModuleShell.jsx
@@ -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 = "",
onRename(module.id, title)} />
+ {spaces.length > 1 && (
+
onMoveToSpace(module.id, event.target.value)}
+ >
+ {moveText}
+ {spaces.filter((space) => space.id !== activeSpaceId).map((space) => {space.name} )}
+
+ )}
{definition.scrollable && (
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 (
+ {!orderedModules.length && }
{orderedModules.map((module) => (
1 && columns.some((modules) => modules.length === 0);
+ const hasEmptyColumn = orderedModules.length > 1 && columns.some((modules) => modules.length === 0);
return (
{columns.map((modules, index) => (
{modules.map((module) => (
))}
))}
+ {!orderedModules.length && }
);
}
diff --git a/website/src/features/toolboxes/storage/modules/shared.js b/website/src/features/toolboxes/storage/modules/shared.js
index 386d127..f619e11 100644
--- a/website/src/features/toolboxes/storage/modules/shared.js
+++ b/website/src/features/toolboxes/storage/modules/shared.js
@@ -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",
diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js
index 768a8b5..0429871 100644
--- a/website/src/features/toolboxes/storage/toolboxStorage.js
+++ b/website/src/features/toolboxes/storage/toolboxStorage.js
@@ -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,
diff --git a/website/src/features/toolboxes/toolboxSpaceUtils.js b/website/src/features/toolboxes/toolboxSpaceUtils.js
new file mode 100644
index 0000000..b2a5a95
--- /dev/null
+++ b/website/src/features/toolboxes/toolboxSpaceUtils.js
@@ -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 };
+ })
+ };
+}
diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js
index 7d82f05..6345b0c 100644
--- a/website/src/features/toolboxes/useToolboxActions.js
+++ b/website/src/features/toolboxes/useToolboxActions.js
@@ -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]) => {
diff --git a/website/src/main.jsx b/website/src/main.jsx
index 081624a..3ee847e 100644
--- a/website/src/main.jsx
+++ b/website/src/main.jsx
@@ -20,6 +20,16 @@ import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/
import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js";
import { GlobalMemosTicker } from "./features/toolboxes/GlobalMemosTicker.jsx";
+const assetRecoveryUrl = new URL(window.location.href);
+if (assetRecoveryUrl.searchParams.has("_asset-recovery")) {
+ assetRecoveryUrl.searchParams.delete("_asset-recovery");
+ window.history.replaceState(
+ window.history.state,
+ "",
+ `${assetRecoveryUrl.pathname}${assetRecoveryUrl.search}${assetRecoveryUrl.hash}`
+ );
+}
+
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
const SHORT_NOTIFICATION_DURATION_MS = 5000;
diff --git a/website/src/styles/_tokens.scss b/website/src/styles/_tokens.scss
index 1e64382..7115a70 100644
--- a/website/src/styles/_tokens.scss
+++ b/website/src/styles/_tokens.scss
@@ -66,6 +66,7 @@
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
+ --space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
diff --git a/website/src/styles/toolboxes/_layout.scss b/website/src/styles/toolboxes/_layout.scss
index 31e3a78..57f4325 100644
--- a/website/src/styles/toolboxes/_layout.scss
+++ b/website/src/styles/toolboxes/_layout.scss
@@ -6,3 +6,4 @@
@use "layout/add-tools";
@use "layout/preferences";
@use "layout/modules";
+@use "layout/spaces";
diff --git a/website/src/styles/toolboxes/_module-shell.scss b/website/src/styles/toolboxes/_module-shell.scss
index 006853a..c32a998 100644
--- a/website/src/styles/toolboxes/_module-shell.scss
+++ b/website/src/styles/toolboxes/_module-shell.scss
@@ -22,6 +22,11 @@
font-size: 17px;
}
+.module-space-select {
+ width: auto;
+ max-width: 150px;
+}
+
.module-content {
min-width: 0;
}
diff --git a/website/src/styles/toolboxes/layout/_spaces.scss b/website/src/styles/toolboxes/layout/_spaces.scss
new file mode 100644
index 0000000..340a659
--- /dev/null
+++ b/website/src/styles/toolboxes/layout/_spaces.scss
@@ -0,0 +1,192 @@
+// Rôle : stylise les onglets, actions et états de dépôt des espaces toolbox.
+.toolbox-spaces-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ min-width: 0;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ background: rgba(16, 20, 38, 0.58);
+}
+
+.toolbox-spaces-toolbar.is-single {
+ justify-content: flex-end;
+}
+
+.toolbox-space-tabs-row {
+ display: flex;
+ flex: 1 1 auto;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+ overflow-x: auto;
+ padding: var(--space-1);
+}
+
+.toolbox-space-tabs {
+ display: flex;
+ flex: 0 0 auto;
+ gap: var(--space-3);
+}
+
+.toolbox-space-tab-wrap {
+ position: relative;
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ min-height: 40px;
+ overflow: hidden;
+ border: 1px solid rgba(165, 180, 252, 0.14);
+ border-radius: var(--radius-md);
+ background: rgba(7, 10, 24, 0.45);
+ transition:
+ border-color var(--duration-fast) var(--ease-standard),
+ background-color var(--duration-fast) var(--ease-standard),
+ box-shadow var(--duration-fast) var(--ease-standard);
+}
+
+.toolbox-space-tab-wrap:hover:not(.active) {
+ border-color: rgba(196, 181, 253, 0.34);
+ background: rgba(139, 92, 246, 0.12);
+ box-shadow:
+ 0 0 14px rgba(139, 92, 246, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
+}
+
+.toolbox-space-tab-wrap:focus-within {
+ outline: 2px solid var(--color-accent-gold);
+ outline-offset: 2px;
+}
+
+.toolbox-space-tab-wrap.active {
+ border-color: rgba(246, 196, 83, 0.48);
+ background: rgba(246, 196, 83, 0.09);
+}
+
+.toolbox-space-tab-wrap.active:hover {
+ border-color: rgba(246, 196, 83, 0.62);
+ background: rgba(246, 196, 83, 0.13);
+}
+
+.toolbox-space-tab-wrap.is-drop-target {
+ border-color: rgba(246, 196, 83, 0.72);
+ box-shadow: none;
+}
+
+.toolbox-space-tab-wrap.is-drop-target::before {
+ position: absolute;
+ z-index: 3;
+ top: 4px;
+ bottom: 4px;
+ left: 0;
+ width: 3px;
+ border-radius: var(--radius-pill);
+ background: rgba(246, 196, 83, 0.9);
+ box-shadow: 0 0 12px rgba(246, 196, 83, 0.24);
+ content: "";
+ pointer-events: none;
+}
+
+.toolbox-space-tab-wrap.is-drop-target.drop-after::before {
+ right: 0;
+ left: auto;
+}
+
+.toolbox-space-tab-wrap.is-module-drop-target {
+ border-color: rgba(246, 196, 83, 0.72);
+ background: rgba(246, 196, 83, 0.14);
+ box-shadow: inset 0 -3px 0 rgba(246, 196, 83, 0.9);
+}
+
+.toolbox-space-tab,
+.toolbox-space-drag-handle {
+ border: 0;
+ background: transparent;
+}
+
+.toolbox-space-tab {
+ min-height: 38px;
+ padding: 0 var(--space-3) 0 var(--space-2);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition: color var(--duration-fast) var(--ease-standard);
+}
+
+.toolbox-space-tab:first-child {
+ padding-inline: var(--space-3);
+}
+
+.toolbox-space-tab.active {
+ color: var(--color-text-primary);
+}
+
+.toolbox-space-tab-wrap.is-module-drop-target .toolbox-space-tab {
+ color: var(--color-accent);
+}
+
+.toolbox-space-drag-handle,
+.toolbox-space-drag-handle.nds-icon-button {
+ flex: 0 0 30px;
+ width: 30px;
+ min-width: 30px;
+ height: 30px;
+ margin-left: var(--space-1);
+ border-right: 1px solid rgba(165, 180, 252, 0.1);
+ border-radius: var(--radius-sm);
+ cursor: grab;
+ opacity: 0.65;
+ touch-action: none;
+}
+
+.toolbox-space-tab-wrap > .toolbox-space-tab:hover:not(:disabled),
+.toolbox-space-tab-wrap > .toolbox-space-drag-handle.nds-icon-button:hover {
+ border-color: transparent;
+ background: transparent;
+ box-shadow: none;
+}
+
+.toolbox-space-tab-wrap > .toolbox-space-tab:focus-visible,
+.toolbox-space-tab-wrap > .toolbox-space-drag-handle.nds-icon-button:focus-visible {
+ outline: none;
+}
+
+.toolbox-space-tab-wrap:hover .toolbox-space-drag-handle,
+.toolbox-space-tab-wrap:focus-within .toolbox-space-drag-handle {
+ opacity: 0.9;
+}
+
+.toolbox-space-actions {
+ display: flex;
+ flex: 0 0 auto;
+ gap: var(--space-2);
+}
+
+.toolbox-space-add-compact {
+ flex: 0 0 auto;
+}
+
+.toolbox-space-empty {
+ grid-column: 1 / -1;
+ width: 100%;
+}
+
+.toolbox-space-modal {
+ width: min(440px, calc(100vw - 32px));
+}
+
+@media (max-width: 760px) {
+ .toolbox-spaces-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .toolbox-space-actions {
+ justify-content: flex-end;
+ }
+
+ .toolbox-space-add {
+ flex: 1 1 auto;
+ }
+}