// 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 { TextExchangeActions } from "./TextExchangeActions.jsx";
const SOCKET_SHAPES = ["ball", "jewel"];
const SOCKET_COLORS = ["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"];
const SOCKET_COLOR_VALUES = {
red: "#ef4444",
orange: "#f97316",
amber: "#f59e0b",
yellow: "var(--color-accent-gold)",
lime: "#84cc16",
green: "#22c55e",
teal: "#14b8a6",
cyan: "var(--color-accent-cyan)",
blue: "#3b82f6",
indigo: "var(--color-accent-indigo)",
violet: "#7c3aed",
purple: "var(--color-primary)",
pink: "var(--color-accent-pink)",
rose: "#f43f5e",
white: "#f8fafc",
gray: "#64748b",
black: "#020617"
};
const EQUIPMENT_ICONS = [
{ icon: "helmet", label: "Casque" },
{ icon: "chest-armor", label: "Chest" },
{ icon: "glove", label: "Gants" },
{ icon: "boot", label: "Bottes" },
{ icon: "necklace", label: "Collier" },
{ icon: "earring", label: "Earring" },
{ icon: "ring", label: "Anneau" },
{ icon: "sword", label: "Épée" },
{ icon: "gun", label: "Gun" },
{ icon: "shield", label: "Bouclier" }
];
const TRAIT_ICONS = ["sword", "shield"];
const DETAIL_TABS = ["stats", "sockets", "craft"];
function getEquipmentIconLabel(icon) {
return EQUIPMENT_ICONS.find((item) => item.icon === icon)?.label || "Équipement";
}
function getCharacteristicCategories(equipment) {
const categories = equipment.characteristics.map((trait) => trait.category).filter(Boolean);
return [
...equipment.categoryOrder.filter((category) => categories.includes(category)),
...categories.filter((category, index) => categories.indexOf(category) === index && !equipment.categoryOrder.includes(category))
];
}
function setItemCategory(item, category) {
const nextItem = { ...item };
if (category) nextItem.category = category;
else delete nextItem.category;
return nextItem;
}
function formatValue(value) {
if (typeof value === "number") return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000);
return String(value || "");
}
function createLocalId(prefix) {
return globalThis.crypto?.randomUUID?.() || `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function getSocketItemName(equipment, socketItemId) {
return equipment.socketItems.find((socketItem) => socketItem.id === socketItemId)?.name || "";
}
function SocketShapeIcon({ shape, color = "gray", className = "" }) {
return (
);
}
function SummaryTotalIcon({ total }) {
if (SOCKET_SHAPES.includes(total.icon)) return ;
return ;
}
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
const summary = useMemo(() => context.summarizeEquipmentPlannerData(data), [context, data]);
const textContent = context.moduleText?.equipmentPlanner || {};
const [equipmentIcon, setEquipmentIcon] = useState("helmet");
const [equipmentName, setEquipmentName] = useState("");
const {
itemReorder,
groupReorder,
getItemProps,
getGroupProps,
getGroupBoundaryProps,
isItemDragging,
isItemDropTarget,
isGroupDragging,
isGroupDropTarget,
isBoundaryDropTarget,
getDropPlacement,
shouldShowGroupBoundaries
} = useGroupedReorder({
namespace: `equipment-planner-${moduleId}`,
items: data.equipments,
getItemId: (equipment) => equipment.id,
onItemMove: (operation) => save({ ...data, equipments: moveItem(data.equipments, operation.sourceId, operation.targetId, operation.placement) })
});
const reorder = {
itemReorder,
groupReorder,
getItemProps,
getGroupProps,
getGroupBoundaryProps,
isItemDragging,
isItemDropTarget,
isGroupDragging,
isGroupDropTarget,
isBoundaryDropTarget,
getDropPlacement,
shouldShowGroupBoundaries
};
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "equipmentPlanner");
}
function addEquipment(event) {
event.preventDefault();
const cleanName = equipmentName.trim();
if (!cleanName) return;
const type = data.types[0] || { id: context.uid("equipmentType"), title: "Équipements", icon: "shield", collapsed: false };
const nextData = {
...data,
types: data.types.length ? data.types : [type],
typeOrder: data.typeOrder.length ? data.typeOrder : [type.id],
equipments: [
...data.equipments,
{
id: context.uid("equipment"),
typeId: type.id,
name: cleanName,
icon: equipmentIcon,
active: true,
obtain: "",
characteristics: [],
socketItems: [],
socketLinks: [],
materials: [],
categoryOrder: [],
collapsedCategories: []
}
]
};
save(nextData);
setEquipmentName("");
}
function updateEquipment(equipmentId, updater) {
save({ ...data, equipments: data.equipments.map((equipment) => equipment.id === equipmentId ? updater(equipment) : equipment) });
}
function deleteEquipment(equipmentId) {
const nextEquipments = data.equipments.filter((equipment) => equipment.id !== equipmentId);
const usedTypeIds = new Set(nextEquipments.map((equipment) => equipment.typeId));
save({
...data,
equipments: nextEquipments,
types: data.types.filter((type) => usedTypeIds.has(type.id)),
typeOrder: data.typeOrder.filter((typeId) => usedTypeIds.has(typeId))
});
}
return (
{editing && (
save(nextData)} />
)}
{data.equipments.length ? data.equipments.map((equipment) => (
updateEquipment(equipment.id, updater)}
onDelete={() => deleteEquipment(equipment.id)}
/>
)) : (
- {textContent.emptyEquipments || "Aucun équipement planifié."}
)}
);
}
function EquipmentPlannerSummary({ summary, textContent }) {
const hasSourceTotals = summary.equipmentTotals.length || summary.socketTotals.length;
const sourceColumnCount = [summary.equipmentTotals.length, summary.socketTotals.length].filter(Boolean).length;
return (
{textContent.summaryTitle || "Résumé"}
{summary.activeEquipmentCount} {summary.activeEquipmentCount > 1 ? textContent.activeEquipmentsPlural || "équipements actifs" : textContent.activeEquipmentsSingular || "équipement actif"}
{hasSourceTotals ? (
{summary.equipmentTotals.length ? : null}
{summary.socketTotals.length ? : null}
) : null}
{summary.mixedTotals.length ?
: null}
{!summary.totals.length ? (
{textContent.numericTotalsTitle || "Totaux"}
{textContent.emptyTotals || "Aucun total numérique."}
) : null}
{summary.textBonuses.length ? (
{textContent.textBonusesTitle || "Bonus"}
{summary.textBonuses.map((bonus) => (
-
{bonus.category ? `${bonus.category} · ` : ""}{bonus.name}
{bonus.source}{bonus.value ? ` · ${bonus.value}` : ""}
))}
) : null}
);
}
function SummaryTotalsPanel({ title, totals, textContent }) {
return (
{title}
{totals.map((total) => (
}
key={total.name}
position="top-start"
>
{total.name}
{formatValue(total.value)}
))}
);
}
function SummaryTotalTooltip({ total, textContent }) {
return (
{textContent.summaryTooltipTitle || "Provenance"}
{(total.contributions || []).map((contribution) => (
{contribution.sourceName || contribution.equipmentName}
{formatValue(contribution.value)}
))}
);
}
function IconChoiceDropdown({ className = "", label, value, options, onChange }) {
const selected = options.find((option) => option.icon === value) || options[0];
return (
(
)}
>
{({ close }) => (
{options.map((option) => (
))}
)}
);
}
function ShapeChoiceDropdown({ label, value, onChange }) {
return (
(
)}
>
{({ close }) => (
{SOCKET_SHAPES.map((shape) => (
))}
)}
);
}
function ColorChoiceDropdown({ label, value, onChange }) {
return (
(
)}
>
{({ close }) => (
{SOCKET_COLORS.map((color) => (
))}
)}
);
}
function FloatingPicker({ className = "", label, columns = 5, renderTrigger, children }) {
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState({});
const rootRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
if (!open) return undefined;
function placeMenu() {
const trigger = rootRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = Math.max(0, Math.min(220, window.innerWidth - 16));
const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8));
setMenuStyle({
top: `${Math.min(rect.bottom + 6, window.innerHeight - 8)}px`,
left: `${left}px`,
maxWidth: `${width}px`
});
}
function handlePointerDown(event) {
if (rootRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) return;
setOpen(false);
}
function handleKeyDown(event) {
if (event.key === "Escape") setOpen(false);
}
placeMenu();
window.addEventListener("resize", placeMenu);
window.addEventListener("scroll", placeMenu, true);
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("resize", placeMenu);
window.removeEventListener("scroll", placeMenu, true);
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
function close() {
setOpen(false);
}
function toggle() {
setOpen((value) => !value);
}
return (
{renderTrigger({ open, toggle, close })}
{open && createPortal(
{children({ close })}
,
document.body
)}
);
}
function EquipmentItem({ equipment, reorder, textContent, onUpdate, onDelete }) {
const [activeTab, setActiveTab] = useState("stats");
const [detailEditing, setDetailEditing] = useState(false);
const nameEdit = useInlineEdit({
value: equipment.name,
onCommit: (name) => onUpdate((current) => ({ ...current, name: name || current.name })),
blurOnEscape: true
});
const 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 (
{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}
);
}