Extract equipment planner components
This commit is contained in:
parent
588c1298cd
commit
fb5feb8c98
7 changed files with 907 additions and 881 deletions
|
|
@ -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 taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
|
||||||
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "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 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 equipmentPlannerOptions = await readFile("website/src/features/toolboxes/modules/equipmentPlannerOptions.js", "utf8");
|
||||||
const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "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");
|
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, /export function EquipmentPlannerModule/);
|
||||||
assert.match(equipmentPlannerModule, /normalizeEquipmentPlannerData/);
|
assert.match(equipmentPlannerModule, /normalizeEquipmentPlannerData/);
|
||||||
assert.match(equipmentPlannerModule, /summarizeEquipmentPlannerData/);
|
assert.match(equipmentPlannerModule, /summarizeEquipmentPlannerData/);
|
||||||
assert.match(equipmentPlannerModule, /socketItems/);
|
assert.match(equipmentPlannerModule, /EquipmentItem/);
|
||||||
assert.match(equipmentPlannerModule, /socketLinks/);
|
assert.match(equipmentPlannerModule, /EquipmentPlannerSummary/);
|
||||||
|
assert.match(equipmentPlannerEditors, /socketItems/);
|
||||||
|
assert.match(equipmentPlannerEditors, /socketLinks/);
|
||||||
assert.match(equipmentPlannerModule, /useGroupedReorder/);
|
assert.match(equipmentPlannerModule, /useGroupedReorder/);
|
||||||
assert.match(equipmentPlannerModule, /reorderFeatures/);
|
assert.match(equipmentPlannerEditors, /reorderFeatures/);
|
||||||
assert.match(equipmentPlannerModule, /TextExchangeActions/);
|
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 const EQUIPMENT_ICONS/);
|
||||||
assert.match(equipmentPlannerOptions, /export function formatValue/);
|
assert.match(equipmentPlannerOptions, /export function formatValue/);
|
||||||
assert.match(checklistModule, /export function ChecklistModule/);
|
assert.match(checklistModule, /export function ChecklistModule/);
|
||||||
|
|
|
||||||
|
|
@ -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 <p className="equipment-planner-muted">{textContent.emptyEquipmentDetails || "Aucun détail renseigné."}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="equipment-planner-display-grid">
|
||||||
|
{hasStats && (
|
||||||
|
<section className="equipment-planner-display-panel">
|
||||||
|
<h4>{textContent.displayStatsTitle || "Statistiques"}</h4>
|
||||||
|
<ul>
|
||||||
|
{equipment.characteristics.map((trait) => (
|
||||||
|
<li className="equipment-planner-display-stat" key={trait.id}>
|
||||||
|
<Icon name={trait.icon || "sword"} />
|
||||||
|
<span>{trait.name}</span>
|
||||||
|
{trait.value !== "" && <b>{formatValue(trait.value)}</b>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{hasSockets && (
|
||||||
|
<section className="equipment-planner-display-panel">
|
||||||
|
<h4>{textContent.displaySocketTitle || "Objets sertis"}</h4>
|
||||||
|
<ul>
|
||||||
|
{equipment.socketItems.map((socketItem) => (
|
||||||
|
<li className="equipment-planner-display-socket" key={socketItem.id}>
|
||||||
|
<SocketShapeIcon shape={socketItem.shape} color={socketItem.color} />
|
||||||
|
<div>
|
||||||
|
<strong>{socketItem.name}</strong>
|
||||||
|
{socketItem.bonuses.map((bonus) => (
|
||||||
|
<small key={bonus.id}>{bonus.name}{bonus.value !== "" ? ` +${formatValue(bonus.value)}` : ""}</small>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{hasCraft && (
|
||||||
|
<section className="equipment-planner-display-panel">
|
||||||
|
<h4>{textContent.craftTab || "Craft"}</h4>
|
||||||
|
{equipment.obtain ? <p>{equipment.obtain}</p> : null}
|
||||||
|
{equipment.materials.length ? (
|
||||||
|
<ul>
|
||||||
|
{equipment.materials.map((material) => <li key={material.id}><span>{material.name}</span><b>x{material.qty}</b></li>)}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabIcon({ tab }) {
|
||||||
|
if (tab === "stats") return <span className="equipment-planner-percent-icon" aria-hidden="true">%</span>;
|
||||||
|
if (tab === "sockets") return <Icon name="jewel" />;
|
||||||
|
return <Icon name="hammer" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="equipment-planner-panel">
|
||||||
|
<h4>{textContent.characteristicsTitle || "Caractéristiques"}</h4>
|
||||||
|
{editing && (
|
||||||
|
<form className="inline-form equipment-planner-trait-form" onSubmit={addTrait}>
|
||||||
|
<IconChoiceDropdown
|
||||||
|
label={textContent.traitIconLabel || "Icône"}
|
||||||
|
value={draft.icon}
|
||||||
|
options={TRAIT_ICONS.map((icon) => ({ icon, label: icon }))}
|
||||||
|
onChange={(icon) => setDraft((current) => ({ ...current, icon }))}
|
||||||
|
/>
|
||||||
|
<input value={draft.category} onChange={(event) => setDraft((current) => ({ ...current, category: event.target.value }))} placeholder={textContent.categoryPlaceholder || "Catégorie"} />
|
||||||
|
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.traitNamePlaceholder || "Nom"} />
|
||||||
|
<input value={draft.value} onChange={(event) => setDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} />
|
||||||
|
<button>{textContent.addTraitButton || "Ajouter"}</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<TraitCategoryList
|
||||||
|
traits={equipment.characteristics}
|
||||||
|
categories={categories}
|
||||||
|
collapsedCategories={equipment.collapsedCategories}
|
||||||
|
reorder={reorder}
|
||||||
|
textContent={textContent}
|
||||||
|
onToggleCategory={(category) => onUpdate((current) => ({
|
||||||
|
...current,
|
||||||
|
collapsedCategories: current.collapsedCategories.includes(category)
|
||||||
|
? current.collapsedCategories.filter((item) => item !== category)
|
||||||
|
: [...current.collapsedCategories, category]
|
||||||
|
}))}
|
||||||
|
onUpdateTrait={updateTrait}
|
||||||
|
editing={editing}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<EquipmentTraitRow
|
||||||
|
key={trait.id}
|
||||||
|
trait={trait}
|
||||||
|
className={traitClassName}
|
||||||
|
itemProps={reorder.getItemProps({ itemId: trait.id, groupId: trait.category })}
|
||||||
|
editing={editing}
|
||||||
|
textContent={textContent}
|
||||||
|
onStartDrag={(event) => reorder.itemReorder.startDrag(event, trait.id)}
|
||||||
|
onUpdateTrait={onUpdateTrait}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="equipment-planner-trait-list">
|
||||||
|
{ungrouped.length ? <ul>{ungrouped.map(renderTrait)}</ul> : 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 (
|
||||||
|
<section className={className} key={category} {...reorder.getGroupProps({ groupId: category })}>
|
||||||
|
<div className="equipment-planner-trait-category-header">
|
||||||
|
<button type="button" className="task-planner-category-drag-handle" onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: category })} title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}>
|
||||||
|
<Icon name="drag" />
|
||||||
|
</button>
|
||||||
|
<strong>{category}</strong>
|
||||||
|
<span>{categoryTraits.length}</span>
|
||||||
|
<button type="button" className="checklist-section-collapse-button" onClick={() => onToggleCategory(category)} aria-expanded={!collapsed}>
|
||||||
|
<Icon name={collapsed ? "chevron-down" : "chevron-up"} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!collapsed && <ul>{categoryTraits.map(renderTrait)}</ul>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!traits.length && <p className="equipment-planner-muted">{textContent.emptyCharacteristics || "Aucune caractéristique."}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li className={className} {...itemProps}>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="task-planner-drag-handle" onPointerDown={onStartDrag} title={textContent.reorderTraitTitle || "Déplacer"}>
|
||||||
|
<Icon name="drag" />
|
||||||
|
</button>
|
||||||
|
<IconChoiceDropdown
|
||||||
|
label={textContent.traitIconLabel || "Icône"}
|
||||||
|
value={trait.icon || "sword"}
|
||||||
|
options={TRAIT_ICONS.map((icon) => ({ icon, label: icon }))}
|
||||||
|
onChange={(icon) => onUpdateTrait(trait.id, (current) => ({ ...current, icon }))}
|
||||||
|
/>
|
||||||
|
<input {...categoryEdit.getInputProps({ placeholder: textContent.categoryPlaceholder || "Catégorie" })} />
|
||||||
|
<input {...nameEdit.getInputProps({ placeholder: textContent.traitNamePlaceholder || "Nom" })} />
|
||||||
|
<input {...valueEdit.getInputProps({ placeholder: textContent.traitValuePlaceholder || "Valeur" })} />
|
||||||
|
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdateTrait(trait.id, () => ({ ...trait, name: "" }))} title={textContent.deleteTraitTitle || "Supprimer"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="equipment-planner-trait-readonly">
|
||||||
|
<Icon name={trait.icon || "sword"} />
|
||||||
|
<span>{trait.name}</span>
|
||||||
|
{trait.value !== "" && <b>{formatValue(trait.value)}</b>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="equipment-planner-panel">
|
||||||
|
<h4>{textContent.materialsTitle || "Matériaux"}</h4>
|
||||||
|
{editing && (
|
||||||
|
<form className="inline-form equipment-planner-material-form" onSubmit={addMaterial}>
|
||||||
|
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.materialNamePlaceholder || "Matériau"} />
|
||||||
|
<input type="number" min="1" value={draft.qty} onChange={(event) => setDraft((current) => ({ ...current, qty: event.target.value }))} aria-label={textContent.materialQtyLabel || "Quantité"} />
|
||||||
|
<button>{textContent.addMaterialButton || "Ajouter"}</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<ul className="equipment-planner-materials">
|
||||||
|
{equipment.materials.map((material) => (
|
||||||
|
<MaterialRow
|
||||||
|
key={material.id}
|
||||||
|
material={material}
|
||||||
|
editing={editing}
|
||||||
|
textContent={textContent}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<input {...nameEdit.getInputProps()} />
|
||||||
|
<input type="number" min="1" value={material.qty} onChange={(event) => 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) }))} />
|
||||||
|
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdate((current) => ({ ...current, materials: current.materials.filter((item) => item.id !== material.id) }))} title={textContent.deleteMaterialTitle || "Supprimer"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="equipment-planner-material-readonly">
|
||||||
|
<span>{material.name}</span>
|
||||||
|
<b>x{material.qty}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="equipment-planner-panel">
|
||||||
|
<h4>{textContent.socketItemsTitle || "Objets sertis"}</h4>
|
||||||
|
{editing && (
|
||||||
|
<form className="inline-form equipment-planner-socket-form" onSubmit={addSocketItem}>
|
||||||
|
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.socketNamePlaceholder || "Joyau, materia..."} />
|
||||||
|
<ShapeChoiceDropdown label={textContent.socketShapeLabel || "Forme"} value={draft.shape} onChange={(shape) => setDraft((current) => ({ ...current, shape }))} />
|
||||||
|
<ColorChoiceDropdown label={textContent.socketColorLabel || "Couleur"} value={draft.color} onChange={(color) => setDraft((current) => ({ ...current, color }))} />
|
||||||
|
<button>{textContent.addSocketButton || "Ajouter"}</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<div className="equipment-planner-socket-grid">
|
||||||
|
{equipment.socketItems.map((socketItem) => (
|
||||||
|
<SocketItemEditor
|
||||||
|
key={socketItem.id}
|
||||||
|
equipment={equipment}
|
||||||
|
socketItem={socketItem}
|
||||||
|
textContent={textContent}
|
||||||
|
editing={editing}
|
||||||
|
onUpdate={(updater) => 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) }))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<article className="equipment-planner-socket-card">
|
||||||
|
<div className="equipment-planner-socket-heading">
|
||||||
|
<SocketShapeIcon shape={socketItem.shape} color={socketItem.color} />
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<input {...nameEdit.getInputProps()} />
|
||||||
|
<button type="button" className="task-planner-delete-button danger" onClick={onDelete} title={textContent.deleteSocketTitle || "Supprimer"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<strong>{socketItem.name}</strong>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editing && (
|
||||||
|
<>
|
||||||
|
<div className="equipment-planner-socket-controls">
|
||||||
|
<ShapeChoiceDropdown label={textContent.socketShapeLabel || "Forme"} value={socketItem.shape} onChange={(shape) => onUpdate((current) => ({ ...current, shape }))} />
|
||||||
|
<ColorChoiceDropdown label={textContent.socketColorLabel || "Couleur"} value={socketItem.color} onChange={(color) => onUpdate((current) => ({ ...current, color }))} />
|
||||||
|
{equipment.socketItems.length > 1 && (
|
||||||
|
<select defaultValue="" onChange={(event) => { onAddLink(event.target.value); event.target.value = ""; }} aria-label={textContent.addSocketLinkLabel || "Lier à"}>
|
||||||
|
<option value="">{textContent.addSocketLinkLabel || "Lier à"}</option>
|
||||||
|
{equipment.socketItems.filter((item) => item.id !== socketItem.id).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form className="inline-form equipment-planner-bonus-form" onSubmit={addBonus}>
|
||||||
|
<input value={bonusDraft.name} onChange={(event) => setBonusDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.bonusNamePlaceholder || "Bonus"} />
|
||||||
|
<input value={bonusDraft.value} onChange={(event) => setBonusDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} />
|
||||||
|
<button aria-label={textContent.addBonusButton || "Ajouter"} title={textContent.addBonusButton || "Ajouter"}>+</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ul className="equipment-planner-socket-bonuses">
|
||||||
|
{socketItem.bonuses.map((bonus) => (
|
||||||
|
<li key={bonus.id}>
|
||||||
|
<span>{bonus.category ? `${bonus.category} · ` : ""}{bonus.name}</span>
|
||||||
|
<b>{formatValue(bonus.value)}</b>
|
||||||
|
{editing && (
|
||||||
|
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdate((current) => ({ ...current, bonuses: current.bonuses.filter((item) => item.id !== bonus.id) }))} title={textContent.deleteBonusTitle || "Supprimer"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{links.length ? (
|
||||||
|
<div className={`equipment-planner-socket-links ${editing ? "is-editing" : ""}`}>
|
||||||
|
{links.map((link) => {
|
||||||
|
const otherSocketItemId = link.fromSocketItemId === socketItem.id ? link.toSocketItemId : link.fromSocketItemId;
|
||||||
|
return (
|
||||||
|
<button key={link.id} type="button" className="equipment-planner-link-chip" onClick={editing ? () => onDeleteLink(link.id) : undefined} title={editing ? textContent.deleteSocketLinkTitle || "Retirer le lien" : getSocketItemName(equipment, otherSocketItemId)}>
|
||||||
|
<span>{getSocketItemName(equipment, otherSocketItemId)}</span>
|
||||||
|
{editing && <Icon name="close" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
website/src/features/toolboxes/modules/EquipmentPlannerItem.jsx
Normal file
106
website/src/features/toolboxes/modules/EquipmentPlannerItem.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<li className={className} {...reorder.getItemProps({ itemId: equipment.id })}>
|
||||||
|
<div className="equipment-planner-line">
|
||||||
|
<button
|
||||||
|
className="task-planner-drag-handle"
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(event) => reorder.itemReorder.startDrag(event, equipment.id)}
|
||||||
|
aria-label={`${textContent.reorderEquipmentTitle || "Déplacer"} ${equipment.name}`}
|
||||||
|
title={textContent.reorderEquipmentTitle || "Déplacer"}
|
||||||
|
>
|
||||||
|
<Icon name="drag" />
|
||||||
|
</button>
|
||||||
|
<div className="equipment-planner-identity">
|
||||||
|
<span className="equipment-planner-type-icon" aria-hidden="true">
|
||||||
|
<Icon name={equipment.icon || "shield"} />
|
||||||
|
</span>
|
||||||
|
<strong {...nameEdit.getContentEditableProps({ className: "equipment-planner-name", title: textContent.renameEquipmentTitle || "Renommer" })}>{equipment.name}</strong>
|
||||||
|
</div>
|
||||||
|
<label className="equipment-planner-include-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={equipment.active}
|
||||||
|
onChange={(event) => onUpdate((current) => ({ ...current, active: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
<b>{textContent.includeInSummaryLabel || "Inclure dans le résumé"}</b>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`task-planner-settings-button equipment-planner-row-edit-button ${detailEditing ? "active" : ""}`}
|
||||||
|
onClick={() => setDetailEditing((value) => !value)}
|
||||||
|
aria-pressed={detailEditing}
|
||||||
|
title={detailEditing ? textContent.viewModeTitle || "Mode affichage" : textContent.editModeTitle || "Mode édition"}
|
||||||
|
>
|
||||||
|
<Icon name="edit" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="task-planner-delete-button danger" onClick={onDelete} aria-label={`${textContent.deleteEquipmentTitle || "Supprimer"} ${equipment.name}`} title={textContent.deleteEquipmentTitle || "Supprimer"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="equipment-planner-details">
|
||||||
|
{detailEditing ? (
|
||||||
|
<>
|
||||||
|
<div className="equipment-planner-tabs" role="tablist" aria-label={textContent.tabsLabel || "Ajout de caractéristiques"}>
|
||||||
|
{DETAIL_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
className={`equipment-planner-tab ${activeTab === tab ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
title={getEquipmentTabLabel(tab, textContent)}
|
||||||
|
>
|
||||||
|
<TabIcon tab={tab} />
|
||||||
|
<span>{getEquipmentTabLabel(tab, textContent)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activeTab === "stats" && <CharacteristicsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />}
|
||||||
|
{activeTab === "sockets" && <SocketItemsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />}
|
||||||
|
{activeTab === "craft" && (
|
||||||
|
<section className="equipment-planner-craft-tab">
|
||||||
|
<label className="equipment-planner-field">
|
||||||
|
<span>{textContent.obtainLabel || "Obtention"}</span>
|
||||||
|
<textarea {...obtainEdit.getInputProps({ placeholder: textContent.obtainPlaceholder || "Comment obtenir ou créer cet équipement" })} />
|
||||||
|
</label>
|
||||||
|
<MaterialsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EquipmentDisplay equipment={equipment} textContent={textContent} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,11 @@
|
||||||
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
|
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
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";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
import {
|
import { EQUIPMENT_ICONS } from "./equipmentPlannerOptions.js";
|
||||||
DETAIL_TABS,
|
import { EquipmentItem } from "./EquipmentPlannerItem.jsx";
|
||||||
EQUIPMENT_ICONS,
|
import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx";
|
||||||
SOCKET_COLORS,
|
import { EquipmentPlannerSummary } from "./EquipmentPlannerSummary.jsx";
|
||||||
SOCKET_COLOR_VALUES,
|
|
||||||
SOCKET_SHAPES,
|
|
||||||
TRAIT_ICONS,
|
|
||||||
createLocalId,
|
|
||||||
formatValue,
|
|
||||||
getCharacteristicCategories,
|
|
||||||
getSocketItemName,
|
|
||||||
setItemCategory
|
|
||||||
} from "./equipmentPlannerOptions.js";
|
|
||||||
|
|
||||||
function SocketShapeIcon({ shape, color = "gray", className = "" }) {
|
|
||||||
return (
|
|
||||||
<span className={`equipment-planner-socket-icon color-${color} ${className}`.trim()} style={{ "--equipment-socket-icon-color": SOCKET_COLOR_VALUES[color] || SOCKET_COLOR_VALUES.gray }} aria-hidden="true">
|
|
||||||
<Icon name={shape} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryTotalIcon({ total }) {
|
|
||||||
if (SOCKET_SHAPES.includes(total.icon)) return <SocketShapeIcon shape={total.icon} color={total.color || "gray"} />;
|
|
||||||
return <Icon name={total.icon || "sword"} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) {
|
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
|
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
|
||||||
|
|
@ -161,849 +135,3 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EquipmentPlannerSummary({ summary, textContent }) {
|
|
||||||
const hasSourceTotals = summary.equipmentTotals.length || summary.socketTotals.length;
|
|
||||||
const sourceColumnCount = [summary.equipmentTotals.length, summary.socketTotals.length].filter(Boolean).length;
|
|
||||||
return (
|
|
||||||
<section className="equipment-planner-summary" aria-label={textContent.summaryTitle || "Résumé"}>
|
|
||||||
<div className="equipment-planner-summary-header">
|
|
||||||
<h3>{textContent.summaryTitle || "Résumé"}</h3>
|
|
||||||
<span>{summary.activeEquipmentCount} {summary.activeEquipmentCount > 1 ? textContent.activeEquipmentsPlural || "équipements actifs" : textContent.activeEquipmentsSingular || "équipement actif"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="equipment-planner-summary-grid">
|
|
||||||
{hasSourceTotals ? (
|
|
||||||
<div className={`equipment-planner-summary-source-grid columns-${sourceColumnCount}`}>
|
|
||||||
{summary.equipmentTotals.length ? <SummaryTotalsPanel title={textContent.equipmentTotalsTitle || "Statistiques de l'équipement"} totals={summary.equipmentTotals} textContent={textContent} /> : null}
|
|
||||||
{summary.socketTotals.length ? <SummaryTotalsPanel title={textContent.socketTotalsTitle || "Objets sertis"} totals={summary.socketTotals} textContent={textContent} /> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{summary.mixedTotals.length ? <SummaryTotalsPanel title={textContent.mixedTotalsTitle || "Totaux fusionnés"} totals={summary.mixedTotals} textContent={textContent} /> : null}
|
|
||||||
{!summary.totals.length ? (
|
|
||||||
<div className="equipment-planner-summary-panel">
|
|
||||||
<strong>{textContent.numericTotalsTitle || "Totaux"}</strong>
|
|
||||||
<p>{textContent.emptyTotals || "Aucun total numérique."}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{summary.textBonuses.length ? (
|
|
||||||
<div className="equipment-planner-summary-panel equipment-planner-summary-text-bonuses">
|
|
||||||
<strong>{textContent.textBonusesTitle || "Bonus"}</strong>
|
|
||||||
<ul>
|
|
||||||
{summary.textBonuses.map((bonus) => (
|
|
||||||
<li key={`${bonus.source}:${bonus.id}`}>
|
|
||||||
<span>{bonus.category ? `${bonus.category} · ` : ""}{bonus.name}</span>
|
|
||||||
<small>{bonus.source}{bonus.value ? ` · ${bonus.value}` : ""}</small>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryTotalsPanel({ title, totals, textContent }) {
|
|
||||||
return (
|
|
||||||
<div className="equipment-planner-summary-panel">
|
|
||||||
<strong>{title}</strong>
|
|
||||||
<ul>
|
|
||||||
{totals.map((total) => (
|
|
||||||
<Tooltip
|
|
||||||
as="li"
|
|
||||||
className="equipment-planner-summary-total"
|
|
||||||
content={<SummaryTotalTooltip total={total} textContent={textContent} />}
|
|
||||||
key={total.name}
|
|
||||||
position="top-start"
|
|
||||||
>
|
|
||||||
<SummaryTotalIcon total={total} />
|
|
||||||
<span>{total.name}</span>
|
|
||||||
<b>{formatValue(total.value)}</b>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryTotalTooltip({ total, textContent }) {
|
|
||||||
return (
|
|
||||||
<span className="equipment-planner-summary-tooltip">
|
|
||||||
<strong>{textContent.summaryTooltipTitle || "Provenance"}</strong>
|
|
||||||
<span>
|
|
||||||
{(total.contributions || []).map((contribution) => (
|
|
||||||
<span className="equipment-planner-summary-tooltip-row" key={`${contribution.sourceKind}:${contribution.id}:${contribution.sourceName}`}>
|
|
||||||
<Icon name={contribution.equipmentIcon || "shield"} />
|
|
||||||
<span>{contribution.sourceName || contribution.equipmentName}</span>
|
|
||||||
<b>{formatValue(contribution.value)}</b>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconChoiceDropdown({ className = "", label, value, options, onChange }) {
|
|
||||||
const selected = options.find((option) => option.icon === value) || options[0];
|
|
||||||
return (
|
|
||||||
<FloatingPicker
|
|
||||||
className={className}
|
|
||||||
label={label}
|
|
||||||
columns={Math.min(options.length, 5)}
|
|
||||||
renderTrigger={({ open, toggle }) => (
|
|
||||||
<button
|
|
||||||
className={`equipment-planner-picker-trigger ${open ? "active" : ""}`}
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
aria-label={label}
|
|
||||||
aria-expanded={open}
|
|
||||||
title={selected.label}
|
|
||||||
>
|
|
||||||
<Icon name={selected.icon} />
|
|
||||||
<Icon name="dropdown" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ close }) => (
|
|
||||||
<div className="equipment-planner-picker-grid">
|
|
||||||
{options.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.icon}
|
|
||||||
className={`equipment-planner-picker-option ${option.icon === value ? "active" : ""}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onChange(option.icon);
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
aria-label={option.label}
|
|
||||||
title={option.label}
|
|
||||||
>
|
|
||||||
<Icon name={option.icon} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FloatingPicker>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShapeChoiceDropdown({ label, value, onChange }) {
|
|
||||||
return (
|
|
||||||
<FloatingPicker
|
|
||||||
className="equipment-planner-shape-picker"
|
|
||||||
label={label}
|
|
||||||
columns={SOCKET_SHAPES.length}
|
|
||||||
renderTrigger={({ open, toggle }) => (
|
|
||||||
<button className={`equipment-planner-picker-trigger ${open ? "active" : ""}`} type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={label}>
|
|
||||||
<SocketShapeIcon shape={value} />
|
|
||||||
<Icon name="dropdown" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ close }) => (
|
|
||||||
<div className="equipment-planner-picker-grid">
|
|
||||||
{SOCKET_SHAPES.map((shape) => (
|
|
||||||
<button
|
|
||||||
key={shape}
|
|
||||||
className={`equipment-planner-picker-option ${shape === value ? "active" : ""}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onChange(shape);
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
aria-label={shape}
|
|
||||||
title={shape}
|
|
||||||
>
|
|
||||||
<SocketShapeIcon shape={shape} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FloatingPicker>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColorChoiceDropdown({ label, value, onChange }) {
|
|
||||||
return (
|
|
||||||
<FloatingPicker
|
|
||||||
label={label}
|
|
||||||
columns={6}
|
|
||||||
renderTrigger={({ open, toggle }) => (
|
|
||||||
<button className={`equipment-planner-picker-trigger ${open ? "active" : ""}`} type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={value}>
|
|
||||||
<span className={`equipment-planner-color-swatch color-${value}`} aria-hidden="true" />
|
|
||||||
<Icon name="dropdown" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ close }) => (
|
|
||||||
<div className="equipment-planner-color-grid">
|
|
||||||
{SOCKET_COLORS.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color}
|
|
||||||
className={`equipment-planner-color-option ${color === value ? "active" : ""}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onChange(color);
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
aria-label={color}
|
|
||||||
title={color}
|
|
||||||
>
|
|
||||||
<span className={`equipment-planner-color-swatch color-${color}`} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FloatingPicker>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className={`equipment-planner-picker ${className} ${open ? "is-open" : ""}`.trim()} ref={rootRef} aria-label={label}>
|
|
||||||
{renderTrigger({ open, toggle, close })}
|
|
||||||
{open && createPortal(
|
|
||||||
<div className="equipment-planner-picker-menu is-open" ref={menuRef} style={{ ...menuStyle, "--picker-columns": columns }} aria-label={label}>
|
|
||||||
{children({ close })}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<li className={className} {...reorder.getItemProps({ itemId: equipment.id })}>
|
|
||||||
<div className="equipment-planner-line">
|
|
||||||
<button
|
|
||||||
className="task-planner-drag-handle"
|
|
||||||
type="button"
|
|
||||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, equipment.id)}
|
|
||||||
aria-label={`${textContent.reorderEquipmentTitle || "Déplacer"} ${equipment.name}`}
|
|
||||||
title={textContent.reorderEquipmentTitle || "Déplacer"}
|
|
||||||
>
|
|
||||||
<Icon name="drag" />
|
|
||||||
</button>
|
|
||||||
<div className="equipment-planner-identity">
|
|
||||||
<span className="equipment-planner-type-icon" aria-hidden="true">
|
|
||||||
<Icon name={equipment.icon || "shield"} />
|
|
||||||
</span>
|
|
||||||
<strong {...nameEdit.getContentEditableProps({ className: "equipment-planner-name", title: textContent.renameEquipmentTitle || "Renommer" })}>{equipment.name}</strong>
|
|
||||||
</div>
|
|
||||||
<label className="equipment-planner-include-switch">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={equipment.active}
|
|
||||||
onChange={(event) => onUpdate((current) => ({ ...current, active: event.target.checked }))}
|
|
||||||
/>
|
|
||||||
<span aria-hidden="true" />
|
|
||||||
<b>{textContent.includeInSummaryLabel || "Inclure dans le résumé"}</b>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`task-planner-settings-button equipment-planner-row-edit-button ${detailEditing ? "active" : ""}`}
|
|
||||||
onClick={() => setDetailEditing((value) => !value)}
|
|
||||||
aria-pressed={detailEditing}
|
|
||||||
title={detailEditing ? textContent.viewModeTitle || "Mode affichage" : textContent.editModeTitle || "Mode édition"}
|
|
||||||
>
|
|
||||||
<Icon name="edit" />
|
|
||||||
</button>
|
|
||||||
<button type="button" className="task-planner-delete-button danger" onClick={onDelete} aria-label={`${textContent.deleteEquipmentTitle || "Supprimer"} ${equipment.name}`} title={textContent.deleteEquipmentTitle || "Supprimer"}>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="equipment-planner-details">
|
|
||||||
{detailEditing ? (
|
|
||||||
<>
|
|
||||||
<div className="equipment-planner-tabs" role="tablist" aria-label={textContent.tabsLabel || "Ajout de caractéristiques"}>
|
|
||||||
{DETAIL_TABS.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
className={`equipment-planner-tab ${activeTab === tab ? "active" : ""}`}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
title={getTabLabel(tab, textContent)}
|
|
||||||
>
|
|
||||||
<TabIcon tab={tab} />
|
|
||||||
<span>{getTabLabel(tab, textContent)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{activeTab === "stats" && <CharacteristicsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />}
|
|
||||||
{activeTab === "sockets" && <SocketItemsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />}
|
|
||||||
{activeTab === "craft" && (
|
|
||||||
<section className="equipment-planner-craft-tab">
|
|
||||||
<label className="equipment-planner-field">
|
|
||||||
<span>{textContent.obtainLabel || "Obtention"}</span>
|
|
||||||
<textarea {...obtainEdit.getInputProps({ placeholder: textContent.obtainPlaceholder || "Comment obtenir ou créer cet équipement" })} />
|
|
||||||
</label>
|
|
||||||
<MaterialsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<EquipmentDisplay equipment={equipment} textContent={textContent} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <p className="equipment-planner-muted">{textContent.emptyEquipmentDetails || "Aucun détail renseigné."}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="equipment-planner-display-grid">
|
|
||||||
{hasStats && (
|
|
||||||
<section className="equipment-planner-display-panel">
|
|
||||||
<h4>{textContent.displayStatsTitle || "Statistiques"}</h4>
|
|
||||||
<ul>
|
|
||||||
{equipment.characteristics.map((trait) => (
|
|
||||||
<li className="equipment-planner-display-stat" key={trait.id}>
|
|
||||||
<Icon name={trait.icon || "sword"} />
|
|
||||||
<span>{trait.name}</span>
|
|
||||||
{trait.value !== "" && <b>{formatValue(trait.value)}</b>}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{hasSockets && (
|
|
||||||
<section className="equipment-planner-display-panel">
|
|
||||||
<h4>{textContent.displaySocketTitle || "Objets sertis"}</h4>
|
|
||||||
<ul>
|
|
||||||
{equipment.socketItems.map((socketItem) => (
|
|
||||||
<li className="equipment-planner-display-socket" key={socketItem.id}>
|
|
||||||
<SocketShapeIcon shape={socketItem.shape} color={socketItem.color} />
|
|
||||||
<div>
|
|
||||||
<strong>{socketItem.name}</strong>
|
|
||||||
{socketItem.bonuses.map((bonus) => (
|
|
||||||
<small key={bonus.id}>{bonus.name}{bonus.value !== "" ? ` +${formatValue(bonus.value)}` : ""}</small>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{hasCraft && (
|
|
||||||
<section className="equipment-planner-display-panel">
|
|
||||||
<h4>{textContent.craftTab || "Craft"}</h4>
|
|
||||||
{equipment.obtain ? <p>{equipment.obtain}</p> : null}
|
|
||||||
{equipment.materials.length ? (
|
|
||||||
<ul>
|
|
||||||
{equipment.materials.map((material) => <li key={material.id}><span>{material.name}</span><b>x{material.qty}</b></li>)}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <span className="equipment-planner-percent-icon" aria-hidden="true">%</span>;
|
|
||||||
if (tab === "sockets") return <Icon name="jewel" />;
|
|
||||||
return <Icon name="hammer" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<section className="equipment-planner-panel">
|
|
||||||
<h4>{textContent.characteristicsTitle || "Caractéristiques"}</h4>
|
|
||||||
{editing && (
|
|
||||||
<form className="inline-form equipment-planner-trait-form" onSubmit={addTrait}>
|
|
||||||
<IconChoiceDropdown
|
|
||||||
label={textContent.traitIconLabel || "Icône"}
|
|
||||||
value={draft.icon}
|
|
||||||
options={TRAIT_ICONS.map((icon) => ({ icon, label: icon }))}
|
|
||||||
onChange={(icon) => setDraft((current) => ({ ...current, icon }))}
|
|
||||||
/>
|
|
||||||
<input value={draft.category} onChange={(event) => setDraft((current) => ({ ...current, category: event.target.value }))} placeholder={textContent.categoryPlaceholder || "Catégorie"} />
|
|
||||||
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.traitNamePlaceholder || "Nom"} />
|
|
||||||
<input value={draft.value} onChange={(event) => setDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} />
|
|
||||||
<button>{textContent.addTraitButton || "Ajouter"}</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
<TraitCategoryList
|
|
||||||
traits={equipment.characteristics}
|
|
||||||
categories={categories}
|
|
||||||
collapsedCategories={equipment.collapsedCategories}
|
|
||||||
reorder={reorder}
|
|
||||||
textContent={textContent}
|
|
||||||
onToggleCategory={(category) => onUpdate((current) => ({
|
|
||||||
...current,
|
|
||||||
collapsedCategories: current.collapsedCategories.includes(category)
|
|
||||||
? current.collapsedCategories.filter((item) => item !== category)
|
|
||||||
: [...current.collapsedCategories, category]
|
|
||||||
}))}
|
|
||||||
onUpdateTrait={updateTrait}
|
|
||||||
editing={editing}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<EquipmentTraitRow
|
|
||||||
key={trait.id}
|
|
||||||
trait={trait}
|
|
||||||
className={traitClassName}
|
|
||||||
itemProps={reorder.getItemProps({ itemId: trait.id, groupId: trait.category })}
|
|
||||||
editing={editing}
|
|
||||||
textContent={textContent}
|
|
||||||
onStartDrag={(event) => reorder.itemReorder.startDrag(event, trait.id)}
|
|
||||||
onUpdateTrait={onUpdateTrait}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="equipment-planner-trait-list">
|
|
||||||
{ungrouped.length ? <ul>{ungrouped.map(renderTrait)}</ul> : 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 (
|
|
||||||
<section className={className} key={category} {...reorder.getGroupProps({ groupId: category })}>
|
|
||||||
<div className="equipment-planner-trait-category-header">
|
|
||||||
<button type="button" className="task-planner-category-drag-handle" onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: category })} title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}>
|
|
||||||
<Icon name="drag" />
|
|
||||||
</button>
|
|
||||||
<strong>{category}</strong>
|
|
||||||
<span>{categoryTraits.length}</span>
|
|
||||||
<button type="button" className="checklist-section-collapse-button" onClick={() => onToggleCategory(category)} aria-expanded={!collapsed}>
|
|
||||||
<Icon name={collapsed ? "chevron-down" : "chevron-up"} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{!collapsed && <ul>{categoryTraits.map(renderTrait)}</ul>}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!traits.length && <p className="equipment-planner-muted">{textContent.emptyCharacteristics || "Aucune caractéristique."}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<li className={className} {...itemProps}>
|
|
||||||
{editing ? (
|
|
||||||
<>
|
|
||||||
<button type="button" className="task-planner-drag-handle" onPointerDown={onStartDrag} title={textContent.reorderTraitTitle || "Déplacer"}>
|
|
||||||
<Icon name="drag" />
|
|
||||||
</button>
|
|
||||||
<IconChoiceDropdown
|
|
||||||
label={textContent.traitIconLabel || "Icône"}
|
|
||||||
value={trait.icon || "sword"}
|
|
||||||
options={TRAIT_ICONS.map((icon) => ({ icon, label: icon }))}
|
|
||||||
onChange={(icon) => onUpdateTrait(trait.id, (current) => ({ ...current, icon }))}
|
|
||||||
/>
|
|
||||||
<input {...categoryEdit.getInputProps({ placeholder: textContent.categoryPlaceholder || "Catégorie" })} />
|
|
||||||
<input {...nameEdit.getInputProps({ placeholder: textContent.traitNamePlaceholder || "Nom" })} />
|
|
||||||
<input {...valueEdit.getInputProps({ placeholder: textContent.traitValuePlaceholder || "Valeur" })} />
|
|
||||||
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdateTrait(trait.id, () => ({ ...trait, name: "" }))} title={textContent.deleteTraitTitle || "Supprimer"}>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="equipment-planner-trait-readonly">
|
|
||||||
<Icon name={trait.icon || "sword"} />
|
|
||||||
<span>{trait.name}</span>
|
|
||||||
{trait.value !== "" && <b>{formatValue(trait.value)}</b>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<section className="equipment-planner-panel">
|
|
||||||
<h4>{textContent.materialsTitle || "Matériaux"}</h4>
|
|
||||||
{editing && (
|
|
||||||
<form className="inline-form equipment-planner-material-form" onSubmit={addMaterial}>
|
|
||||||
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.materialNamePlaceholder || "Matériau"} />
|
|
||||||
<input type="number" min="1" value={draft.qty} onChange={(event) => setDraft((current) => ({ ...current, qty: event.target.value }))} aria-label={textContent.materialQtyLabel || "Quantité"} />
|
|
||||||
<button>{textContent.addMaterialButton || "Ajouter"}</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
<ul className="equipment-planner-materials">
|
|
||||||
{equipment.materials.map((material) => (
|
|
||||||
<MaterialRow
|
|
||||||
key={material.id}
|
|
||||||
material={material}
|
|
||||||
editing={editing}
|
|
||||||
textContent={textContent}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<li>
|
|
||||||
{editing ? (
|
|
||||||
<>
|
|
||||||
<input {...nameEdit.getInputProps()} />
|
|
||||||
<input type="number" min="1" value={material.qty} onChange={(event) => 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) }))} />
|
|
||||||
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdate((current) => ({ ...current, materials: current.materials.filter((item) => item.id !== material.id) }))} title={textContent.deleteMaterialTitle || "Supprimer"}>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="equipment-planner-material-readonly">
|
|
||||||
<span>{material.name}</span>
|
|
||||||
<b>x{material.qty}</b>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<section className="equipment-planner-panel">
|
|
||||||
<h4>{textContent.socketItemsTitle || "Objets sertis"}</h4>
|
|
||||||
{editing && (
|
|
||||||
<form className="inline-form equipment-planner-socket-form" onSubmit={addSocketItem}>
|
|
||||||
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.socketNamePlaceholder || "Joyau, materia..."} />
|
|
||||||
<ShapeChoiceDropdown label={textContent.socketShapeLabel || "Forme"} value={draft.shape} onChange={(shape) => setDraft((current) => ({ ...current, shape }))} />
|
|
||||||
<ColorChoiceDropdown label={textContent.socketColorLabel || "Couleur"} value={draft.color} onChange={(color) => setDraft((current) => ({ ...current, color }))} />
|
|
||||||
<button>{textContent.addSocketButton || "Ajouter"}</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
<div className="equipment-planner-socket-grid">
|
|
||||||
{equipment.socketItems.map((socketItem) => (
|
|
||||||
<SocketItemEditor
|
|
||||||
key={socketItem.id}
|
|
||||||
equipment={equipment}
|
|
||||||
socketItem={socketItem}
|
|
||||||
textContent={textContent}
|
|
||||||
editing={editing}
|
|
||||||
onUpdate={(updater) => 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) }))}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<article className="equipment-planner-socket-card">
|
|
||||||
<div className="equipment-planner-socket-heading">
|
|
||||||
<SocketShapeIcon shape={socketItem.shape} color={socketItem.color} />
|
|
||||||
{editing ? (
|
|
||||||
<>
|
|
||||||
<input {...nameEdit.getInputProps()} />
|
|
||||||
<button type="button" className="task-planner-delete-button danger" onClick={onDelete} title={textContent.deleteSocketTitle || "Supprimer"}>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<strong>{socketItem.name}</strong>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{editing && (
|
|
||||||
<>
|
|
||||||
<div className="equipment-planner-socket-controls">
|
|
||||||
<ShapeChoiceDropdown label={textContent.socketShapeLabel || "Forme"} value={socketItem.shape} onChange={(shape) => onUpdate((current) => ({ ...current, shape }))} />
|
|
||||||
<ColorChoiceDropdown label={textContent.socketColorLabel || "Couleur"} value={socketItem.color} onChange={(color) => onUpdate((current) => ({ ...current, color }))} />
|
|
||||||
{equipment.socketItems.length > 1 && (
|
|
||||||
<select defaultValue="" onChange={(event) => { onAddLink(event.target.value); event.target.value = ""; }} aria-label={textContent.addSocketLinkLabel || "Lier à"}>
|
|
||||||
<option value="">{textContent.addSocketLinkLabel || "Lier à"}</option>
|
|
||||||
{equipment.socketItems.filter((item) => item.id !== socketItem.id).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<form className="inline-form equipment-planner-bonus-form" onSubmit={addBonus}>
|
|
||||||
<input value={bonusDraft.name} onChange={(event) => setBonusDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.bonusNamePlaceholder || "Bonus"} />
|
|
||||||
<input value={bonusDraft.value} onChange={(event) => setBonusDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} />
|
|
||||||
<button aria-label={textContent.addBonusButton || "Ajouter"} title={textContent.addBonusButton || "Ajouter"}>+</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ul className="equipment-planner-socket-bonuses">
|
|
||||||
{socketItem.bonuses.map((bonus) => (
|
|
||||||
<li key={bonus.id}>
|
|
||||||
<span>{bonus.category ? `${bonus.category} · ` : ""}{bonus.name}</span>
|
|
||||||
<b>{formatValue(bonus.value)}</b>
|
|
||||||
{editing && (
|
|
||||||
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdate((current) => ({ ...current, bonuses: current.bonuses.filter((item) => item.id !== bonus.id) }))} title={textContent.deleteBonusTitle || "Supprimer"}>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{links.length ? (
|
|
||||||
<div className={`equipment-planner-socket-links ${editing ? "is-editing" : ""}`}>
|
|
||||||
{links.map((link) => {
|
|
||||||
const otherSocketItemId = link.fromSocketItemId === socketItem.id ? link.toSocketItemId : link.fromSocketItemId;
|
|
||||||
return (
|
|
||||||
<button key={link.id} type="button" className="equipment-planner-link-chip" onClick={editing ? () => onDeleteLink(link.id) : undefined} title={editing ? textContent.deleteSocketLinkTitle || "Retirer le lien" : getSocketItemName(equipment, otherSocketItemId)}>
|
|
||||||
<span>{getSocketItemName(equipment, otherSocketItemId)}</span>
|
|
||||||
{editing && <Icon name="close" />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<span className={`equipment-planner-socket-icon color-${color} ${className}`.trim()} style={{ "--equipment-socket-icon-color": SOCKET_COLOR_VALUES[color] || SOCKET_COLOR_VALUES.gray }} aria-hidden="true">
|
||||||
|
<Icon name={shape} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChoiceDropdown({ className = "", label, value, options, onChange }) {
|
||||||
|
const selected = options.find((option) => option.icon === value) || options[0];
|
||||||
|
return (
|
||||||
|
<FloatingPicker
|
||||||
|
className={className}
|
||||||
|
label={label}
|
||||||
|
columns={Math.min(options.length, 5)}
|
||||||
|
renderTrigger={({ open, toggle }) => (
|
||||||
|
<button
|
||||||
|
className={`equipment-planner-picker-trigger ${open ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={open}
|
||||||
|
title={selected.label}
|
||||||
|
>
|
||||||
|
<Icon name={selected.icon} />
|
||||||
|
<Icon name="dropdown" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<div className="equipment-planner-picker-grid">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.icon}
|
||||||
|
className={`equipment-planner-picker-option ${option.icon === value ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.icon);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
aria-label={option.label}
|
||||||
|
title={option.label}
|
||||||
|
>
|
||||||
|
<Icon name={option.icon} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FloatingPicker>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShapeChoiceDropdown({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<FloatingPicker
|
||||||
|
className="equipment-planner-shape-picker"
|
||||||
|
label={label}
|
||||||
|
columns={SOCKET_SHAPES.length}
|
||||||
|
renderTrigger={({ open, toggle }) => (
|
||||||
|
<button className={`equipment-planner-picker-trigger ${open ? "active" : ""}`} type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={label}>
|
||||||
|
<SocketShapeIcon shape={value} />
|
||||||
|
<Icon name="dropdown" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<div className="equipment-planner-picker-grid">
|
||||||
|
{SOCKET_SHAPES.map((shape) => (
|
||||||
|
<button
|
||||||
|
key={shape}
|
||||||
|
className={`equipment-planner-picker-option ${shape === value ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(shape);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
aria-label={shape}
|
||||||
|
title={shape}
|
||||||
|
>
|
||||||
|
<SocketShapeIcon shape={shape} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FloatingPicker>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorChoiceDropdown({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<FloatingPicker
|
||||||
|
label={label}
|
||||||
|
columns={6}
|
||||||
|
renderTrigger={({ open, toggle }) => (
|
||||||
|
<button className={`equipment-planner-picker-trigger ${open ? "active" : ""}`} type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={value}>
|
||||||
|
<span className={`equipment-planner-color-swatch color-${value}`} aria-hidden="true" />
|
||||||
|
<Icon name="dropdown" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<div className="equipment-planner-color-grid">
|
||||||
|
{SOCKET_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
className={`equipment-planner-color-option ${color === value ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(color);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
aria-label={color}
|
||||||
|
title={color}
|
||||||
|
>
|
||||||
|
<span className={`equipment-planner-color-swatch color-${color}`} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FloatingPicker>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={`equipment-planner-picker ${className} ${open ? "is-open" : ""}`.trim()} ref={rootRef} aria-label={label}>
|
||||||
|
{renderTrigger({ open, toggle, close })}
|
||||||
|
{open && createPortal(
|
||||||
|
<div className="equipment-planner-picker-menu is-open" ref={menuRef} style={{ ...menuStyle, "--picker-columns": columns }} aria-label={label}>
|
||||||
|
{children({ close })}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<section className="equipment-planner-summary" aria-label={textContent.summaryTitle || "Résumé"}>
|
||||||
|
<div className="equipment-planner-summary-header">
|
||||||
|
<h3>{textContent.summaryTitle || "Résumé"}</h3>
|
||||||
|
<span>{summary.activeEquipmentCount} {summary.activeEquipmentCount > 1 ? textContent.activeEquipmentsPlural || "équipements actifs" : textContent.activeEquipmentsSingular || "équipement actif"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="equipment-planner-summary-grid">
|
||||||
|
{hasSourceTotals ? (
|
||||||
|
<div className={`equipment-planner-summary-source-grid columns-${sourceColumnCount}`}>
|
||||||
|
{summary.equipmentTotals.length ? <SummaryTotalsPanel title={textContent.equipmentTotalsTitle || "Statistiques de l'équipement"} totals={summary.equipmentTotals} textContent={textContent} /> : null}
|
||||||
|
{summary.socketTotals.length ? <SummaryTotalsPanel title={textContent.socketTotalsTitle || "Objets sertis"} totals={summary.socketTotals} textContent={textContent} /> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{summary.mixedTotals.length ? <SummaryTotalsPanel title={textContent.mixedTotalsTitle || "Totaux fusionnés"} totals={summary.mixedTotals} textContent={textContent} /> : null}
|
||||||
|
{!summary.totals.length ? (
|
||||||
|
<div className="equipment-planner-summary-panel">
|
||||||
|
<strong>{textContent.numericTotalsTitle || "Totaux"}</strong>
|
||||||
|
<p>{textContent.emptyTotals || "Aucun total numérique."}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{summary.textBonuses.length ? (
|
||||||
|
<div className="equipment-planner-summary-panel equipment-planner-summary-text-bonuses">
|
||||||
|
<strong>{textContent.textBonusesTitle || "Bonus"}</strong>
|
||||||
|
<ul>
|
||||||
|
{summary.textBonuses.map((bonus) => (
|
||||||
|
<li key={`${bonus.source}:${bonus.id}`}>
|
||||||
|
<span>{bonus.category ? `${bonus.category} · ` : ""}{bonus.name}</span>
|
||||||
|
<small>{bonus.source}{bonus.value ? ` · ${bonus.value}` : ""}</small>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryTotalsPanel({ title, totals, textContent }) {
|
||||||
|
return (
|
||||||
|
<div className="equipment-planner-summary-panel">
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<ul>
|
||||||
|
{totals.map((total) => (
|
||||||
|
<Tooltip
|
||||||
|
as="li"
|
||||||
|
className="equipment-planner-summary-total"
|
||||||
|
content={<SummaryTotalTooltip total={total} textContent={textContent} />}
|
||||||
|
key={total.name}
|
||||||
|
position="top-start"
|
||||||
|
>
|
||||||
|
<SummaryTotalIcon total={total} />
|
||||||
|
<span>{total.name}</span>
|
||||||
|
<b>{formatValue(total.value)}</b>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryTotalIcon({ total }) {
|
||||||
|
if (SOCKET_SHAPES.includes(total.icon)) return <SocketShapeIcon shape={total.icon} color={total.color || "gray"} />;
|
||||||
|
return <Icon name={total.icon || "sword"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryTotalTooltip({ total, textContent }) {
|
||||||
|
return (
|
||||||
|
<span className="equipment-planner-summary-tooltip">
|
||||||
|
<strong>{textContent.summaryTooltipTitle || "Provenance"}</strong>
|
||||||
|
<span>
|
||||||
|
{(total.contributions || []).map((contribution) => (
|
||||||
|
<span className="equipment-planner-summary-tooltip-row" key={`${contribution.sourceKind}:${contribution.id}:${contribution.sourceName}`}>
|
||||||
|
<Icon name={contribution.equipmentIcon || "shield"} />
|
||||||
|
<span>{contribution.sourceName || contribution.equipmentName}</span>
|
||||||
|
<b>{formatValue(contribution.value)}</b>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -62,3 +62,9 @@ export function createLocalId(prefix) {
|
||||||
export function getSocketItemName(equipment, socketItemId) {
|
export function getSocketItemName(equipment, socketItemId) {
|
||||||
return equipment.socketItems.find((socketItem) => socketItem.id === socketItemId)?.name || "";
|
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";
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue