diff --git a/AGENTS.md b/AGENTS.md index 2c76df4..dc50913 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,12 @@ Privilégier : Éviter les styles isolés qui ne réutilisent pas le thème. +## Serveur de développement + +Si le port `5173` est déjà occupé, considérer qu'un `npm run dev` est probablement déjà en cours. Ne pas lancer automatiquement un nouveau serveur sur `5174` dans ce cas. + +Quand une vérification navigateur est nécessaire après des modifications, utiliser l'instance déjà ouverte sur `http://localhost:5173` si elle répond. + ## Validation Avant de terminer une modification significative, lancer : diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 6d21d9e..a933ac3 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -165,6 +165,91 @@ Stockage compact : - `collapsed` est omis si `false`. - les sections sans item sont ignorées. +## Outil Planificateur D'équipements + +Type : `equipmentPlanner` + +```json +{ + "types": [ + { + "id": "type1", + "title": "Armes", + "icon": "sword", + "collapsed": false + } + ], + "equipments": [ + { + "id": "equipment1", + "typeId": "type1", + "name": "Épée runique", + "icon": "sword", + "active": true, + "obtain": "Forge après le boss", + "characteristics": [ + { + "id": "trait1", + "category": "Stats", + "icon": "sword", + "name": "Force", + "value": 12 + } + ], + "socketItems": [ + { + "id": "socket1", + "name": "Joyau attaque", + "shape": "jewel", + "color": "yellow", + "bonuses": [ + { + "id": "bonus1", + "category": "Compétences", + "name": "Attaque", + "value": 1 + } + ] + } + ], + "socketLinks": [ + { + "id": "link1", + "fromSocketItemId": "socket1", + "toSocketItemId": "socket2" + } + ], + "materials": [ + { + "id": "material1", + "name": "Minerai rare", + "qty": 4 + } + ], + "categoryOrder": ["Stats", "Compétences"], + "collapsedCategories": [] + } + ], + "typeOrder": ["type1"] +} +``` + +Stockage compact : + +- les types sans équipement sont ignorés ; +- `icon` des types vaut `boot`, `chest-armor`, `helmet`, `glove`, `shield`, `sword`, `gun`, `earring`, `necklace` ou `ring` ; +- `icon` des équipements utilise la même liste et pilote l'icône affichée sur la ligne de l'équipement ; +- `active` est omis quand la valeur vaut `true` ; +- `obtain`, `characteristics`, `socketItems`, `socketLinks`, `materials`, `categoryOrder` et `collapsedCategories` sont omis si vides ; +- `collapsed` est omis si `false` ; +- `qty` des matériaux est normalisé à minimum `1` ; +- les caractéristiques et bonus sans nom sont ignorés ; +- `icon` des caractéristiques directes vaut `sword` ou `shield` ; +- `shape` vaut `ball` ou `jewel`, avec `jewel` par défaut ; +- `color` vaut `red`, `orange`, `amber`, `yellow`, `lime`, `green`, `teal`, `cyan`, `blue`, `indigo`, `violet`, `purple`, `pink`, `rose`, `white`, `gray` ou `black` ; +- les liens de sertissage invalides, dupliqués ou hors équipement sont ignorés ; +- seuls les équipements actifs contribuent au résumé. + ## Outil Images Type : `images` diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index af5eef2..a039c9b 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -332,6 +332,73 @@ export function validateSiteContent(site) { "toolboxes.modules.taskPlanner.prerequisiteCheckboxLabel", "toolboxes.modules.taskPlanner.removeRelationTitle", "toolboxes.modules.taskPlanner.emptyTasks", + "toolboxes.modules.equipmentPlanner.typePlaceholder", + "toolboxes.modules.equipmentPlanner.equipmentIconLabel", + "toolboxes.modules.equipmentPlanner.equipmentPlaceholder", + "toolboxes.modules.equipmentPlanner.addEquipmentButton", + "toolboxes.modules.equipmentPlanner.summaryTitle", + "toolboxes.modules.equipmentPlanner.activeEquipmentsSingular", + "toolboxes.modules.equipmentPlanner.activeEquipmentsPlural", + "toolboxes.modules.equipmentPlanner.numericTotalsTitle", + "toolboxes.modules.equipmentPlanner.equipmentTotalsTitle", + "toolboxes.modules.equipmentPlanner.socketTotalsTitle", + "toolboxes.modules.equipmentPlanner.mixedTotalsTitle", + "toolboxes.modules.equipmentPlanner.summaryTooltipTitle", + "toolboxes.modules.equipmentPlanner.textBonusesTitle", + "toolboxes.modules.equipmentPlanner.emptyTotals", + "toolboxes.modules.equipmentPlanner.emptyTextBonuses", + "toolboxes.modules.equipmentPlanner.emptyEquipments", + "toolboxes.modules.equipmentPlanner.activeLabel", + "toolboxes.modules.equipmentPlanner.includeInSummaryLabel", + "toolboxes.modules.equipmentPlanner.reorderTypeTitle", + "toolboxes.modules.equipmentPlanner.renameTypeTitle", + "toolboxes.modules.equipmentPlanner.showTypeTitle", + "toolboxes.modules.equipmentPlanner.hideTypeTitle", + "toolboxes.modules.equipmentPlanner.reorderEquipmentTitle", + "toolboxes.modules.equipmentPlanner.renameEquipmentTitle", + "toolboxes.modules.equipmentPlanner.showDetailsTitle", + "toolboxes.modules.equipmentPlanner.hideDetailsTitle", + "toolboxes.modules.equipmentPlanner.deleteEquipmentTitle", + "toolboxes.modules.equipmentPlanner.tabsLabel", + "toolboxes.modules.equipmentPlanner.statsTab", + "toolboxes.modules.equipmentPlanner.socketsTab", + "toolboxes.modules.equipmentPlanner.craftTab", + "toolboxes.modules.equipmentPlanner.displayStatsTitle", + "toolboxes.modules.equipmentPlanner.displaySocketTitle", + "toolboxes.modules.equipmentPlanner.editModeTitle", + "toolboxes.modules.equipmentPlanner.viewModeTitle", + "toolboxes.modules.equipmentPlanner.editModeButton", + "toolboxes.modules.equipmentPlanner.viewModeButton", + "toolboxes.modules.equipmentPlanner.obtainLabel", + "toolboxes.modules.equipmentPlanner.obtainPlaceholder", + "toolboxes.modules.equipmentPlanner.emptyObtain", + "toolboxes.modules.equipmentPlanner.characteristicsTitle", + "toolboxes.modules.equipmentPlanner.traitIconLabel", + "toolboxes.modules.equipmentPlanner.categoryPlaceholder", + "toolboxes.modules.equipmentPlanner.traitNamePlaceholder", + "toolboxes.modules.equipmentPlanner.traitValuePlaceholder", + "toolboxes.modules.equipmentPlanner.addTraitButton", + "toolboxes.modules.equipmentPlanner.reorderTraitTitle", + "toolboxes.modules.equipmentPlanner.reorderCategoryTitle", + "toolboxes.modules.equipmentPlanner.deleteTraitTitle", + "toolboxes.modules.equipmentPlanner.emptyCharacteristics", + "toolboxes.modules.equipmentPlanner.materialsTitle", + "toolboxes.modules.equipmentPlanner.materialNamePlaceholder", + "toolboxes.modules.equipmentPlanner.materialQtyLabel", + "toolboxes.modules.equipmentPlanner.addMaterialButton", + "toolboxes.modules.equipmentPlanner.deleteMaterialTitle", + "toolboxes.modules.equipmentPlanner.socketItemsTitle", + "toolboxes.modules.equipmentPlanner.emptySocketItems", + "toolboxes.modules.equipmentPlanner.socketNamePlaceholder", + "toolboxes.modules.equipmentPlanner.socketShapeLabel", + "toolboxes.modules.equipmentPlanner.socketColorLabel", + "toolboxes.modules.equipmentPlanner.addSocketButton", + "toolboxes.modules.equipmentPlanner.bonusNamePlaceholder", + "toolboxes.modules.equipmentPlanner.addBonusButton", + "toolboxes.modules.equipmentPlanner.deleteSocketTitle", + "toolboxes.modules.equipmentPlanner.deleteBonusTitle", + "toolboxes.modules.equipmentPlanner.addSocketLinkLabel", + "toolboxes.modules.equipmentPlanner.deleteSocketLinkTitle", "toolboxes.modules.imageAnnotation.addImage", "toolboxes.modules.imageAnnotation.replaceImage", "toolboxes.modules.imageAnnotation.pastePlaceholder", @@ -432,7 +499,7 @@ export function validateLibraryData(payload) { assert.equal(typeof payload.modules[module.id], "object", `library.modules.${module.id} must be an object`); }); - ["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "taskPlanner", "imageAnnotation"].forEach((type) => { + ["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "taskPlanner", "equipmentPlanner", "imageAnnotation"].forEach((type) => { assert.ok(payload.toolbox.modules.some((module) => module.type === type), `library must include a ${type} example`); }); @@ -451,6 +518,12 @@ export function validateLibraryData(payload) { assert.ok(Array.isArray(taskData.tasks), "library task planner example must contain tasks"); assert.ok(Array.isArray(taskData.relations), "library task planner example must contain relations"); assert.ok(taskData.relations.length >= 1, "library task planner example must include a parent/child relation"); + + const equipmentModule = payload.toolbox.modules.find((module) => module.type === "equipmentPlanner"); + const equipmentData = payload.modules[equipmentModule.id]; + assert.ok(Array.isArray(equipmentData.types), "library equipment planner example must contain types"); + assert.ok(Array.isArray(equipmentData.equipments), "library equipment planner example must contain equipments"); + assert.ok(equipmentData.equipments.some((equipment) => Array.isArray(equipment.socketItems) && equipment.socketItems.length), "library equipment planner example must include socket items"); } function assertSnakeCase(value, path) { diff --git a/tests/static-app.test.mjs b/tests/static-app.test.mjs index 23852fa..a9bef9a 100644 --- a/tests/static-app.test.mjs +++ b/tests/static-app.test.mjs @@ -66,6 +66,9 @@ test("vite entrypoint and app shell are wired", async () => { assert.match(libraryPage, /export function LibraryPage/); assert.match(libraryPage, /LIBRARY_TOOLBOX_ID/); assert.match(libraryPage, /TaskPlannerModule/); + assert.match(libraryPage, /EquipmentPlannerModule/); + assert.match(libraryPage, /normalizeEquipmentPlannerData/); + assert.match(libraryPage, /summarizeEquipmentPlannerData/); assert.match(bodyScrollLock, /modalLocks/); assert.match(bodyScrollLock, /is-modal-open/); }); diff --git a/tests/static-styles.test.mjs b/tests/static-styles.test.mjs index c0a368e..6d88837 100644 --- a/tests/static-styles.test.mjs +++ b/tests/static-styles.test.mjs @@ -78,6 +78,7 @@ test("style entrypoint and theme layers are wired", async () => { assert.match(styleIcons, /ui-icon-grab/); assert.match(styleIcons, /ui-icon-shield/); assert.match(styleIcons, /ui-icon-gun/); + assert.match(styleToolboxes, /module-icon-chest-armor/); assert.match(styleToolboxes, /\.combo-hold-controls/); assert.match(styleToolboxes, /\.combo-input-direction/); assert.match(styleToolboxes, /\.combo-device-n64\.combo-value-stick-up/); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index c06fc7b..74ba301 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -43,6 +43,8 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxStorage, /export function normalizeTableData/); assert.match(toolboxStorage, /export function normalizeTimerData/); assert.match(toolboxStorage, /export function normalizeTaskPlannerData/); + assert.match(toolboxStorage, /export function normalizeEquipmentPlannerData/); + assert.match(toolboxStorage, /export function summarizeEquipmentPlannerData/); assert.match(toolboxStorage, /export function normalizeImageAnnotationData/); assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/); assert.match(toolboxStorage, /scrollResults/); @@ -108,6 +110,7 @@ test("toolbox module registry and modules expose expected behavior", async () => const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8"); const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8"); const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8"); + const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8"); const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8"); const imagesModule = await readFile("website/src/features/toolboxes/modules/ImagesModule.jsx", "utf8"); const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8"); @@ -133,6 +136,8 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/); assert.match(moduleRegistry, /timer:/); assert.match(moduleRegistry, /taskPlanner:/); + assert.match(moduleRegistry, /equipmentPlanner:/); + assert.match(moduleRegistry, /Planificateur d'équipements/); assert.match(moduleRegistry, /imageAnnotation:/); assert.match(moduleRegistry, /Annotation d'images/); assert.match(moduleRegistry, /editable: false/); @@ -245,6 +250,13 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(taskPlannerModule, /reorderFeatures/); assert.doesNotMatch(taskPlannerModule, /canMoveItem:/); assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/); + assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/); + assert.match(equipmentPlannerModule, /normalizeEquipmentPlannerData/); + assert.match(equipmentPlannerModule, /summarizeEquipmentPlannerData/); + assert.match(equipmentPlannerModule, /socketItems/); + assert.match(equipmentPlannerModule, /socketLinks/); + assert.match(equipmentPlannerModule, /useGroupedReorder/); + assert.match(equipmentPlannerModule, /reorderFeatures/); assert.match(checklistModule, /export function ChecklistModule/); assert.match(checklistModule, /TextImportModal/); assert.match(checklistModule, /Icon name="import"/); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index ecad531..0752728 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; -import { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; +import { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeEquipmentPlannerData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; test("colon text import keeps urls intact after the first separator", () => { @@ -96,6 +96,228 @@ test("table storage clamps dimensions and compacts non-empty cells", () => { assert.equal(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {} }), null); }); +test("equipment planner storage normalizes sockets, materials and links", () => { + const normalized = normalizeEquipmentPlannerData({ + types: [ + { id: "type1", title: "Armes" }, + { id: "empty", title: "Vide" } + ], + equipments: [ + { + id: "eq1", + typeId: "type1", + name: "Épée", + active: false, + characteristics: [ + { id: "trait1", category: "Stats", name: "Force", value: "12" }, + { id: "ignored", category: "Stats", name: "", value: 4 } + ], + materials: [ + { id: "mat1", name: "Minerai", qty: 0 } + ], + socketItems: [ + { + id: "socket1", + name: "Materia feu", + shape: "triangle", + color: "invalid", + bonuses: [ + { id: "bonus1", category: "Stats", name: "Force", value: "3" }, + { id: "bonus2", category: "Compétences", name: "", value: 1 } + ] + }, + { + id: "socket2", + name: "Materia soin", + shape: "ball", + color: "cyan", + bonuses: [ + { id: "bonus3", category: "Sorts", name: "Soin", value: "" } + ] + } + ], + socketLinks: [ + { id: "link1", fromSocketItemId: "socket1", toSocketItemId: "socket2" }, + { id: "duplicate", fromSocketItemId: "socket2", toSocketItemId: "socket1" }, + { id: "invalid", fromSocketItemId: "socket1", toSocketItemId: "missing" } + ], + categoryOrder: ["Compétences", "Stats", "Missing"], + collapsedCategories: ["Stats", "Missing"] + } + ], + typeOrder: ["empty", "type1"] + }); + + assert.deepEqual(normalized.types.map((type) => type.id), ["type1"]); + assert.equal(normalized.equipments[0].active, false); + assert.equal(normalized.equipments[0].materials[0].qty, 1); + assert.equal(normalized.equipments[0].characteristics.length, 1); + assert.equal(normalized.equipments[0].socketItems[0].shape, "jewel"); + assert.equal(normalized.equipments[0].socketItems[0].color, "yellow"); + assert.equal(normalized.equipments[0].socketItems[0].bonuses.length, 1); + assert.equal(normalized.equipments[0].socketLinks.length, 1); + assert.deepEqual(normalized.equipments[0].categoryOrder, ["Stats", "Sorts"]); + assert.deepEqual(normalized.equipments[0].collapsedCategories, ["Stats"]); + + const compact = compactModuleDataForStorage("equipmentPlanner", normalized); + assert.equal(compact.equipments[0].active, false); + assert.equal(compact.equipments[0].socketItems[0].shape, "jewel"); + assert.equal(compact.equipments[0].socketLinks.length, 1); + assert.equal(compactModuleDataForStorage("equipmentPlanner", { types: [{ id: "empty", title: "Vide" }], equipments: [] }), null); +}); + +test("equipment planner summary only uses active equipments", () => { + const summary = summarizeEquipmentPlannerData({ + types: [{ id: "type1", title: "Build" }], + equipments: [ + { + id: "eq1", + typeId: "type1", + name: "Casque", + active: true, + characteristics: [{ id: "trait1", category: "Stats", name: "Force", value: 10 }], + socketItems: [{ + id: "socket1", + name: "Joyau attaque", + shape: "ball", + color: "yellow", + bonuses: [ + { id: "bonus1", category: "Stats", name: "Force", value: 2 }, + { id: "bonus2", category: "Affixes", name: "Brutalité", value: "" } + ] + }] + }, + { + id: "eq2", + typeId: "type1", + name: "Armure", + active: false, + characteristics: [{ id: "trait2", category: "Stats", name: "Force", value: 99 }] + } + ] + }); + + assert.equal(summary.activeEquipmentCount, 1); + assert.deepEqual(summary.totals, [{ + name: "Force", + icon: "sword", + sourceKind: "mixed", + contributions: [ + { id: "trait1", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Casque", sourceName: "Casque", value: 10 }, + { id: "bonus1", sourceKind: "socket", equipmentIcon: "shield", equipmentName: "Casque", sourceName: "Casque · Joyau attaque", value: 2 } + ], + value: 12 + }]); + assert.deepEqual(summary.equipmentTotals, []); + assert.deepEqual(summary.socketTotals, []); + assert.deepEqual(summary.mixedTotals, summary.totals); + assert.equal(summary.textBonuses.length, 1); + assert.equal(summary.textBonuses[0].name, "Brutalité"); +}); + +test("equipment planner summary prioritizes direct stat icons over socket icons", () => { + const summary = summarizeEquipmentPlannerData({ + types: [{ id: "type1", title: "Build" }], + equipments: [ + { + id: "eq1", + typeId: "type1", + name: "Épée", + characteristics: [{ id: "trait1", category: "Stats", icon: "shield", name: "Attaque", value: 100 }], + socketItems: [{ + id: "socket1", + name: "Joyau rouge", + shape: "jewel", + color: "red", + bonuses: [ + { id: "bonus1", category: "Stats", name: "Attaque", value: 10 }, + { id: "bonus2", category: "Compétences", name: "Attaque de feu", value: 1 } + ] + }] + }, + { + id: "eq2", + typeId: "type1", + name: "Fusil", + characteristics: [ + { id: "trait2", category: "Stats", icon: "sword", name: "Défense", value: 10 }, + { id: "trait3", category: "Stats", icon: "sword", name: "Défense", value: 20 }, + { id: "trait4", category: "Stats", icon: "shield", name: "Défense", value: 30 } + ] + } + ] + }); + + assert.deepEqual(summary.totals.find((total) => total.name === "Attaque"), { + name: "Attaque", + icon: "shield", + sourceKind: "mixed", + contributions: [ + { id: "trait1", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Épée", sourceName: "Épée", value: 100 }, + { id: "bonus1", sourceKind: "socket", equipmentIcon: "shield", equipmentName: "Épée", sourceName: "Épée · Joyau rouge", value: 10 } + ], + value: 110 + }); + assert.deepEqual(summary.totals.find((total) => total.name === "Attaque de feu"), { + name: "Attaque de feu", + icon: "jewel", + color: "red", + sourceKind: "socket", + contributions: [{ id: "bonus2", sourceKind: "socket", equipmentIcon: "shield", equipmentName: "Épée", sourceName: "Épée · Joyau rouge", value: 1 }], + value: 1 + }); + assert.deepEqual(summary.totals.find((total) => total.name === "Défense"), { + name: "Défense", + icon: "sword", + sourceKind: "direct", + contributions: [ + { id: "trait2", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Fusil", sourceName: "Fusil", value: 10 }, + { id: "trait3", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Fusil", sourceName: "Fusil", value: 20 }, + { id: "trait4", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Fusil", sourceName: "Fusil", value: 30 } + ], + value: 60 + }); + assert.deepEqual(summary.equipmentTotals.map((total) => total.name), ["Défense"]); + assert.deepEqual(summary.socketTotals.map((total) => total.name), ["Attaque de feu"]); + assert.deepEqual(summary.mixedTotals.map((total) => total.name), ["Attaque"]); +}); + +test("equipment planner export remaps nested ids and socket links", () => { + const payload = createToolboxExportPayload( + { + id: "toolbox1", + name: "Build", + modules: [{ id: "module1", type: "equipmentPlanner" }] + }, + { + "toolbox1:module1": { + types: [{ id: "type1", title: "Build" }], + equipments: [{ + id: "eq1", + typeId: "type1", + name: "Casque", + characteristics: [{ id: "trait1", name: "Force", value: 1 }], + materials: [{ id: "mat1", name: "Minerai", qty: 2 }], + socketItems: [ + { id: "socket1", name: "Joyau attaque", shape: "ball", color: "yellow", bonuses: [{ id: "bonus1", name: "Attaque", value: 1 }] }, + { id: "socket2", name: "Joyau expert", shape: "jewel", color: "cyan", bonuses: [] } + ], + socketLinks: [{ id: "link1", fromSocketItemId: "socket1", toSocketItemId: "socket2" }] + }], + typeOrder: ["type1"] + } + } + ); + const moduleId = payload.toolbox.modules[0].id; + const exported = payload.modules[moduleId]; + + assert.notEqual(exported.types[0].id, "type1"); + assert.equal(exported.equipments[0].typeId, exported.types[0].id); + assert.notEqual(exported.equipments[0].socketItems[0].id, "socket1"); + assert.equal(exported.equipments[0].socketLinks[0].fromSocketItemId, exported.equipments[0].socketItems[0].id); + assert.equal(exported.equipments[0].socketLinks[0].toSocketItemId, exported.equipments[0].socketItems[1].id); +}); + test("grouped reorder helpers group flat items and complete group order", () => { const items = [ { id: "a", category: "" }, diff --git a/website/public/data/library.json b/website/public/data/library.json index 8a6a573..25a53c0 100644 --- a/website/public/data/library.json +++ b/website/public/data/library.json @@ -47,15 +47,20 @@ { "id": "mb", "type": "imageAnnotation" + }, + { + "id": "mc", + "type": "equipmentPlanner" } ], - "updatedAt": "2026-08-03T08:04:53.696Z" + "updatedAt": "2026-08-09T07:34:31.425Z", + "moduleColumns": 1 }, "modules": { "m1": { - "html": "

