diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index f8d0099..402a384 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -122,6 +122,10 @@ test("toolbox module registry and modules expose expected behavior", async () => 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"); @@ -283,11 +287,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/); 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"}

+ +
+ )} + {hasSockets && ( +
+

{textContent.displaySocketTitle || "Objets sertis"}

+ +
+ )} + {hasCraft && ( +
+

{textContent.craftTab || "Craft"}

+ {equipment.obtain ?

{equipment.obtain}

: null} + {equipment.materials.length ? ( + + ) : 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" && ( +
    +