Add equipment planner toolbox module
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-09 10:03:55 +02:00
parent ac33a55c8b
commit d53d4160af
33 changed files with 3522 additions and 20 deletions

View file

@ -77,6 +77,12 @@ Privilégier :
Éviter les styles isolés qui ne réutilisent pas le thème. Éviter les styles isolés qui ne réutilisent pas le thème.
## Serveur de développement
Si le port `5173` est déjà occupé, considérer qu'un `npm run dev` est probablement déjà en cours. Ne pas lancer automatiquement un nouveau serveur sur `5174` dans ce cas.
Quand une vérification navigateur est nécessaire après des modifications, utiliser l'instance déjà ouverte sur `http://localhost:5173` si elle répond.
## Validation ## Validation
Avant de terminer une modification significative, lancer : Avant de terminer une modification significative, lancer :

View file

@ -165,6 +165,91 @@ Stockage compact :
- `collapsed` est omis si `false`. - `collapsed` est omis si `false`.
- les sections sans item sont ignorées. - les sections sans item sont ignorées.
## Outil Planificateur D'équipements
Type : `equipmentPlanner`
```json
{
"types": [
{
"id": "type1",
"title": "Armes",
"icon": "sword",
"collapsed": false
}
],
"equipments": [
{
"id": "equipment1",
"typeId": "type1",
"name": "Épée runique",
"icon": "sword",
"active": true,
"obtain": "Forge après le boss",
"characteristics": [
{
"id": "trait1",
"category": "Stats",
"icon": "sword",
"name": "Force",
"value": 12
}
],
"socketItems": [
{
"id": "socket1",
"name": "Joyau attaque",
"shape": "jewel",
"color": "yellow",
"bonuses": [
{
"id": "bonus1",
"category": "Compétences",
"name": "Attaque",
"value": 1
}
]
}
],
"socketLinks": [
{
"id": "link1",
"fromSocketItemId": "socket1",
"toSocketItemId": "socket2"
}
],
"materials": [
{
"id": "material1",
"name": "Minerai rare",
"qty": 4
}
],
"categoryOrder": ["Stats", "Compétences"],
"collapsedCategories": []
}
],
"typeOrder": ["type1"]
}
```
Stockage compact :
- les types sans équipement sont ignorés ;
- `icon` des types vaut `boot`, `chest-armor`, `helmet`, `glove`, `shield`, `sword`, `gun`, `earring`, `necklace` ou `ring` ;
- `icon` des équipements utilise la même liste et pilote l'icône affichée sur la ligne de l'équipement ;
- `active` est omis quand la valeur vaut `true` ;
- `obtain`, `characteristics`, `socketItems`, `socketLinks`, `materials`, `categoryOrder` et `collapsedCategories` sont omis si vides ;
- `collapsed` est omis si `false` ;
- `qty` des matériaux est normalisé à minimum `1` ;
- les caractéristiques et bonus sans nom sont ignorés ;
- `icon` des caractéristiques directes vaut `sword` ou `shield` ;
- `shape` vaut `ball` ou `jewel`, avec `jewel` par défaut ;
- `color` vaut `red`, `orange`, `amber`, `yellow`, `lime`, `green`, `teal`, `cyan`, `blue`, `indigo`, `violet`, `purple`, `pink`, `rose`, `white`, `gray` ou `black` ;
- les liens de sertissage invalides, dupliqués ou hors équipement sont ignorés ;
- seuls les équipements actifs contribuent au résumé.
## Outil Images ## Outil Images
Type : `images` Type : `images`

View file