Obtenir Mew :



", + "html": "

Obtenir Mew :


", "text": "Obtenir Mew :\n- Obtenir la CS Coupe à l'intérieur du bateau le S.S. Anne.\n- Faire exprès de perdre un combat (tomber K.O.) pour réapparaître au Centre Pokémon sans faire partir le navire.\n- Revenir sur les quais de Carmin-sur-Mer après avoir débloqué et appris la compétence Surf.\n- Naviguer sur l'eau pour atteindre la zone cachée où repose le véhicule.", - "updatedAt": "2026-08-03T08:27:16.932Z", + "updatedAt": "2026-08-05T14:24:01.662Z", "drawingMode": "permanent", "drawings": { "strokes": [ @@ -1295,7 +1300,6 @@ "id": "z4", "label": "Boss Mondial", "type": "daily_time", - "autoRefresh": true, "time": "14:00:00", "targetAt": 1785758400000 }, @@ -1304,12 +1308,22 @@ "label": "Legion", "type": "interval", "intervalMs": 1800000, - "anchorAt": 1785746172680 + "anchorAt": 1785746172680, + "targetAt": 1785747972680, + "autoRefresh": true + }, + { + "id": "z6", + "label": "Durée", + "type": "duration", + "durationMs": 1800000, + "targetAt": 1785767200357 } ] }, "ma": { - "lastResetAt": 1785708000000, + "lastResetAt": 1786226400000, + "hideCompleted": true, "tasks": [ { "id": "a1", @@ -1339,8 +1353,6 @@ "id": "a5", "title": "Faire les boss du Sanctuaire Noir (Solo)", "type": "weekly", - "checked": true, - "checkedAt": 1785746891981, "weeklyResetDay": 0 }, { @@ -1358,7 +1370,9 @@ { "id": "a8", "title": "Ameliorer le bateau", - "type": "unique" + "type": "unique", + "checked": true, + "checkedAt": 1785789855306 } ], "categoryOrder": [ @@ -4121,6 +4135,178 @@ } ] } + }, + "mc": { + "types": [ + { + "id": "y1", + "title": "Casque", + "icon": "helmet" + } + ], + "equipments": [ + { + "id": "q1", + "typeId": "y1", + "name": "Nu Udra γ", + "icon": "helmet", + "obtain": "Avoir un certificat Alpha supreme", + "characteristics": [ + { + "id": "v1", + "name": "Défense", + "icon": "shield", + "value": 68 + }, + { + "id": "v2", + "name": "Resistance au feu", + "icon": "shield", + "value": 5 + }, + { + "id": "v3", + "name": "Resistance a l'eau", + "icon": "shield", + "value": -4 + }, + { + "id": "v4", + "name": "Resistance à la foudre", + "icon": "shield", + "value": 0 + }, + { + "id": "v5", + "name": "Resistance à la glace", + "icon": "shield", + "value": 1 + }, + { + "id": "v6", + "name": "Resistance au dragon", + "icon": "shield", + "value": 0 + } + ], + "socketItems": [ + { + "id": "j1", + "name": "Joyau antivirus", + "shape": "jewel", + "color": "violet", + "bonuses": [ + { + "id": "b1", + "name": "Antivirus", + "value": 1 + } + ] + }, + { + "id": "j2", + "name": "Joyau antivirus", + "shape": "jewel", + "color": "violet", + "bonuses": [ + { + "id": "b2", + "name": "Antivirus", + "value": 1 + } + ] + }, + { + "id": "j3", + "name": "Joyau antivirus", + "shape": "jewel", + "color": "violet", + "bonuses": [ + { + "id": "b3", + "name": "Antivirus", + "value": 1 + } + ] + } + ] + }, + { + "id": "q2", + "typeId": "y1", + "name": "Nu Udra γ", + "icon": "chest-armor", + "characteristics": [ + { + "id": "v7", + "name": "Défense", + "icon": "shield", + "value": 68 + }, + { + "id": "v8", + "name": "Resistance au feu", + "icon": "shield", + "value": 5 + }, + { + "id": "v9", + "name": "Resistance a l'eau", + "icon": "shield", + "value": -4 + }, + { + "id": "va", + "name": "Resistance à la foudre", + "icon": "shield", + "value": 0 + }, + { + "id": "vb", + "name": "Resistance à la glace", + "icon": "shield", + "value": 1 + }, + { + "id": "vc", + "name": "Resistance au dragon", + "icon": "shield", + "value": 0 + } + ], + "socketItems": [ + { + "id": "j4", + "name": "Joyau Mise a mort", + "shape": "jewel", + "color": "indigo", + "bonuses": [ + { + "id": "b4", + "name": "Mise a mort", + "value": 1 + } + ] + }, + { + "id": "j5", + "name": "Joyau Attaque", + "shape": "jewel", + "color": "red", + "bonuses": [ + { + "id": "b5", + "name": "Attaque", + "value": 2 + } + ] + } + ] + } + ], + "typeOrder": [ + "y1" + ] } } } diff --git a/website/public/data/site.json b/website/public/data/site.json index b98a0d3..7c69651 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -505,6 +505,75 @@ "removeRelationTitle": "Retirer", "emptyTasks": "Aucune tâche planifiée." }, + "equipmentPlanner": { + "equipmentIconLabel": "Type", + "typePlaceholder": "Type d'équipement", + "equipmentPlaceholder": "Nouvel équipement", + "addEquipmentButton": "Ajouter", + "summaryTitle": "Résumé", + "activeEquipmentsSingular": "équipement actif", + "activeEquipmentsPlural": "équipements actifs", + "numericTotalsTitle": "Totaux", + "equipmentTotalsTitle": "Statistiques de l'équipement", + "socketTotalsTitle": "Objets sertis", + "mixedTotalsTitle": "Totaux fusionnés", + "summaryTooltipTitle": "Provenance", + "textBonusesTitle": "Bonus", + "emptyTotals": "Aucun total numérique.", + "emptyTextBonuses": "Aucun bonus non numérique.", + "emptyEquipments": "Aucun équipement planifié.", + "activeLabel": "Actif", + "includeInSummaryLabel": "Inclure dans le résumé", + "reorderTypeTitle": "Déplacer le type", + "renameTypeTitle": "Renommer le type", + "showTypeTitle": "Afficher ce type", + "hideTypeTitle": "Réduire ce type", + "reorderEquipmentTitle": "Déplacer", + "renameEquipmentTitle": "Renommer", + "showDetailsTitle": "Afficher les détails", + "hideDetailsTitle": "Masquer les détails", + "deleteEquipmentTitle": "Supprimer", + "tabsLabel": "Ajout de caractéristiques", + "statsTab": "Stats / affixe", + "socketsTab": "Objets à sertir", + "craftTab": "Craft", + "displayStatsTitle": "Statistiques", + "displaySocketTitle": "Objets sertis", + "editModeTitle": "Mode édition", + "viewModeTitle": "Mode affichage", + "editModeButton": "Éditer", + "viewModeButton": "Affichage", + "obtainLabel": "Obtention", + "obtainPlaceholder": "Comment obtenir ou créer cet équipement", + "emptyObtain": "Aucune obtention renseignée.", + "characteristicsTitle": "Caractéristiques", + "traitIconLabel": "Icône", + "categoryPlaceholder": "Catégorie", + "traitNamePlaceholder": "Nom", + "traitValuePlaceholder": "Valeur", + "addTraitButton": "Ajouter", + "reorderTraitTitle": "Déplacer", + "reorderCategoryTitle": "Déplacer la catégorie", + "deleteTraitTitle": "Supprimer", + "emptyCharacteristics": "Aucune caractéristique.", + "materialsTitle": "Matériaux", + "materialNamePlaceholder": "Matériau", + "materialQtyLabel": "Quantité", + "addMaterialButton": "Ajouter", + "deleteMaterialTitle": "Supprimer", + "socketItemsTitle": "Objets sertis", + "emptySocketItems": "Aucun objet serti.", + "socketNamePlaceholder": "Joyau, materia...", + "socketShapeLabel": "Forme", + "socketColorLabel": "Couleur", + "addSocketButton": "Ajouter", + "bonusNamePlaceholder": "Bonus", + "addBonusButton": "Ajouter", + "deleteSocketTitle": "Supprimer", + "deleteBonusTitle": "Supprimer", + "addSocketLinkLabel": "Lier à", + "deleteSocketLinkTitle": "Retirer le lien" + }, "imageAnnotation": { "addImage": "Ajouter une image", "replaceImage": "Remplacer l'image", diff --git a/website/public/static/icons/ball.svg b/website/public/static/icons/ball.svg new file mode 100644 index 0000000..ea9546f --- /dev/null +++ b/website/public/static/icons/ball.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/website/public/static/icons/boot.svg b/website/public/static/icons/boot.svg new file mode 100644 index 0000000..2bbf931 --- /dev/null +++ b/website/public/static/icons/boot.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/public/static/icons/chest-armor.svg b/website/public/static/icons/chest-armor.svg new file mode 100644 index 0000000..1f6faea --- /dev/null +++ b/website/public/static/icons/chest-armor.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/website/public/static/icons/earring.svg b/website/public/static/icons/earring.svg new file mode 100644 index 0000000..a50b485 --- /dev/null +++ b/website/public/static/icons/earring.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/public/static/icons/glove.svg b/website/public/static/icons/glove.svg new file mode 100644 index 0000000..69f2ccd --- /dev/null +++ b/website/public/static/icons/glove.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/public/static/icons/hammer.svg b/website/public/static/icons/hammer.svg new file mode 100644 index 0000000..a42a1fa --- /dev/null +++ b/website/public/static/icons/hammer.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/public/static/icons/helmet.svg b/website/public/static/icons/helmet.svg new file mode 100644 index 0000000..d062564 --- /dev/null +++ b/website/public/static/icons/helmet.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/public/static/icons/jewel.svg b/website/public/static/icons/jewel.svg new file mode 100644 index 0000000..b3cf428 --- /dev/null +++ b/website/public/static/icons/jewel.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/website/public/static/icons/necklace.svg b/website/public/static/icons/necklace.svg new file mode 100644 index 0000000..71ad225 --- /dev/null +++ b/website/public/static/icons/necklace.svg @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/website/public/static/icons/ring.svg b/website/public/static/icons/ring.svg new file mode 100644 index 0000000..03a923e --- /dev/null +++ b/website/public/static/icons/ring.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/public/static/icons/sword.svg b/website/public/static/icons/sword.svg new file mode 100644 index 0000000..4a3359e --- /dev/null +++ b/website/public/static/icons/sword.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/website/src/components/ImageViewer.jsx b/website/src/components/ImageViewer.jsx index 145aa16..02d6b00 100644 --- a/website/src/components/ImageViewer.jsx +++ b/website/src/components/ImageViewer.jsx @@ -4,6 +4,7 @@ import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.j import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx"; import { lockBodyScroll } from "../utils/bodyScrollLock.js"; import { Icon } from "./Icon.jsx"; +import { Tooltip } from "./Tooltip.jsx"; const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"]; const DRAWING_WIDTHS = [2, 4, 8, 12]; @@ -188,14 +189,16 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis. /> )} {viewerMarkers.map((marker, index) => ( - {index + 1} - + ))} diff --git a/website/src/components/Tooltip.jsx b/website/src/components/Tooltip.jsx new file mode 100644 index 0000000..690a0b6 --- /dev/null +++ b/website/src/components/Tooltip.jsx @@ -0,0 +1,70 @@ +// Rôle : fournit une infobulle accessible et réutilisable pour les contrôles et données denses. +import { useEffect, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +export function Tooltip({ as: Component = "span", children, content, className = "", tooltipClassName = "", position = "top", ...props }) { + const tooltipId = useId(); + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + const [style, setStyle] = useState({}); + + function placeTooltip() { + const anchor = anchorRef.current; + if (!anchor) return; + const rect = anchor.getBoundingClientRect(); + if (position === "top-start") { + setStyle({ top: `${Math.max(8, rect.top - 8)}px`, left: `${Math.max(8, rect.left)}px` }); + return; + } + setStyle({ top: `${Math.max(8, rect.top - 8)}px`, left: `${Math.min(window.innerWidth - 8, Math.max(8, rect.left + rect.width / 2))}px` }); + } + + useEffect(() => { + if (!open) return undefined; + placeTooltip(); + window.addEventListener("resize", placeTooltip); + window.addEventListener("scroll", placeTooltip, true); + return () => { + window.removeEventListener("resize", placeTooltip); + window.removeEventListener("scroll", placeTooltip, true); + }; + }, [open, position]); + + if (!content) return {children}; + + return ( + { + placeTooltip(); + setOpen(true); + props.onMouseEnter?.(event); + }} + onMouseLeave={(event) => { + setOpen(false); + props.onMouseLeave?.(event); + }} + onFocus={(event) => { + placeTooltip(); + setOpen(true); + props.onFocus?.(event); + }} + onBlur={(event) => { + setOpen(false); + props.onBlur?.(event); + }} + > + {children} + {open && createPortal( + + {content} + , + document.body + )} + + ); +} diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index 09d6564..ec76aa0 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -17,12 +17,14 @@ import { normalizeChecklistData, normalizeCombosData, normalizeCountersData, + normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeLinksData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, + summarizeEquipmentPlannerData, normalizeUrl, uid } from "./storage/toolboxStorage.js"; @@ -152,12 +154,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto normalizeCombosData, normalizeLinksData, normalizeCountersData, + normalizeEquipmentPlannerData, normalizeCalculatorData, normalizeImageAnnotationData, normalizeNotepadData, normalizeTableData, normalizeTimerData, normalizeTaskPlannerData, + summarizeEquipmentPlannerData, normalizeUrl, hostnameFromUrl, copyText, diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx new file mode 100644 index 0000000..0cdfec1 --- /dev/null +++ b/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx @@ -0,0 +1,1057 @@ +// Rôle : fournit l'outil de planification d'équipements, sertissages et résumé de bonus. +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Icon } from "../../../components/Icon.jsx"; +import { Tooltip } from "../../../components/Tooltip.jsx"; +import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; + +const SOCKET_SHAPES = ["ball", "jewel"]; +const SOCKET_COLORS = ["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"]; +const SOCKET_COLOR_VALUES = { + red: "#ef4444", + orange: "#f97316", + amber: "#f59e0b", + yellow: "var(--color-accent-gold)", + lime: "#84cc16", + green: "#22c55e", + teal: "#14b8a6", + cyan: "var(--color-accent-cyan)", + blue: "#3b82f6", + indigo: "var(--color-accent-indigo)", + violet: "#7c3aed", + purple: "var(--color-primary)", + pink: "var(--color-accent-pink)", + rose: "#f43f5e", + white: "#f8fafc", + gray: "#64748b", + black: "#020617" +}; +const EQUIPMENT_ICONS = [ + { icon: "helmet", label: "Casque" }, + { icon: "chest-armor", label: "Chest" }, + { icon: "glove", label: "Gants" }, + { icon: "boot", label: "Bottes" }, + { icon: "necklace", label: "Collier" }, + { icon: "earring", label: "Earring" }, + { icon: "ring", label: "Anneau" }, + { icon: "sword", label: "Épée" }, + { icon: "gun", label: "Gun" }, + { icon: "shield", label: "Bouclier" } +]; +const TRAIT_ICONS = ["sword", "shield"]; +const DETAIL_TABS = ["stats", "sockets", "craft"]; + +function getEquipmentIconLabel(icon) { + return EQUIPMENT_ICONS.find((item) => item.icon === icon)?.label || "Équipement"; +} + +function getCharacteristicCategories(equipment) { + const categories = equipment.characteristics.map((trait) => trait.category).filter(Boolean); + return [ + ...equipment.categoryOrder.filter((category) => categories.includes(category)), + ...categories.filter((category, index) => categories.indexOf(category) === index && !equipment.categoryOrder.includes(category)) + ]; +} + +function setItemCategory(item, category) { + const nextItem = { ...item }; + if (category) nextItem.category = category; + else delete nextItem.category; + return nextItem; +} + +function formatValue(value) { + if (typeof value === "number") return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000); + return String(value || ""); +} + +function createLocalId(prefix) { + return globalThis.crypto?.randomUUID?.() || `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function getSocketItemName(equipment, socketItemId) { + return equipment.socketItems.find((socketItem) => socketItem.id === socketItemId)?.name || ""; +} + +function SocketShapeIcon({ shape, color = "gray", className = "" }) { + return ( + + ); +} + +function SummaryTotalIcon({ total }) { + if (SOCKET_SHAPES.includes(total.icon)) return ; + return ; +} + +export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) { + const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] })); + const summary = useMemo(() => context.summarizeEquipmentPlannerData(data), [context, data]); + const textContent = context.moduleText?.equipmentPlanner || {}; + const [equipmentIcon, setEquipmentIcon] = useState("helmet"); + const [equipmentName, setEquipmentName] = useState(""); + const { + itemReorder, + groupReorder, + getItemProps, + getGroupProps, + getGroupBoundaryProps, + isItemDragging, + isItemDropTarget, + isGroupDragging, + isGroupDropTarget, + isBoundaryDropTarget, + getDropPlacement, + shouldShowGroupBoundaries + } = useGroupedReorder({ + namespace: `equipment-planner-${moduleId}`, + items: data.equipments, + getItemId: (equipment) => equipment.id, + onItemMove: (operation) => save({ ...data, equipments: moveItem(data.equipments, operation.sourceId, operation.targetId, operation.placement) }) + }); + const reorder = { + itemReorder, + groupReorder, + getItemProps, + getGroupProps, + getGroupBoundaryProps, + isItemDragging, + isItemDropTarget, + isGroupDragging, + isGroupDropTarget, + isBoundaryDropTarget, + getDropPlacement, + shouldShowGroupBoundaries + }; + + function save(nextData) { + context.setModuleData(toolboxId, moduleId, nextData, "equipmentPlanner"); + } + + function addEquipment(event) { + event.preventDefault(); + const cleanName = equipmentName.trim(); + if (!cleanName) return; + const type = data.types[0] || { id: context.uid("equipmentType"), title: "Équipements", icon: "shield", collapsed: false }; + const nextData = { + ...data, + types: data.types.length ? data.types : [type], + typeOrder: data.typeOrder.length ? data.typeOrder : [type.id], + equipments: [ + ...data.equipments, + { + id: context.uid("equipment"), + typeId: type.id, + name: cleanName, + icon: equipmentIcon, + active: true, + obtain: "", + characteristics: [], + socketItems: [], + socketLinks: [], + materials: [], + categoryOrder: [], + collapsedCategories: [] + } + ] + }; + save(nextData); + setEquipmentName(""); + } + + function updateEquipment(equipmentId, updater) { + save({ ...data, equipments: data.equipments.map((equipment) => equipment.id === equipmentId ? updater(equipment) : equipment) }); + } + + function deleteEquipment(equipmentId) { + const nextEquipments = data.equipments.filter((equipment) => equipment.id !== equipmentId); + const usedTypeIds = new Set(nextEquipments.map((equipment) => equipment.typeId)); + save({ + ...data, + equipments: nextEquipments, + types: data.types.filter((type) => usedTypeIds.has(type.id)), + typeOrder: data.typeOrder.filter((typeId) => usedTypeIds.has(typeId)) + }); + } + + return ( +
+ {editing && ( +
+
+ + setEquipmentName(event.target.value)} placeholder={textContent.equipmentPlaceholder || "Nouvel équipement"} /> + + +
+ )} + + + +
    + {data.equipments.length ? data.equipments.map((equipment) => ( + updateEquipment(equipment.id, updater)} + onDelete={() => deleteEquipment(equipment.id)} + /> + )) : ( +
  • {textContent.emptyEquipments || "Aucun équipement planifié."}
  • + )} +
