diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 155b6bf..b2eb214 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -173,6 +173,10 @@ test("toolbox module registry and modules expose expected behavior", async () => const goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.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 equipmentPlannerCharacteristics = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerCharacteristics.jsx", "utf8"); + const equipmentPlannerMaterials = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerMaterials.jsx", "utf8"); + const equipmentPlannerSockets = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerSockets.jsx", "utf8"); + const equipmentPlannerBundle = `${equipmentPlannerEditors}\n${equipmentPlannerCharacteristics}\n${equipmentPlannerMaterials}\n${equipmentPlannerSockets}`; 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"); @@ -427,10 +431,13 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(equipmentPlannerModule, /summarizeEquipmentPlannerData/); assert.match(equipmentPlannerModule, /EquipmentItem/); assert.match(equipmentPlannerModule, /EquipmentPlannerSummary/); - assert.match(equipmentPlannerEditors, /socketItems/); - assert.match(equipmentPlannerEditors, /socketLinks/); + assert.match(equipmentPlannerBundle, /socketItems/); + assert.match(equipmentPlannerSockets, /socketLinks/); assert.match(equipmentPlannerModule, /useGroupedReorder/); - assert.match(equipmentPlannerEditors, /reorderFeatures/); + assert.match(equipmentPlannerCharacteristics, /reorderFeatures/); + assert.match(equipmentPlannerEditors, /export \{ CharacteristicsEditor \}/); + assert.match(equipmentPlannerEditors, /export \{ MaterialsEditor \}/); + assert.match(equipmentPlannerEditors, /export \{ SocketItemsEditor \}/); assert.match(equipmentPlannerModule, /TextExchangeActions/); assert.match(equipmentPlannerItem, /export function EquipmentItem/); assert.match(equipmentPlannerPickers, /export function IconChoiceDropdown/); diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerCharacteristics.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerCharacteristics.jsx new file mode 100644 index 0000000..fbd26e7 --- /dev/null +++ b/website/src/features/toolboxes/modules/EquipmentPlannerCharacteristics.jsx @@ -0,0 +1,210 @@ +// Rôle : rend l'éditeur des caractéristiques d'un équipement avec catégories réordonnables. +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, + setItemCategory +} from "./equipmentPlannerOptions.js"; +import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx"; + +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 ? : 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)}} +
    + )} +
  • + ); +} diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx index 82a2a42..45271e3 100644 --- a/website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx +++ b/website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx @@ -1,17 +1,11 @@ -// Rôle : contient les panneaux d'affichage et d'édition détaillée du planificateur d'équipements. -import { useState } from "react"; +// Rôle : expose l'affichage détaillé et les éditeurs spécialisés du planificateur d'équipements. 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"; +import { formatValue } from "./equipmentPlannerOptions.js"; +import { SocketShapeIcon } from "./EquipmentPlannerPickers.jsx"; + +export { CharacteristicsEditor } from "./EquipmentPlannerCharacteristics.jsx"; +export { MaterialsEditor } from "./EquipmentPlannerMaterials.jsx"; +export { SocketItemsEditor } from "./EquipmentPlannerSockets.jsx"; export function EquipmentDisplay({ equipment, textContent }) { const hasStats = equipment.characteristics.length > 0; @@ -60,9 +54,9 @@ export function EquipmentDisplay({ equipment, textContent }) {

    {textContent.craftTab || "Craft"}

    {equipment.obtain ?

    {equipment.obtain}

    : null} {equipment.materials.length ? ( - + ) : null} )} @@ -75,419 +69,3 @@ export function TabIcon({ tab }) { 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/EquipmentPlannerMaterials.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerMaterials.jsx new file mode 100644 index 0000000..a56a1e5 --- /dev/null +++ b/website/src/features/toolboxes/modules/EquipmentPlannerMaterials.jsx @@ -0,0 +1,71 @@ +// Rôle : rend l'éditeur des matériaux de craft d'un équipement. +import { useState } from "react"; +import { Icon } from "../../../components/Icon.jsx"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; +import { createLocalId } from "./equipmentPlannerOptions.js"; + +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} +
    + )} +
  • + ); +} diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerSockets.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerSockets.jsx new file mode 100644 index 0000000..1b35b21 --- /dev/null +++ b/website/src/features/toolboxes/modules/EquipmentPlannerSockets.jsx @@ -0,0 +1,163 @@ +// Rôle : rend l'éditeur des objets sertis, bonus et liens de sockets. +import { useState } from "react"; +import { Icon } from "../../../components/Icon.jsx"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; +import { + createLocalId, + formatValue, + getSocketItemName +} from "./equipmentPlannerOptions.js"; +import { ColorChoiceDropdown, ShapeChoiceDropdown, SocketShapeIcon } from "./EquipmentPlannerPickers.jsx"; + +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} +
    + ); +}