@ -332,6 +332,73 @@ export function validateSiteContent(site) {
"toolboxes.modules.taskPlanner.prerequisiteCheckboxLabel", "toolboxes.modules.taskPlanner.prerequisiteCheckboxLabel",
"toolboxes.modules.taskPlanner.removeRelationTitle", "toolboxes.modules.taskPlanner.removeRelationTitle",
"toolboxes.modules.taskPlanner.emptyTasks", "toolboxes.modules.taskPlanner.emptyTasks",
"toolboxes.modules.equipmentPlanner.typePlaceholder",
"toolboxes.modules.equipmentPlanner.equipmentIconLabel",
"toolboxes.modules.equipmentPlanner.equipmentPlaceholder",
"toolboxes.modules.equipmentPlanner.addEquipmentButton",
"toolboxes.modules.equipmentPlanner.summaryTitle",
"toolboxes.modules.equipmentPlanner.activeEquipmentsSingular",
"toolboxes.modules.equipmentPlanner.activeEquipmentsPlural",
"toolboxes.modules.equipmentPlanner.numericTotalsTitle",
"toolboxes.modules.equipmentPlanner.equipmentTotalsTitle",
"toolboxes.modules.equipmentPlanner.socketTotalsTitle",
"toolboxes.modules.equipmentPlanner.mixedTotalsTitle",
"toolboxes.modules.equipmentPlanner.summaryTooltipTitle",
"toolboxes.modules.equipmentPlanner.textBonusesTitle",
"toolboxes.modules.equipmentPlanner.emptyTotals",
"toolboxes.modules.equipmentPlanner.emptyTextBonuses",
"toolboxes.modules.equipmentPlanner.emptyEquipments",
"toolboxes.modules.equipmentPlanner.activeLabel",
"toolboxes.modules.equipmentPlanner.includeInSummaryLabel",
"toolboxes.modules.equipmentPlanner.reorderTypeTitle",
"toolboxes.modules.equipmentPlanner.renameTypeTitle",
"toolboxes.modules.equipmentPlanner.showTypeTitle",
"toolboxes.modules.equipmentPlanner.hideTypeTitle",
"toolboxes.modules.equipmentPlanner.reorderEquipmentTitle",
"toolboxes.modules.equipmentPlanner.renameEquipmentTitle",
"toolboxes.modules.equipmentPlanner.showDetailsTitle",
"toolboxes.modules.equipmentPlanner.hideDetailsTitle",
"toolboxes.modules.equipmentPlanner.deleteEquipmentTitle",
"toolboxes.modules.equipmentPlanner.tabsLabel",
"toolboxes.modules.equipmentPlanner.statsTab",
"toolboxes.modules.equipmentPlanner.socketsTab",
"toolboxes.modules.equipmentPlanner.craftTab",
"toolboxes.modules.equipmentPlanner.displayStatsTitle",
"toolboxes.modules.equipmentPlanner.displaySocketTitle",
"toolboxes.modules.equipmentPlanner.editModeTitle",
"toolboxes.modules.equipmentPlanner.viewModeTitle",
"toolboxes.modules.equipmentPlanner.editModeButton",
"toolboxes.modules.equipmentPlanner.viewModeButton",
"toolboxes.modules.equipmentPlanner.obtainLabel",
"toolboxes.modules.equipmentPlanner.obtainPlaceholder",
"toolboxes.modules.equipmentPlanner.emptyObtain",
"toolboxes.modules.equipmentPlanner.characteristicsTitle",
"toolboxes.modules.equipmentPlanner.traitIconLabel",
"toolboxes.modules.equipmentPlanner.categoryPlaceholder",
"toolboxes.modules.equipmentPlanner.traitNamePlaceholder",
"toolboxes.modules.equipmentPlanner.traitValuePlaceholder",
"toolboxes.modules.equipmentPlanner.addTraitButton",
"toolboxes.modules.equipmentPlanner.reorderTraitTitle",
"toolboxes.modules.equipmentPlanner.reorderCategoryTitle",
"toolboxes.modules.equipmentPlanner.deleteTraitTitle",
"toolboxes.modules.equipmentPlanner.emptyCharacteristics",
"toolboxes.modules.equipmentPlanner.materialsTitle",
"toolboxes.modules.equipmentPlanner.materialNamePlaceholder",
"toolboxes.modules.equipmentPlanner.materialQtyLabel",
"toolboxes.modules.equipmentPlanner.addMaterialButton",
"toolboxes.modules.equipmentPlanner.deleteMaterialTitle",
"toolboxes.modules.equipmentPlanner.socketItemsTitle",
"toolboxes.modules.equipmentPlanner.emptySocketItems",
"toolboxes.modules.equipmentPlanner.socketNamePlaceholder",
"toolboxes.modules.equipmentPlanner.socketShapeLabel",
"toolboxes.modules.equipmentPlanner.socketColorLabel",
"toolboxes.modules.equipmentPlanner.addSocketButton",
"toolboxes.modules.equipmentPlanner.bonusNamePlaceholder",
"toolboxes.modules.equipmentPlanner.addBonusButton",
"toolboxes.modules.equipmentPlanner.deleteSocketTitle",
"toolboxes.modules.equipmentPlanner.deleteBonusTitle",
"toolboxes.modules.equipmentPlanner.addSocketLinkLabel",
"toolboxes.modules.equipmentPlanner.deleteSocketLinkTitle",
"toolboxes.modules.imageAnnotation.addImage", "toolboxes.modules.imageAnnotation.addImage",
"toolboxes.modules.imageAnnotation.replaceImage", "toolboxes.modules.imageAnnotation.replaceImage",
"toolboxes.modules.imageAnnotation.pastePlaceholder", "toolboxes.modules.imageAnnotation.pastePlaceholder",
@ -432,7 +499,7 @@ export function validateLibraryData(payload) {
assert.equal(typeof payload.modules[module.id], "object", `library.modules.${module.id} must be an object`); assert.equal(typeof payload.modules[module.id], "object", `library.modules.${module.id} must be an object`);
}); });
["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "taskPlanner", "imageAnnotation"].forEach((type) => { ["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "taskPlanner", "equipmentPlanner", "imageAnnotation"].forEach((type) => {
assert.ok(payload.toolbox.modules.some((module) => module.type === type), `library must include a ${type} example`); assert.ok(payload.toolbox.modules.some((module) => module.type === type), `library must include a ${type} example`);
}); });
@ -451,6 +518,12 @@ export function validateLibraryData(payload) {
assert.ok(Array.isArray(taskData.tasks), "library task planner example must contain tasks"); assert.ok(Array.isArray(taskData.tasks), "library task planner example must contain tasks");
assert.ok(Array.isArray(taskData.relations), "library task planner example must contain relations"); assert.ok(Array.isArray(taskData.relations), "library task planner example must contain relations");
assert.ok(taskData.relations.length >= 1, "library task planner example must include a parent/child relation"); assert.ok(taskData.relations.length >= 1, "library task planner example must include a parent/child relation");
const equipmentModule = payload.toolbox.modules.find((module) => module.type === "equipmentPlanner");
const equipmentData = payload.modules[equipmentModule.id];
assert.ok(Array.isArray(equipmentData.types), "library equipment planner example must contain types");
assert.ok(Array.isArray(equipmentData.equipments), "library equipment planner example must contain equipments");
assert.ok(equipmentData.equipments.some((equipment) => Array.isArray(equipment.socketItems) && equipment.socketItems.length), "library equipment planner example must include socket items");
} }
function assertSnakeCase(value, path) { function assertSnakeCase(value, path) {

View file

@ -66,6 +66,9 @@ test("vite entrypoint and app shell are wired", async () => {
assert.match(libraryPage, /export function LibraryPage/); assert.match(libraryPage, /export function LibraryPage/);
assert.match(libraryPage, /LIBRARY_TOOLBOX_ID/); assert.match(libraryPage, /LIBRARY_TOOLBOX_ID/);
assert.match(libraryPage, /TaskPlannerModule/); assert.match(libraryPage, /TaskPlannerModule/);
assert.match(libraryPage, /EquipmentPlannerModule/);
assert.match(libraryPage, /normalizeEquipmentPlannerData/);
assert.match(libraryPage, /summarizeEquipmentPlannerData/);
assert.match(bodyScrollLock, /modalLocks/); assert.match(bodyScrollLock, /modalLocks/);
assert.match(bodyScrollLock, /is-modal-open/); assert.match(bodyScrollLock, /is-modal-open/);
}); });

View file

@ -78,6 +78,7 @@ test("style entrypoint and theme layers are wired", async () => {
assert.match(styleIcons, /ui-icon-grab/); assert.match(styleIcons, /ui-icon-grab/);
assert.match(styleIcons, /ui-icon-shield/); assert.match(styleIcons, /ui-icon-shield/);
assert.match(styleIcons, /ui-icon-gun/); assert.match(styleIcons, /ui-icon-gun/);
assert.match(styleToolboxes, /module-icon-chest-armor/);
assert.match(styleToolboxes, /\.combo-hold-controls/); assert.match(styleToolboxes, /\.combo-hold-controls/);
assert.match(styleToolboxes, /\.combo-input-direction/); assert.match(styleToolboxes, /\.combo-input-direction/);
assert.match(styleToolboxes, /\.combo-device-n64\.combo-value-stick-up/); assert.match(styleToolboxes, /\.combo-device-n64\.combo-value-stick-up/);

View file

@ -43,6 +43,8 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(toolboxStorage, /export function normalizeTableData/); assert.match(toolboxStorage, /export function normalizeTableData/);
assert.match(toolboxStorage, /export function normalizeTimerData/); assert.match(toolboxStorage, /export function normalizeTimerData/);
assert.match(toolboxStorage, /export function normalizeTaskPlannerData/); assert.match(toolboxStorage, /export function normalizeTaskPlannerData/);
assert.match(toolboxStorage, /export function normalizeEquipmentPlannerData/);
assert.match(toolboxStorage, /export function summarizeEquipmentPlannerData/);
assert.match(toolboxStorage, /export function normalizeImageAnnotationData/); assert.match(toolboxStorage, /export function normalizeImageAnnotationData/);
assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/); assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/);
assert.match(toolboxStorage, /scrollResults/); assert.match(toolboxStorage, /scrollResults/);
@ -108,6 +110,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8"); const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8"); const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8"); const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "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");
const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8"); const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8");
@ -133,6 +136,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/); assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/);
assert.match(moduleRegistry, /timer:/); assert.match(moduleRegistry, /timer:/);
assert.match(moduleRegistry, /taskPlanner:/); assert.match(moduleRegistry, /taskPlanner:/);
assert.match(moduleRegistry, /equipmentPlanner:/);
assert.match(moduleRegistry, /Planificateur d'équipements/);
assert.match(moduleRegistry, /imageAnnotation:/); assert.match(moduleRegistry, /imageAnnotation:/);
assert.match(moduleRegistry, /Annotation d'images/); assert.match(moduleRegistry, /Annotation d'images/);
assert.match(moduleRegistry, /editable: false/); assert.match(moduleRegistry, /editable: false/);
@ -245,6 +250,13 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(taskPlannerModule, /reorderFeatures/); assert.match(taskPlannerModule, /reorderFeatures/);
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/); assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/); assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/);
assert.match(equipmentPlannerModule, /normalizeEquipmentPlannerData/);
assert.match(equipmentPlannerModule, /summarizeEquipmentPlannerData/);
assert.match(equipmentPlannerModule, /socketItems/);
assert.match(equipmentPlannerModule, /socketLinks/);
assert.match(equipmentPlannerModule, /useGroupedReorder/);
assert.match(equipmentPlannerModule, /reorderFeatures/);
assert.match(checklistModule, /export function ChecklistModule/); assert.match(checklistModule, /export function ChecklistModule/);
assert.match(checklistModule, /TextImportModal/); assert.match(checklistModule, /TextImportModal/);
assert.match(checklistModule, /Icon name="import"/); assert.match(checklistModule, /Icon name="import"/);