+
+ ); +} + +function EquipmentPlannerSummary({ summary, textContent }) { + const hasSourceTotals = summary.equipmentTotals.length || summary.socketTotals.length; + const sourceColumnCount = [summary.equipmentTotals.length, summary.socketTotals.length].filter(Boolean).length; + return ( +
+
+

{textContent.summaryTitle || "Résumé"}

+ {summary.activeEquipmentCount} {summary.activeEquipmentCount > 1 ? textContent.activeEquipmentsPlural || "équipements actifs" : textContent.activeEquipmentsSingular || "équipement actif"} +
+
+ {hasSourceTotals ? ( +
+ {summary.equipmentTotals.length ? : null} + {summary.socketTotals.length ? : null} +
+ ) : null} + {summary.mixedTotals.length ? : null} + {!summary.totals.length ? ( +
+ {textContent.numericTotalsTitle || "Totaux"} +

{textContent.emptyTotals || "Aucun total numérique."}

+
+ ) : null} + {summary.textBonuses.length ? ( +
+ {textContent.textBonusesTitle || "Bonus"} +
    + {summary.textBonuses.map((bonus) => ( +
  • + {bonus.category ? `${bonus.category} · ` : ""}{bonus.name} + {bonus.source}{bonus.value ? ` · ${bonus.value}` : ""} +
  • + ))} +
+
+ ) : null} +
+
+ ); +} + +function SummaryTotalsPanel({ title, totals, textContent }) { + return ( +
+ {title} +
    + {totals.map((total) => ( + } + key={total.name} + position="top-start" + > + + {total.name} + {formatValue(total.value)} + + ))} +
+
+ ); +} + +function SummaryTotalTooltip({ total, textContent }) { + return ( + + {textContent.summaryTooltipTitle || "Provenance"} + + {(total.contributions || []).map((contribution) => ( + + + {contribution.sourceName || contribution.equipmentName} + {formatValue(contribution.value)} + + ))} + + + ); +} + +function IconChoiceDropdown({ className = "", label, value, options, onChange }) { + const selected = options.find((option) => option.icon === value) || options[0]; + return ( + ( + + )} + > + {({ close }) => ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} + +function ShapeChoiceDropdown({ label, value, onChange }) { + return ( + ( + + )} + > + {({ close }) => ( +
+ {SOCKET_SHAPES.map((shape) => ( + + ))} +
+ )} +
+ ); +} + +function ColorChoiceDropdown({ label, value, onChange }) { + return ( + ( + + )} + > + {({ close }) => ( +
+ {SOCKET_COLORS.map((color) => ( + + ))} +
+ )} +
+ ); +} + +function FloatingPicker({ className = "", label, columns = 5, renderTrigger, children }) { + const [open, setOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState({}); + const rootRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + if (!open) return undefined; + + function placeMenu() { + const trigger = rootRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + const width = Math.max(0, Math.min(220, window.innerWidth - 16)); + const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8)); + setMenuStyle({ + top: `${Math.min(rect.bottom + 6, window.innerHeight - 8)}px`, + left: `${left}px`, + maxWidth: `${width}px` + }); + } + + function handlePointerDown(event) { + if (rootRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) return; + setOpen(false); + } + + function handleKeyDown(event) { + if (event.key === "Escape") setOpen(false); + } + + placeMenu(); + window.addEventListener("resize", placeMenu); + window.addEventListener("scroll", placeMenu, true); + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("resize", placeMenu); + window.removeEventListener("scroll", placeMenu, true); + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + function close() { + setOpen(false); + } + + function toggle() { + setOpen((value) => !value); + } + + return ( +
+ {renderTrigger({ open, toggle, close })} + {open && createPortal( +
+ {children({ close })} +
, + document.body + )} +
+ ); +} + +function EquipmentItem({ equipment, reorder, textContent, onUpdate, onDelete }) { + const [activeTab, setActiveTab] = useState("stats"); + const [detailEditing, setDetailEditing] = useState(false); + const nameEdit = useInlineEdit({ + value: equipment.name, + onCommit: (name) => onUpdate((current) => ({ ...current, name: name || current.name })), + blurOnEscape: true + }); + const className = [ + "equipment-planner-item", + equipment.active ? "is-active" : "is-inactive", + reorder.isItemDragging(equipment.id) ? "is-dragging" : "", + reorder.isItemDropTarget(equipment.id) ? "is-drop-target" : "", + reorder.getDropPlacement("item", equipment.id) === "after" ? "drop-after" : "" + ].filter(Boolean).join(" "); + + return ( +
  • +
    + +
    + + {equipment.name} +
    + + + +
    +
    + {detailEditing ? ( + <> +
    + {DETAIL_TABS.map((tab) => ( + + ))} +
    + {activeTab === "stats" && } + {activeTab === "sockets" && } + {activeTab === "craft" && ( +
    +