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"}
+
+ {equipment.characteristics.map((trait) => (
+ -
+
+ {trait.name}
+ {trait.value !== "" && {formatValue(trait.value)}}
+
+ ))}
+
+
+ )}
+ {hasSockets && (
+
+ {textContent.displaySocketTitle || "Objets sertis"}
+
+
+ )}
+ {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 && (
+
+ )}
+ 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 && (
+
+ )}
+
+ {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 && (
+
+ )}
+
+ {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 && (
+
+ )}
+
+
+ >
+ )}
+
+ {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" && (
+
+
+
+
+ )}
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx
index 580f728..bf5cd8a 100644
--- a/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx
+++ b/website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx
@@ -1,37 +1,11 @@
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
-import { useEffect, useMemo, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { Icon } from "../../../components/Icon.jsx";
-import { Tooltip } from "../../../components/Tooltip.jsx";
-import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
-import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
+import { useMemo, useState } from "react";
+import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
-import {
- DETAIL_TABS,
- EQUIPMENT_ICONS,
- SOCKET_COLORS,
- SOCKET_COLOR_VALUES,
- SOCKET_SHAPES,
- TRAIT_ICONS,
- createLocalId,
- formatValue,
- getCharacteristicCategories,
- getSocketItemName,
- setItemCategory
-} from "./equipmentPlannerOptions.js";
-
-function SocketShapeIcon({ shape, color = "gray", className = "" }) {
- return (
-
-
-
- );
-}
-
-function SummaryTotalIcon({ total }) {
- if (SOCKET_SHAPES.includes(total.icon)) return ;
- return ;
-}
+import { EQUIPMENT_ICONS } from "./equipmentPlannerOptions.js";
+import { EquipmentItem } from "./EquipmentPlannerItem.jsx";
+import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx";
+import { EquipmentPlannerSummary } from "./EquipmentPlannerSummary.jsx";
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
@@ -161,849 +135,3 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }
);
}
-
-function EquipmentPlannerSummary({ summary, textContent }) {
- const hasSourceTotals = summary.equipmentTotals.length || summary.socketTotals.length;
- const sourceColumnCount = [summary.equipmentTotals.length, summary.socketTotals.length].filter(Boolean).length;
- return (
-
-
-
{textContent.summaryTitle || "Résumé"}
- {summary.activeEquipmentCount} {summary.activeEquipmentCount > 1 ? textContent.activeEquipmentsPlural || "équipements actifs" : textContent.activeEquipmentsSingular || "équipement actif"}
-
-
- {hasSourceTotals ? (
-
- {summary.equipmentTotals.length ? : null}
- {summary.socketTotals.length ? : null}
-
- ) : null}
- {summary.mixedTotals.length ?
: null}
- {!summary.totals.length ? (
-
-
{textContent.numericTotalsTitle || "Totaux"}
-
{textContent.emptyTotals || "Aucun total numérique."}
-
- ) : null}
- {summary.textBonuses.length ? (
-
-
{textContent.textBonusesTitle || "Bonus"}
-
- {summary.textBonuses.map((bonus) => (
- -
- {bonus.category ? `${bonus.category} · ` : ""}{bonus.name}
- {bonus.source}{bonus.value ? ` · ${bonus.value}` : ""}
-
- ))}
-
-
- ) : null}
-
-
- );
-}
-
-function SummaryTotalsPanel({ title, totals, textContent }) {
- return (
-
-
{title}
-
- {totals.map((total) => (
- }
- key={total.name}
- position="top-start"
- >
-
- {total.name}
- {formatValue(total.value)}
-
- ))}
-
-
- );
-}
-
-function SummaryTotalTooltip({ total, textContent }) {
- return (
-
- {textContent.summaryTooltipTitle || "Provenance"}
-
- {(total.contributions || []).map((contribution) => (
-
-
- {contribution.sourceName || contribution.equipmentName}
- {formatValue(contribution.value)}
-
- ))}
-
-
- );
-}
-
-function IconChoiceDropdown({ className = "", label, value, options, onChange }) {
- const selected = options.find((option) => option.icon === value) || options[0];
- return (
- (
-
- )}
- >
- {({ close }) => (
-
- {options.map((option) => (
-
- ))}
-
- )}
-
- );
-}
-
-function ShapeChoiceDropdown({ label, value, onChange }) {
- return (
- (
-
- )}
- >
- {({ close }) => (
-
- {SOCKET_SHAPES.map((shape) => (
-
- ))}
-
- )}
-
- );
-}
-
-function ColorChoiceDropdown({ label, value, onChange }) {
- return (
- (
-
- )}
- >
- {({ close }) => (
-
- {SOCKET_COLORS.map((color) => (
-
- ))}
-
- )}
-
- );
-}
-
-function FloatingPicker({ className = "", label, columns = 5, renderTrigger, children }) {
- const [open, setOpen] = useState(false);
- const [menuStyle, setMenuStyle] = useState({});
- const rootRef = useRef(null);
- const menuRef = useRef(null);
-
- useEffect(() => {
- if (!open) return undefined;
-
- function placeMenu() {
- const trigger = rootRef.current;
- if (!trigger) return;
- const rect = trigger.getBoundingClientRect();
- const width = Math.max(0, Math.min(220, window.innerWidth - 16));
- const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8));
- setMenuStyle({
- top: `${Math.min(rect.bottom + 6, window.innerHeight - 8)}px`,
- left: `${left}px`,
- maxWidth: `${width}px`
- });
- }
-
- function handlePointerDown(event) {
- if (rootRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) return;
- setOpen(false);
- }
-
- function handleKeyDown(event) {
- if (event.key === "Escape") setOpen(false);
- }
-
- placeMenu();
- window.addEventListener("resize", placeMenu);
- window.addEventListener("scroll", placeMenu, true);
- document.addEventListener("pointerdown", handlePointerDown);
- document.addEventListener("keydown", handleKeyDown);
- return () => {
- window.removeEventListener("resize", placeMenu);
- window.removeEventListener("scroll", placeMenu, true);
- document.removeEventListener("pointerdown", handlePointerDown);
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [open]);
-
- function close() {
- setOpen(false);
- }
-
- function toggle() {
- setOpen((value) => !value);
- }
-
- return (
-
- {renderTrigger({ open, toggle, close })}
- {open && createPortal(
-
- {children({ close })}
-
,
- document.body
- )}
-
- );
-}
-
-function EquipmentItem({ equipment, reorder, textContent, onUpdate, onDelete }) {
- const [activeTab, setActiveTab] = useState("stats");
- const [detailEditing, setDetailEditing] = useState(false);
- const nameEdit = useInlineEdit({
- value: equipment.name,
- onCommit: (name) => onUpdate((current) => ({ ...current, name: name || current.name })),
- blurOnEscape: true
- });
- const 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" && (
-
-
-
-
- )}
- >
- ) : (
-
- )}
-
-
- );
-}
-
-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"}
-
-
- )}
- {hasCraft && (
-
- {textContent.craftTab || "Craft"}
- {equipment.obtain ? {equipment.obtain}
: null}
- {equipment.materials.length ? (
-
- {equipment.materials.map((material) => - {material.name}x{material.qty}
)}
-
- ) : null}
-
- )}
-
- );
-}
-
-function getTabLabel(tab, textContent) {
- if (tab === "stats") return textContent.statsTab || "Stats / affixe";
- if (tab === "sockets") return textContent.socketsTab || "Objets à sertir";
- return textContent.craftTab || "Craft";
-}
-
-function TabIcon({ tab }) {
- if (tab === "stats") return %;
- if (tab === "sockets") return ;
- return ;
-}
-
-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 && (
-
- )}
- 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)}}
-
- )}
-
- );
-}
-
-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 && (
-
- )}
-
- {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}
-
- )}
-
- );
-}
-
-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 && (
-
- )}
-
- {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 && (
-
- )}
-
-
- >
- )}
-
- {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/EquipmentPlannerPickers.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerPickers.jsx
new file mode 100644
index 0000000..eb67436
--- /dev/null
+++ b/website/src/features/toolboxes/modules/EquipmentPlannerPickers.jsx
@@ -0,0 +1,193 @@
+// Rôle : regroupe les menus flottants de sélection d'icônes, formes et couleurs de l'équipement.
+import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { Icon } from "../../../components/Icon.jsx";
+import { SOCKET_COLORS, SOCKET_COLOR_VALUES, SOCKET_SHAPES } from "./equipmentPlannerOptions.js";
+
+export function SocketShapeIcon({ shape, color = "gray", className = "" }) {
+ return (
+
+
+
+ );
+}
+
+export function IconChoiceDropdown({ className = "", label, value, options, onChange }) {
+ const selected = options.find((option) => option.icon === value) || options[0];
+ return (
+ (
+
+ )}
+ >
+ {({ close }) => (
+
+ {options.map((option) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function ShapeChoiceDropdown({ label, value, onChange }) {
+ return (
+ (
+
+ )}
+ >
+ {({ close }) => (
+
+ {SOCKET_SHAPES.map((shape) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function ColorChoiceDropdown({ label, value, onChange }) {
+ return (
+ (
+
+ )}
+ >
+ {({ close }) => (
+
+ {SOCKET_COLORS.map((color) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function FloatingPicker({ className = "", label, columns = 5, renderTrigger, children }) {
+ const [open, setOpen] = useState(false);
+ const [menuStyle, setMenuStyle] = useState({});
+ const rootRef = useRef(null);
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return undefined;
+
+ function placeMenu() {
+ const trigger = rootRef.current;
+ if (!trigger) return;
+ const rect = trigger.getBoundingClientRect();
+ const width = Math.max(0, Math.min(220, window.innerWidth - 16));
+ const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8));
+ setMenuStyle({
+ top: `${Math.min(rect.bottom + 6, window.innerHeight - 8)}px`,
+ left: `${left}px`,
+ maxWidth: `${width}px`
+ });
+ }
+
+ function handlePointerDown(event) {
+ if (rootRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) return;
+ setOpen(false);
+ }
+
+ function handleKeyDown(event) {
+ if (event.key === "Escape") setOpen(false);
+ }
+
+ placeMenu();
+ window.addEventListener("resize", placeMenu);
+ window.addEventListener("scroll", placeMenu, true);
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("resize", placeMenu);
+ window.removeEventListener("scroll", placeMenu, true);
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [open]);
+
+ function close() {
+ setOpen(false);
+ }
+
+ function toggle() {
+ setOpen((value) => !value);
+ }
+
+ return (
+
+ {renderTrigger({ open, toggle, close })}
+ {open && createPortal(
+
+ {children({ close })}
+
,
+ document.body
+ )}
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/EquipmentPlannerSummary.jsx b/website/src/features/toolboxes/modules/EquipmentPlannerSummary.jsx
new file mode 100644
index 0000000..5efe37e
--- /dev/null
+++ b/website/src/features/toolboxes/modules/EquipmentPlannerSummary.jsx
@@ -0,0 +1,91 @@
+// Rôle : affiche le résumé agrégé du planificateur d'équipements.
+import { Icon } from "../../../components/Icon.jsx";
+import { Tooltip } from "../../../components/Tooltip.jsx";
+import { formatValue, SOCKET_SHAPES } from "./equipmentPlannerOptions.js";
+import { SocketShapeIcon } from "./EquipmentPlannerPickers.jsx";
+
+export function EquipmentPlannerSummary({ summary, textContent }) {
+ const hasSourceTotals = summary.equipmentTotals.length || summary.socketTotals.length;
+ const sourceColumnCount = [summary.equipmentTotals.length, summary.socketTotals.length].filter(Boolean).length;
+ return (
+
+
+
{textContent.summaryTitle || "Résumé"}
+ {summary.activeEquipmentCount} {summary.activeEquipmentCount > 1 ? textContent.activeEquipmentsPlural || "équipements actifs" : textContent.activeEquipmentsSingular || "équipement actif"}
+
+
+ {hasSourceTotals ? (
+
+ {summary.equipmentTotals.length ? : null}
+ {summary.socketTotals.length ? : null}
+
+ ) : null}
+ {summary.mixedTotals.length ?
: null}
+ {!summary.totals.length ? (
+
+
{textContent.numericTotalsTitle || "Totaux"}
+
{textContent.emptyTotals || "Aucun total numérique."}
+
+ ) : null}
+ {summary.textBonuses.length ? (
+
+
{textContent.textBonusesTitle || "Bonus"}
+
+ {summary.textBonuses.map((bonus) => (
+ -
+ {bonus.category ? `${bonus.category} · ` : ""}{bonus.name}
+ {bonus.source}{bonus.value ? ` · ${bonus.value}` : ""}
+
+ ))}
+
+
+ ) : null}
+
+
+ );
+}
+
+function SummaryTotalsPanel({ title, totals, textContent }) {
+ return (
+
+
{title}
+
+ {totals.map((total) => (
+ }
+ key={total.name}
+ position="top-start"
+ >
+
+ {total.name}
+ {formatValue(total.value)}
+
+ ))}
+
+
+ );
+}
+
+function SummaryTotalIcon({ total }) {
+ if (SOCKET_SHAPES.includes(total.icon)) return ;
+ return ;
+}
+
+function SummaryTotalTooltip({ total, textContent }) {
+ return (
+
+ {textContent.summaryTooltipTitle || "Provenance"}
+
+ {(total.contributions || []).map((contribution) => (
+
+
+ {contribution.sourceName || contribution.equipmentName}
+ {formatValue(contribution.value)}
+
+ ))}
+
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/equipmentPlannerOptions.js b/website/src/features/toolboxes/modules/equipmentPlannerOptions.js
index 0bed408..09dfeec 100644
--- a/website/src/features/toolboxes/modules/equipmentPlannerOptions.js
+++ b/website/src/features/toolboxes/modules/equipmentPlannerOptions.js
@@ -62,3 +62,9 @@ export function createLocalId(prefix) {
export function getSocketItemName(equipment, socketItemId) {
return equipment.socketItems.find((socketItem) => socketItem.id === socketItemId)?.name || "";
}
+
+export function getEquipmentTabLabel(tab, textContent) {
+ if (tab === "stats") return textContent.statsTab || "Stats / affixe";
+ if (tab === "sockets") return textContent.socketsTab || "Objets à sertir";
+ return textContent.craftTab || "Craft";
+}