View file

@ -4,7 +4,7 @@ import assert from "node:assert/strict";
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeEquipmentPlannerData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
test("colon text import keeps urls intact after the first separator", () => { test("colon text import keeps urls intact after the first separator", () => {
@ -96,6 +96,228 @@ test("table storage clamps dimensions and compacts non-empty cells", () => {
assert.equal(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {} }), null); assert.equal(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {} }), null);
}); });
test("equipment planner storage normalizes sockets, materials and links", () => {
const normalized = normalizeEquipmentPlannerData({
types: [
{ id: "type1", title: "Armes" },
{ id: "empty", title: "Vide" }
],
equipments: [
{
id: "eq1",
typeId: "type1",
name: "Épée",
active: false,
characteristics: [
{ id: "trait1", category: "Stats", name: "Force", value: "12" },
{ id: "ignored", category: "Stats", name: "", value: 4 }
],
materials: [
{ id: "mat1", name: "Minerai", qty: 0 }
],
socketItems: [
{
id: "socket1",
name: "Materia feu",
shape: "triangle",
color: "invalid",
bonuses: [
{ id: "bonus1", category: "Stats", name: "Force", value: "3" },
{ id: "bonus2", category: "Compétences", name: "", value: 1 }
]
},
{
id: "socket2",
name: "Materia soin",
shape: "ball",
color: "cyan",
bonuses: [
{ id: "bonus3", category: "Sorts", name: "Soin", value: "" }
]
}
],
socketLinks: [
{ id: "link1", fromSocketItemId: "socket1", toSocketItemId: "socket2" },
{ id: "duplicate", fromSocketItemId: "socket2", toSocketItemId: "socket1" },
{ id: "invalid", fromSocketItemId: "socket1", toSocketItemId: "missing" }
],
categoryOrder: ["Compétences", "Stats", "Missing"],
collapsedCategories: ["Stats", "Missing"]
}
],
typeOrder: ["empty", "type1"]
});
assert.deepEqual(normalized.types.map((type) => type.id), ["type1"]);
assert.equal(normalized.equipments[0].active, false);
assert.equal(normalized.equipments[0].materials[0].qty, 1);
assert.equal(normalized.equipments[0].characteristics.length, 1);
assert.equal(normalized.equipments[0].socketItems[0].shape, "jewel");
assert.equal(normalized.equipments[0].socketItems[0].color, "yellow");
assert.equal(normalized.equipments[0].socketItems[0].bonuses.length, 1);
assert.equal(normalized.equipments[0].socketLinks.length, 1);
assert.deepEqual(normalized.equipments[0].categoryOrder, ["Stats", "Sorts"]);
assert.deepEqual(normalized.equipments[0].collapsedCategories, ["Stats"]);
const compact = compactModuleDataForStorage("equipmentPlanner", normalized);
assert.equal(compact.equipments[0].active, false);
assert.equal(compact.equipments[0].socketItems[0].shape, "jewel");
assert.equal(compact.equipments[0].socketLinks.length, 1);
assert.equal(compactModuleDataForStorage("equipmentPlanner", { types: [{ id: "empty", title: "Vide" }], equipments: [] }), null);
});
test("equipment planner summary only uses active equipments", () => {
const summary = summarizeEquipmentPlannerData({
types: [{ id: "type1", title: "Build" }],
equipments: [
{
id: "eq1",
typeId: "type1",
name: "Casque",
active: true,
characteristics: [{ id: "trait1", category: "Stats", name: "Force", value: 10 }],
socketItems: [{
id: "socket1",
name: "Joyau attaque",
shape: "ball",
color: "yellow",
bonuses: [
{ id: "bonus1", category: "Stats", name: "Force", value: 2 },
{ id: "bonus2", category: "Affixes", name: "Brutalité", value: "" }
]
}]
},
{
id: "eq2",
typeId: "type1",
name: "Armure",
active: false,
characteristics: [{ id: "trait2", category: "Stats", name: "Force", value: 99 }]
}
]
});
assert.equal(summary.activeEquipmentCount, 1);
assert.deepEqual(summary.totals, [{
name: "Force",
icon: "sword",
sourceKind: "mixed",
contributions: [
{ id: "trait1", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Casque", sourceName: "Casque", value: 10 },
{ id: "bonus1", sourceKind: "socket", equipmentIcon: "shield", equipmentName: "Casque", sourceName: "Casque · Joyau attaque", value: 2 }
],
value: 12
}]);
assert.deepEqual(summary.equipmentTotals, []);
assert.deepEqual(summary.socketTotals, []);
assert.deepEqual(summary.mixedTotals, summary.totals);
assert.equal(summary.textBonuses.length, 1);
assert.equal(summary.textBonuses[0].name, "Brutalité");
});
test("equipment planner summary prioritizes direct stat icons over socket icons", () => {
const summary = summarizeEquipmentPlannerData({
types: [{ id: "type1", title: "Build" }],
equipments: [
{
id: "eq1",
typeId: "type1",
name: "Épée",
characteristics: [{ id: "trait1", category: "Stats", icon: "shield", name: "Attaque", value: 100 }],
socketItems: [{
id: "socket1",
name: "Joyau rouge",
shape: "jewel",
color: "red",
bonuses: [
{ id: "bonus1", category: "Stats", name: "Attaque", value: 10 },
{ id: "bonus2", category: "Compétences", name: "Attaque de feu", value: 1 }
]
}]
},
{
id: "eq2",
typeId: "type1",
name: "Fusil",
characteristics: [
{ id: "trait2", category: "Stats", icon: "sword", name: "Défense", value: 10 },
{ id: "trait3", category: "Stats", icon: "sword", name: "Défense", value: 20 },
{ id: "trait4", category: "Stats", icon: "shield", name: "Défense", value: 30 }
]
}
]
});
assert.deepEqual(summary.totals.find((total) => total.name === "Attaque"), {
name: "Attaque",
icon: "shield",
sourceKind: "mixed",
contributions: [
{ id: "trait1", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Épée", sourceName: "Épée", value: 100 },
{ id: "bonus1", sourceKind: "socket", equipmentIcon: "shield", equipmentName: "Épée", sourceName: "Épée · Joyau rouge", value: 10 }
],
value: 110
});
assert.deepEqual(summary.totals.find((total) => total.name === "Attaque de feu"), {
name: "Attaque de feu",
icon: "jewel",
color: "red",
sourceKind: "socket",
contributions: [{ id: "bonus2", sourceKind: "socket", equipmentIcon: "shield", equipmentName: "Épée", sourceName: "Épée · Joyau rouge", value: 1 }],
value: 1
});
assert.deepEqual(summary.totals.find((total) => total.name === "Défense"), {
name: "Défense",
icon: "sword",
sourceKind: "direct",
contributions: [
{ id: "trait2", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Fusil", sourceName: "Fusil", value: 10 },
{ id: "trait3", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Fusil", sourceName: "Fusil", value: 20 },
{ id: "trait4", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Fusil", sourceName: "Fusil", value: 30 }
],
value: 60
});
assert.deepEqual(summary.equipmentTotals.map((total) => total.name), ["Défense"]);
assert.deepEqual(summary.socketTotals.map((total) => total.name), ["Attaque de feu"]);
assert.deepEqual(summary.mixedTotals.map((total) => total.name), ["Attaque"]);
});
test("equipment planner export remaps nested ids and socket links", () => {
const payload = createToolboxExportPayload(
{
id: "toolbox1",
name: "Build",
modules: [{ id: "module1", type: "equipmentPlanner" }]
},
{
"toolbox1:module1": {
types: [{ id: "type1", title: "Build" }],
equipments: [{
id: "eq1",
typeId: "type1",
name: "Casque",
characteristics: [{ id: "trait1", name: "Force", value: 1 }],
materials: [{ id: "mat1", name: "Minerai", qty: 2 }],
socketItems: [
{ id: "socket1", name: "Joyau attaque", shape: "ball", color: "yellow", bonuses: [{ id: "bonus1", name: "Attaque", value: 1 }] },
{ id: "socket2", name: "Joyau expert", shape: "jewel", color: "cyan", bonuses: [] }
],
socketLinks: [{ id: "link1", fromSocketItemId: "socket1", toSocketItemId: "socket2" }]
}],
typeOrder: ["type1"]
}
}
);
const moduleId = payload.toolbox.modules[0].id;
const exported = payload.modules[moduleId];
assert.notEqual(exported.types[0].id, "type1");
assert.equal(exported.equipments[0].typeId, exported.types[0].id);
assert.notEqual(exported.equipments[0].socketItems[0].id, "socket1");
assert.equal(exported.equipments[0].socketLinks[0].fromSocketItemId, exported.equipments[0].socketItems[0].id);
assert.equal(exported.equipments[0].socketLinks[0].toSocketItemId, exported.equipments[0].socketItems[1].id);
});
test("grouped reorder helpers group flat items and complete group order", () => { test("grouped reorder helpers group flat items and complete group order", () => {
const items = [ const items = [
{ id: "a", category: "" }, { id: "a", category: "" },

View file

@ -47,15 +47,20 @@
{ {
"id": "mb", "id": "mb",
"type": "imageAnnotation" "type": "imageAnnotation"
},
{
"id": "mc",
"type": "equipmentPlanner"
} }
], ],
"updatedAt": "2026-08-03T08:04:53.696Z" "updatedAt": "2026-08-09T07:34:31.425Z",
"moduleColumns": 1
}, },
"modules": { "modules": {
"m1": { "m1": {
"html": "<h3><span>Obtenir Mew</span> :</h3><div><ul><li>Obtenir la CS <span style=\"color: #f6c453\">Coupe</span> à l'intérieur du bateau le <span style=\"color: #d946ef\">S.S. Anne</span>.</li><li>Faire exprès de perdre un combat <span>(tomber K.O.)</span> pour réapparaître au Centre Pokémon sans faire partir le navire.</li><li>Revenir sur les quais de <span style=\"color: #d946ef\">Carmin-sur-Mer</span> après avoir débloqué et appris la compétence <span style=\"color: #f6c453\">Surf</span>.</li><li>Naviguer sur l'eau pour atteindre la zone cachée où repose le véhicule.</li></ul><div><br></div></div><div><br></div>", "html": "<h3>Obtenir Mew :</h3><div><ul><li>Obtenir la CS <span style=\"color: #f6c453\">Coupe </span>à l'intérieur du bateau le <span style=\"color: #d946ef\">S.S. Anne</span>.</li><li>Faire exprès de perdre un combat (tomber K.O.) pour réapparaître au Centre Pokémon sans faire partir le navire.</li><li>Revenir sur les quais de <span style=\"color: #d946ef\">Carmin-sur-Mer</span> après avoir débloqué et appris la compétence <span style=\"color: #f6c453\">Surf</span>.</li><li>Naviguer sur l'eau pour atteindre la zone cachée où repose le véhicule.</li></ul></div><div><br></div><p></p>",
"text": "Obtenir Mew :\n- Obtenir la CS Coupe à l'intérieur du bateau le S.S. Anne.\n- Faire exprès de perdre un combat (tomber K.O.) pour réapparaître au Centre Pokémon sans faire partir le navire.\n- Revenir sur les quais de Carmin-sur-Mer après avoir débloqué et appris la compétence Surf.\n- Naviguer sur l'eau pour atteindre la zone cachée où repose le véhicule.", "text": "Obtenir Mew :\n- Obtenir la CS Coupe à l'intérieur du bateau le S.S. Anne.\n- Faire exprès de perdre un combat (tomber K.O.) pour réapparaître au Centre Pokémon sans faire partir le navire.\n- Revenir sur les quais de Carmin-sur-Mer après avoir débloqué et appris la compétence Surf.\n- Naviguer sur l'eau pour atteindre la zone cachée où repose le véhicule.",
"updatedAt": "2026-08-03T08:27:16.932Z", "updatedAt": "2026-08-05T14:24:01.662Z",
"drawingMode": "permanent", "drawingMode": "permanent",
"drawings": { "drawings": {
"strokes": [ "strokes": [
@ -1295,7 +1300,6 @@
"id": "z4", "id": "z4",
"label": "Boss Mondial", "label": "Boss Mondial",
"type": "daily_time", "type": "daily_time",
"autoRefresh": true,
"time": "14:00:00", "time": "14:00:00",
"targetAt": 1785758400000 "targetAt": 1785758400000
}, },
@ -1304,12 +1308,22 @@
"label": "Legion", "label": "Legion",
"type": "interval", "type": "interval",
"intervalMs": 1800000, "intervalMs": 1800000,
"anchorAt": 1785746172680 "anchorAt": 1785746172680,
"targetAt": 1785747972680,
"autoRefresh": true
},
{
"id": "z6",
"label": "Durée",
"type": "duration",
"durationMs": 1800000,
"targetAt": 1785767200357
} }
] ]
}, },
"ma": { "ma": {
"lastResetAt": 1785708000000, "lastResetAt": 1786226400000,
"hideCompleted": true,
"tasks": [ "tasks": [
{ {
"id": "a1", "id": "a1",
@ -1339,8 +1353,6 @@
"id": "a5", "id": "a5",
"title": "Faire les boss du Sanctuaire Noir (Solo)", "title": "Faire les boss du Sanctuaire Noir (Solo)",
"type": "weekly", "type": "weekly",
"checked": true,
"checkedAt": 1785746891981,
"weeklyResetDay": 0 "weeklyResetDay": 0
}, },
{ {
@ -1358,7 +1370,9 @@
{ {
"id": "a8", "id": "a8",
"title": "Ameliorer le bateau", "title": "Ameliorer le bateau",
"type": "unique" "type": "unique",
"checked": true,
"checkedAt": 1785789855306
} }
], ],
"categoryOrder": [ "categoryOrder": [
@ -4121,6 +4135,178 @@
} }
] ]
} }
},
"mc": {
"types": [
{
"id": "y1",
"title": "Casque",
"icon": "helmet"
}
],
"equipments": [
{
"id": "q1",
"typeId": "y1",
"name": "Nu Udra γ",
"icon": "helmet",
"obtain": "Avoir un certificat Alpha supreme",
"characteristics": [
{
"id": "v1",
"name": "Défense",
"icon": "shield",
"value": 68
},
{
"id": "v2",
"name": "Resistance au feu",
"icon": "shield",
"value": 5
},
{
"id": "v3",
"name": "Resistance a l'eau",
"icon": "shield",
"value": -4
},
{
"id": "v4",
"name": "Resistance à la foudre",
"icon": "shield",
"value": 0
},
{
"id": "v5",
"name": "Resistance à la glace",
"icon": "shield",
"value": 1
},
{
"id": "v6",
"name": "Resistance au dragon",
"icon": "shield",
"value": 0
}
],
"socketItems": [
{
"id": "j1",
"name": "Joyau antivirus",
"shape": "jewel",
"color": "violet",
"bonuses": [
{
"id": "b1",
"name": "Antivirus",
"value": 1
}
]
},
{
"id": "j2",
"name": "Joyau antivirus",
"shape": "jewel",
"color": "violet",
"bonuses": [
{
"id": "b2",
"name": "Antivirus",
"value": 1
}
]
},
{
"id": "j3",
"name": "Joyau antivirus",
"shape": "jewel",
"color": "violet",
"bonuses": [
{
"id": "b3",
"name": "Antivirus",
"value": 1
}
]
}
]
},
{
"id": "q2",
"typeId": "y1",
"name": "Nu Udra γ",
"icon": "chest-armor",
"characteristics": [
{
"id": "v7",
"name": "Défense",
"icon": "shield",
"value": 68
},
{
"id": "v8",
"name": "Resistance au feu",
"icon": "shield",
"value": 5
},
{
"id": "v9",
"name": "Resistance a l'eau",
"icon": "shield",
"value": -4
},
{
"id": "va",
"name": "Resistance à la foudre",
"icon": "shield",
"value": 0
},
{
"id": "vb",
"name": "Resistance à la glace",
"icon": "shield",
"value": 1
},
{
"id": "vc",
"name": "Resistance au dragon",
"icon": "shield",
"value": 0
}
],
"socketItems": [
{
"id": "j4",
"name": "Joyau Mise a mort",
"shape": "jewel",
"color": "indigo",
"bonuses": [
{
"id": "b4",
"name": "Mise a mort",
"value": 1
}
]
},
{
"id": "j5",
"name": "Joyau Attaque",
"shape": "jewel",
"color": "red",
"bonuses": [
{
"id": "b5",
"name": "Attaque",
"value": 2
}
]
}
]
}
],
"typeOrder": [
"y1"
]
} }
} }
} }

View file

@ -505,6 +505,75 @@
"removeRelationTitle": "Retirer", "removeRelationTitle": "Retirer",
"emptyTasks": "Aucune tâche planifiée." "emptyTasks": "Aucune tâche planifiée."
}, },
"equipmentPlanner": {
"equipmentIconLabel": "Type",
"typePlaceholder": "Type d'équipement",
"equipmentPlaceholder": "Nouvel équipement",
"addEquipmentButton": "Ajouter",
"summaryTitle": "Résumé",
"activeEquipmentsSingular": "équipement actif",
"activeEquipmentsPlural": "équipements actifs",
"numericTotalsTitle": "Totaux",
"equipmentTotalsTitle": "Statistiques de l'équipement",
"socketTotalsTitle": "Objets sertis",
"mixedTotalsTitle": "Totaux fusionnés",
"summaryTooltipTitle": "Provenance",
"textBonusesTitle": "Bonus",
"emptyTotals": "Aucun total numérique.",
"emptyTextBonuses": "Aucun bonus non numérique.",
"emptyEquipments": "Aucun équipement planifié.",
"activeLabel": "Actif",
"includeInSummaryLabel": "Inclure dans le résumé",
"reorderTypeTitle": "Déplacer le type",
"renameTypeTitle": "Renommer le type",
"showTypeTitle": "Afficher ce type",
"hideTypeTitle": "Réduire ce type",
"reorderEquipmentTitle": "Déplacer",
"renameEquipmentTitle": "Renommer",
"showDetailsTitle": "Afficher les détails",
"hideDetailsTitle": "Masquer les détails",
"deleteEquipmentTitle": "Supprimer",
"tabsLabel": "Ajout de caractéristiques",
"statsTab": "Stats / affixe",
"socketsTab": "Objets à sertir",
"craftTab": "Craft",
"displayStatsTitle": "Statistiques",
"displaySocketTitle": "Objets sertis",
"editModeTitle": "Mode édition",
"viewModeTitle": "Mode affichage",
"editModeButton": "Éditer",
"viewModeButton": "Affichage",
"obtainLabel": "Obtention",
"obtainPlaceholder": "Comment obtenir ou créer cet équipement",
"emptyObtain": "Aucune obtention renseignée.",
"characteristicsTitle": "Caractéristiques",
"traitIconLabel": "Icône",
"categoryPlaceholder": "Catégorie",
"traitNamePlaceholder": "Nom",
"traitValuePlaceholder": "Valeur",
"addTraitButton": "Ajouter",
"reorderTraitTitle": "Déplacer",
"reorderCategoryTitle": "Déplacer la catégorie",
"deleteTraitTitle": "Supprimer",
"emptyCharacteristics": "Aucune caractéristique.",
"materialsTitle": "Matériaux",
"materialNamePlaceholder": "Matériau",
"materialQtyLabel": "Quantité",
"addMaterialButton": "Ajouter",
"deleteMaterialTitle": "Supprimer",
"socketItemsTitle": "Objets sertis",
"emptySocketItems": "Aucun objet serti.",
"socketNamePlaceholder": "Joyau, materia...",
"socketShapeLabel": "Forme",
"socketColorLabel": "Couleur",
"addSocketButton": "Ajouter",
"bonusNamePlaceholder": "Bonus",
"addBonusButton": "Ajouter",
"deleteSocketTitle": "Supprimer",
"deleteBonusTitle": "Supprimer",
"addSocketLinkLabel": "Lier à",
"deleteSocketLinkTitle": "Retirer le lien"
},
"imageAnnotation": { "imageAnnotation": {
"addImage": "Ajouter une image", "addImage": "Ajouter une image",
"replaceImage": "Remplacer l'image", "replaceImage": "Remplacer l'image",

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="#323232" stroke-width="2"/>
<path d="M8.5 8.5C7.83333 9.16667 7.5 10.1 7.5 11.3C7.5 12.1837 7.85804 13.0316 8.4948 13.6508L10.2 15.3" stroke="#323232" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;stroke:#020202;stroke-miterlimit:10;stroke-width:1.91px;}</style></defs><rect class="cls-1" x="2.45" y="18.68" width="19.09" height="3.82"/><path class="cls-1" d="M7.23,12h1a0,0,0,0,1,0,0v6.68a0,0,0,0,1,0,0H2.45a0,0,0,0,1,0,0V16.77A4.77,4.77,0,0,1,7.23,12Z"/><line class="cls-1" x1="6.27" y1="20.59" x2="6.27" y2="22.5"/><line class="cls-1" x1="10.09" y1="20.59" x2="10.09" y2="22.5"/><line class="cls-1" x1="13.91" y1="20.59" x2="13.91" y2="22.5"/><line class="cls-1" x1="17.73" y1="20.59" x2="17.73" y2="22.5"/><path class="cls-1" d="M21.55,18.68V6.27a1.92,1.92,0,0,0-1.91-1.91H18.59a5.16,5.16,0,0,1-4.31-2.31h0a1.26,1.26,0,0,0-1-.55h0A1.25,1.25,0,0,0,12,2.74V8.18A3.82,3.82,0,0,1,8.18,12h0"/><line class="cls-1" x1="12" y1="7.23" x2="15.82" y2="7.23"/><line class="cls-1" x1="11.09" y1="11.05" x2="15.82" y2="11.05"/><path class="cls-1" d="M16.77,18.68a4.78,4.78,0,0,1,4.78-4.77"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Icon">
<path d="M25,21.5c0,-0.319 -0.152,-0.619 -0.409,-0.807c-0.258,-0.188 -0.589,-0.243 -0.893,-0.146l-7.698,2.44c-0,0 -7.698,-2.44 -7.698,-2.44c-0.304,-0.097 -0.635,-0.042 -0.893,0.146c-0.257,0.188 -0.409,0.488 -0.409,0.807l0,6c0,0.552 0.448,1 1,1l16,0c0.552,0 1,-0.448 1,-1l0,-6Zm-2,1.366l0,3.634l-14,0c0,-0 0,-3.634 0,-3.634c0,0 6.698,2.123 6.698,2.123c0.196,0.063 0.408,0.063 0.604,0l6.698,-2.123Zm-2.002,-14.31c0.02,-0.341 -0.137,-0.668 -0.414,-0.868c-0.278,-0.199 -0.638,-0.243 -0.955,-0.116l-2.5,1c-0.38,0.151 -0.629,0.519 -0.629,0.928l0,11c0,0.317 0.151,0.616 0.406,0.804c0.255,0.189 0.585,0.245 0.888,0.152l6.5,-2c0.42,-0.129 0.706,-0.517 0.706,-0.956l0,-6c0,-0.552 -0.448,-1 -1,-1c-0.892,0 -1.663,-0.246 -2.203,-0.739c-0.516,-0.472 -0.797,-1.166 -0.797,-2.02c0,-0.062 -0.005,-0.124 -0.002,-0.185Zm-8.627,-0.984c-0.317,-0.127 -0.677,-0.083 -0.955,0.116c-0.277,0.2 -0.434,0.527 -0.414,0.868c0.003,0.061 -0.002,0.123 -0.002,0.185c0,0.854 -0.281,1.548 -0.797,2.02c-0.54,0.493 -1.311,0.739 -2.203,0.739c-0.552,0 -1,0.448 -1,1l0,6c0,0.439 0.286,0.827 0.706,0.956l6.5,2c0.303,0.093 0.633,0.037 0.888,-0.152c0.255,-0.188 0.406,-0.487 0.406,-0.804l0,-11c0,-0.409 -0.249,-0.777 -0.629,-0.928l-2.5,-1Zm6.756,2.354c0.21,0.942 0.675,1.72 1.32,2.31c0.666,0.609 1.537,1.023 2.553,1.186c0,0 0,4.339 0,4.339c0,0 -4.5,1.385 -4.5,1.385c0,0 0,-8.969 0,-8.969l0.627,-0.251Zm-6.254,0l0.627,0.251c0,0 0,8.969 0,8.969c-0,0 -4.5,-1.385 -4.5,-1.385c0,0 0,-4.339 0,-4.339c1.016,-0.163 1.887,-0.577 2.553,-1.186c0.645,-0.59 1.11,-1.368 1.32,-2.31Zm-1.892,-5.23c0.058,-0.294 -0.018,-0.598 -0.208,-0.83c-0.19,-0.232 -0.473,-0.366 -0.773,-0.366c-1.611,0 -3.965,1.17 -5.569,2.638c-1.191,1.089 -1.931,2.354 -1.931,3.362c0,0.552 0.448,1 1,1l5.5,0l0.981,-0.804l1,-5Zm11.019,-1.196c-0.3,0 -0.583,0.134 -0.773,0.366c-0.19,0.232 -0.266,0.536 -0.208,0.83l1,5l0.981,0.804l5.5,0c0.552,0 1,-0.448 1,-1c-0,-1.008 -0.74,-2.273 -1.931,-3.362c-1.604,-1.468 -3.958,-2.638 -5.569,-2.638Zm-13.82,5l-3.216,0c0.222,-0.299 0.501,-0.598 0.816,-0.886c0.847,-0.775 1.944,-1.485 2.948,-1.852l-0.548,2.738Zm15.64,0l-0.548,-2.738c1.004,0.367 2.101,1.078 2.948,1.852c0.315,0.288 0.594,0.587 0.816,0.886l-3.216,0Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<path d="M149.299,181.001c-2.202-3.311-5.163-5.931-8.499-7.868V74.445c14.865-5.299,25.6-19.379,25.6-36.045
C166.4,17.229,149.171,0,128,0S89.6,17.229,89.6,38.4c0,16.666,10.735,30.737,25.6,36.045v98.697
c-3.337,1.937-6.298,4.557-8.499,7.868C97.084,195.413,12.8,323.951,12.8,396.8C12.8,460.322,64.478,512,128,512
s115.2-51.678,115.2-115.2C243.2,323.951,158.916,195.413,149.299,181.001z M128,25.6c7.066,0,12.8,5.726,12.8,12.8
c0,7.066-5.734,12.8-12.8,12.8s-12.8-5.734-12.8-12.8C115.2,31.326,120.934,25.6,128,25.6z M128,198.076c0,0,64,96,64,144
c0,35.354-28.655,64-64,64s-64-28.655-64-64C64,294.076,128,198.076,128,198.076z M128,486.4c-49.485,0-89.6-40.115-89.6-89.6
c0-8.713,1.553-18.586,4.181-29.116c11.093,36.881,44.971,63.991,85.419,63.991s74.325-27.11,85.419-63.991
c2.62,10.53,4.181,20.403,4.181,29.116C217.6,446.285,177.485,486.4,128,486.4z"/>
</g>
</g>
<g>
<g>
<path d="M405.299,181.001c-2.21-3.311-5.163-5.931-8.499-7.868V74.445c14.865-5.299,25.6-19.379,25.6-36.045
C422.4,17.229,405.171,0,384,0c-21.171,0-38.4,17.229-38.4,38.4c0,16.666,10.735,30.737,25.6,36.045v98.697
c-3.336,1.937-6.298,4.557-8.499,7.868C353.084,195.413,268.8,323.951,268.8,396.8c0,63.522,51.678,115.2,115.2,115.2
s115.2-51.678,115.2-115.2C499.2,323.951,414.916,195.413,405.299,181.001z M384,25.6c7.066,0,12.8,5.726,12.8,12.8
c0,7.066-5.734,12.8-12.8,12.8c-7.066,0-12.8-5.734-12.8-12.8C371.2,31.326,376.934,25.6,384,25.6z M384,198.076c0,0,64,96,64,144
c0,35.345-28.655,64-64,64c-35.345,0-64-28.655-64-64C320,294.076,384,198.076,384,198.076z M384,486.4
c-49.485,0-89.6-40.115-89.6-89.6c0-8.713,1.553-18.586,4.181-29.116c11.093,36.881,44.971,63.991,85.419,63.991
c40.448,0,74.325-27.119,85.419-63.991c2.62,10.53,4.181,20.403,4.181,29.116C473.6,446.285,433.485,486.4,384,486.4z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;stroke:#020202;stroke-miterlimit:10;stroke-width:1.91px;}</style></defs><path class="cls-1" d="M20.55,8.18v10.5H7.18L2.05,13.55a2,2,0,0,1,2.86-2.87l.64.65a1,1,0,0,0,1.63-.68V8.46a6.9,6.9,0,0,1,6.9-7,6.7,6.7,0,0,1,6.47,6.68Z"/><rect class="cls-1" x="7.18" y="18.68" width="13.36" height="3.82"/></svg>

After

Width:  |  Height:  |  Size: 571 B

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;stroke:#020202;stroke-miterlimit:10;stroke-width:1.95px;}</style></defs><rect class="cls-1" x="10.39" y="2.21" width="7.6" height="15.19" transform="translate(-2.78 12.91) rotate(-45)"/><path class="cls-1" d="M13,14,4.79,22.14a2,2,0,0,1-1.47.61,2.07,2.07,0,0,1-2.07-2.07,2,2,0,0,1,.61-1.47L10,11Z"/><path class="cls-1" d="M20.41,5.66a2.05,2.05,0,0,1-.6,1.46L18.34,8.59,15.41,5.66l1.47-1.47a2.07,2.07,0,0,1,3.53,1.47Z"/></svg>

After

Width:  |  Height:  |  Size: 696 B

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.553,16.105A1,1,0,0,0,13,17v3a1,1,0,0,0,.293.707l2,2a1,1,0,0,0,1.154.188l4-2A1,1,0,0,0,21,20V10A9,9,0,0,0,3,10V20a1,1,0,0,0,.553.895l4,2a1,1,0,0,0,1.154-.188l2-2A1,1,0,0,0,11,20V17a1,1,0,0,0-.553-.895L9,15.382V12.618L11.553,13.9a1,1,0,0,0,.894,0L15,12.618v2.764Zm2.972-5.956a1,1,0,0,0-.972-.044L12,11.882,8.447,10.105A1,1,0,0,0,7,11v5a1,1,0,0,0,.553.895L9,17.618v1.968l-1.2,1.2L5,19.382V10a7,7,0,0,1,14,0v9.382l-2.8,1.4-1.2-1.2V17.618l1.447-.723A1,1,0,0,0,17,16V11A1,1,0,0,0,16.525,10.149ZM15,7a1,1,0,0,1-1,1H10a1,1,0,0,1,0-2h4A1,1,0,0,1,15,7Z"/></svg>

After

Width:  |  Height:  |  Size: 788 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.8057 5.70615C5.39093 4.87011 5.68354 4.45209 6.11769 4.22604C6.55184 4 7.0621 4 8.08262 4H12H15.9174C16.9379 4 17.4482 4 17.8823 4.22604C18.3165 4.45209 18.6091 4.87011 19.1943 5.70615L19.7915 6.55926C20.6144 7.73493 21.0259 8.32277 21.0064 8.98546C20.9869 9.64815 20.5415 10.2107 19.6507 11.3359L14.375 18V18C13.6417 18.9263 13.275 19.3895 12.8472 19.5895C12.3103 19.8406 11.6897 19.8406 11.1528 19.5895C10.725 19.3895 10.3583 18.9263 9.625 18V18L4.34927 11.3359C3.4585 10.2107 3.01312 9.64815 2.99359 8.98546C2.97407 8.32277 3.38555 7.73493 4.20852 6.55926L4.8057 5.70615Z" stroke="#323232" stroke-width="2" stroke-linejoin="round"/>
<path d="M9 7.5L8.5 8.25V8.25C8.20344 8.69484 8.23479 9.28176 8.57706 9.69247L10.5 12" stroke="#323232" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<path d="M488.515,0c-9.172,0-16.608,7.436-16.608,16.608v9.731c0,119.051-96.856,215.907-215.907,215.907
S40.093,145.39,40.093,26.34v-9.731C40.093,7.436,32.657,0,23.485,0S6.877,7.436,6.877,16.608v9.731
c0,133.596,104.156,240.171,232.515,248.565v42.686c-25.397-14.658-58.489-11.15-80.188,10.55
c-25.901,25.9-25.901,68.043,0,93.943l85.052,85.052c6.486,6.486,17.001,6.486,23.487,0l85.052-85.052
c25.901-25.9,25.901-68.043,0-93.943c-21.698-21.698-54.79-25.207-80.188-10.55v-42.686
c128.419-8.397,232.516-115.032,232.516-248.565v-9.731C505.123,7.436,497.687,0,488.515,0z M282.34,351.628
c12.949-12.949,34.019-12.949,46.968,0c12.949,12.949,12.949,34.019,0,46.968L256,471.904l-73.309-73.309
c-12.949-12.949-12.949-34.019,0-46.968c6.475-6.475,14.979-9.711,23.484-9.711c8.504,0,17.009,3.237,23.484,9.711l14.594,14.594
c6.411,6.413,16.971,6.524,23.493,0L282.34,351.628z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3,14A9,9,0,1,0,14.822,5.46l1.019-1.585a1,1,0,0,0,.031-1.032l-.75-1.333A1,1,0,0,0,14.25,1H9.75a1,1,0,0,0-.872.51l-.75,1.333a1,1,0,0,0,.031,1.032L9.178,5.46A9.011,9.011,0,0,0,3,14Zm8.282-8.966L10.167,3.3l.168-.3h3.33l.168.3L12.718,5.034C12.468,5.011,12.23,5,12,5S11.532,5.011,11.282,5.034ZM12,7a7,7,0,1,1-7,7A7.022,7.022,0,0,1,12,7Z"/></svg>

After

Width:  |  Height:  |  Size: 573 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" version="1.1" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
<path d="m2.75 9.25 1.5 2.5 2 1.5m-4.5 0 1 1m1.5-2.5-1.5 1.5m3-1 8.5-8.5v-2h-2l-8.5 8.5"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

View file

@ -4,6 +4,7 @@ import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.j
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx"; import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
import { lockBodyScroll } from "../utils/bodyScrollLock.js"; import { lockBodyScroll } from "../utils/bodyScrollLock.js";
import { Icon } from "./Icon.jsx"; import { Icon } from "./Icon.jsx";
import { Tooltip } from "./Tooltip.jsx";
const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"]; const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
const DRAWING_WIDTHS = [2, 4, 8, 12]; const DRAWING_WIDTHS = [2, 4, 8, 12];
@ -188,14 +189,16 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
/> />
)} )}
{viewerMarkers.map((marker, index) => ( {viewerMarkers.map((marker, index) => (
<span <Tooltip
as="span"
className="annotation-marker image-viewer-marker" className="annotation-marker image-viewer-marker"
key={marker.id || `${marker.x}-${marker.y}-${index}`} 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}%` }} style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
title={marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}
> >
{index + 1} {index + 1}
</span> </Tooltip>
))} ))}
</div> </div>
</div> </div>

View 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>
);
}

View file

@ -17,12 +17,14 @@ import {
normalizeChecklistData, normalizeChecklistData,
normalizeCombosData, normalizeCombosData,
normalizeCountersData, normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeImageAnnotationData, normalizeImageAnnotationData,
normalizeLinksData, normalizeLinksData,
normalizeNotepadData, normalizeNotepadData,
normalizeTableData, normalizeTableData,
normalizeTaskPlannerData, normalizeTaskPlannerData,
normalizeTimerData, normalizeTimerData,
summarizeEquipmentPlannerData,
normalizeUrl, normalizeUrl,
uid uid
} from "./storage/toolboxStorage.js"; } from "./storage/toolboxStorage.js";
@ -152,12 +154,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
normalizeCombosData, normalizeCombosData,
normalizeLinksData, normalizeLinksData,
normalizeCountersData, normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeCalculatorData, normalizeCalculatorData,
normalizeImageAnnotationData, normalizeImageAnnotationData,
normalizeNotepadData, normalizeNotepadData,
normalizeTableData, normalizeTableData,
normalizeTimerData, normalizeTimerData,
normalizeTaskPlannerData, normalizeTaskPlannerData,
summarizeEquipmentPlannerData,
normalizeUrl, normalizeUrl,
hostnameFromUrl, hostnameFromUrl,
copyText, copyText,

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables. // Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx"; import { DrawingOverlay } from "./DrawingOverlay.jsx";
function markerLabel(index, marker, textContent) { function markerLabel(index, marker, textContent) {
@ -160,14 +161,16 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
)} )}
</div> </div>
{markers.map((marker, index) => ( {markers.map((marker, index) => (
<span <Tooltip
as="span"
key={marker.id} key={marker.id}
className="annotation-marker" className="annotation-marker"
content={markerLabel(index, marker, textContent)}
position="top"
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }} style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
title={markerLabel(index, marker, textContent)}
> >
{index + 1} {index + 1}
</span> </Tooltip>
))} ))}
<DrawingOverlay <DrawingOverlay
active={false} active={false}

View file

@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { InlineNotice } from "../../../components/AppOverlays.jsx"; import { InlineNotice } from "../../../components/AppOverlays.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.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>)} {TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select> </select>
{missingPrerequisites.length > 0 && ( {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"} {textContent.missingPrerequisiteBadge || "Pré requis"}
</span> </Tooltip>
)} )}
<div className="task-planner-actions"> <div className="task-planner-actions">
<button <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 }) { function TaskResetSettings({ task, data, textContent, onUpdateTask }) {
if (task.type === "daily") { if (task.type === "daily") {
const hasOverride = Boolean(task.dailyResetTime); const hasOverride = Boolean(task.dailyResetTime);

View file

@ -11,6 +11,7 @@ import { ChecklistModule } from "./ChecklistModule.jsx";
import { CombosModule } from "./CombosModule.jsx"; import { CombosModule } from "./CombosModule.jsx";
import { CountersModule } from "./CountersModule.jsx"; import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx"; import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { EquipmentPlannerModule } from "./EquipmentPlannerModule.jsx";
import { LinksModule } from "./LinksModule.jsx"; import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx"; import { NotepadModule } from "./NotepadModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx"; import { ImagesModule } from "./ImagesModule.jsx";
@ -29,6 +30,7 @@ const MODULE_COMPONENTS = {
table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true }, table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true },
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false }, timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },
taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true }, 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 } imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
}; };

View file

@ -1,5 +1,27 @@
// Rôle : normalise, compacte et sérialise les données toolbox persistées. // 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 ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]); 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"]); 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 TASK_TYPES = new Set(["unique", "daily", "weekly"]);
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]); const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]); 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_WEEKLY_RESET_DAY = 1;
const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00"; const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00";
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/"; const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
@ -56,6 +82,7 @@ const DEFAULT_MODULE_TITLES = {
table: "Tableau", table: "Tableau",
timer: "Timer", timer: "Timer",
taskPlanner: "Planificateur de tâches", taskPlanner: "Planificateur de tâches",
equipmentPlanner: "Planificateur d'équipements",
imageAnnotation: "Annotation d'images" 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) { function clampPercent(value) {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0; if (!Number.isFinite(parsed)) return 0;
@ -716,6 +1007,64 @@ function compactChecklistSectionForStorage(section) {
return compact; 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) { export function compactModuleDataForStorage(type, value) {
if (type === "notepad") { if (type === "notepad") {
const normalized = normalizeNotepadData(value); const normalized = normalizeNotepadData(value);
@ -889,6 +1238,21 @@ export function compactModuleDataForStorage(type, value) {
} }
return Object.keys(compact).length ? compact : null; 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; return value;
} }
@ -1000,6 +1364,41 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped; 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; return compact;
} }

View file

@ -5,6 +5,7 @@ import { CalculatorModule } from "../features/toolboxes/modules/CalculatorModule
import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx"; import { ChecklistModule } from "../features/toolboxes/modules/ChecklistModule.jsx";
import { CombosModule } from "../features/toolboxes/modules/CombosModule.jsx"; import { CombosModule } from "../features/toolboxes/modules/CombosModule.jsx";
import { CountersModule } from "../features/toolboxes/modules/CountersModule.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 { ImageAnnotationModule } from "../features/toolboxes/modules/ImageAnnotationModule.jsx";
import { ImagesModule } from "../features/toolboxes/modules/ImagesModule.jsx"; import { ImagesModule } from "../features/toolboxes/modules/ImagesModule.jsx";
import { LinksModule } from "../features/toolboxes/modules/LinksModule.jsx"; import { LinksModule } from "../features/toolboxes/modules/LinksModule.jsx";
@ -19,12 +20,14 @@ import {
normalizeChecklistData, normalizeChecklistData,
normalizeCombosData, normalizeCombosData,
normalizeCountersData, normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeImageAnnotationData, normalizeImageAnnotationData,
normalizeLinksData, normalizeLinksData,
normalizeNotepadData, normalizeNotepadData,
normalizeTableData, normalizeTableData,
normalizeTaskPlannerData, normalizeTaskPlannerData,
normalizeTimerData, normalizeTimerData,
summarizeEquipmentPlannerData,
normalizeUrl, normalizeUrl,
uid uid
} from "../features/toolboxes/storage/toolboxStorage.js"; } 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 }, table: { label: "Tableau", mode: "Grille + formules", icon: "table", Component: TableModule, scrollable: true },
timer: { label: "Timer", mode: "Chrono + comptes à rebours", icon: "clock", Component: TimerModule }, 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 }, 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 } imageAnnotation: { label: "Annotation d'images", mode: "Image + marqueurs", icon: "map", Component: ImageAnnotationModule, editable: true }
}; };
@ -128,12 +132,14 @@ export function LibraryPage({ siteContent, actions }) {
normalizeCombosData, normalizeCombosData,
normalizeLinksData, normalizeLinksData,
normalizeCountersData, normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeCalculatorData, normalizeCalculatorData,
normalizeImageAnnotationData, normalizeImageAnnotationData,
normalizeNotepadData, normalizeNotepadData,
normalizeTableData, normalizeTableData,
normalizeTimerData, normalizeTimerData,
normalizeTaskPlannerData, normalizeTaskPlannerData,
summarizeEquipmentPlannerData,
normalizeUrl, normalizeUrl,
hostnameFromUrl, hostnameFromUrl,
clampQty, clampQty,

View 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;
}

View file

@ -262,11 +262,36 @@
-webkit-mask-image: url("/static/icons/foot.svg"); -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 { .ui-icon-grab {
mask-image: url("/static/icons/grab.svg"); mask-image: url("/static/icons/grab.svg");
-webkit-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 { .ui-icon-shield {
mask-image: url("/static/icons/shield.svg"); mask-image: url("/static/icons/shield.svg");
-webkit-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"); -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 { .ui-icon-trash {
mask-image: url("/static/icons/trashcan.svg"); mask-image: url("/static/icons/trashcan.svg");
-webkit-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

View file

@ -5,6 +5,7 @@
@use "shell"; @use "shell";
@use "home"; @use "home";
@use "cards"; @use "cards";
@use "components";
@use "icons"; @use "icons";
@use "games"; @use "games";
@use "toolboxes"; @use "toolboxes";