Add equipment planner toolbox module
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
ac33a55c8b
commit
d53d4160af
33 changed files with 3522 additions and 20 deletions
|
|
@ -4,6 +4,7 @@ import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.j
|
|||
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
|
||||
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
|
||||
import { Icon } from "./Icon.jsx";
|
||||
import { Tooltip } from "./Tooltip.jsx";
|
||||
|
||||
const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
|
||||
const DRAWING_WIDTHS = [2, 4, 8, 12];
|
||||
|
|
@ -188,14 +189,16 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
/>
|
||||
)}
|
||||
{viewerMarkers.map((marker, index) => (
|
||||
<span
|
||||
<Tooltip
|
||||
as="span"
|
||||
className="annotation-marker image-viewer-marker"
|
||||
key={marker.id || `${marker.x}-${marker.y}-${index}`}
|
||||
content={marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}
|
||||
position="top"
|
||||
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
|
||||
title={marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
70
website/src/components/Tooltip.jsx
Normal file
70
website/src/components/Tooltip.jsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// Rôle : fournit une infobulle accessible et réutilisable pour les contrôles et données denses.
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function Tooltip({ as: Component = "span", children, content, className = "", tooltipClassName = "", position = "top", ...props }) {
|
||||
const tooltipId = useId();
|
||||
const anchorRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [style, setStyle] = useState({});
|
||||
|
||||
function placeTooltip() {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
if (position === "top-start") {
|
||||
setStyle({ top: `${Math.max(8, rect.top - 8)}px`, left: `${Math.max(8, rect.left)}px` });
|
||||
return;
|
||||
}
|
||||
setStyle({ top: `${Math.max(8, rect.top - 8)}px`, left: `${Math.min(window.innerWidth - 8, Math.max(8, rect.left + rect.width / 2))}px` });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
placeTooltip();
|
||||
window.addEventListener("resize", placeTooltip);
|
||||
window.addEventListener("scroll", placeTooltip, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", placeTooltip);
|
||||
window.removeEventListener("scroll", placeTooltip, true);
|
||||
};
|
||||
}, [open, position]);
|
||||
|
||||
if (!content) return <Component className={className} {...props}>{children}</Component>;
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
className={`ui-tooltip-anchor ${className}`.trim()}
|
||||
ref={anchorRef}
|
||||
tabIndex={props.tabIndex ?? 0}
|
||||
aria-describedby={tooltipId}
|
||||
onMouseEnter={(event) => {
|
||||
placeTooltip();
|
||||
setOpen(true);
|
||||
props.onMouseEnter?.(event);
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
setOpen(false);
|
||||
props.onMouseLeave?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
placeTooltip();
|
||||
setOpen(true);
|
||||
props.onFocus?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setOpen(false);
|
||||
props.onBlur?.(event);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{open && createPortal(
|
||||
<span id={tooltipId} className={`ui-tooltip ui-tooltip-${position} ${tooltipClassName}`.trim()} role="tooltip" style={style}>
|
||||
{content}
|
||||
</span>,
|
||||
document.body
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,12 +17,14 @@ import {
|
|||
normalizeChecklistData,
|
||||
normalizeCombosData,
|
||||
normalizeCountersData,
|
||||
normalizeEquipmentPlannerData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeLinksData,
|
||||
normalizeNotepadData,
|
||||
normalizeTableData,
|
||||
normalizeTaskPlannerData,
|
||||
normalizeTimerData,
|
||||
summarizeEquipmentPlannerData,
|
||||
normalizeUrl,
|
||||
uid
|
||||
} from "./storage/toolboxStorage.js";
|
||||
|
|
@ -152,12 +154,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
normalizeCombosData,
|
||||
normalizeLinksData,
|
||||
normalizeCountersData,
|
||||
normalizeEquipmentPlannerData,
|
||||
normalizeCalculatorData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeNotepadData,
|
||||
normalizeTableData,
|
||||
normalizeTimerData,
|
||||
normalizeTaskPlannerData,
|
||||
summarizeEquipmentPlannerData,
|
||||
normalizeUrl,
|
||||
hostnameFromUrl,
|
||||
copyText,
|
||||
|
|
|
|||
1057
website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx
Normal file
1057
website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
||||
|
||||
function markerLabel(index, marker, textContent) {
|
||||
|
|
@ -160,14 +161,16 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
|||
)}
|
||||
</div>
|
||||
{markers.map((marker, index) => (
|
||||
<span
|
||||
<Tooltip
|
||||
as="span"
|
||||
key={marker.id}
|
||||
className="annotation-marker"
|
||||
content={markerLabel(index, marker, textContent)}
|
||||
position="top"
|
||||
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
|
||||
title={markerLabel(index, marker, textContent)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
<DrawingOverlay
|
||||
active={false}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { InlineNotice } from "../../../components/AppOverlays.jsx";
|
||||
import { Tooltip } from "../../../components/Tooltip.jsx";
|
||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
|
|
@ -867,9 +868,13 @@ function TaskPlannerItem({
|
|||
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
|
||||
</select>
|
||||
{missingPrerequisites.length > 0 && (
|
||||
<span className="task-planner-warning" title={textContent.missingPrerequisiteTitle || "Pré requis non effectué"}>
|
||||
<Tooltip
|
||||
className="task-planner-warning"
|
||||
content={<MissingPrerequisitesTooltip missingPrerequisites={missingPrerequisites} tasks={tasks} textContent={textContent} />}
|
||||
position="top-start"
|
||||
>
|
||||
{textContent.missingPrerequisiteBadge || "Pré requis"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="task-planner-actions">
|
||||
<button
|
||||
|
|
@ -947,6 +952,19 @@ function TaskPlannerItem({
|
|||
);
|
||||
}
|
||||
|
||||
function MissingPrerequisitesTooltip({ missingPrerequisites, tasks, textContent }) {
|
||||
return (
|
||||
<span className="task-planner-prerequisite-tooltip">
|
||||
<strong>{textContent.missingPrerequisiteTitle || "Pré requis non effectué"}</strong>
|
||||
<span>
|
||||
{missingPrerequisites.map((relation) => (
|
||||
<span key={relation.id}>{getTaskTitle(tasks, relation.toTaskId)}</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskResetSettings({ task, data, textContent, onUpdateTask }) {
|
||||
if (task.type === "daily") {
|
||||
const hasOverride = Boolean(task.dailyResetTime);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { ChecklistModule } from "./ChecklistModule.jsx";
|
|||
import { CombosModule } from "./CombosModule.jsx";
|
||||
import { CountersModule } from "./CountersModule.jsx";
|
||||
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
|
||||
import { EquipmentPlannerModule } from "./EquipmentPlannerModule.jsx";
|
||||
import { LinksModule } from "./LinksModule.jsx";
|
||||
import { NotepadModule } from "./NotepadModule.jsx";
|
||||
import { ImagesModule } from "./ImagesModule.jsx";
|
||||
|
|
@ -29,6 +30,7 @@ const MODULE_COMPONENTS = {
|
|||
table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true },
|
||||
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },
|
||||
taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true },
|
||||
equipmentPlanner: { label: "Planificateur d'équipements", icon: "chest-armor", Component: EquipmentPlannerModule, editable: true, scrollable: true },
|
||||
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,27 @@
|
|||
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
|
||||
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", combo: "o", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" };
|
||||
const ID_PREFIXES = {
|
||||
tbx: "t",
|
||||
mod: "m",
|
||||
item: "i",
|
||||
section: "g",
|
||||
image: "s",
|
||||
link: "l",
|
||||
counter: "c",
|
||||
combo: "o",
|
||||
calc: "r",
|
||||
marker: "k",
|
||||
timer: "z",
|
||||
task: "a",
|
||||
relation: "e",
|
||||
stroke: "d",
|
||||
equipmentType: "y",
|
||||
equipment: "q",
|
||||
trait: "v",
|
||||
material: "n",
|
||||
socketItem: "j",
|
||||
socketLink: "u",
|
||||
socketBonus: "b"
|
||||
};
|
||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]);
|
||||
const NOTEPAD_ALLOWED_TAGS = new Set(["p", "div", "br", "strong", "b", "em", "i", "u", "s", "strike", "ul", "ol", "li", "h3", "h4", "span", "mark"]);
|
||||
|
|
@ -21,6 +43,10 @@ const TIMER_MIN_AUTO_REFRESH_MS = 5 * 60 * 1000;
|
|||
const TASK_TYPES = new Set(["unique", "daily", "weekly"]);
|
||||
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
|
||||
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
|
||||
const EQUIPMENT_SOCKET_SHAPES = new Set(["ball", "jewel"]);
|
||||
const EQUIPMENT_SOCKET_COLORS = new Set(["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"]);
|
||||
const EQUIPMENT_TYPE_ICONS = new Set(["boot", "chest-armor", "helmet", "glove", "shield", "sword", "gun", "earring", "necklace", "ring"]);
|
||||
const EQUIPMENT_TRAIT_ICONS = new Set(["sword", "shield"]);
|
||||
const DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY = 1;
|
||||
const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00";
|
||||
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
|
||||
|
|
@ -56,6 +82,7 @@ const DEFAULT_MODULE_TITLES = {
|
|||
table: "Tableau",
|
||||
timer: "Timer",
|
||||
taskPlanner: "Planificateur de tâches",
|
||||
equipmentPlanner: "Planificateur d'équipements",
|
||||
imageAnnotation: "Annotation d'images"
|
||||
};
|
||||
|
||||
|
|
@ -678,6 +705,270 @@ export function normalizeTaskPlannerData(data) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeEquipmentPlannerType(type) {
|
||||
const title = String(type?.title || "").trim().slice(0, 80);
|
||||
if (!title) return null;
|
||||
const icon = EQUIPMENT_TYPE_ICONS.has(type?.icon) ? type.icon : "shield";
|
||||
return {
|
||||
id: type?.id || uid("equipmentType"),
|
||||
title,
|
||||
icon,
|
||||
collapsed: type?.collapsed === true
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEquipmentPlannerValue(value) {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : "";
|
||||
const text = String(value ?? "").trim().slice(0, 80);
|
||||
if (!text) return "";
|
||||
const numeric = Number(text.replace(",", "."));
|
||||
return Number.isFinite(numeric) && /^[-+]?\d+(?:[,.]\d+)?$/.test(text) ? Math.round(numeric * 1000) / 1000 : text;
|
||||
}
|
||||
|
||||
function normalizeEquipmentPlannerTrait(trait, prefix = "trait") {
|
||||
const name = String(trait?.name || "").trim().slice(0, 80);
|
||||
if (!name) return null;
|
||||
return {
|
||||
id: trait?.id || uid(prefix),
|
||||
category: String(trait?.category || "").trim().slice(0, 80),
|
||||
icon: EQUIPMENT_TRAIT_ICONS.has(trait?.icon) ? trait.icon : "sword",
|
||||
name,
|
||||
value: normalizeEquipmentPlannerValue(trait?.value)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEquipmentPlannerMaterial(material) {
|
||||
const name = String(material?.name || "").trim().slice(0, 80);
|
||||
if (!name) return null;
|
||||
return {
|
||||
id: material?.id || uid("material"),
|
||||
name,
|
||||
qty: Math.max(1, parsePositiveInt(material?.qty, 1))
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEquipmentPlannerSocketItem(socketItem) {
|
||||
const name = String(socketItem?.name || "").trim().slice(0, 80);
|
||||
if (!name) return null;
|
||||
const shape = EQUIPMENT_SOCKET_SHAPES.has(socketItem?.shape) ? socketItem.shape : "jewel";
|
||||
const color = EQUIPMENT_SOCKET_COLORS.has(socketItem?.color) ? socketItem.color : "yellow";
|
||||
return {
|
||||
id: socketItem?.id || uid("socketItem"),
|
||||
name,
|
||||
shape,
|
||||
color,
|
||||
bonuses: (Array.isArray(socketItem?.bonuses) ? socketItem.bonuses : [])
|
||||
.map((bonus) => normalizeEquipmentPlannerTrait(bonus, "socketBonus"))
|
||||
.filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEquipmentPlannerCategoryOrder(value, categories) {
|
||||
const available = new Set(categories.filter(Boolean));
|
||||
const ordered = [];
|
||||
(Array.isArray(value) ? value : []).forEach((category) => {
|
||||
const normalized = String(category || "").trim().slice(0, 80);
|
||||
if (normalized && available.has(normalized) && !ordered.includes(normalized)) ordered.push(normalized);
|
||||
});
|
||||
categories.forEach((category) => {
|
||||
if (category && !ordered.includes(category)) ordered.push(category);
|
||||
});
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function normalizeEquipmentPlannerEquipment(equipment, typeIds) {
|
||||
const name = String(equipment?.name || "").trim().slice(0, 100);
|
||||
const typeId = String(equipment?.typeId || "");
|
||||
if (!name || !typeIds.has(typeId)) return null;
|
||||
const characteristics = (Array.isArray(equipment?.characteristics) ? equipment.characteristics : [])
|
||||
.map((trait) => normalizeEquipmentPlannerTrait(trait, "trait"))
|
||||
.filter(Boolean);
|
||||
const socketItems = (Array.isArray(equipment?.socketItems) ? equipment.socketItems : [])
|
||||
.map(normalizeEquipmentPlannerSocketItem)
|
||||
.filter(Boolean);
|
||||
const socketItemIds = new Set(socketItems.map((socketItem) => socketItem.id));
|
||||
const socketLinks = (Array.isArray(equipment?.socketLinks) ? equipment.socketLinks : [])
|
||||
.map((link) => {
|
||||
const fromSocketItemId = String(link?.fromSocketItemId || "");
|
||||
const toSocketItemId = String(link?.toSocketItemId || "");
|
||||
if (!socketItemIds.has(fromSocketItemId) || !socketItemIds.has(toSocketItemId) || fromSocketItemId === toSocketItemId) return null;
|
||||
const pairKey = [fromSocketItemId, toSocketItemId].sort().join(":");
|
||||
return {
|
||||
id: link?.id || uid("socketLink"),
|
||||
fromSocketItemId,
|
||||
toSocketItemId,
|
||||
pairKey
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((link, index, links) => links.findIndex((item) => item.pairKey === link.pairKey) === index)
|
||||
.map(({ pairKey, ...link }) => link);
|
||||
const categories = [
|
||||
...characteristics.map((trait) => trait.category),
|
||||
...socketItems.flatMap((socketItem) => socketItem.bonuses.map((bonus) => bonus.category))
|
||||
].filter(Boolean);
|
||||
const categoryOrder = normalizeEquipmentPlannerCategoryOrder(equipment?.categoryOrder, categories);
|
||||
return {
|
||||
id: equipment?.id || uid("equipment"),
|
||||
typeId,
|
||||
name,
|
||||
icon: EQUIPMENT_TYPE_ICONS.has(equipment?.icon) ? equipment.icon : "shield",
|
||||
active: equipment?.active !== false,
|
||||
obtain: String(equipment?.obtain || "").trim().slice(0, 500),
|
||||
characteristics,
|
||||
socketItems,
|
||||
socketLinks,
|
||||
materials: (Array.isArray(equipment?.materials) ? equipment.materials : [])
|
||||
.map(normalizeEquipmentPlannerMaterial)
|
||||
.filter(Boolean),
|
||||
categoryOrder,
|
||||
collapsedCategories: (Array.isArray(equipment?.collapsedCategories) ? equipment.collapsedCategories : [])
|
||||
.map((category) => String(category || "").trim().slice(0, 80))
|
||||
.filter((category, index, collapsedCategories) => category && categoryOrder.includes(category) && collapsedCategories.indexOf(category) === index)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEquipmentPlannerData(data) {
|
||||
const rawTypes = (Array.isArray(data?.types) ? data.types : []).map(normalizeEquipmentPlannerType).filter(Boolean);
|
||||
const typeIds = new Set(rawTypes.map((type) => type.id));
|
||||
const equipments = (Array.isArray(data?.equipments) ? data.equipments : [])
|
||||
.map((equipment) => normalizeEquipmentPlannerEquipment(equipment, typeIds))
|
||||
.filter(Boolean);
|
||||
const usedTypeIds = new Set(equipments.map((equipment) => equipment.typeId));
|
||||
const types = rawTypes.filter((type) => usedTypeIds.has(type.id));
|
||||
const orderedTypeIds = (Array.isArray(data?.typeOrder) ? data.typeOrder : [])
|
||||
.map((typeId) => String(typeId || ""))
|
||||
.filter((typeId, index, order) => usedTypeIds.has(typeId) && order.indexOf(typeId) === index);
|
||||
return {
|
||||
types,
|
||||
equipments,
|
||||
typeOrder: [
|
||||
...orderedTypeIds,
|
||||
...types.map((type) => type.id).filter((typeId) => !orderedTypeIds.includes(typeId))
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeEquipmentPlannerData(data) {
|
||||
const normalized = normalizeEquipmentPlannerData(data);
|
||||
const totals = new Map();
|
||||
const textBonuses = [];
|
||||
const activeEquipments = normalized.equipments.filter((equipment) => equipment.active);
|
||||
|
||||
function incrementCount(counts, key) {
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
function pickMostUsedKey(counts) {
|
||||
return [...counts.entries()].reduce((picked, [key, count]) => {
|
||||
if (!picked) return { key, count };
|
||||
if (count !== picked.count) return count > picked.count ? { key, count } : picked;
|
||||
return key.localeCompare(picked.key) < 0 ? { key, count } : picked;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function parseIconKey(key) {
|
||||
const [icon, color = ""] = String(key || "").split(":");
|
||||
return { icon: icon || "sword", color };
|
||||
}
|
||||
|
||||
function pickSummaryIcon(iconSources) {
|
||||
return parseIconKey(pickMostUsedKey(iconSources.direct)?.key || pickMostUsedKey(iconSources.socket)?.key || "sword:");
|
||||
}
|
||||
|
||||
function getSummarySourceKind(sourceKinds) {
|
||||
if (sourceKinds.size !== 1) return "mixed";
|
||||
return sourceKinds.has("direct") ? "direct" : "socket";
|
||||
}
|
||||
|
||||
function collectEntry(entry, contribution, icon, sourceKind, color = "") {
|
||||
if (typeof entry.value === "number") {
|
||||
const existing = totals.get(entry.name);
|
||||
const iconKey = icon || "sword";
|
||||
const colorKey = sourceKind === "socket" ? color || "gray" : "";
|
||||
const iconSources = existing?.iconSources || { direct: new Map(), socket: new Map() };
|
||||
const sourceKinds = existing?.sourceKinds || new Set();
|
||||
const contributions = existing?.contributions || [];
|
||||
sourceKinds.add(sourceKind);
|
||||
incrementCount(iconSources[sourceKind], `${iconKey}:${colorKey}`);
|
||||
const pickedIcon = pickSummaryIcon(iconSources);
|
||||
totals.set(entry.name, {
|
||||
name: entry.name,
|
||||
icon: pickedIcon.icon,
|
||||
color: pickedIcon.color,
|
||||
sourceKind: getSummarySourceKind(sourceKinds),
|
||||
iconSources,
|
||||
sourceKinds,
|
||||
contributions: [
|
||||
...contributions,
|
||||
{
|
||||
id: entry.id,
|
||||
sourceKind,
|
||||
equipmentIcon: contribution.equipmentIcon,
|
||||
equipmentName: contribution.equipmentName,
|
||||
sourceName: contribution.sourceName,
|
||||
value: entry.value
|
||||
}
|
||||
],
|
||||
value: (existing?.value || 0) + entry.value
|
||||
});
|
||||
return;
|
||||
}
|
||||
textBonuses.push({
|
||||
id: entry.id,
|
||||
category: entry.category,
|
||||
name: entry.name,
|
||||
value: entry.value,
|
||||
source: contribution.sourceName
|
||||
});
|
||||
}
|
||||
|
||||
activeEquipments.forEach((equipment) => {
|
||||
equipment.characteristics.forEach((trait) => collectEntry(
|
||||
trait,
|
||||
{ equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: equipment.name },
|
||||
trait.icon || "sword",
|
||||
"direct"
|
||||
));
|
||||
equipment.socketItems.forEach((socketItem) => {
|
||||
socketItem.bonuses.forEach((bonus) => collectEntry(
|
||||
bonus,
|
||||
{ equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: `${equipment.name} · ${socketItem.name}` },
|
||||
socketItem.shape || "jewel",
|
||||
"socket",
|
||||
socketItem.color
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
const numericTotals = [...totals.values()]
|
||||
.map(({ iconSources, sourceKinds, color, sourceKind, ...total }) => ({
|
||||
...total,
|
||||
...(color ? { color } : {}),
|
||||
sourceKind
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
activeEquipmentCount: activeEquipments.length,
|
||||
totals: numericTotals,
|
||||
equipmentTotals: numericTotals.filter((total) => total.sourceKind === "direct"),
|
||||
socketTotals: numericTotals.filter((total) => total.sourceKind === "socket"),
|
||||
mixedTotals: numericTotals.filter((total) => total.sourceKind === "mixed"),
|
||||
textBonuses,
|
||||
socketItems: activeEquipments.flatMap((equipment) => equipment.socketItems.map((socketItem) => ({
|
||||
...socketItem,
|
||||
equipmentId: equipment.id,
|
||||
equipmentName: equipment.name
|
||||
}))),
|
||||
socketLinks: activeEquipments.flatMap((equipment) => equipment.socketLinks.map((link) => ({
|
||||
...link,
|
||||
equipmentId: equipment.id,
|
||||
equipmentName: equipment.name
|
||||
})))
|
||||
};
|
||||
}
|
||||
|
||||
function clampPercent(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
|
|
@ -716,6 +1007,64 @@ function compactChecklistSectionForStorage(section) {
|
|||
return compact;
|
||||
}
|
||||
|
||||
function compactEquipmentPlannerTraitForStorage(trait, prefix = "trait") {
|
||||
const normalized = normalizeEquipmentPlannerTrait(trait, prefix);
|
||||
if (!normalized) return null;
|
||||
const compact = { id: normalized.id, name: normalized.name };
|
||||
if (normalized.category) compact.category = normalized.category;
|
||||
if (normalized.icon !== "sword") compact.icon = normalized.icon;
|
||||
if (normalized.value !== "") compact.value = normalized.value;
|
||||
return compact;
|
||||
}
|
||||
|
||||
function compactEquipmentPlannerMaterialForStorage(material) {
|
||||
const normalized = normalizeEquipmentPlannerMaterial(material);
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function compactEquipmentPlannerSocketItemForStorage(socketItem) {
|
||||
const normalized = normalizeEquipmentPlannerSocketItem(socketItem);
|
||||
if (!normalized) return null;
|
||||
const compact = {
|
||||
id: normalized.id,
|
||||
name: normalized.name,
|
||||
shape: normalized.shape,
|
||||
color: normalized.color
|
||||
};
|
||||
if (normalized.bonuses.length) {
|
||||
compact.bonuses = normalized.bonuses
|
||||
.map((bonus) => compactEquipmentPlannerTraitForStorage(bonus, "socketBonus"))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
function compactEquipmentPlannerEquipmentForStorage(equipment) {
|
||||
const compact = {
|
||||
id: equipment.id,
|
||||
typeId: equipment.typeId,
|
||||
name: equipment.name
|
||||
};
|
||||
if (equipment.icon !== "shield") compact.icon = equipment.icon;
|
||||
if (equipment.active === false) compact.active = false;
|
||||
if (equipment.obtain) compact.obtain = equipment.obtain;
|
||||
if (equipment.characteristics.length) {
|
||||
compact.characteristics = equipment.characteristics
|
||||
.map((trait) => compactEquipmentPlannerTraitForStorage(trait, "trait"))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (equipment.socketItems.length) {
|
||||
compact.socketItems = equipment.socketItems
|
||||
.map(compactEquipmentPlannerSocketItemForStorage)
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (equipment.socketLinks.length) compact.socketLinks = equipment.socketLinks;
|
||||
if (equipment.materials.length) compact.materials = equipment.materials.map(compactEquipmentPlannerMaterialForStorage).filter(Boolean);
|
||||
if (equipment.categoryOrder.length) compact.categoryOrder = equipment.categoryOrder;
|
||||
if (equipment.collapsedCategories.length) compact.collapsedCategories = equipment.collapsedCategories;
|
||||
return compact;
|
||||
}
|
||||
|
||||
export function compactModuleDataForStorage(type, value) {
|
||||
if (type === "notepad") {
|
||||
const normalized = normalizeNotepadData(value);
|
||||
|
|
@ -889,6 +1238,21 @@ export function compactModuleDataForStorage(type, value) {
|
|||
}
|
||||
return Object.keys(compact).length ? compact : null;
|
||||
}
|
||||
if (type === "equipmentPlanner") {
|
||||
const normalized = normalizeEquipmentPlannerData(value);
|
||||
if (!normalized.equipments.length) return null;
|
||||
const compact = {
|
||||
types: normalized.types.map((type) => {
|
||||
const compactType = { id: type.id, title: type.title };
|
||||
if (type.icon !== "shield") compactType.icon = type.icon;
|
||||
if (type.collapsed) compactType.collapsed = true;
|
||||
return compactType;
|
||||
}),
|
||||
equipments: normalized.equipments.map(compactEquipmentPlannerEquipmentForStorage)
|
||||
};
|
||||
if (normalized.typeOrder.length) compact.typeOrder = normalized.typeOrder;
|
||||
return compact;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
|
@ -1000,6 +1364,41 @@ function remapModuleDataForExport(type, data, nextId) {
|
|||
return remapped;
|
||||
}
|
||||
|
||||
if (type === "equipmentPlanner") {
|
||||
const typeIdMap = new Map();
|
||||
const equipmentIdMap = new Map();
|
||||
compact.types?.forEach((typeEntry) => typeIdMap.set(typeEntry.id, nextId("equipmentType")));
|
||||
compact.equipments?.forEach((equipment) => equipmentIdMap.set(equipment.id, nextId("equipment")));
|
||||
const remapped = {
|
||||
...compact,
|
||||
types: (compact.types || []).map((typeEntry) => ({ ...typeEntry, id: typeIdMap.get(typeEntry.id) })),
|
||||
equipments: (compact.equipments || []).map((equipment) => {
|
||||
const socketItemIdMap = new Map();
|
||||
equipment.socketItems?.forEach((socketItem) => socketItemIdMap.set(socketItem.id, nextId("socketItem")));
|
||||
return {
|
||||
...equipment,
|
||||
id: equipmentIdMap.get(equipment.id),
|
||||
typeId: typeIdMap.get(equipment.typeId) || "",
|
||||
characteristics: equipment.characteristics?.map((trait) => ({ ...trait, id: nextId("trait") })),
|
||||
materials: equipment.materials?.map((material) => ({ ...material, id: nextId("material") })),
|
||||
socketItems: equipment.socketItems?.map((socketItem) => ({
|
||||
...socketItem,
|
||||
id: socketItemIdMap.get(socketItem.id),
|
||||
bonuses: socketItem.bonuses?.map((bonus) => ({ ...bonus, id: nextId("socketBonus") }))
|
||||
})),
|
||||
socketLinks: equipment.socketLinks?.map((link) => ({
|
||||
...link,
|
||||
id: nextId("socketLink"),
|
||||
fromSocketItemId: socketItemIdMap.get(link.fromSocketItemId),
|
||||
toSocketItemId: socketItemIdMap.get(link.toSocketItemId)
|
||||
})).filter((link) => link.fromSocketItemId && link.toSocketItemId)
|
||||
};
|
||||
}).filter((equipment) => equipment.typeId)
|
||||
};
|
||||
if (compact.typeOrder) remapped.typeOrder = compact.typeOrder.map((typeId) => typeIdMap.get(typeId)).filter(Boolean);
|
||||
return remapped;
|
||||
}
|
||||
|
||||
return compact;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { CalculatorModule } from "../features/toolboxes/modules/CalculatorModule
|
|||
import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx";
|
||||
import { CombosModule } from "../features/toolboxes/modules/CombosModule.jsx";
|
||||
import { CountersModule } from "../features/toolboxes/modules/CountersModule.jsx";
|
||||
import { EquipmentPlannerModule } from "../features/toolboxes/modules/EquipmentPlannerModule.jsx";
|
||||
import { ImageAnnotationModule } from "../features/toolboxes/modules/ImageAnnotationModule.jsx";
|
||||
import { ImagesModule } from "../features/toolboxes/modules/ImagesModule.jsx";
|
||||
import { LinksModule } from "../features/toolboxes/modules/LinksModule.jsx";
|
||||
|
|
@ -19,12 +20,14 @@ import {
|
|||
normalizeChecklistData,
|
||||
normalizeCombosData,
|
||||
normalizeCountersData,
|
||||
normalizeEquipmentPlannerData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeLinksData,
|
||||
normalizeNotepadData,
|
||||
normalizeTableData,
|
||||
normalizeTaskPlannerData,
|
||||
normalizeTimerData,
|
||||
summarizeEquipmentPlannerData,
|
||||
normalizeUrl,
|
||||
uid
|
||||
} from "../features/toolboxes/storage/toolboxStorage.js";
|
||||
|
|
@ -42,6 +45,7 @@ const MODULE_COMPONENTS = {
|
|||
table: { label: "Tableau", mode: "Grille + formules", icon: "table", Component: TableModule, scrollable: true },
|
||||
timer: { label: "Timer", mode: "Chrono + comptes à rebours", icon: "clock", Component: TimerModule },
|
||||
taskPlanner: { label: "Planificateur de tâches", mode: "Catégories + parent/enfant", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true },
|
||||
equipmentPlanner: { label: "Planificateur d'équipements", mode: "Build + sertissages", icon: "chest-armor", Component: EquipmentPlannerModule, editable: true, scrollable: true },
|
||||
imageAnnotation: { label: "Annotation d'images", mode: "Image + marqueurs", icon: "map", Component: ImageAnnotationModule, editable: true }
|
||||
};
|
||||
|
||||
|
|
@ -128,12 +132,14 @@ export function LibraryPage({ siteContent, actions }) {
|
|||
normalizeCombosData,
|
||||
normalizeLinksData,
|
||||
normalizeCountersData,
|
||||
normalizeEquipmentPlannerData,
|
||||
normalizeCalculatorData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeNotepadData,
|
||||
normalizeTableData,
|
||||
normalizeTimerData,
|
||||
normalizeTaskPlannerData,
|
||||
summarizeEquipmentPlannerData,
|
||||
normalizeUrl,
|
||||
hostnameFromUrl,
|
||||
clampQty,
|
||||
|
|
|
|||
46
website/src/styles/_components.scss
Normal file
46
website/src/styles/_components.scss
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Rôle : regroupe les styles de composants UI partagés hors layout global.
|
||||
.ui-tooltip-anchor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ui-tooltip {
|
||||
position: fixed;
|
||||
z-index: 520;
|
||||
width: max-content;
|
||||
max-width: min(280px, calc(100vw - 24px));
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(165, 180, 252, 0.18);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(7, 10, 24, 0.98);
|
||||
box-shadow: var(--shadow-md);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.35;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -100%);
|
||||
transition:
|
||||
opacity var(--duration-fast) var(--ease-standard),
|
||||
transform var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ui-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 1px solid rgba(165, 180, 252, 0.18);
|
||||
border-bottom: 1px solid rgba(165, 180, 252, 0.18);
|
||||
background: rgba(7, 10, 24, 0.98);
|
||||
transform: translate(-50%, -4px) rotate(45deg);
|
||||
}
|
||||
|
||||
.ui-tooltip-top-start {
|
||||
transform: translate(0, -100%);
|
||||
}
|
||||
|
||||
.ui-tooltip-top-start::after {
|
||||
left: 16px;
|
||||
}
|
||||
|
|
@ -262,11 +262,36 @@
|
|||
-webkit-mask-image: url("/static/icons/foot.svg");
|
||||
}
|
||||
|
||||
.ui-icon-boot {
|
||||
mask-image: url("/static/icons/boot.svg");
|
||||
-webkit-mask-image: url("/static/icons/boot.svg");
|
||||
}
|
||||
|
||||
.ui-icon-chest-armor {
|
||||
mask-image: url("/static/icons/chest-armor.svg");
|
||||
-webkit-mask-image: url("/static/icons/chest-armor.svg");
|
||||
}
|
||||
|
||||
.ui-icon-helmet {
|
||||
mask-image: url("/static/icons/helmet.svg");
|
||||
-webkit-mask-image: url("/static/icons/helmet.svg");
|
||||
}
|
||||
|
||||
.ui-icon-glove {
|
||||
mask-image: url("/static/icons/glove.svg");
|
||||
-webkit-mask-image: url("/static/icons/glove.svg");
|
||||
}
|
||||
|
||||
.ui-icon-grab {
|
||||
mask-image: url("/static/icons/grab.svg");
|
||||
-webkit-mask-image: url("/static/icons/grab.svg");
|
||||
}
|
||||
|
||||
.ui-icon-sword {
|
||||
mask-image: url("/static/icons/sword.svg");
|
||||
-webkit-mask-image: url("/static/icons/sword.svg");
|
||||
}
|
||||
|
||||
.ui-icon-shield {
|
||||
mask-image: url("/static/icons/shield.svg");
|
||||
-webkit-mask-image: url("/static/icons/shield.svg");
|
||||
|
|
@ -277,6 +302,36 @@
|
|||
-webkit-mask-image: url("/static/icons/gun.svg");
|
||||
}
|
||||
|
||||
.ui-icon-earring {
|
||||
mask-image: url("/static/icons/earring.svg");
|
||||
-webkit-mask-image: url("/static/icons/earring.svg");
|
||||
}
|
||||
|
||||
.ui-icon-necklace {
|
||||
mask-image: url("/static/icons/necklace.svg");
|
||||
-webkit-mask-image: url("/static/icons/necklace.svg");
|
||||
}
|
||||
|
||||
.ui-icon-ring {
|
||||
mask-image: url("/static/icons/ring.svg");
|
||||
-webkit-mask-image: url("/static/icons/ring.svg");
|
||||
}
|
||||
|
||||
.ui-icon-hammer {
|
||||
mask-image: url("/static/icons/hammer.svg");
|
||||
-webkit-mask-image: url("/static/icons/hammer.svg");
|
||||
}
|
||||
|
||||
.ui-icon-jewel {
|
||||
mask-image: url("/static/icons/jewel.svg");
|
||||
-webkit-mask-image: url("/static/icons/jewel.svg");
|
||||
}
|
||||
|
||||
.ui-icon-ball {
|
||||
mask-image: url("/static/icons/ball.svg");
|
||||
-webkit-mask-image: url("/static/icons/ball.svg");
|
||||
}
|
||||
|
||||
.ui-icon-trash {
|
||||
mask-image: url("/static/icons/trashcan.svg");
|
||||
-webkit-mask-image: url("/static/icons/trashcan.svg");
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@
|
|||
@use "shell";
|
||||
@use "home";
|
||||
@use "cards";
|
||||
@use "components";
|
||||
@use "icons";
|
||||
@use "games";
|
||||
@use "toolboxes";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue