diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index c06eb46..4ebfd03 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -8,7 +8,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Déclarer l'outil dans `website/src/features/toolboxes/modules/index.jsx`. - Ajouter les textes nécessaires dans `website/public/data/site.json`. - Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`. -- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/toolboxStorage.js`. +- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`. - Mettre à jour `docs/STORAGE_SCHEMA.md`. - Vérifier l'import et l'export si l'outil stocke des données. - Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip dans le panneau d'ajout. diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 500c63a..543207e 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -4,6 +4,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { SOCKET_SHAPES, formatValue, getSocketItemName, setItemCategory } from "../website/src/features/toolboxes/modules/equipmentPlannerOptions.js"; import { applyDueResets, getTaskParentMap, getTaskTreeIds, moveTask } from "../website/src/features/toolboxes/modules/taskPlannerUtils.js"; +import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js"; +import { getBoundsFromStartAndSize, getSelectionBounds, moveSelectionCells, parseTsvBlock, pasteCellBlock } from "../website/src/features/toolboxes/modules/tableUtils.js"; +import { canToggleAutoRefresh, getCountdownValidationError, isAlertGuarded } from "../website/src/features/toolboxes/modules/timerOptions.js"; test("toolbox storage, cards and pages are wired", async () => { const source = await readFile("website/src/main.jsx", "utf8"); @@ -11,6 +14,10 @@ test("toolbox storage, cards and pages are wired", async () => { const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8"); const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "utf8"); const toolboxStorage = await readFile("website/src/features/toolboxes/storage/toolboxStorage.js", "utf8"); + const storageCompactorRegistry = await readFile("website/src/features/toolboxes/storage/modules/index.js", "utf8"); + const checklistStorage = await readFile("website/src/features/toolboxes/storage/modules/checklist.js", "utf8"); + const timerStorage = await readFile("website/src/features/toolboxes/storage/modules/timer.js", "utf8"); + const equipmentStorage = await readFile("website/src/features/toolboxes/storage/modules/equipmentPlanner.js", "utf8"); const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8"); const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8"); const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); @@ -31,25 +38,27 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/); assert.match(toolboxStorage, /export function normalizeToolboxIcon/); assert.match(toolboxStorage, /export function createGlobalExportPayload/); - assert.match(toolboxStorage, /qtyTarget/); - assert.match(toolboxStorage, /qtyCurrent/); - assert.match(toolboxStorage, /hideCompletedSections/); - assert.match(toolboxStorage, /hideCompletedSectionsFully/); - assert.match(toolboxStorage, /hideWhenComplete/); - assert.match(toolboxStorage, /collapsed/); + assert.match(checklistStorage, /qtyTarget/); + assert.match(checklistStorage, /qtyCurrent/); + assert.match(checklistStorage, /hideCompletedSections/); + assert.match(checklistStorage, /hideCompletedSectionsFully/); + assert.match(checklistStorage, /hideWhenComplete/); + assert.match(equipmentStorage, /collapsed/); assert.match(toolboxStorage, /module\.scrollable/); - assert.match(toolboxStorage, /export function normalizeLinksData/); - assert.match(toolboxStorage, /export function normalizeCountersData/); - assert.match(toolboxStorage, /export function normalizeCombosData/); - assert.match(toolboxStorage, /export function normalizeCalculatorData/); - 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, /normalizeLinksData/); + assert.match(toolboxStorage, /normalizeCountersData/); + assert.match(toolboxStorage, /normalizeCombosData/); + assert.match(toolboxStorage, /normalizeCalculatorData/); + assert.match(toolboxStorage, /normalizeTableData/); + assert.match(toolboxStorage, /normalizeTimerData/); + assert.match(toolboxStorage, /normalizeTaskPlannerData/); + assert.match(toolboxStorage, /normalizeEquipmentPlannerData/); + assert.match(toolboxStorage, /summarizeEquipmentPlannerData/); + assert.match(toolboxStorage, /normalizeImageAnnotationData/); assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/); - assert.match(toolboxStorage, /scrollResults/); + assert.match(storageCompactorRegistry, /export const MODULE_DATA_COMPACTORS/); + assert.match(storageCompactorRegistry, /timer: compactTimerDataForStorage/); + assert.match(timerStorage, /scrollResults/); assert.match(toolboxCard, /DEFAULT_TOOLBOX_ICON/); assert.match(toolboxCard, /export function ToolboxCard/); assert.match(toolboxCard, /export function ToolboxIconPicker/); @@ -108,14 +117,24 @@ test("toolbox module registry and modules expose expected behavior", async () => const moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8"); const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); const combosModule = await readFile("website/src/features/toolboxes/modules/CombosModule.jsx", "utf8"); + const combosParts = await readFile("website/src/features/toolboxes/modules/CombosParts.jsx", "utf8"); + const combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8"); const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8"); + const tableGridParts = await readFile("website/src/features/toolboxes/modules/TableGridParts.jsx", "utf8"); + const tableUtils = await readFile("website/src/features/toolboxes/modules/tableUtils.js", "utf8"); 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 timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8"); + const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8"); const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8"); const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8"); const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8"); const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8"); + const equipmentPlannerEditors = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx", "utf8"); + const equipmentPlannerItem = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerItem.jsx", "utf8"); + const equipmentPlannerPickers = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerPickers.jsx", "utf8"); + const equipmentPlannerSummary = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerSummary.jsx", "utf8"); const equipmentPlannerOptions = await readFile("website/src/features/toolboxes/modules/equipmentPlannerOptions.js", "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"); @@ -170,26 +189,26 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(notepadModule, /TextExchangeActions/); assert.match(notepadModule, /type="notepad"/); assert.match(combosModule, /export function CombosModule/); - assert.match(combosModule, /keyboardMouse/); - assert.match(combosModule, /Nintendo 64/); - assert.match(combosModule, /ComboInputToken/); - assert.match(combosModule, /ComboSequence/); + assert.match(combosUtils, /keyboardMouse/); + assert.match(combosUtils, /Nintendo 64/); + assert.match(combosParts, /export function ComboInputToken/); + assert.match(combosParts, /export function ComboSequence/); assert.match(combosModule, /keyboardLayout/); assert.match(combosModule, /simultaneousMode/); - assert.match(combosModule, /SPECIAL_INPUT_GROUP/); - assert.match(combosModule, /grab/); - assert.match(combosModule, /protect/); - assert.match(combosModule, /shoot/); + assert.match(combosUtils, /SPECIAL_INPUT_GROUP/); + assert.match(combosUtils, /grab/); + assert.match(combosUtils, /protect/); + assert.match(combosUtils, /shoot/); assert.match(combosModule, /holdMs/); assert.match(combosModule, /toggleSelectedInputHold/); assert.match(combosModule, /combo-hold-field/); - assert.match(combosModule, /useInlineEdit/); + assert.match(combosParts, /useInlineEdit/); assert.match(combosModule, /useGroupedReorder/); assert.match(combosModule, /reorderFeatures/); assert.match(combosModule, /namespace: `combos-\$\{moduleId\}`/); assert.match(combosModule, /getGroupBoundaryProps/); assert.match(combosModule, /combos-category-boundary-drop-zone/); - assert.match(combosModule, /function setComboItemCategory/); + assert.match(combosUtils, /function setComboItemCategory/); assert.match(combosModule, /setItemGroup: setComboItemCategory/); assert.doesNotMatch(combosModule, /canMoveItem:/); assert.doesNotMatch(combosModule, /canMoveGroup:/); @@ -228,11 +247,11 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(tableModule, /draggingSelectionRef/); assert.match(tableModule, /movingSelectionRef/); assert.match(tableModule, /copiedSelectionRef/); - assert.match(tableModule, /moveSelectionCells/); - assert.match(tableModule, /pasteCellBlock/); - assert.match(tableModule, /parseTsvBlock/); - assert.match(tableModule, /is-move-target/); - assert.match(tableModule, /isMultiCellSelection/); + assert.match(tableUtils, /moveSelectionCells/); + assert.match(tableUtils, /pasteCellBlock/); + assert.match(tableUtils, /parseTsvBlock/); + assert.match(tableGridParts, /is-move-target/); + assert.match(tableUtils, /isMultiCellSelection/); assert.match(tableModule, /handleCellKeyDown/); assert.match(tableModule, /clearSelection/); assert.match(tableModule, /event\.key === "Delete"/); @@ -240,10 +259,10 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(tableModule, /ArrowDown/); assert.match(tableModule, /ArrowLeft/); assert.match(tableModule, /ArrowRight/); - assert.match(tableModule, /function HeaderLabelInput/); - assert.match(tableModule, /useInlineEdit/); + assert.match(tableGridParts, /export function HeaderLabelInput/); + assert.match(tableGridParts, /useInlineEdit/); assert.match(tableModule, /TextExchangeActions/); - assert.match(tableModule, /event\.target\.select/); + assert.match(tableGridParts, /event\.target\.select/); assert.match(tableFormulaEngine, /export function evaluateTableCell/); assert.doesNotMatch(tableFormulaEngine, /Function\(/); assert.doesNotMatch(tableFormulaEngine, /\beval\(/); @@ -251,7 +270,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(timerModule, /Tabs/); assert.match(timerModule, /stopwatch/); assert.match(timerModule, /countdown/); - assert.match(timerModule, /useInlineEdit/); + assert.match(timerParts, /useInlineEdit/); assert.match(timerModule, /useDraftForm/); assert.match(timerModule, /normalizeTimerData/); assert.match(timerModule, /TextExchangeActions/); @@ -277,11 +296,16 @@ test("toolbox module registry and modules expose expected behavior", async () => 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, /EquipmentItem/); + assert.match(equipmentPlannerModule, /EquipmentPlannerSummary/); + assert.match(equipmentPlannerEditors, /socketItems/); + assert.match(equipmentPlannerEditors, /socketLinks/); assert.match(equipmentPlannerModule, /useGroupedReorder/); - assert.match(equipmentPlannerModule, /reorderFeatures/); + assert.match(equipmentPlannerEditors, /reorderFeatures/); assert.match(equipmentPlannerModule, /TextExchangeActions/); + assert.match(equipmentPlannerItem, /export function EquipmentItem/); + assert.match(equipmentPlannerPickers, /export function IconChoiceDropdown/); + assert.match(equipmentPlannerSummary, /export function EquipmentPlannerSummary/); assert.match(equipmentPlannerOptions, /export const EQUIPMENT_ICONS/); assert.match(equipmentPlannerOptions, /export function formatValue/); assert.match(checklistModule, /export function ChecklistModule/); @@ -350,7 +374,11 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(timerModule, /useGroupedReorder/); assert.match(timerModule, /reorderEnabled={!data\.sortResults}/); assert.match(timerModule, /parentId=\{moduleId\}/); - assert.match(timerModule, /timer-drag-handle/); + assert.match(timerParts, /timer-drag-handle/); + assert.match(timerParts, /export function StopwatchControls/); + assert.match(timerParts, /export function CountdownControls/); + assert.match(timerParts, /export function CountdownList/); + assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/); assert.match(textImport, /export function parseColonImportLines/); assert.match(textImport, /export function exportModuleText/); assert.match(textImport, /export function importModuleText/); @@ -396,3 +424,46 @@ test("equipment planner options expose stable formatting and socket helpers", () assert.deepEqual(setItemCategory({ id: "item", category: "DPS" }, ""), { id: "item" }); assert.equal(getSocketItemName({ socketItems: [{ id: "gem", name: "Ruby" }] }, "gem"), "Ruby"); }); + +test("combos helpers keep labels, hold parsing and custom keys stable", () => { + assert.deepEqual(createInput("button", "a", 1500), { kind: "button", value: "a", holdMs: 1500 }); + assert.deepEqual(createInput("button", "a"), { kind: "button", value: "a" }); + assert.equal(getInputLabel({ kind: "key", value: "q" }), "Q"); + assert.equal(getInputLabel({ kind: "mouse", value: "left" }), "Clic G"); + assert.equal(parseHoldSeconds("120s"), 99); + assert.equal(parseHoldSeconds("abc"), 0); + assert.equal(normalizeCustomKey(" Ctrl Alt Shift "), "Ctrl Alt Shift"); +}); + +test("table helpers keep selection, movement and TSV behavior stable", () => { + const selection = { + anchor: { rowIndex: 2, columnIndex: 3 }, + focus: { rowIndex: 1, columnIndex: 1 } + }; + assert.deepEqual(getSelectionBounds(selection), { + startRow: 1, + endRow: 2, + startColumn: 1, + endColumn: 3 + }); + assert.deepEqual(parseTsvBlock("A\tB\nC\tD"), { + height: 2, + width: 2, + values: [["A", "B"], ["C", "D"]] + }); + + const moved = moveSelectionCells({ A1: "a", B1: "b", A2: "c" }, getBoundsFromStartAndSize(0, 0, 1, 2), getBoundsFromStartAndSize(1, 1, 1, 2)); + assert.deepEqual(moved, { A2: "c", B2: "a", C2: "b" }); + + const pasted = pasteCellBlock({}, parseTsvBlock("A\t\nC\tD"), 0, 0, 2, 2); + assert.deepEqual(pasted.cells, { A1: "A", A2: "C", B2: "D" }); +}); + +test("timer options keep alert guards and countdown validation stable", () => { + assert.equal(isAlertGuarded({ type: "interval", intervalMs: 60_000 }), true); + assert.equal(isAlertGuarded({ type: "interval", intervalMs: 600_000 }), false); + assert.equal(canToggleAutoRefresh({ type: "daily_time" }), true); + assert.equal(canToggleAutoRefresh({ type: "interval", intervalMs: 60_000 }), false); + assert.equal(getCountdownValidationError("duration", { duration: { hours: "", minutes: "", seconds: "" } }, {}), "Valeur requise."); + assert.equal(getCountdownValidationError("daily_time", { time: { hours: "12", minutes: "30", seconds: "" } }, {}), ""); +}); diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx index 47f2644..89d7498 100644 --- a/website/src/features/toolboxes/modules/CombosModule.jsx +++ b/website/src/features/toolboxes/modules/CombosModule.jsx @@ -1,333 +1,25 @@ // Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte. import { useMemo, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; -import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { applyGroupedReorderOperation, getGroupedEntries, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; - -const DEVICE_OPTIONS = [ - { value: "playstation", label: "PlayStation" }, - { value: "xbox", label: "Xbox" }, - { value: "switch", label: "Switch" }, - { value: "n64", label: "Nintendo 64" }, - { value: "keyboardMouse", label: "Clavier/Souris" } -]; - -const SPECIAL_INPUT_GROUP = { title: "Spéciale", column: "actions", inputs: [["button", "fist"], ["button", "foot"], ["button", "grab"], ["button", "protect"], ["button", "shoot"]] }; - -const DEVICE_GROUPS = { - playstation: [ - { title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] }, - { title: "Actions", column: "actions", inputs: [["button", "triangle"], ["button", "circle"], ["button", "cross"], ["button", "square"], ["button", "l1"], ["button", "r1"], ["button", "l2"], ["button", "r2"]] }, - SPECIAL_INPUT_GROUP, - { title: "Système", inputs: [["button", "options"], ["button", "share"], ["button", "l3"], ["button", "r3"]] } - ], - xbox: [ - { title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] }, - { title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "lb"], ["button", "rb"], ["button", "lt"], ["button", "rt"]] }, - SPECIAL_INPUT_GROUP, - { title: "Système", inputs: [["button", "menu"], ["button", "view"], ["button", "ls"], ["button", "rs"]] } - ], - switch: [ - { title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] }, - { title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "l"], ["button", "r"], ["button", "zl"], ["button", "zr"]] }, - SPECIAL_INPUT_GROUP, - { title: "Système", inputs: [["button", "plus"], ["button", "minus"], ["button", "ls"], ["button", "rs"]] } - ], - n64: [ - { title: "Stick", kind: "directions", column: "movement", inputs: [["direction", "stick-up-left"], ["direction", "stick-up"], ["direction", "stick-up-right"], ["direction", "stick-left"], null, ["direction", "stick-right"], ["direction", "stick-down-left"], ["direction", "stick-down"], ["direction", "stick-down-right"]] }, - { title: "D-Pad", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] }, - { title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "z"], ["button", "start"], ["button", "l"], ["button", "r"]] }, - SPECIAL_INPUT_GROUP, - { title: "C-buttons", kind: "directions", inputs: [["button", "c-up"], ["button", "c-down"], ["button", "c-left"], ["button", "c-right"]] } - ] -}; - -const KEYBOARD_GROUPS = { - azerty: [ - { title: "ZQSD", kind: "directions", column: "movement", inputs: [null, ["key", "z"], null, ["key", "q"], ["key", "s"], ["key", "d"], null, null, null] }, - { title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] }, - { title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "a"], ["key", "r"], ["key", "f"]] }, - SPECIAL_INPUT_GROUP, - { title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] } - ], - qwerty: [ - { title: "WASD", kind: "directions", column: "movement", inputs: [null, ["key", "w"], null, ["key", "a"], ["key", "s"], ["key", "d"], null, null, null] }, - { title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] }, - { title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "q"], ["key", "r"], ["key", "f"]] }, - SPECIAL_INPUT_GROUP, - { title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] } - ] -}; - -const INPUT_LABELS = { - triangle: "△", - circle: "○", - cross: "×", - square: "□", - options: "Options", - share: "Share", - up: "↑", - down: "↓", - left: "←", - right: "→", - "up-left": "↖", - "up-right": "↗", - "down-left": "↙", - "down-right": "↘", - "stick-up": "↑", - "stick-down": "↓", - "stick-left": "←", - "stick-right": "→", - "stick-up-left": "↖", - "stick-up-right": "↗", - "stick-down-left": "↙", - "stick-down-right": "↘", - plus: "+", - minus: "-", - "c-up": "C↑", - "c-down": "C↓", - "c-left": "C←", - "c-right": "C→", - fist: "Poing", - foot: "Pied", - grab: "Grab", - protect: "Protect", - shoot: "Shoot", - space: "Espace" -}; - -const INPUT_ICONS = { - fist: "fist", - foot: "foot", - grab: "grab", - protect: "shield", - shoot: "gun" -}; - -const MOUSE_LABELS = { - left: "Clic G", - right: "Clic D", - middle: "Molette", - "wheel-up": "Molette ↑", - "wheel-down": "Molette ↓" -}; - -function getInputLabel(input) { - const value = String(input?.value || ""); - if (input?.kind === "key" && value.length === 1) return value.toUpperCase(); - if (input?.kind === "mouse") return MOUSE_LABELS[value] || value; - return INPUT_LABELS[value] || value.toUpperCase(); -} - -function inputKey(input) { - return `${input.kind}:${input.value}:${input.hold ? "hold" : ""}:${input.holdMs || 0}`; -} - -function createInput(kind, value, holdMs = 0) { - const input = { kind, value }; - if (holdMs > 0) input.holdMs = holdMs; - return input; -} - -function getHoldSeconds(input) { - return Math.round((Number(input?.holdMs) || 0) / 1000); -} - -function formatHoldLabel(input) { - const seconds = getHoldSeconds(input); - if (!seconds) return input?.hold ? "hold" : ""; - return `${seconds}s`; -} - -function parseHoldSeconds(value) { - const seconds = Number.parseInt(String(value || "").replace(/[^\d]/g, ""), 10); - if (!Number.isInteger(seconds) || seconds <= 0) return 0; - return Math.min(99, seconds); -} - -function isDirectionInput(input) { - return input?.kind === "direction"; -} - -function createCombo(context, id, name, category, inputs, fallbackName, device) { - const combo = { - id: id || context.uid("combo"), - name: name.trim() || fallbackName, - device, - inputs: inputs.map((step) => step.map((input) => ({ ...input }))).filter((step) => step.length) - }; - if (category.trim()) combo.category = category.trim(); - return combo; -} - -function normalizeCustomKey(value) { - return String(value || "") - .trim() - .replace(/\s+/g, " ") - .slice(0, 16); -} - -function getPaletteGroups(device, keyboardLayout) { - return device === "keyboardMouse" ? KEYBOARD_GROUPS[keyboardLayout] : DEVICE_GROUPS[device] || DEVICE_GROUPS.playstation; -} - -function getPaletteColumns(groups) { - return [ - groups.filter((group) => group.column === "movement"), - groups.filter((group) => group.column !== "movement") - ]; -} - -function getComboCategory(combo) { - return String(combo?.category || "").trim(); -} - -function setComboItemCategory(combo, category) { - const nextCombo = { ...combo }; - if (category) nextCombo.category = category; - else delete nextCombo.category; - return nextCombo; -} - - -function ComboInputToken({ input, device, palette = false, onClick, onDragStart }) { - const label = getInputLabel(input); - const iconName = INPUT_ICONS[input.value]; - const holdLabel = !palette ? formatHoldLabel(input) : ""; - const classNames = [ - "combo-input-token", - `combo-input-${input.kind}`, - iconName ? "combo-input-icon" : "", - holdLabel ? "has-hold" : "", - input.kind === "key" && label.length === 1 ? "combo-input-short" : "", - `combo-device-${device}`, - `combo-value-${String(input.value || "").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}` - ].filter(Boolean).join(" "); - - if (palette) { - return ( - - ); - } - - return ( - - {iconName ? : label} - {holdLabel && {holdLabel}} - - ); -} - -function ComboStep({ step, stepIndex, device, active = false, selectedInputIndex = -1, onClick, onSelectInput, onDropInput, onDragInput }) { - const className = [ - "combo-step-inputs", - step.length > 1 ? "is-simultaneous" : "", - step.length <= 1 ? "is-single" : "", - active ? "is-active" : "" - ].filter(Boolean).join(" "); - const content = step.length ? step.map((input, inputIndex) => ( - - {inputIndex > 0 && } - { - event.stopPropagation(); - onSelectInput(stepIndex, inputIndex); - } : null} - onDragStart={onDragInput ? (event) => onDragInput(event, stepIndex, inputIndex, input) : null} - /> - - )) : Étape vide; - - if (onClick) { - return ( - - ); - } - - return {content}; -} - -function ComboSequence({ inputs, device, emptyLabel, activeStepIndex = -1, selectedInput, onSelectStep, onSelectInput, onDropInput, onDropNewStep, onDragInput }) { - const visibleInputs = inputs.map((step, index) => ({ step, index })).filter(({ step }) => step.length || onSelectStep); - if (!visibleInputs.length) return {emptyLabel}; - return ( -
{ - if (!onDropNewStep) return; - event.preventDefault(); - }} - onDrop={(event) => { - if (!onDropNewStep || event.target.closest(".combo-step-inputs")) return; - event.preventDefault(); - onDropNewStep(event); - }} - > - {visibleInputs.map(({ step, index: stepIndex }, visibleIndex) => { - const nextStep = visibleInputs[visibleIndex + 1]?.step || []; - const hideSeparator = step.every(isDirectionInput) && nextStep.every(isDirectionInput); - return ( - - onSelectStep(stepIndex) : null} - onSelectInput={onSelectInput} - onDropInput={onDropInput ? (event) => onDropInput(event, stepIndex) : null} - onDragInput={onDragInput} - /> - {visibleIndex < visibleInputs.length - 1 && !hideSeparator && } - - ); - })} -
- ); -} - -function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) { - const inlineEdit = useInlineEdit({ - value, - onCommit, - blurOnEscape: true - }); - - return ( - - ); -} +import { ComboInputToken, ComboSequence, InlineTextInput } from "./CombosParts.jsx"; +import { + DEVICE_OPTIONS, + createCombo, + createInput, + getComboCategory, + getHoldSeconds, + getPaletteColumns, + getPaletteGroups, + normalizeCustomKey, + parseHoldSeconds, + setComboItemCategory +} from "./combosUtils.js"; export function CombosModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] })); diff --git a/website/src/features/toolboxes/modules/CombosParts.jsx b/website/src/features/toolboxes/modules/CombosParts.jsx new file mode 100644 index 0000000..24691f7 --- /dev/null +++ b/website/src/features/toolboxes/modules/CombosParts.jsx @@ -0,0 +1,138 @@ +// Rôle : rend les tokens, étapes et séquences visuelles de l'outil Combos. +import { Icon } from "../../../components/Icon.jsx"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; +import { formatHoldLabel, getInputLabel, inputKey, INPUT_ICONS, isDirectionInput } from "./combosUtils.js"; + +export function ComboInputToken({ input, device, palette = false, onClick, onDragStart }) { + const label = getInputLabel(input); + const iconName = INPUT_ICONS[input.value]; + const holdLabel = !palette ? formatHoldLabel(input) : ""; + const classNames = [ + "combo-input-token", + `combo-input-${input.kind}`, + iconName ? "combo-input-icon" : "", + holdLabel ? "has-hold" : "", + input.kind === "key" && label.length === 1 ? "combo-input-short" : "", + `combo-device-${device}`, + `combo-value-${String(input.value || "").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}` + ].filter(Boolean).join(" "); + + if (palette) { + return ( + + ); + } + + return ( + + {iconName ? : label} + {holdLabel && {holdLabel}} + + ); +} + +export function ComboSequence({ inputs, device, emptyLabel, activeStepIndex = -1, selectedInput, onSelectStep, onSelectInput, onDropInput, onDropNewStep, onDragInput }) { + const visibleInputs = inputs.map((step, index) => ({ step, index })).filter(({ step }) => step.length || onSelectStep); + if (!visibleInputs.length) return {emptyLabel}; + return ( +
{ + if (!onDropNewStep) return; + event.preventDefault(); + }} + onDrop={(event) => { + if (!onDropNewStep || event.target.closest(".combo-step-inputs")) return; + event.preventDefault(); + onDropNewStep(event); + }} + > + {visibleInputs.map(({ step, index: stepIndex }, visibleIndex) => { + const nextStep = visibleInputs[visibleIndex + 1]?.step || []; + const hideSeparator = step.every(isDirectionInput) && nextStep.every(isDirectionInput); + return ( + + onSelectStep(stepIndex) : null} + onSelectInput={onSelectInput} + onDropInput={onDropInput ? (event) => onDropInput(event, stepIndex) : null} + onDragInput={onDragInput} + /> + {visibleIndex < visibleInputs.length - 1 && !hideSeparator && } + + ); + })} +
+ ); +} + +export function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) { + const inlineEdit = useInlineEdit({ + value, + onCommit, + blurOnEscape: true + }); + + return ( + + ); +} + +function ComboStep({ step, stepIndex, device, active = false, selectedInputIndex = -1, onClick, onSelectInput, onDropInput, onDragInput }) { + const className = [ + "combo-step-inputs", + step.length > 1 ? "is-simultaneous" : "", + step.length <= 1 ? "is-single" : "", + active ? "is-active" : "" + ].filter(Boolean).join(" "); + const content = step.length ? step.map((input, inputIndex) => ( + + {inputIndex > 0 && } + { + event.stopPropagation(); + onSelectInput(stepIndex, inputIndex); + } : null} + onDragStart={onDragInput ? (event) => onDragInput(event, stepIndex, inputIndex, input) : null} + /> + + )) : Étape vide; + + if (onClick) { + return ( + + ); + } + + return {content}; +} diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx new file mode 100644 index 0000000..82a2a42 --- /dev/null +++ b/website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx @@ -0,0 +1,493 @@ +// Rôle : contient les panneaux d'affichage et d'édition détaillée du planificateur d'équipements. +import { useState } from "react"; +import { Icon } from "../../../components/Icon.jsx"; +import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; +import { + TRAIT_ICONS, + createLocalId, + formatValue, + getCharacteristicCategories, + getSocketItemName, + setItemCategory +} from "./equipmentPlannerOptions.js"; +import { ColorChoiceDropdown, IconChoiceDropdown, ShapeChoiceDropdown, SocketShapeIcon } from "./EquipmentPlannerPickers.jsx"; + +export function EquipmentDisplay({ equipment, textContent }) { + const hasStats = equipment.characteristics.length > 0; + const hasSockets = equipment.socketItems.length > 0; + const hasCraft = Boolean(equipment.obtain || equipment.materials.length); + if (!hasStats && !hasSockets && !hasCraft) { + return

{textContent.emptyEquipmentDetails || "Aucun détail renseigné."}

; + } + + return ( +
+ {hasStats && ( +
+

{textContent.displayStatsTitle || "Statistiques"}

+
    + {equipment.characteristics.map((trait) => ( +
  • + + {trait.name} + {trait.value !== "" && {formatValue(trait.value)}} +
  • + ))} +
+
+ )} + {hasSockets && ( +
+

{textContent.displaySocketTitle || "Objets sertis"}

+
    + {equipment.socketItems.map((socketItem) => ( +
  • + +
    + {socketItem.name} + {socketItem.bonuses.map((bonus) => ( + {bonus.name}{bonus.value !== "" ? ` +${formatValue(bonus.value)}` : ""} + ))} +
    +
  • + ))} +
+
+ )} + {hasCraft && ( +
+

{textContent.craftTab || "Craft"}

+ {equipment.obtain ?

{equipment.obtain}

: null} + {equipment.materials.length ? ( +
    + {equipment.materials.map((material) =>
  • {material.name}x{material.qty}
  • )} +
+ ) : null} +
+ )} +
+ ); +} + +export function TabIcon({ tab }) { + if (tab === "stats") return ; + if (tab === "sockets") return ; + return ; +} + +export function CharacteristicsEditor({ equipment, textContent, editing, onUpdate }) { + const [draft, setDraft] = useState({ icon: "sword", category: "", name: "", value: "" }); + const categories = getCharacteristicCategories(equipment); + const { + itemReorder, + groupReorder, + getItemProps, + getGroupProps, + isItemDragging, + isItemDropTarget, + isGroupDragging, + isGroupDropTarget, + getDropPlacement + } = useGroupedReorder({ + namespace: `equipment-characteristics-${equipment.id}`, + items: equipment.characteristics, + getItemId: (trait) => trait.id, + getItemGroup: (trait) => trait.category, + getEffectiveGroup: (trait) => trait.category, + reorderFeatures: { + item: { groupChange: true }, + group: { reorder: true, itemDrop: true } + }, + onItemMove: (operation) => onUpdate((current) => applyGroupedReorderOperation(current, { + operation, + itemsKey: "characteristics", + groupOrderKey: "categoryOrder", + collapsedGroupsKey: "collapsedCategories", + getItemGroup: (trait) => trait.category, + setItemGroup: setItemCategory + })), + onGroupMove: (operation) => onUpdate((current) => applyGroupedReorderOperation(current, { + operation, + itemsKey: "characteristics", + groupOrderKey: "categoryOrder", + collapsedGroupsKey: "collapsedCategories", + getItemGroup: (trait) => trait.category, + setItemGroup: setItemCategory + })) + }); + const reorder = { itemReorder, groupReorder, getItemProps, getGroupProps, isItemDragging, isItemDropTarget, isGroupDragging, isGroupDropTarget, getDropPlacement }; + + function addTrait(event) { + event.preventDefault(); + const cleanName = draft.name.trim(); + if (!cleanName) return; + onUpdate((current) => ({ + ...current, + characteristics: [...current.characteristics, { id: createLocalId("trait"), icon: draft.icon, category: draft.category.trim(), name: cleanName, value: draft.value.trim() }], + categoryOrder: draft.category.trim() && !current.categoryOrder.includes(draft.category.trim()) ? [...current.categoryOrder, draft.category.trim()] : current.categoryOrder + })); + setDraft({ icon: draft.icon, category: draft.category, name: "", value: "" }); + } + + function updateTrait(traitId, updater) { + onUpdate((current) => ({ ...current, characteristics: current.characteristics.map((trait) => trait.id === traitId ? updater(trait) : trait).filter((trait) => trait.name) })); + } + + return ( +
+

{textContent.characteristicsTitle || "Caractéristiques"}

+ {editing && ( +
+ ({ icon, label: icon }))} + onChange={(icon) => setDraft((current) => ({ ...current, icon }))} + /> + setDraft((current) => ({ ...current, category: event.target.value }))} placeholder={textContent.categoryPlaceholder || "Catégorie"} /> + setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.traitNamePlaceholder || "Nom"} /> + setDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} /> + + + )} + onUpdate((current) => ({ + ...current, + collapsedCategories: current.collapsedCategories.includes(category) + ? current.collapsedCategories.filter((item) => item !== category) + : [...current.collapsedCategories, category] + }))} + onUpdateTrait={updateTrait} + editing={editing} + /> +
+ ); +} + +function TraitCategoryList({ traits, categories, collapsedCategories, reorder, textContent, onToggleCategory, onUpdateTrait, editing }) { + const ungrouped = traits.filter((trait) => !trait.category); + + function renderTrait(trait) { + const traitClassName = [ + "equipment-planner-trait", + reorder.isItemDragging(trait.id) ? "is-dragging" : "", + reorder.isItemDropTarget(trait.id) ? "is-drop-target" : "", + reorder.getDropPlacement("item", trait.id) === "after" ? "drop-after" : "" + ].filter(Boolean).join(" "); + return ( + reorder.itemReorder.startDrag(event, trait.id)} + onUpdateTrait={onUpdateTrait} + /> + ); + } + + return ( +
+ {ungrouped.length ?
    {ungrouped.map(renderTrait)}
: null} + {categories.map((category) => { + const categoryTraits = traits.filter((trait) => trait.category === category); + const collapsed = collapsedCategories.includes(category); + const className = [ + "equipment-planner-trait-category", + collapsed ? "is-collapsed" : "", + reorder.isGroupDragging(category) ? "is-dragging" : "", + reorder.isGroupDropTarget(category) ? "is-drop-target" : "" + ].filter(Boolean).join(" "); + return ( +
+
+ + {category} + {categoryTraits.length} + +
+ {!collapsed &&
    {categoryTraits.map(renderTrait)}
} +
+ ); + })} + {!traits.length &&

{textContent.emptyCharacteristics || "Aucune caractéristique."}

} +
+ ); +} + +function EquipmentTraitRow({ trait, className, itemProps, editing, textContent, onStartDrag, onUpdateTrait }) { + const categoryEdit = useInlineEdit({ + value: trait.category || "", + onCommit: (category) => onUpdateTrait(trait.id, (current) => ({ ...current, category })) + }); + const nameEdit = useInlineEdit({ + value: trait.name, + transform: (draft) => String(draft || "").trim() || trait.name, + onCommit: (name) => onUpdateTrait(trait.id, (current) => ({ ...current, name })) + }); + const valueEdit = useInlineEdit({ + value: formatValue(trait.value), + onCommit: (value) => onUpdateTrait(trait.id, (current) => ({ ...current, value })) + }); + + return ( +
  • + {editing ? ( + <> + + ({ icon, label: icon }))} + onChange={(icon) => onUpdateTrait(trait.id, (current) => ({ ...current, icon }))} + /> + + + + + + ) : ( +
    + + {trait.name} + {trait.value !== "" && {formatValue(trait.value)}} +
    + )} +
  • + ); +} + +export function MaterialsEditor({ equipment, textContent, editing, onUpdate }) { + const [draft, setDraft] = useState({ name: "", qty: 1 }); + + function addMaterial(event) { + event.preventDefault(); + const cleanName = draft.name.trim(); + if (!cleanName) return; + onUpdate((current) => ({ + ...current, + materials: [...current.materials, { id: createLocalId("material"), name: cleanName, qty: Math.max(1, Number.parseInt(draft.qty, 10) || 1) }] + })); + setDraft({ name: "", qty: 1 }); + } + + return ( +
    +

    {textContent.materialsTitle || "Matériaux"}

    + {editing && ( +
    + setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.materialNamePlaceholder || "Matériau"} /> + setDraft((current) => ({ ...current, qty: event.target.value }))} aria-label={textContent.materialQtyLabel || "Quantité"} /> + +
    + )} +
      + {equipment.materials.map((material) => ( + + ))} +
    +
    + ); +} + +function MaterialRow({ material, editing, textContent, onUpdate }) { + const nameEdit = useInlineEdit({ + value: material.name, + transform: (draft) => String(draft || "").trim() || material.name, + onCommit: (name) => onUpdate((current) => ({ ...current, materials: current.materials.map((item) => item.id === material.id ? { ...item, name } : item) })) + }); + + return ( +
  • + {editing ? ( + <> + + onUpdate((current) => ({ ...current, materials: current.materials.map((item) => item.id === material.id ? { ...item, qty: Math.max(1, Number.parseInt(event.target.value, 10) || 1) } : item) }))} /> + + + ) : ( +
    + {material.name} + x{material.qty} +
    + )} +
  • + ); +} + +export function SocketItemsEditor({ equipment, textContent, editing, onUpdate }) { + const [draft, setDraft] = useState({ name: "", shape: "jewel", color: "yellow" }); + + function addSocketItem(event) { + event.preventDefault(); + const cleanName = draft.name.trim(); + if (!cleanName) return; + onUpdate((current) => ({ + ...current, + socketItems: [...current.socketItems, { id: createLocalId("socket"), name: cleanName, shape: draft.shape, color: draft.color, bonuses: [] }] + })); + setDraft({ ...draft, name: "" }); + } + + function updateSocketItem(socketItemId, updater) { + onUpdate((current) => ({ + ...current, + socketItems: current.socketItems.map((socketItem) => socketItem.id === socketItemId ? updater(socketItem) : socketItem).filter((socketItem) => socketItem.name) + })); + } + + function deleteSocketItem(socketItemId) { + onUpdate((current) => ({ + ...current, + socketItems: current.socketItems.filter((socketItem) => socketItem.id !== socketItemId), + socketLinks: current.socketLinks.filter((link) => link.fromSocketItemId !== socketItemId && link.toSocketItemId !== socketItemId) + })); + } + + function addSocketLink(fromSocketItemId, toSocketItemId) { + if (!toSocketItemId || fromSocketItemId === toSocketItemId) return; + const pair = [fromSocketItemId, toSocketItemId].sort().join(":"); + if (equipment.socketLinks.some((link) => [link.fromSocketItemId, link.toSocketItemId].sort().join(":") === pair)) return; + onUpdate((current) => ({ + ...current, + socketLinks: [...current.socketLinks, { id: createLocalId("socket-link"), fromSocketItemId, toSocketItemId }] + })); + } + + return ( +
    +

    {textContent.socketItemsTitle || "Objets sertis"}

    + {editing && ( +
    + setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.socketNamePlaceholder || "Joyau, materia..."} /> + setDraft((current) => ({ ...current, shape }))} /> + setDraft((current) => ({ ...current, color }))} /> + + + )} +
    + {equipment.socketItems.map((socketItem) => ( + updateSocketItem(socketItem.id, updater)} + onDelete={() => deleteSocketItem(socketItem.id)} + onAddLink={(toSocketItemId) => addSocketLink(socketItem.id, toSocketItemId)} + onDeleteLink={(linkId) => onUpdate((current) => ({ ...current, socketLinks: current.socketLinks.filter((link) => link.id !== linkId) }))} + /> + ))} +
    +
    + ); +} + +function SocketItemEditor({ equipment, socketItem, textContent, editing, onUpdate, onDelete, onAddLink, onDeleteLink }) { + const [bonusDraft, setBonusDraft] = useState({ name: "", value: "" }); + const links = equipment.socketLinks.filter((link) => link.fromSocketItemId === socketItem.id || link.toSocketItemId === socketItem.id); + const nameEdit = useInlineEdit({ + value: socketItem.name, + transform: (draft) => String(draft || "").trim() || socketItem.name, + onCommit: (name) => onUpdate((current) => ({ ...current, name })) + }); + + function addBonus(event) { + event.preventDefault(); + const cleanName = bonusDraft.name.trim(); + if (!cleanName) return; + onUpdate((current) => ({ + ...current, + bonuses: [...current.bonuses, { id: createLocalId("bonus"), category: "", name: cleanName, value: bonusDraft.value.trim() }] + })); + setBonusDraft({ name: "", value: "" }); + } + + return ( +
    +
    + + {editing ? ( + <> + + + + ) : ( + {socketItem.name} + )} +
    + {editing && ( + <> +
    + onUpdate((current) => ({ ...current, shape }))} /> + onUpdate((current) => ({ ...current, color }))} /> + {equipment.socketItems.length > 1 && ( + + )} +
    +
    + setBonusDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.bonusNamePlaceholder || "Bonus"} /> + setBonusDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} /> + +
    + + )} +
      + {socketItem.bonuses.map((bonus) => ( +
    • + {bonus.category ? `${bonus.category} · ` : ""}{bonus.name} + {formatValue(bonus.value)} + {editing && ( + + )} +
    • + ))} +
    + {links.length ? ( +
    + {links.map((link) => { + const otherSocketItemId = link.fromSocketItemId === socketItem.id ? link.toSocketItemId : link.fromSocketItemId; + return ( + + ); + })} +
    + ) : null} +
    + ); +} diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerItem.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerItem.jsx new file mode 100644 index 0000000..fa24c65 --- /dev/null +++ b/website/src/features/toolboxes/modules/EquipmentPlannerItem.jsx @@ -0,0 +1,106 @@ +// Rôle : rend une ligne d'équipement et délègue ses panneaux d'édition spécialisés. +import { useState } from "react"; +import { Icon } from "../../../components/Icon.jsx"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; +import { DETAIL_TABS, getEquipmentTabLabel } from "./equipmentPlannerOptions.js"; +import { CharacteristicsEditor, EquipmentDisplay, MaterialsEditor, SocketItemsEditor, TabIcon } from "./EquipmentPlannerEditors.jsx"; + +export 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 obtainEdit = useInlineEdit({ + value: equipment.obtain || "", + onCommit: (obtain) => onUpdate((current) => ({ ...current, obtain })), + commitOnEnter: false + }); + 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" && ( +
    +