Add goal tree toolbox module
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-22 18:15:39 +02:00
parent e4cbe3e087
commit 59fdcb0927
63 changed files with 4062 additions and 252 deletions

View file

@ -36,6 +36,10 @@
"id": "m8",
"type": "table"
},
{
"id": "mf",
"type": "goalTree"
},
{
"id": "m9",
"type": "timer"
@ -213,8 +217,8 @@
}
],
"moduleOrder": {
"one": ["m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8", "m9", "md", "me", "ma", "mb", "mc"],
"two": [["m1", "m2", "m3", "m4", "m5", "m6", "ma"], ["m7", "m8", "m9", "md", "me", "mb", "mc"]]
"one": ["m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8", "mf", "m9", "md", "me", "ma", "mb", "mc"],
"two": [["m1", "m2", "m3", "m4", "m5", "m6", "ma"], ["m7", "m8", "mf", "m9", "md", "me", "mb", "mc"]]
},
"updatedAt": "2026-08-09T07:34:31.425Z",
"moduleColumns": 1
@ -1436,6 +1440,103 @@
"3": "Valeur"
}
},
"mf": {
"lastResetAt": 1787263200000,
"orientation": "vertical",
"nodes": [
{
"id": "goal1",
"title": "Potion d'invincibilité",
"icon": "flask",
"color": "green",
"qtyTarget": 10
},
{
"id": "goal2",
"title": "Potion de vie",
"parentId": "goal1",
"icon": "flask",
"color": "red",
"formula": "base"
},
{
"id": "goal3",
"title": "Ingredients",
"parentId": "goal2",
"kind": "checklist",
"items": [
{
"id": "xn2okr5",
"label": "Herbe magique",
"formula": "base",
"qtyTarget": 10,
"qtyCurrent": 7
},
{
"id": "xm5h5uu",
"label": "Eau rajeunissante",
"formula": "base",
"qtyTarget": 10,
"qtyCurrent": 5
}
]
},
{
"id": "goal4",
"title": "Potion de résistance",
"parentId": "goal1",
"icon": "flask",
"color": "blue",
"formula": "base"
},
{
"id": "goal5",
"title": "Ingredients supp.",
"parentId": "goal1",
"kind": "checklist",
"items": [
{
"id": "x3mlygm",
"label": "Poudre magique",
"formula": "base",
"qtyTarget": 10
},
{
"id": "x1dqx6w",
"label": "Herbe magique",
"formula": "base*2",
"qtyTarget": 20
}
]
},
{
"id": "goal6",
"title": "Parler a l'alchimiste",
"collapsed": true
},
{
"id": "goal7",
"title": "Acheter une station d'alchimie",
"parentId": "goal6",
"formula": "base",
"collapsed": true
},
{
"id": "goal8",
"title": "Apprendre la recette de potion d'invicibilité",
"parentId": "goal7",
"formula": "base",
"collapsed": true
},
{
"id": "goal9",
"title": "S'inscrire à l'academie d'alchimie",
"parentId": "goal6",
"formula": "base",
"collapsed": true
}
]
},
"m9": {
"activeTab": "countdown",
"stopwatch": {

View file

@ -673,6 +673,81 @@
"example": "Lingot de champignite: 100\n Lingot de chlorophyte: 100\n Champignon luisant: 1500\nLingot de chlorophyte: 100\n Minerai de chlorophyte: 500"
}
},
"goalTree": {
"controls": [
{
"group": "Arbre",
"icon": "rotate",
"title": "Changer le mode d'affichage",
"preview": {
"kind": "switch",
"icon": "rotate",
"active": true
}
},
{
"group": "Arbre",
"icon": "chevron-down",
"title": "Déployer l'objectif",
"preview": {
"kind": "iconButton",
"icon": "chevron-down"
}
},
{
"group": "Objectifs",
"icon": "edit",
"title": "Éditer l'objectif",
"preview": {
"kind": "iconButton",
"icon": "edit"
}
},
{
"group": "Objectifs",
"icon": "trash",
"title": "Supprimer l'objectif et ces étapes",
"preview": {
"kind": "iconButton",
"icon": "trash",
"danger": true
}
}
],
"features": [
{
"title": "Décomposition visuelle",
"text": "Un objectif peut être ouvert en étapes, sous-étapes et ressources finales.",
"details": [
"Chaque bloc peut être sélectionné pour éditer ses quantités et son type.",
"Les branches se replient pour garder une vue lisible quand l'arbre grandit.",
"Le même outil peut représenter un craft, une route de farm ou un objectif long."
]
},
{
"title": "Quantités liées",
"text": "Les sous-étapes peuvent utiliser une formule basée sur la quantité du parent.",
"details": [
"La variable base référence la cible calculée du parent.",
"Les feuilles suivent leur quantité actuelle, tandis que les parents affichent une progression agrégée.",
"Les formules restent limitées aux opérations numériques simples."
]
},
{
"title": "Resets optionnels",
"text": "Un nœud peut être ponctuel, quotidien ou hebdomadaire sans imposer un reset à tout l'arbre.",
"details": [
"Les resets remettent seulement la quantité actuelle à zéro.",
"Le jour hebdomadaire et l'heure de reset se règlent au niveau de l'outil.",
"La structure de l'arbre reste inchangée après une réinitialisation."
]
}
],
"importFormat": {
"text": "Une ligne par nœud avec indentation de deux espaces. Utilisez xN pour une quantité simple ou = base * N pour une formule.",
"example": "- Breuvage de rage x10\n - Élixir de destruction = base * 3\n - Poudre de flamme x5\n - Sève de cèdre x7\n - Catalyseur magique x10"
}
},
"table": {
"controls": [
{
@ -1792,6 +1867,101 @@
"renameTitle": "Renommer",
"deleteTitle": "Supprimer"
},
"goalTree": {
"importPlaceholder": "- Breuvage de rage x10\n - Élixir de destruction = base * 3\n - Poudre de flamme x5\n - Sève de cèdre x7\n - Catalyseur magique x10",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
"importModalTitle": "Importer un arbre d'objectifs",
"exportModalTitle": "Exporter un arbre d'objectifs",
"importButton": "Importer le texte",
"exportButton": "Copier le texte",
"importSuccess": "Import d'arbre terminé.",
"exportSuccess": "Export d'arbre copié.",
"importError": "Format d'arbre d'objectifs invalide.",
"editorTitle": "Édition de l'arbre d'objectifs",
"treeTitle": "Arbre d'objectifs",
"addRootButton": "Objectif",
"addRootSchemaButton": "Ajouter un objectif a la racine",
"addChildButton": "Sous-étape",
"addChildTitle": "Ajouter une sous-étape",
"addContentItemTitle": "Ajouter une entrée",
"addContentItemTooltip": "Ajouter une entrée interne à cette carte.",
"verticalViewTitle": "Afficher verticalement",
"horizontalViewTitle": "Afficher horizontalement",
"cardActionsLabel": "Actions de l'objectif",
"editTitle": "Modifier",
"baseTab": "Base",
"contentTab": "Contenu",
"advancedTab": "Avancé",
"defaultRootTitle": "Nouvel objectif",
"defaultChildTitle": "Nouvelle étape",
"resetTimeLabel": "Heure de reset",
"weeklyResetDayLabel": "Jour de reset",
"titleLabel": "Titre de l'objectif",
"listTitleLabel": "Titre de la liste",
"kindLabel": "Type de l'objectif",
"stepKind": "Étape",
"textKind": "Texte",
"checklistKind": "Checklist",
"iconLabel": "Icône",
"iconColorLabel": "Couleur de l'icône",
"iconcube": "Cube",
"iconfire": "Feu",
"iconflask": "Fiole",
"icongrass": "Herbe",
"iconleaf": "Feuille",
"iconmeat": "Viande",
"iconpowder": "Poudre",
"icontear": "Goutte",
"iconball": "Boule",
"colorgold": "Or",
"colorcyan": "Cyan",
"colorviolet": "Violet",
"colorpink": "Rose",
"colorgreen": "Vert",
"colorgray": "Gris",
"colorred": "Rouge",
"colororange": "Orange",
"coloryellow": "Jaune",
"colorblue": "Bleu",
"colorindigo": "Indigo",
"targetQuantityLabel": "Cible",
"quantitySectionTitle": "Quantité",
"progressSectionTitle": "Progression",
"autoProgressLabel": "Automatique",
"manualProgressLabel": "Manuelle",
"autoProgressTitle": "Progression automatique",
"manualProgressTitle": "Mise à jour manuelle",
"autoProgressHint": "Calcule la progression depuis les enfants.",
"manualProgressHint": "Affiche une checkbox ou une quantité à mettre à jour directement.",
"contentSectionTitle": "Contenu",
"itemLabelLabel": "Libellé",
"itemLabelPlaceholder": "Élément",
"itemCheckedLabel": "État",
"itemTotalLabel": "Total",
"itemTotalPlaceholder": "base",
"itemTotalHelp": "Saisissez un nombre ou une formule avec base pour référencer la cible du parent.",
"prerequisitesSectionTitle": "Prérequis supplémentaires",
"contentTabHint": "Le contenu se modifie dans l'onglet Contenu.",
"stepContentUnavailable": "Les étapes n'ont pas de contenu interne.",
"decrementItemTitle": "Retirer",
"incrementItemTitle": "Ajouter",
"addContentItemButton": "Ajouter une ligne",
"formulaPlaceholder": "base * 3",
"formulaHelp": "Utilisez base pour référencer la cible du parent.",
"resetTypeLabel": "Type de reset",
"noneReset": "Aucun",
"dailyReset": "Quotidien",
"weeklyReset": "Hebdomadaire",
"closeButton": "Fermer",
"deleteButton": "Supprimer la branche",
"deleteTitle": "Supprimer",
"reorderTitle": "Déplacer",
"showChildrenTitle": "Afficher la branche",
"hideChildrenTitle": "Réduire la branche",
"emptySelection": "Sélectionnez ou créez un objectif.",
"emptyTree": "Aucun objectif."
},
"table": {
"importPlaceholder": "Collez un TSV pur ou un TSV exporté par Sokko G.",
"importOpenButton": "Importer TSV",

View file

@ -3,22 +3,22 @@
{
"id": "default",
"label": "Toolbox",
"src": "/static/img/toolbox-icons/default.svg"
"src": "/static/icons/toolbox.svg"
},
{
"id": "controller",
"label": "Controller",
"src": "/static/img/toolbox-icons/controller.svg"
"src": "/static/icons/controller.svg"
},
{
"id": "quest",
"label": "Quest",
"src": "/static/img/toolbox-icons/quest.svg"
"src": "/static/icons/tasklist.svg"
},
{
"id": "target",
"label": "Target",
"src": "/static/img/toolbox-icons/target.svg"
"src": "/static/icons/diagram.svg"
}
]
}

View file

Before

Width:  |  Height:  |  Size: 821 B

After

Width:  |  Height:  |  Size: 821 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 923 B

After

Width:  |  Height:  |  Size: 923 B

Before After
Before After

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" fill="#000000" class="bi bi-diagram-3">
<path fill-rule="evenodd" d="M6 3.5A1.5 1.5 0 0 1 7.5 2h1A1.5 1.5 0 0 1 10 3.5v1A1.5 1.5 0 0 1 8.5 6v1H14a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0V8h-5v.5a.5.5 0 0 1-1 0V8h-5v.5a.5.5 0 0 1-1 0v-1A.5.5 0 0 1 2 7h5.5V6A1.5 1.5 0 0 1 6 4.5v-1zM8.5 5a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1zM0 11.5A1.5 1.5 0 0 1 1.5 10h1A1.5 1.5 0 0 1 4 11.5v1A1.5 1.5 0 0 1 2.5 14h-1A1.5 1.5 0 0 1 0 12.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zm4.5.5A1.5 1.5 0 0 1 7.5 10h1a1.5 1.5 0 0 1 1.5 1.5v1A1.5 1.5 0 0 1 8.5 14h-1A1.5 1.5 0 0 1 6 12.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zm4.5.5a1.5 1.5 0 0 1 1.5-1.5h1a1.5 1.5 0 0 1 1.5 1.5v1a1.5 1.5 0 0 1-1.5 1.5h-1a1.5 1.5 0 0 1-1.5-1.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

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 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4V10L5.20285 16.8531C4.27496 18.1786 5.22327 20 6.84131 20H17.1587C18.7767 20 19.725 18.1786 18.7972 16.8531L14 10V4M10 4H14M10 4H8M14 4H16" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 467 B

View file

@ -0,0 +1,6 @@
<?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" xmlns="http://www.w3.org/2000/svg">
<title>grass</title>
<path d="M5,2c.82.82,5.61,2.88,6,18,0,.65,0,1.31,0,2h3C14,2.12,7,2,5,2ZM19,5a7,7,0,0,0-5.56,3,16.3,16.3,0,0,1,1.12,4C15.74,6.79,18.39,5.61,19,5ZM2,9c.77.77,4.72,3.09,5,12,0,.32,0,.66,0,1h3C10,13.52,7.5,9.22,2,9ZM22,9c-2,0-7,2.25-7,13h3C18,13,21.12,9.88,22,9Z"/>
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 552 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.44893 17.009C-0.246384 7.83762 7.34051 0.686125 19.5546 3.61245C20.416 3.81881 21.0081 4.60984 20.965 5.49452C20.5862 13.288 17.0341 17.7048 6.13252 17.9857C5.43022 18.0038 4.76908 17.6344 4.44893 17.009Z" stroke="#323232" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.99999 21C5.50005 15.5 6 12.5 12 9.99997" stroke="#323232" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!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 height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M110.26,202.977c-4.636-3.246-10.289-5.311-16.284-5.303c-6.003-0.008-11.664,2.057-16.3,5.303
c-6.979,4.912-12.054,12.192-15.584,20.725c-3.514,8.548-5.433,18.464-5.45,29.061c0.032,14.128,3.416,27.036,9.516,37.13
c3.058,5.035,6.849,9.395,11.517,12.648c4.636,3.254,10.297,5.327,16.3,5.312c5.995,0.016,11.648-2.058,16.284-5.312
c6.996-4.888,12.063-12.192,15.584-20.708c3.514-8.549,5.45-18.48,5.45-29.07c-0.024-14.128-3.4-27.045-9.5-37.13
C118.719,210.597,114.93,206.23,110.26,202.977z M105.185,279.791c-1.936,3.181-4.083,5.45-6.018,6.8
c-1.968,1.334-3.571,1.806-5.19,1.806c-1.627,0-3.229-0.472-5.189-1.806c-2.928-1.992-6.296-6.198-8.703-12.16
c-2.457-5.929-4.002-13.478-3.986-21.668c-0.033-10.923,2.774-20.708,6.654-27.036c1.936-3.156,4.083-5.441,6.027-6.783
c1.968-1.35,3.57-1.814,5.197-1.83c1.619,0.016,3.222,0.48,5.19,1.83c2.912,1.976,6.279,6.189,8.703,12.144
c2.44,5.945,3.986,13.486,3.986,21.676C111.879,263.686,109.073,273.455,105.185,279.791z"/>
<path class="st0" d="M512,223.221c0-17.105-6.963-32.729-18.204-43.93c-11.176-11.208-26.808-18.203-43.922-18.187
c-16.674-0.016-31.949,6.646-43.044,17.382c-7.499,7.223-13.127,16.39-16.25,26.662h-21.994c-1.464-1.521-3.124-3.277-4.88-5.164
c-3.92-4.23-8.247-9.11-10.655-12.233c-12.623-16.284-24.328-31.339-37.334-44.947c-19.44-20.432-42.279-37.488-73.562-48.567
c-31.298-11.118-70.502-16.634-124.34-16.65c-5.726,0-11.623,0.065-17.706,0.195v0.016C84.298,77.856,69.576,84.2,57.432,94.155
c-9.239,7.516-17.284,17.089-24.279,28.183C22.66,138.997,14.47,159.185,8.784,181.821C3.123,204.473,0,229.549,0,256
c0.041,47.037,9.761,89.747,26.508,122.046c8.402,16.146,18.618,29.769,30.924,39.823c12.16,9.939,26.882,16.291,42.702,16.356
c6.385,0.13,12.558,0.187,18.553,0.187c37.936,0,68.396-2.481,93.814-7.434c19.066-3.726,35.292-8.858,49.477-15.438
c21.302-9.826,37.765-22.921,51.837-37.871c13.949-14.794,25.784-31.249,38.992-49.062l16.072-17.732h21.701
c3.123,10.273,8.751,19.44,16.25,26.67c11.095,10.728,26.37,17.382,43.044,17.382c17.113,0,32.746-6.987,43.922-18.195
c11.241-11.2,18.204-26.816,18.204-43.93c0-12.022-3.514-23.262-9.468-32.788C508.486,246.492,512,235.243,512,223.221z
M127.292,396.819c-2.863,2.131-5.726,3.758-8.573,5.19c-0.016,0-0.024,0-0.032,0c-5.774,0-11.737-0.065-17.91-0.171l-0.162-0.025
h-0.162c-7.32-0.016-14.641-2.709-22.563-9.085c-5.888-4.774-11.843-11.64-17.316-20.334c-8.24-13.054-15.381-30.225-20.351-50.079
c-4.969-19.87-7.816-42.417-7.816-66.314c-0.049-42.442,9.094-80.743,22.855-107.081c6.832-13.176,14.771-23.286,22.628-29.638
c7.922-6.386,15.243-9.061,22.563-9.093h0.179h0.163c5.856-0.131,11.534-0.188,17.023-0.188c0.309,0,0.61,0.008,0.92,0.008
c2.838,1.424,5.694,3.066,8.556,5.189c13.534,9.988,26.613,28.102,36,52.365c9.435,24.239,15.275,54.512,15.258,88.438
c0.032,45.256-10.411,83.98-25.759,110.585C145.138,379.908,136.305,390.141,127.292,396.819z M347.666,276.887
c-3.4,6.515-8.459,13.9-15.421,22.148l-4.522,4.994l-0.472,0.635c-1.138,1.26-2.163,2.472-3.383,3.766l0.342,0.341
c-10.753,14.535-20.261,27.427-29.957,38.286c-7.962,8.938-16.056,16.584-25.32,23.181c-13.901,9.874-30.583,17.65-54.431,23.189
c-15.991,3.7-35.284,6.271-58.611,7.572c11.404-12.762,21.05-29.127,28.542-48.331c10.565-27.207,16.796-60.14,16.796-96.668
c-0.017-48.712-11.029-90.992-28.769-121.884c-4.962-8.622-10.501-16.251-16.479-22.97c17.332,1.115,32.469,2.994,45.686,5.62
c16.61,3.286,30.274,7.686,42.051,13.014c17.666,8.044,31.242,18.146,44.264,31.022c12.168,12.014,23.799,26.622,36.846,43.442
l-0.951,0.903c8.735,9.167,16.584,17.537,21.92,25.581c5.368,8.085,8.215,15.446,8.231,23.677
C354.027,260.717,352.319,267.949,347.666,276.887z M470.648,267.582c5.548,5.474,8.931,12.86,8.948,21.221
c-0.017,8.264-3.302,15.576-8.704,21.017c-5.433,5.4-12.753,8.694-21.017,8.703c-8.036-0.008-15.178-3.139-20.546-8.296
c-5.352-5.189-8.752-12.249-9.061-20.203l-0.618-15.544h-49.876c2.408-6.913,3.709-13.624,3.693-20.074
c0.016-5.962-1.026-11.558-2.75-16.861h48.932l0.618-15.544c0.309-7.963,3.709-15.015,9.061-20.196
c5.368-5.166,12.51-8.289,20.546-8.297c8.264,0.008,15.584,3.302,21.017,8.703c5.401,5.424,8.687,12.746,8.704,21.009
c-0.017,8.361-3.4,15.747-8.931,21.213l-11.826,11.582L470.648,267.582z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 189.99999 190.00002"
version="1.1"
id="svg5"
width="190"
height="190"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs5" />
<path
d="m 22.960347,162.04213 q -7,0 -10,-7 -2.9999996,-7 3,-13 l 60,-61.999999 q 20,-21 40.000003,0 l 60,61.999999 q 6,6 3,13 -3,7 -10,7 z"
fill="none"
stroke="currentColor"
stroke-width="14"
stroke-linejoin="round"
stroke-linecap="round"
id="path1" />
<circle
cx="95.96035"
cy="94.04213"
r="7"
fill="currentColor"
id="circle1" />
<circle
cx="75.96035"
cy="114.04213"
r="7"
fill="currentColor"
id="circle2" />
<circle
cx="115.96036"
cy="114.04213"
r="7"
fill="currentColor"
id="circle3" />
<circle
cx="63.960346"
cy="134.04213"
r="7"
fill="currentColor"
id="circle4" />
<circle
cx="127.96036"
cy="134.04213"
r="7"
fill="currentColor"
id="circle5" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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 fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M14.7071068,4 L17.5,4 C18.8807119,4 20,5.11928813 20,6.5 L20,9.29289322 L21.1464466,8.14644661 C21.3417088,7.95118446 21.6582912,7.95118446 21.8535534,8.14644661 C22.0488155,8.34170876 22.0488155,8.65829124 21.8535534,8.85355339 L19.8535534,10.8535534 C19.6582912,11.0488155 19.3417088,11.0488155 19.1464466,10.8535534 L17.1464466,8.85355339 C16.9511845,8.65829124 16.9511845,8.34170876 17.1464466,8.14644661 C17.3417088,7.95118446 17.6582912,7.95118446 17.8535534,8.14644661 L19,9.29289322 L19,6.5 C19,5.67157288 18.3284271,5 17.5,5 L14.7071068,5 L15.8535534,6.14644661 C16.0488155,6.34170876 16.0488155,6.65829124 15.8535534,6.85355339 C15.6582912,7.04881554 15.3417088,7.04881554 15.1464466,6.85355339 L13.1464466,4.85355339 C12.9511845,4.65829124 12.9511845,4.34170876 13.1464466,4.14644661 L15.1464466,2.14644661 C15.3417088,1.95118446 15.6582912,1.95118446 15.8535534,2.14644661 C16.0488155,2.34170876 16.0488155,2.65829124 15.8535534,2.85355339 L14.7071068,4 Z M4.5,2 L9.5,2 C10.8807119,2 12,3.11928813 12,4.5 L12,15.5 C12,16.8807119 10.8807119,18 9.5,18 L4.5,18 C3.11928813,18 2,16.8807119 2,15.5 L2,4.5 C2,3.11928813 3.11928813,2 4.5,2 Z M4.5,3 C3.67157288,3 3,3.67157288 3,4.5 L3,15.5 C3,16.3284271 3.67157288,17 4.5,17 L9.5,17 C10.3284271,17 11,16.3284271 11,15.5 L11,4.5 C11,3.67157288 10.3284271,3 9.5,3 L4.5,3 Z M16.5,13 C16.2238576,13 16,12.7761424 16,12.5 C16,12.2238576 16.2238576,12 16.5,12 L17.5,12 C17.7761424,12 18,12.2238576 18,12.5 C18,12.7761424 17.7761424,13 17.5,13 L16.5,13 Z M13.5,13 C13.2238576,13 13,12.7761424 13,12.5 C13,12.2238576 13.2238576,12 13.5,12 L14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L13.5,13 Z M16.5,22 C16.2238576,22 16,21.7761424 16,21.5 C16,21.2238576 16.2238576,21 16.5,21 L17.5,21 C17.7761424,21 18,21.2238576 18,21.5 C18,21.7761424 17.7761424,22 17.5,22 L16.5,22 Z M13.5,22 C13.2238576,22 13,21.7761424 13,21.5 C13,21.2238576 13.2238576,21 13.5,21 L14.5,21 C14.7761424,21 15,21.2238576 15,21.5 C15,21.7761424 14.7761424,22 14.5,22 L13.5,22 Z M10.5,22 C10.2238576,22 10,21.7761424 10,21.5 C10,21.2238576 10.2238576,21 10.5,21 L11.5,21 C11.7761424,21 12,21.2238576 12,21.5 C12,21.7761424 11.7761424,22 11.5,22 L10.5,22 Z M19.5,13 C19.2238576,13 19,12.7761424 19,12.5 C19,12.2238576 19.2238576,12 19.5,12 C20.8807119,12 22,13.1192881 22,14.5 C22,14.7761424 21.7761424,15 21.5,15 C21.2238576,15 21,14.7761424 21,14.5 C21,13.6715729 20.3284271,13 19.5,13 Z M21,16.5 C21,16.2238576 21.2238576,16 21.5,16 C21.7761424,16 22,16.2238576 22,16.5 L22,17.5 C22,17.7761424 21.7761424,18 21.5,18 C21.2238576,18 21,17.7761424 21,17.5 L21,16.5 Z M21,19.5 C21,19.2238576 21.2238576,19 21.5,19 C21.7761424,19 22,19.2238576 22,19.5 C22,20.8807119 20.8807119,22 19.5,22 C19.2238576,22 19,21.7761424 19,21.5 C19,21.2238576 19.2238576,21 19.5,21 C20.3284271,21 21,20.3284271 21,19.5 Z M8.5,21 C8.77614237,21 9,21.2238576 9,21.5 C9,21.7761424 8.77614237,22 8.5,22 C7.11928813,22 6,20.8807119 6,19.5 C6,19.2238576 6.22385763,19 6.5,19 C6.77614237,19 7,19.2238576 7,19.5 C7,20.3284271 7.67157288,21 8.5,21 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.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" data-name="Layer 1"><path d="M12.56,2.17a1,1,0,0,0-1.12,0c-.3.2-7.19,5-7.19,12.08a7.75,7.75,0,0,0,15.5,0C19.75,7.05,12.85,2.36,12.56,2.17ZM12,20a5.76,5.76,0,0,1-5.75-5.75c0-5,4.21-8.77,5.75-10,1.55,1.21,5.75,5,5.75,10A5.76,5.76,0,0,1,12,20Z"/></svg>

After

Width:  |  Height:  |  Size: 472 B

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before After
Before After

View file

@ -1,4 +0,0 @@
<?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 fill-rule="evenodd" clip-rule="evenodd" d="M10.1548 3.74068C9.13306 4.24326 7.70274 4.75901 5.07021 4.93689C4.43314 4.97993 4.01738 5.47217 4.03533 5.965C4.13502 8.70059 4.45127 11.4217 5.0553 13.6538C5.67349 15.9383 6.53465 17.4879 7.55469 18.1679C9.1615 19.2392 10.4837 20.1681 11.3705 20.8038C11.7447 21.072 12.2553 21.072 12.6294 20.8038C13.5162 20.1681 14.8385 19.2392 16.4453 18.1679C17.4653 17.4879 18.3265 15.9383 18.9447 13.6539C19.5487 11.4218 19.865 8.70066 19.9646 5.965C19.9826 5.47217 19.5668 4.97993 18.9298 4.93689C16.2972 4.75901 14.8669 4.24326 13.8452 3.74068C13.5533 3.5971 13.3276 3.47746 13.1423 3.37923C12.6352 3.11045 12.4305 3.00195 12 3.00195C11.5694 3.00195 11.3647 3.11045 10.8577 3.37923C10.6724 3.47746 10.4467 3.5971 10.1548 3.74068ZM9.68627 1.72824C10.3021 1.39456 11.0268 1.00195 12 1.00195C12.9732 1.00195 13.6978 1.39456 14.3137 1.72825C14.4571 1.80594 14.5946 1.88044 14.728 1.94605C15.503 2.32729 16.6774 2.78014 19.0646 2.94144C20.6316 3.04732 22.0258 4.32298 21.9633 6.03782C21.8605 8.8586 21.5333 11.7445 20.8752 14.1763C20.2313 16.5559 19.2117 18.7274 17.5547 19.8321C15.97 20.8885 14.6667 21.8042 13.7947 22.4293C12.724 23.1969 11.276 23.1969 10.2053 22.4293C9.33329 21.8042 8.02992 20.8885 6.44528 19.8321C4.78826 18.7274 3.76867 16.5559 3.12474 14.1763C2.46666 11.7444 2.13944 8.85853 2.03666 6.03783C1.97417 4.32298 3.36833 3.04732 4.93538 2.94144C7.32256 2.78014 8.49696 2.32729 9.27198 1.94605C9.40536 1.88044 9.54287 1.80594 9.68627 1.72824Z" fill="#0F0F0F"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,224 @@
// Rôle : dessine des liaisons SVG entre éléments DOM mesurés dans un conteneur scrollable.
import { useEffect, useState } from "react";
const MAX_LAYOUT_RETRIES = 12;
function attributeSelector(name, value) {
return `[${name}="${String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"]`;
}
function buildConnectorPath(source, target, orientation = "vertical") {
if (orientation === "horizontal") {
const startX = source.left + source.width;
const startY = source.top + source.height / 2;
const endX = target.left;
const endY = target.top + target.height / 2;
const midX = startX + Math.max(18, (endX - startX) / 2);
return `M ${startX} ${startY} H ${midX} V ${endY} H ${endX}`;
}
const startX = source.left + source.width / 2;
const startY = source.top + source.height;
const endX = target.left + target.width / 2;
const endY = target.top;
const midY = startY + Math.max(18, (endY - startY) / 2);
return `M ${startX} ${startY} V ${midY} H ${endX} V ${endY}`;
}
function buildHorizontalDirectConnectorPath(source, target, preferSourceEnd = false) {
const sourceCenterX = source.left + source.width / 2;
const targetCenterX = target.left + target.width / 2;
const sourceBeforeTarget = preferSourceEnd || sourceCenterX <= targetCenterX;
const startX = sourceBeforeTarget ? source.left + source.width : source.left;
const startY = source.top + source.height / 2;
const endX = sourceBeforeTarget ? target.left : target.left + target.width;
const endY = target.top + target.height / 2;
const distance = Math.abs(endX - startX);
const midX = sourceBeforeTarget
? startX + Math.max(18, distance / 2)
: startX - Math.max(18, distance / 2);
return `M ${startX} ${startY} H ${midX} V ${endY} H ${endX}`;
}
function buildVerticalDirectConnectorPath(source, target, preferSourceEnd = false) {
const sourceCenterY = source.top + source.height / 2;
const targetCenterY = target.top + target.height / 2;
const sourceAboveTarget = preferSourceEnd || sourceCenterY <= targetCenterY;
const startX = source.left + source.width / 2;
const startY = sourceAboveTarget ? source.top + source.height : source.top;
const endX = target.left + target.width / 2;
const endY = sourceAboveTarget ? target.top : target.top + target.height;
const distance = Math.abs(endY - startY);
const midY = sourceAboveTarget
? startY + Math.max(18, distance / 2)
: startY - Math.max(18, distance / 2);
return `M ${startX} ${startY} V ${midY} H ${endX} V ${endY}`;
}
function buildDirectConnectorPath(source, target, orientation = "vertical") {
if (orientation === "horizontal") {
return buildHorizontalDirectConnectorPath(source, target, true);
}
return buildVerticalDirectConnectorPath(source, target, true);
}
function buildGroupedConnectorPath(source, targets, orientation = "vertical") {
if (targets.length === 1) return buildConnectorPath(source, targets[0], orientation);
if (orientation === "horizontal") {
const startX = source.left + source.width;
const startY = source.top + source.height / 2;
const targetPoints = targets.map((target) => ({
x: target.left,
y: target.top + target.height / 2
}));
const leftTargetX = Math.min(...targetPoints.map((target) => target.x));
const midX = startX + Math.max(18, (leftTargetX - startX) / 2);
const minY = Math.min(startY, ...targetPoints.map((target) => target.y));
const maxY = Math.max(startY, ...targetPoints.map((target) => target.y));
const childBranches = targetPoints.map((target) => `M ${midX} ${target.y} H ${target.x}`).join(" ");
return `M ${startX} ${startY} H ${midX} M ${midX} ${minY} V ${maxY} ${childBranches}`;
}
const startX = source.left + source.width / 2;
const startY = source.top + source.height;
const targetPoints = targets.map((target) => ({
x: target.left + target.width / 2,
y: target.top
}));
const topTargetY = Math.min(...targetPoints.map((target) => target.y));
const midY = startY + Math.max(18, (topTargetY - startY) / 2);
const minX = Math.min(startX, ...targetPoints.map((target) => target.x));
const maxX = Math.max(startX, ...targetPoints.map((target) => target.x));
const childBranches = targetPoints.map((target) => `M ${target.x} ${midY} V ${target.y}`).join(" ");
return `M ${startX} ${startY} V ${midY} M ${minX} ${midY} H ${maxX} ${childBranches}`;
}
export function DiagramConnectors({ containerRef, edges, nodeAttribute = "data-diagram-node", className = "", stroke = "currentColor", refreshKey = "", scale = 1, orientation = "vertical" }) {
const [layout, setLayout] = useState({ width: 0, height: 0, paths: [] });
useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
let frameId = 0;
let settleFrameId = 0;
let cancelled = false;
const observedElements = new Set();
function observeCurrentNodes(resizeObserver) {
container.querySelectorAll(`[${nodeAttribute}]`).forEach((element) => {
if (observedElements.has(element)) return;
observedElements.add(element);
resizeObserver.observe(element);
});
}
function measure(resizeObserver) {
observeCurrentNodes(resizeObserver);
const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const containerRect = container.getBoundingClientRect();
const rects = new Map();
const expectedNodeIds = new Set();
let hasIncompleteNode = false;
edges.forEach((edge) => {
[edge.from, edge.to].forEach((nodeId) => {
expectedNodeIds.add(nodeId);
if (rects.has(nodeId)) return;
const element = container.querySelector(attributeSelector(nodeAttribute, nodeId));
if (!element) {
hasIncompleteNode = true;
return;
}
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height) hasIncompleteNode = true;
rects.set(nodeId, {
left: (rect.left - containerRect.left) / normalizedScale + container.scrollLeft,
top: (rect.top - containerRect.top) / normalizedScale + container.scrollTop,
width: rect.width / normalizedScale,
height: rect.height / normalizedScale
});
});
});
const width = Math.max(container.scrollWidth, container.offsetWidth, container.clientWidth);
const height = Math.max(container.scrollHeight, container.offsetHeight, container.clientHeight);
const groupedEdges = edges.filter((edge) => edge.grouped !== false);
const directEdges = edges.filter((edge) => edge.grouped === false);
const edgesBySource = new Map();
groupedEdges.forEach((edge) => {
edgesBySource.set(edge.from, [...(edgesBySource.get(edge.from) || []), edge.to]);
});
const paths = [
...Array.from(edgesBySource.entries())
.map(([sourceId, targetIds]) => {
const source = rects.get(sourceId);
const targets = targetIds.map((targetId) => rects.get(targetId)).filter(Boolean);
return source && targets.length ? { id: `${sourceId}:${targetIds.join(":")}`, d: buildGroupedConnectorPath(source, targets, orientation) } : null;
}),
...directEdges.map((edge) => {
const source = rects.get(edge.from);
const target = rects.get(edge.to);
return source && target ? { id: `${edge.from}:${edge.to}:direct`, d: buildDirectConnectorPath(source, target, orientation) } : null;
})
].filter(Boolean);
setLayout({
width,
height,
paths
});
return !hasIncompleteNode && rects.size === expectedNodeIds.size;
}
function scheduleMeasure(attempt = 0) {
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
const complete = measure(resizeObserver);
if (!cancelled && !complete && attempt < MAX_LAYOUT_RETRIES) scheduleMeasure(attempt + 1);
});
}
function scheduleSettledMeasure() {
cancelAnimationFrame(settleFrameId);
settleFrameId = requestAnimationFrame(() => {
settleFrameId = requestAnimationFrame(() => {
if (!cancelled) measure(resizeObserver);
});
});
}
const resizeObserver = new ResizeObserver(scheduleMeasure);
const mutationObserver = new MutationObserver(() => {
observeCurrentNodes(resizeObserver);
scheduleMeasure();
});
resizeObserver.observe(container);
observeCurrentNodes(resizeObserver);
mutationObserver.observe(container, { childList: true, subtree: true, attributes: true, attributeFilter: [nodeAttribute, "class", "style"] });
container.addEventListener("scroll", scheduleMeasure, { passive: true });
window.addEventListener("resize", scheduleMeasure);
scheduleMeasure();
scheduleSettledMeasure();
document.fonts?.ready.then(() => {
if (!cancelled) scheduleSettledMeasure();
});
return () => {
cancelled = true;
cancelAnimationFrame(frameId);
cancelAnimationFrame(settleFrameId);
resizeObserver.disconnect();
mutationObserver.disconnect();
container.removeEventListener("scroll", scheduleMeasure);
window.removeEventListener("resize", scheduleMeasure);
};
}, [containerRef, edges, nodeAttribute, refreshKey, scale, orientation]);
return (
<svg
className={`diagram-connectors ${className}`.trim()}
width={layout.width || 1}
height={layout.height || 1}
viewBox={`0 0 ${layout.width || 1} ${layout.height || 1}`}
style={{ width: `${layout.width || 1}px`, height: `${layout.height || 1}px` }}
aria-hidden="true"
>
{layout.paths.map((path) => <path key={path.id} d={path.d} fill="none" stroke={stroke} />)}
</svg>
);
}

View file

@ -17,7 +17,7 @@ export function getCategoryLabel(category) {
export function getCategoryIconStyle(category) {
const icon = typeof category === "string" ? "" : category?.icon;
return icon ? { "--diablo4-category-icon": `url("/static/img/diablo4/${icon}.svg")` } : undefined;
return icon ? { "--diablo4-category-icon": `url("/static/icons/${icon}.svg")` } : undefined;
}
export function getFilteredDiablo4Affixes({ affixes, filters }) {

View file

@ -20,6 +20,7 @@ import {
normalizeCombosData,
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeGoalTreeData,
normalizeImageAnnotationData,
normalizeLinksData,
normalizeModuleOrder,
@ -175,7 +176,7 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
return toolbox?.tutorial === true || pendingTutorialToolboxId === toolbox?.id;
}
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) {
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) {
const toolboxId = toolbox.id;
const toolboxTutorial = toolbox.tutorial;
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
@ -185,12 +186,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
const currentToolboxIdRef = useRef(toolboxId);
const moduleContext = {
getModuleData,
storageReady,
moduleText,
normalizeChecklistData,
normalizeCombosData,
normalizeLinksData,
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeGoalTreeData,
normalizeCalculatorData,
normalizeCalendarData,
normalizeImageAnnotationData,
@ -329,7 +332,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
);
}
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
const selectedId = links[gameId] || "";
const toolbox = toolboxes.find((item) => item.id === selectedId);
const [width, setWidth] = useState("");
@ -389,7 +392,7 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
</div>
<button className="drawer-close-button" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</header>
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} /> : (
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} storageReady={storageReady} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} /> : (
<div className="empty"><p>Aucune toolbox associée à cette page jeu.</p><button className="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div>
)}
<StorageQuota usage={storageUsage} />

View file

@ -116,7 +116,7 @@ function FormattedCalculatorNumber({ value }) {
});
}
export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
const calculatedEntries = useMemo(() => evaluateCalculatorEntries(data.entries), [data.entries]);
const calculatedData = useMemo(() => ({ ...data, entries: calculatedEntries }), [data, calculatedEntries]);
@ -235,8 +235,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
return (
<div className="calculator-module">
{editing && (
<div className="module-add-panel calculator-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calculator" data={calculatedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
</div>
)}
@ -289,7 +289,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
</div>
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{calculatedEntries.length ? (
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} editing={editing} reorder={reorder} onUse={useEntry} onRename={renameEntry} onUpdateFormula={updateEntryFormula} onDelete={deleteEntry} />
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={useEntry} onRename={renameEntry} onUpdateFormula={updateEntryFormula} onDelete={deleteEntry} />
) : (
<p className="muted">{textContent.emptyResults || "Aucun résultat enregistré."}</p>
)}
@ -299,7 +299,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
);
}
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, editing, reorder, onUse, onRename, onUpdateFormula, onDelete }) {
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, reorder, onUse, onRename, onUpdateFormula, onDelete }) {
const children = getChildren(entries, parentId);
const [editingId, setEditingId] = useState("");
useEffect(() => {
@ -342,8 +342,8 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
</button>
{entry.id === editingId ? (
<span className="calculator-entry-edit-fields" data-calculator-edit-fields={entry.id}>
<EditableCalculatorFormula entry={entry} entries={entries} textContent={textContent} onUpdateFormula={onUpdateFormula} autoFocus={editing} />
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} autoFocus={!editing} />
<EditableCalculatorFormula entry={entry} entries={entries} textContent={textContent} onUpdateFormula={onUpdateFormula} />
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} />
</span>
) : (
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
@ -362,7 +362,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
<Icon name="trash" />
</button>
</div>
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeParentId={activeParentId} textContent={textContent} editing={editing} reorder={reorder} onUse={onUse} onRename={onRename} onUpdateFormula={onUpdateFormula} onDelete={onDelete} />
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={onUse} onRename={onRename} onUpdateFormula={onUpdateFormula} onDelete={onDelete} />
</li>
);
})}

View file

@ -306,7 +306,7 @@ function getMonthOccurrenceSegments(days, occurrences) {
});
}
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing }) {
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
const textContent = context.moduleText?.calendar || {};
const [now, setNow] = useState(() => new Date());
@ -780,8 +780,8 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
</div>
{error && <p className="timer-form-error calendar-form-error" role="alert"><Icon name="warning" /><span>{error}</span></p>}
</form>}
{editing && (
<div className="calendar-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

View file

@ -5,7 +5,7 @@ import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
export function ChecklistModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
const textContent = context.moduleText?.checklist || {};
const [label, setLabel] = useState("");
@ -117,6 +117,10 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
</div>
)}

View file

@ -21,7 +21,7 @@ import {
setComboItemCategory
} from "./combosUtils.js";
export function CombosModule({ toolboxId, moduleId, context, editing }) {
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
const textContent = context.moduleText?.combos || {};
const [comboCategory, setComboCategory] = useState("");
@ -589,9 +589,11 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
</div>
</div>
</form>
{editing && !editingComboId && (
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
)}
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

View file

@ -4,7 +4,7 @@ import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing }) {
export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
const textContent = context.moduleText?.counters || {};
const counterDraft = useDraftForm({ label: "" });
@ -50,6 +50,10 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) {
<input {...counterDraft.getFieldProps("label", { placeholder: textContent.labelPlaceholder || "Nom du compteur" })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
</div>
)}

View file

@ -7,7 +7,7 @@ import { EquipmentItem } from "./EquipmentPlannerItem.jsx";
import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx";
import { EquipmentPlannerSummary } from "./EquipmentPlannerSummary.jsx";
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) {
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
const summary = useMemo(() => context.summarizeEquipmentPlannerData(data), [context, data]);
const textContent = context.moduleText?.equipmentPlanner || {};
@ -112,6 +112,10 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }
<input value={equipmentName} onChange={(event) => setEquipmentName(event.target.value)} placeholder={textContent.equipmentPlaceholder || "Nouvel équipement"} />
<button className="primary">{textContent.addEquipmentButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="equipmentPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ function markerLabel(index, marker, textContent) {
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
}
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing }) {
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeImageAnnotationData(context.getModuleData(toolboxId, moduleId, { image: "", markers: [] }));
const textContent = context.moduleText?.imageAnnotation || {};
const previewMediaRef = useRef(null);
@ -105,6 +105,10 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
>
{pastePlaceholder}
</div>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
save(nextData);
setTemporaryDrawings({ strokes: [] });

View file

@ -4,7 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ImagesModule({ toolboxId, moduleId, context, editing }) {
export function ImagesModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.getModuleData(toolboxId, moduleId, { images: [] });
const textContent = context.moduleText?.images || {};
const [dragOver, setDragOver] = useState(false);
@ -86,6 +86,10 @@ export function ImagesModule({ toolboxId, moduleId, context, editing }) {
>
{pastePlaceholder}
</div>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
</div>
)}

View file

@ -5,7 +5,7 @@ import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function LinksModule({ toolboxId, moduleId, context, editing }) {
export function LinksModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] }));
const textContent = context.moduleText?.links || {};
const linkDraft = useDraftForm({ title: "", url: "" });
@ -63,6 +63,10 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
<input {...linkDraft.getFieldProps("url", { placeholder: textContent.urlPlaceholder || "https://..." })} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
</div>
)}

View file

@ -145,7 +145,7 @@ function ListTypeDropdown({ textContent, activeFormats, onCommand }) {
);
}
export function NotepadModule({ toolboxId, moduleId, context, editing = false }) {
export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const storedData = context.getModuleData(toolboxId, moduleId, { text: "" });
const normalizedData = useMemo(() => context.normalizeNotepadData(storedData), [context, storedData]);
const textContent = context.moduleText?.notepad || {};
@ -360,8 +360,8 @@ export function NotepadModule({ toolboxId, moduleId, context, editing = false })
return (
<div className="notepad-module">
{editing && (
<div className="module-add-panel notepad-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="notepad" data={normalizedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "notepad")} />
</div>
)}

View file

@ -26,7 +26,7 @@ import {
pasteCellBlock
} from "./tableUtils.js";
export function TableModule({ toolboxId, moduleId, context, editing }) {
export function TableModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
const textContent = context.moduleText?.table || {};
const [editingCell, setEditingCell] = useState("");
@ -406,8 +406,8 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
return (
<div className="table-module">
{editing && (
<div className="module-add-panel table-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
</div>
)}

View file

@ -18,7 +18,7 @@ import {
moveTask
} from "./taskPlannerUtils.js";
export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
const textContent = context.moduleText?.taskPlanner || {};
const taskDraft = useDraftForm({ title: "", type: "daily" });
@ -314,6 +314,10 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
</select>
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

View file

@ -29,7 +29,7 @@ import {
isAlertGuarded
} from "./timerOptions.js";
export function TimerModule({ toolboxId, moduleId, context, editing }) {
export function TimerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
const textContent = context.moduleText?.timer || {};
const activeTab = data.activeTab;
@ -247,8 +247,8 @@ export function TimerModule({ toolboxId, moduleId, context, editing }) {
return (
<div className="timer-module">
{editing && (
<div className="module-add-panel timer-exchange-panel">
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}

View file

@ -0,0 +1,269 @@
// Rôle : regroupe les calculs purs de l'arbre d'objectifs.
export const GOAL_TREE_KINDS = ["step", "text", "checklist"];
export const GOAL_TREE_RESET_TYPES = ["none", "daily", "weekly"];
export const GOAL_TREE_ICON_NAMES = ["cube", "fire", "flask", "grass", "leaf", "meat", "powder", "tear", "ball"];
export const GOAL_TREE_COLORS = ["gold", "cyan", "violet", "pink", "green", "gray", "red", "orange", "yellow", "blue", "indigo"];
export const GOAL_TREE_COLOR_VALUES = {
gold: "#f6c453",
cyan: "#22d3ee",
violet: "#8b5cf6",
pink: "#d946ef",
green: "#34d399",
gray: "#94a3b8",
red: "#ef4444",
orange: "#f97316",
yellow: "#eab308",
blue: "#3b82f6",
indigo: "#6366f1"
};
export const GOAL_TREE_WEEK_DAYS = [
{ value: 1, label: "Lundi" },
{ value: 2, label: "Mardi" },
{ value: 3, label: "Mercredi" },
{ value: 4, label: "Jeudi" },
{ value: 5, label: "Vendredi" },
{ value: 6, label: "Samedi" },
{ value: 0, label: "Dimanche" }
];
function formatResult(value) {
if (!Number.isFinite(value)) return "";
return Number.parseFloat(value.toFixed(6)).toString();
}
export function calculateGoalTreeFormula(expression, baseValue = null, { clamp = true } = {}) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
if (!normalized) return null;
const withBase = normalized.replace(/\bbase\b/gi, () => Number.isFinite(baseValue) ? `(${formatResult(baseValue)})` : "NaN");
if (!/^[\d\s+\-*/().]+$/.test(withBase)) return null;
try {
const value = Function(`"use strict"; return (${withBase})`)();
if (!Number.isFinite(value)) return null;
return clamp ? Math.max(0, value) : value;
} catch {
return null;
}
}
export function formatGoalTreeNumber(value) {
if (!Number.isFinite(value)) return "0";
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 2 }).format(Number.parseFloat(value.toFixed(2)));
}
export function getGoalTreeChildren(nodes, parentId) {
return nodes.filter((node) => (node.parentId || "") === parentId);
}
export function getGoalTreeParentIds(node) {
return [...new Set([node?.parentId || "", ...(Array.isArray(node?.parentIds) ? node.parentIds : [])].filter(Boolean))];
}
export function getGoalTreeDescendantIds(nodes, parentId) {
return getGoalTreeChildren(nodes, parentId).flatMap((child) => [child.id, ...getGoalTreeDescendantIds(nodes, child.id)]);
}
export function getGoalTreeParentMap(nodes) {
const ids = new Set(nodes.map((node) => node.id));
return new Map(nodes.map((node) => [node.id, ids.has(node.parentId) ? node.parentId : ""]));
}
function isBaseScaledFormula(expression) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
return /\bbase\b/i.test(normalized) && /^[\d\s*/().base]+$/i.test(normalized);
}
export function evaluateGoalTreeNodes(nodes) {
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
const childrenByParent = new Map();
nodes.forEach((node) => {
const parentId = node.parentId || "";
childrenByParent.set(parentId, [...(childrenByParent.get(parentId) || []), node.id]);
});
const computedTargets = new Map();
const progressMap = new Map();
function computeTarget(nodeId, seen = new Set()) {
if (computedTargets.has(nodeId)) return computedTargets.get(nodeId);
if (seen.has(nodeId)) return 1;
const node = nodeMap.get(nodeId);
if (!node) return 1;
const parentTarget = node.parentId ? computeTarget(node.parentId, new Set([...seen, nodeId])) : null;
const formulaTarget = node.formula ? calculateGoalTreeFormula(node.formula, parentTarget) : null;
const target = Math.max(0, formulaTarget ?? node.qtyTarget ?? 1);
computedTargets.set(nodeId, target);
return target;
}
nodes.forEach((node) => computeTarget(node.id));
function evaluateChecklistItems(node) {
const parentTarget = node.parentId ? computedTargets.get(node.parentId) : null;
return (node.items || []).map((item) => {
const target = calculateGoalTreeFormula(item.formula || "1", parentTarget) ?? item.qtyTarget ?? 1;
return { ...item, qtyTarget: Math.max(1, target) };
});
}
function getNodeProgressTarget(node) {
if (node?.kind === "checklist") {
return evaluateChecklistItems(node).reduce((sum, item) => sum + Math.max(1, item.qtyTarget || 1), 0) || 1;
}
if (node?.kind === "text") return Math.max(1, node.items?.length || 1);
return Math.max(1, computedTargets.get(node?.id) ?? node?.qtyTarget ?? 1);
}
function getEffectiveStepProgressMode(node, childIds) {
if (node?.kind !== "step") return "auto";
if (node?.progressMode) return node.progressMode;
if (!childIds.length) return "manual";
return childIds.every((childId) => getNodeProgressTarget(nodeMap.get(childId)) <= 1) ? "manual" : "auto";
}
function getBaseScaledRequirementCount(childNode, childProgress, parentTarget) {
if (!parentTarget) return null;
if (childNode.kind === "checklist") {
const items = evaluateChecklistItems(childNode);
if (!items.length || !items.every((item) => isBaseScaledFormula(item.formula))) return null;
return items.map((item) => {
const target = Math.max(1, item.qtyTarget || 1);
const ratio = target / parentTarget;
if (!ratio) return null;
return Math.max(0, item.qtyCurrent || 0) / ratio;
});
}
if (!isBaseScaledFormula(childNode.formula)) return null;
const target = Math.max(0, computedTargets.get(childNode.id) ?? childProgress.target ?? 0);
const ratio = target / parentTarget;
if (!ratio) return null;
return [Math.max(0, childProgress.current || 0) / ratio];
}
function computeBaseScaledChildrenProgress(node, childIds, childProgress) {
const target = Math.max(1, computedTargets.get(node.id) ?? node.qtyTarget ?? 1);
const requirementCounts = [];
for (let index = 0; index < childIds.length; index += 1) {
const childNode = nodeMap.get(childIds[index]);
const counts = childNode ? getBaseScaledRequirementCount(childNode, childProgress[index], target) : null;
if (!counts) return null;
requirementCounts.push(...counts);
}
if (!requirementCounts.length) return null;
const current = Math.min(target, Math.floor(Math.min(...requirementCounts)));
return {
current,
target,
ratio: target ? Math.min(1, current / target) : 0,
complete: current >= target
};
}
function computeProgress(nodeId, seen = new Set()) {
if (progressMap.has(nodeId)) return progressMap.get(nodeId);
if (seen.has(nodeId)) return { current: 0, target: 1, ratio: 0, complete: false };
const node = nodeMap.get(nodeId);
const childIds = childrenByParent.get(nodeId) || [];
let progress;
if (childIds.length && getEffectiveStepProgressMode(node, childIds) !== "manual") {
const childProgress = childIds.map((childId) => computeProgress(childId, new Set([...seen, nodeId])));
progress = computeBaseScaledChildrenProgress(node, childIds, childProgress);
if (!progress) {
const target = childProgress.reduce((sum, item) => sum + item.target, 0);
const current = childProgress.reduce((sum, item) => sum + Math.min(item.current, item.target), 0);
progress = {
current,
target: target || 1,
ratio: target ? Math.min(1, current / target) : 0,
complete: childProgress.length > 0 && childProgress.every((item) => item.complete)
};
}
} else {
const target = computedTargets.get(nodeId) ?? 1;
const current = Math.max(0, node?.qtyCurrent || 0);
if (node?.kind === "checklist") {
const items = evaluateChecklistItems(node);
const total = items.reduce((sum, item) => sum + Math.max(0, item.qtyTarget || 1), 0);
const currentTotal = items.reduce((sum, item) => sum + Math.min(Math.max(0, item.qtyCurrent || 0), Math.max(0, item.qtyTarget || 1)), 0);
progress = {
current: currentTotal,
target: total || 1,
ratio: total ? currentTotal / total : 0,
complete: items.length > 0 && currentTotal >= total
};
} else if (node?.kind === "text") {
const count = (node.items || []).length;
progress = {
current: count,
target: count || 1,
ratio: count ? 1 : 0,
complete: count > 0
};
} else {
progress = {
current,
target,
ratio: target ? Math.min(1, current / target) : 1,
complete: current >= target
};
}
}
progressMap.set(nodeId, progress);
return progress;
}
nodes.forEach((node) => computeProgress(node.id));
return nodes.map((node) => ({
...node,
items: node.kind === "checklist" ? evaluateChecklistItems(node) : node.items,
computedQtyTarget: computedTargets.get(node.id) ?? node.qtyTarget,
progress: progressMap.get(node.id) || { current: 0, target: 1, ratio: 0, complete: false }
}));
}
function parseResetTime(value) {
const [hours, minutes] = String(value || "00:00").split(":").map((part) => Number.parseInt(part, 10));
return {
hours: Number.isInteger(hours) ? Math.min(23, Math.max(0, hours)) : 0,
minutes: Number.isInteger(minutes) ? Math.min(59, Math.max(0, minutes)) : 0
};
}
function getLatestDailyReset(now, resetTime) {
const { hours, minutes } = parseResetTime(resetTime);
const reset = new Date(now);
reset.setHours(hours, minutes, 0, 0);
if (reset.getTime() > now) reset.setDate(reset.getDate() - 1);
return reset.getTime();
}
function getLatestWeeklyReset(now, resetTime, weekDay) {
const { hours, minutes } = parseResetTime(resetTime);
const reset = new Date(now);
reset.setHours(hours, minutes, 0, 0);
const dayDiff = (reset.getDay() - weekDay + 7) % 7;
reset.setDate(reset.getDate() - dayDiff);
if (reset.getTime() > now) reset.setDate(reset.getDate() - 7);
return reset.getTime();
}
export function applyGoalTreeDueResets(data, now = Date.now()) {
let latestResetAt = data.lastResetAt || 0;
let changed = false;
const nodes = data.nodes.map((node) => {
if (!["daily", "weekly"].includes(node.resetType)) return node;
const resetAt = node.resetType === "daily"
? getLatestDailyReset(now, node.resetTime)
: getLatestWeeklyReset(now, node.resetTime, node.weeklyResetDay);
latestResetAt = Math.max(latestResetAt, resetAt);
if (resetAt <= (node.checkedAt || data.lastResetAt || 0)) return node;
if (node.kind === "checklist" && node.items?.some((item) => item.qtyCurrent)) {
changed = true;
return { ...node, items: node.items.map((item) => ({ ...item, qtyCurrent: 0 })), checkedAt: 0 };
}
if (!node.qtyCurrent) return node;
changed = true;
return { ...node, qtyCurrent: 0, checkedAt: 0 };
});
if (latestResetAt > (data.lastResetAt || 0)) changed = true;
return changed ? { ...data, lastResetAt: latestResetAt, nodes } : data;
}

View file

@ -7,6 +7,7 @@ import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { TOOLBOX_MODULE_DEFINITIONS, TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
import { supportsTextExchange } from "./textImport.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { CalendarModule } from "./CalendarModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
@ -14,6 +15,7 @@ import { CombosModule } from "./CombosModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { EquipmentPlannerModule } from "./EquipmentPlannerModule.jsx";
import { GoalTreeModule } from "./GoalTreeModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx";
@ -31,6 +33,7 @@ const MODULE_COMPONENTS = {
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
table: { ...TOOLBOX_MODULE_DEFINITIONS.table, Component: TableModule },
goalTree: { ...TOOLBOX_MODULE_DEFINITIONS.goalTree, Component: GoalTreeModule },
timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
equipmentPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.equipmentPlanner, Component: EquipmentPlannerModule },
@ -286,6 +289,9 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
const Component = definition.Component;
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
const scrollable = definition.scrollable && module.scrollable === true;
const className = [
"module",
@ -298,6 +304,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
return (
<article
className={className}
data-module-type={module.type}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>
@ -337,10 +344,14 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
<i aria-hidden="true" />
</button>
)}
{definition.editable && (
{hasAddPanel && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
onClick={() => setEditing((value) => !value)}
onClick={() => setEditing((value) => {
const nextEditing = !value;
if (nextEditing) setTextExchangeOpen(false);
return nextEditing;
})}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
aria-pressed={editing}
title={editing ? "Masquer l'ajout" : "Ajouter"}
@ -348,13 +359,39 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
<Icon name="add" />
</button>
)}
{hasTextExchange && (
<button
className={`module-exchange-button ${textExchangeOpen ? "active" : ""}`}
onClick={() => setTextExchangeOpen((value) => {
const nextOpen = !value;
if (nextOpen) setEditing(false);
return nextOpen;
})}
aria-label={`${textExchangeOpen ? "Masquer" : "Afficher"} l'import/export de ${module.title || label}`}
aria-pressed={textExchangeOpen}
title={textExchangeOpen ? "Masquer import/export" : "Import / export"}
>
<Icon name="import" />
</button>
)}
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
</button>
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} setEditing={setEditing} />
<Component
toolboxId={toolbox.id}
moduleId={module.id}
context={context}
editing={editing}
setEditing={setEditing}
openEditor={() => {
setTextExchangeOpen(false);
setEditing(true);
}}
textExchangeOpen={textExchangeOpen}
/>
</div>
</article>
);

View file

@ -30,6 +30,7 @@ const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
"timer",
"calendar",
"taskPlanner",
"goalTree",
"equipmentPlanner",
"images",
"imageAnnotation"
@ -61,6 +62,7 @@ export function hasTextExchangeContent(type, data) {
if (type === "timer") return Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs);
if (type === "calendar") return Boolean(data?.events?.length);
if (type === "taskPlanner") return Boolean(data?.tasks?.length);
if (type === "goalTree") return Boolean(data?.nodes?.length);
if (type === "equipmentPlanner") return Boolean(data?.equipments?.length);
if (type === "images") return Boolean(data?.images?.length);
if (type === "imageAnnotation") return Boolean(data?.image);
@ -78,6 +80,7 @@ export function exportModuleText(type, data) {
if (type === "timer") return exportTimerText(data);
if (type === "calendar") return exportCalendarText(data);
if (type === "taskPlanner") return exportTaskPlannerText(data);
if (type === "goalTree") return exportGoalTreeText(data);
if (type === "equipmentPlanner") return exportEquipmentPlannerText(data);
if (type === "images") return exportImagesText(data);
if (type === "imageAnnotation") return exportImageAnnotationText(data);
@ -99,6 +102,7 @@ export function importModuleText(type, text, context) {
else if (type === "timer") data = importTimerText(value, context);
else if (type === "calendar") data = importCalendarText(value, context);
else if (type === "taskPlanner") data = importTaskPlannerText(value, context);
else if (type === "goalTree") data = importGoalTreeText(value, context);
else if (type === "equipmentPlanner") data = importEquipmentPlannerText(value, context);
else if (type === "images") data = importImagesText(value, context);
else if (type === "imageAnnotation") data = importImageAnnotationText(value, context);
@ -586,6 +590,90 @@ function importTaskPlannerText(text, context) {
return context.normalizeTaskPlannerData(data);
}
function exportGoalTreeText(data) {
const lines = [];
if (data.lastResetAt) lines.push(`@lastResetAt: ${data.lastResetAt}`);
function visit(parentId = "", depth = 0) {
(data.nodes || []).filter((node) => (node.parentId || "") === parentId).forEach((node) => {
const prefix = " ".repeat(depth);
const formula = String(node.formula || "").trim();
const qty = Number.isFinite(node.computedQtyTarget) ? node.computedQtyTarget : node.qtyTarget;
const suffix = node.kind === "step" ? (formula ? ` = ${formula}` : ` x${qty}`) : "";
const meta = formatMeta({
kind: node.kind !== "step" ? node.kind : "",
icon: node.kind === "step" && node.icon && node.icon !== "cube" ? node.icon : "",
color: node.kind === "step" && node.color && node.color !== "gold" ? node.color : "",
items: node.kind !== "step" && node.items?.length ? encodeURIComponent(JSON.stringify(node.items)) : "",
current: node.qtyCurrent || "",
reset: node.resetType !== "none" ? node.resetType : "",
resetTime: node.resetTime && node.resetTime !== "00:00" ? node.resetTime : "",
weeklyResetDay: node.weeklyResetDay !== 1 ? node.weeklyResetDay : "",
collapsed: node.collapsed
});
lines.push(`${prefix}- ${node.title}${suffix}${meta ? ` | ${meta}` : ""}`);
visit(node.id, depth + 1);
});
}
visit();
return lines.join("\n");
}
function importGoalTreeText(text, context) {
const nodes = [];
const stack = [];
const data = { lastResetAt: 0, nodes };
splitLines(text).forEach((line, index) => {
if (!line.trim()) return;
const cleanLine = line.trim();
const rootMeta = parseMetaLine(cleanLine);
if (rootMeta && !stack.length) {
if (rootMeta.key === "lastResetAt") data.lastResetAt = parseInteger(rootMeta.value, 0);
return;
}
const depth = Math.floor((line.match(/^ */)?.[0].length || 0) / 2);
const match = cleanLine.match(/^-\s+(.+)$/);
if (!match) throw lineError(index, `ligne arbre d'objectifs invalide: ${cleanLine}`);
const { title: rawTitle, meta } = parsePipeParts(match[1]);
const formulaIndex = rawTitle.lastIndexOf("=");
const quantityMatch = rawTitle.match(/\s+x(\d+(?:[,.]\d+)?)\s*$/i);
const hasFormula = formulaIndex >= 0;
const title = (hasFormula ? rawTitle.slice(0, formulaIndex) : rawTitle.replace(/\s+x(\d+(?:[,.]\d+)?)\s*$/i, "")).trim();
if (!title) throw lineError(index, "titre manquant.");
const parent = stack[depth - 1];
const node = {
id: context.uid("goal"),
parentId: parent?.id || "",
title,
kind: meta.kind || "step",
icon: meta.icon || "cube",
color: meta.color || "gold",
qtyTarget: hasFormula ? 1 : Math.max(0, parseNumber(quantityMatch?.[1], 1)) || 1,
qtyCurrent: 0,
formula: hasFormula ? rawTitle.slice(formulaIndex + 1).trim() : "",
items: parseGoalTreeItemsMeta(meta.items),
resetType: meta.reset || "none",
resetTime: meta.resetTime || "00:00",
weeklyResetDay: parseInteger(meta.weeklyResetDay, 1),
checkedAt: 0,
collapsed: parseBoolean(meta.collapsed)
};
nodes.push(node);
stack[depth] = node;
stack.length = depth + 1;
});
return context.normalizeGoalTreeData(data);
}
function parseGoalTreeItemsMeta(value) {
if (!value) return [];
try {
const parsed = JSON.parse(decodeURIComponent(value));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function exportTimerText(data) {
const lines = [];
if (data.activeTab && data.activeTab !== "stopwatch") lines.push(`@activeTab: ${data.activeTab}`);

View file

@ -0,0 +1,163 @@
// Rôle : normalise et compacte les données persistées de l'arbre d'objectifs.
import { normalizeTimestamp, uid } from "./shared.js";
export const DEFAULT_GOAL_TREE_WEEKLY_RESET_DAY = 1;
export const DEFAULT_GOAL_TREE_RESET_TIME = "00:00";
const NODE_KINDS = new Set(["step", "text", "checklist"]);
const NODE_ICONS = new Set(["cube", "fire", "flask", "grass", "leaf", "meat", "powder", "tear", "ball"]);
const NODE_COLORS = new Set(["gold", "cyan", "violet", "pink", "green", "gray", "red", "orange", "yellow", "blue", "indigo"]);
const RESET_TYPES = new Set(["none", "daily", "weekly"]);
const ORIENTATIONS = new Set(["horizontal", "vertical"]);
const PROGRESS_MODES = new Set(["auto", "manual"]);
function isClockTimeString(value) {
if (!/^\d{2}:\d{2}$/.test(String(value || ""))) return false;
const [hours, minutes] = String(value).split(":").map(Number);
return Number.isInteger(hours) && Number.isInteger(minutes) && hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59;
}
function normalizeWeekDay(value, fallback = DEFAULT_GOAL_TREE_WEEKLY_RESET_DAY) {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 6 ? parsed : fallback;
}
function normalizeQuantity(value, fallback = 0) {
const parsed = Number(String(value ?? "").replace(",", "."));
return Number.isFinite(parsed) ? Math.max(0, parsed) : fallback;
}
function normalizeFormula(value) {
return String(value || "").replaceAll(",", ".").trim();
}
function normalizeNodeItems(kind, items) {
if (!["text", "checklist"].includes(kind)) return [];
return (Array.isArray(items) ? items : [])
.map((item) => {
const label = String(item?.label || item?.text || "");
const normalized = { id: item?.id || uid("goalItem"), label };
if (kind === "checklist") {
normalized.formula = normalizeFormula(item?.formula || item?.qty || item?.qtyTarget || "1") || "1";
normalized.qtyTarget = normalizeQuantity(item?.qtyTarget, item?.checked === true ? 1 : 1) || 1;
normalized.qtyCurrent = normalizeQuantity(item?.qtyCurrent, item?.checked === true ? 1 : 0);
}
return normalized;
});
}
function normalizeParentIds(value) {
return [...new Set((Array.isArray(value) ? value : [])
.map((id) => String(id || "").trim())
.filter(Boolean))];
}
function normalizeGoalTreeNode(node) {
const title = String(node?.title || "") || "Objectif";
const kind = NODE_KINDS.has(node?.kind) ? node.kind : "step";
const resetType = RESET_TYPES.has(node?.resetType) ? node.resetType : "none";
const resetTime = isClockTimeString(node?.resetTime) ? node.resetTime : DEFAULT_GOAL_TREE_RESET_TIME;
const isStep = kind === "step";
return {
id: node?.id || uid("goal"),
parentId: String(node?.parentId || ""),
parentIds: normalizeParentIds(node?.parentIds),
title,
kind,
icon: NODE_ICONS.has(node?.icon) ? node.icon : "cube",
color: NODE_COLORS.has(node?.color) ? node.color : "gold",
qtyTarget: isStep ? normalizeQuantity(node?.qtyTarget, 1) || 1 : 1,
qtyCurrent: isStep ? normalizeQuantity(node?.qtyCurrent, 0) : 0,
formula: isStep ? normalizeFormula(node?.formula) : "",
progressMode: isStep && PROGRESS_MODES.has(node?.progressMode) ? node.progressMode : "",
items: normalizeNodeItems(kind, node?.items),
resetType,
resetTime,
weeklyResetDay: normalizeWeekDay(node?.weeklyResetDay),
checkedAt: normalizeTimestamp(node?.checkedAt),
collapsed: node?.collapsed === true
};
}
function clearInvalidParents(nodes) {
const nodeIds = new Set(nodes.map((node) => node.id));
const parentById = new Map(nodes.map((node) => [node.id, nodeIds.has(node.parentId) ? node.parentId : ""]));
nodes.forEach((node) => {
const visited = new Set([node.id]);
let parentId = parentById.get(node.id);
while (parentId) {
if (visited.has(parentId)) {
parentById.set(node.id, "");
return;
}
visited.add(parentId);
parentId = parentById.get(parentId);
}
});
const normalizedPrimary = nodes.map((node) => ({ ...node, parentId: parentById.get(node.id) || "" }));
const rawParentIdsById = new Map(normalizedPrimary.map((node) => [
node.id,
[...new Set([node.parentId, ...node.parentIds].filter((parentId) => nodeIds.has(parentId) && parentId !== node.id))]
]));
function wouldCreateCycle(nodeId, parentId) {
const visited = new Set([nodeId]);
const stack = [parentId];
while (stack.length) {
const currentId = stack.pop();
if (currentId === nodeId) return true;
if (!currentId || visited.has(currentId)) continue;
visited.add(currentId);
stack.push(...(rawParentIdsById.get(currentId) || []));
}
return false;
}
return normalizedPrimary.map((node) => ({
...node,
parentIds: (rawParentIdsById.get(node.id) || []).filter((parentId) => !wouldCreateCycle(node.id, parentId))
}));
}
export function normalizeGoalTreeData(data) {
const nodes = clearInvalidParents((Array.isArray(data?.nodes) ? data.nodes : []).map(normalizeGoalTreeNode));
return {
lastResetAt: normalizeTimestamp(data?.lastResetAt),
orientation: ORIENTATIONS.has(data?.orientation) ? data.orientation : "horizontal",
nodes
};
}
export function compactGoalTreeDataForStorage(value) {
const normalized = normalizeGoalTreeData(value);
const compact = {};
if (normalized.lastResetAt) compact.lastResetAt = normalized.lastResetAt;
if (normalized.orientation !== "horizontal") compact.orientation = normalized.orientation;
if (normalized.nodes.length) {
compact.nodes = normalized.nodes.map((node) => {
const item = { id: node.id, title: node.title };
if (node.parentId) item.parentId = node.parentId;
if (node.parentIds.some((parentId) => parentId !== node.parentId)) item.parentIds = node.parentIds;
if (node.kind !== "step") item.kind = node.kind;
if (node.icon !== "cube") item.icon = node.icon;
if (node.color !== "gold") item.color = node.color;
if (node.kind === "step" && node.qtyTarget !== 1) item.qtyTarget = node.qtyTarget;
if (node.kind === "step" && node.qtyCurrent) item.qtyCurrent = node.qtyCurrent;
if (node.kind === "step" && node.formula) item.formula = node.formula;
if (node.kind === "step" && node.progressMode) item.progressMode = node.progressMode;
if (node.kind !== "step" && node.items.length) item.items = node.items.map((entry) => {
const compactEntry = { id: entry.id, label: entry.label };
if (node.kind === "checklist") {
if (entry.formula !== "1") compactEntry.formula = entry.formula;
if (entry.qtyTarget !== 1) compactEntry.qtyTarget = entry.qtyTarget;
if (entry.qtyCurrent) compactEntry.qtyCurrent = entry.qtyCurrent;
}
return compactEntry;
});
if (node.resetType !== "none") item.resetType = node.resetType;
if (node.resetTime !== DEFAULT_GOAL_TREE_RESET_TIME) item.resetTime = node.resetTime;
if (node.weeklyResetDay !== DEFAULT_GOAL_TREE_WEEKLY_RESET_DAY) item.weeklyResetDay = node.weeklyResetDay;
if (node.checkedAt) item.checkedAt = node.checkedAt;
if (node.collapsed) item.collapsed = true;
return item;
});
}
return Object.keys(compact).length ? compact : null;
}

View file

@ -5,6 +5,7 @@ import { compactChecklistDataForStorage } from "./checklist.js";
import { compactCombosDataForStorage } from "./combos.js";
import { compactCountersDataForStorage } from "./counters.js";
import { compactEquipmentPlannerDataForStorage } from "./equipmentPlanner.js";
import { compactGoalTreeDataForStorage } from "./goalTree.js";
import { compactImageAnnotationDataForStorage, compactImagesDataForStorage } from "./imageAnnotation.js";
import { compactLinksDataForStorage } from "./links.js";
import { compactNotepadDataForStorage } from "./notepad.js";
@ -25,5 +26,6 @@ export const MODULE_DATA_COMPACTORS = {
table: compactTableDataForStorage,
timer: compactTimerDataForStorage,
taskPlanner: compactTaskPlannerDataForStorage,
goalTree: compactGoalTreeDataForStorage,
equipmentPlanner: compactEquipmentPlannerDataForStorage
};

View file

@ -13,6 +13,7 @@ export const ID_PREFIXES = {
marker: "k",
timer: "z",
task: "a",
goal: "g",
relation: "e",
stroke: "d",
equipmentType: "y",

View file

@ -10,6 +10,7 @@ export { normalizeCombosData } from "./modules/combos.js";
export { normalizeCountersData } from "./modules/counters.js";
export { normalizeDrawingData } from "./modules/drawing.js";
export { normalizeEquipmentPlannerData, summarizeEquipmentPlannerData } from "./modules/equipmentPlanner.js";
export { normalizeGoalTreeData } from "./modules/goalTree.js";
export { labelFromFileName, normalizeImageAnnotationData } from "./modules/imageAnnotation.js";
export { hostnameFromUrl, normalizeLinksData, normalizeUrl } from "./modules/links.js";
export { normalizeNotepadData, sanitizeNotepadHtml } from "./modules/notepad.js";
@ -275,6 +276,22 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped;
}
if (type === "goalTree") {
const nodeIdMap = new Map();
compact.nodes?.forEach((node) => nodeIdMap.set(node.id, nextId("goal")));
const remapped = { ...compact };
if (compact.nodes) {
remapped.nodes = compact.nodes.map((node) => {
const nextNode = { ...node, id: nodeIdMap.get(node.id) };
if (node.parentId) nextNode.parentId = nodeIdMap.get(node.parentId) || "";
if (node.parentIds) nextNode.parentIds = node.parentIds.map((parentId) => nodeIdMap.get(parentId)).filter(Boolean);
if (node.items) nextNode.items = node.items.map((item) => ({ ...item, id: nextId("goal") }));
return nextNode;
});
}
return remapped;
}
if (type === "equipmentPlanner") {
const typeIdMap = new Map();
const equipmentIdMap = new Map();

View file

@ -6,6 +6,7 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Texte riche",
icon: "notepad",
editable: true,
addable: false,
libraryCategory: "notesTracking"
},
checklist: {
@ -59,6 +60,7 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Arborescence",
icon: "calculator",
editable: true,
addable: false,
libraryCategory: "calculationData"
},
table: {
@ -67,6 +69,16 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Grille + formules",
icon: "table",
editable: true,
addable: false,
scrollable: true,
libraryCategory: "calculationData"
},
goalTree: {
label: "Arbre d'objectifs",
defaultTitle: "Arbre d'objectifs",
mode: "Objectifs + quantités",
icon: "diagram",
editable: true,
scrollable: true,
libraryCategory: "calculationData"
},
@ -76,6 +88,7 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
mode: "Chrono + comptes à rebours",
icon: "clock",
editable: true,
addable: false,
libraryCategory: "timeRoutines"
},
calendar: {
@ -123,7 +136,7 @@ export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(TOOLBOX_MODULE_
export const LIBRARY_CATEGORY_DEFINITIONS = [
{ key: "notesTracking", types: ["notepad", "checklist", "counters"] },
{ key: "references", types: ["images", "imageAnnotation", "links"] },
{ key: "calculationData", types: ["calculator", "table"] },
{ key: "calculationData", types: ["calculator", "table", "goalTree"] },
{ key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
];

View file

@ -119,6 +119,7 @@ function App() {
getToolboxGame={getToolboxGame}
actions={actions}
storageUsage={store.storageUsage}
storageReady={store.ready}
toastPosition={store.toastPosition}
setToastPosition={store.setToastPosition}
getModuleData={store.getModuleData}
@ -138,6 +139,7 @@ function App() {
links={store.links}
actions={actions}
storageUsage={store.storageUsage}
storageReady={store.ready}
getModuleData={store.getModuleData}
updateToolbox={actions.updateToolbox}
updateModuleData={store.updateModuleData}

View file

@ -11,6 +11,7 @@ import {
normalizeCombosData,
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeGoalTreeData,
normalizeImageAnnotationData,
normalizeLinksData,
normalizeNotepadData,
@ -34,6 +35,7 @@ const MODULE_COMPONENTS = {
calculator: lazy(() => import("../features/toolboxes/modules/CalculatorModule.jsx").then((module) => ({ default: module.CalculatorModule }))),
calendar: lazy(() => import("../features/toolboxes/modules/CalendarModule.jsx").then((module) => ({ default: module.CalendarModule }))),
table: lazy(() => import("../features/toolboxes/modules/TableModule.jsx").then((module) => ({ default: module.TableModule }))),
goalTree: lazy(() => import("../features/toolboxes/modules/GoalTreeModule.jsx").then((module) => ({ default: module.GoalTreeModule }))),
timer: lazy(() => import("../features/toolboxes/modules/TimerModule.jsx").then((module) => ({ default: module.TimerModule }))),
taskPlanner: lazy(() => import("../features/toolboxes/modules/TaskPlannerModule.jsx").then((module) => ({ default: module.TaskPlannerModule }))),
equipmentPlanner: lazy(() => import("../features/toolboxes/modules/EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule }))),
@ -153,6 +155,7 @@ export function LibraryPage({ siteContent, actions }) {
normalizeLinksData,
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeGoalTreeData,
normalizeCalculatorData,
normalizeCalendarData,
normalizeImageAnnotationData,
@ -361,7 +364,7 @@ function LibraryToolExample({ module, sourceData, description, categoryTitle, do
<i aria-hidden="true" />
</button>
)}
{definition.editable && (
{definition.editable && definition.addable !== false && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
type="button"
@ -380,7 +383,14 @@ function LibraryToolExample({ module, sourceData, description, categoryTitle, do
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Suspense fallback={<div className="empty"><p>Chargement de l'outil...</p></div>}>
<Component toolboxId={LIBRARY_TOOLBOX_ID} moduleId={module.id} context={context} editing={editing} />
<Component
toolboxId={LIBRARY_TOOLBOX_ID}
moduleId={module.id}
context={context}
editing={editing}
setEditing={setEditing}
openEditor={() => setEditing(true)}
/>
</Suspense>
</div>
</article>

View file

@ -266,6 +266,7 @@ body.is-modal-open {
.filter-reset-button,
.image-viewer-button,
.module-edit-button,
.module-exchange-button,
.module-delete-button,
.image-annotate-button,
.image-delete-button,
@ -303,6 +304,7 @@ body.is-modal-open {
.filter-reset-button,
.image-viewer-button,
.module-edit-button,
.module-exchange-button,
.module-delete-button,
.image-annotate-button,
.image-delete-button,
@ -324,6 +326,7 @@ body.is-modal-open {
.filter-reset-button,
.image-viewer-button,
.module-edit-button,
.module-exchange-button,
.module-delete-button,
.image-annotate-button,
.image-delete-button,
@ -350,6 +353,7 @@ body.is-modal-open {
.filter-reset-button,
.image-viewer-button,
.module-edit-button,
.module-exchange-button,
.module-delete-button,
.image-annotate-button,
.image-delete-button,
@ -375,6 +379,7 @@ body.is-modal-open {
.filter-reset-button,
.image-viewer-button,
.module-edit-button,
.module-exchange-button,
.module-delete-button,
.image-annotate-button,
.image-delete-button,
@ -402,6 +407,7 @@ body.is-modal-open {
.filter-reset-button,
.image-viewer-button,
.module-edit-button,
.module-exchange-button,
.module-delete-button,
.image-annotate-button,
.image-delete-button,

View file

@ -26,7 +26,7 @@
height: 82px;
background: currentColor;
filter: drop-shadow(0 16px 24px rgba(0, 0, 0, 0.44));
mask: url("/static/img/diablo4/cube.svg") center / contain no-repeat;
mask: url("/static/icons/cube.svg") center / contain no-repeat;
}
.diablo4-affix-grid {

View file

@ -82,6 +82,61 @@
-webkit-mask-image: url("/static/icons/tasklist.svg");
}
.ui-icon-notepad {
mask-image: url("/static/icons/notepad.svg");
-webkit-mask-image: url("/static/icons/notepad.svg");
}
.ui-icon-checklist {
mask-image: url("/static/icons/checklist.svg");
-webkit-mask-image: url("/static/icons/checklist.svg");
}
.ui-icon-diagram {
mask-image: url("/static/icons/diagram.svg");
-webkit-mask-image: url("/static/icons/diagram.svg");
}
.ui-icon-cube {
mask-image: url("/static/icons/cube.svg");
-webkit-mask-image: url("/static/icons/cube.svg");
}
.ui-icon-fire {
mask-image: url("/static/icons/fire.svg");
-webkit-mask-image: url("/static/icons/fire.svg");
}
.ui-icon-flask {
mask-image: url("/static/icons/flask.svg");
-webkit-mask-image: url("/static/icons/flask.svg");
}
.ui-icon-grass {
mask-image: url("/static/icons/grass.svg");
-webkit-mask-image: url("/static/icons/grass.svg");
}
.ui-icon-leaf {
mask-image: url("/static/icons/leaf.svg");
-webkit-mask-image: url("/static/icons/leaf.svg");
}
.ui-icon-meat {
mask-image: url("/static/icons/meat.svg");
-webkit-mask-image: url("/static/icons/meat.svg");
}
.ui-icon-powder {
mask-image: url("/static/icons/powder.svg");
-webkit-mask-image: url("/static/icons/powder.svg");
}
.ui-icon-tear {
mask-image: url("/static/icons/tear.svg");
-webkit-mask-image: url("/static/icons/tear.svg");
}
.ui-icon-edit {
mask-image: url("/static/icons/edit.svg");
-webkit-mask-image: url("/static/icons/edit.svg");
@ -177,6 +232,11 @@
-webkit-mask-image: url("/static/icons/scrollable.svg");
}
.ui-icon-rotate {
mask-image: url("/static/icons/rotate.svg");
-webkit-mask-image: url("/static/icons/rotate.svg");
}
.ui-icon-sort-time {
mask-image: url("/static/icons/sort-time.svg");
-webkit-mask-image: url("/static/icons/sort-time.svg");

View file

@ -12,13 +12,6 @@
padding: var(--space-4);
}
.calculator-exchange-panel,
.timer-exchange-panel,
.table-exchange-panel {
grid-column: 1 / -1;
padding: 0;
}
.calculator-form {
display: grid;
grid-template-columns: 1fr;

View file

@ -115,47 +115,6 @@
.calendar-event-form select {
width: 100%;
min-width: 0;
min-height: 36px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: rgba(15, 23, 42, 0.64);
color: var(--color-text-primary);
}
.calendar-event-form select {
appearance: none;
-webkit-appearance: none;
overflow: hidden;
padding-right: 34px;
text-overflow: ellipsis;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23c4b5fd' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(139, 92, 246, 0.04), rgba(31, 41, 78, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.38), rgba(21, 26, 48, 0.38));
background-position:
calc(100% - 10px) 50%,
0 0,
0 0;
background-size:
11px 11px,
auto,
auto;
background-repeat: no-repeat;
}
.calendar-event-form select:hover,
.calendar-event-form select:focus {
border-color: rgba(196, 181, 253, 0.22);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f6c453' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(246, 196, 83, 0.06), rgba(139, 92, 246, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.58), rgba(21, 26, 48, 0.58));
color: var(--color-text-primary);
}
.calendar-event-form select option {
background: #11182f;
color: var(--color-text-primary);
}
.calendar-month-date-dropdown {
@ -518,11 +477,6 @@
align-self: end;
}
.calendar-exchange-panel .text-import-actions {
justify-content: flex-end;
padding-top: 12px;
}
.calendar-form-actions {
display: flex;
align-items: end;

View file

@ -10,6 +10,7 @@
@use "combos";
@use "counters";
@use "calculator";
@use "goal-tree";
@use "timer";
@use "calendar";
@use "calculator-results";

File diff suppressed because it is too large Load diff

View file

@ -108,11 +108,13 @@
}
.module-edit-button,
.module-exchange-button,
.module-delete-button {
flex: 0 0 40px;
}
.module-edit-button {
.module-edit-button,
.module-exchange-button {
color: var(--color-text-secondary);
}
@ -162,7 +164,8 @@
box-shadow var(--duration-fast) var(--ease-standard);
}
.module-edit-button.active {
.module-edit-button.active,
.module-exchange-button.active {
@include mixins.temporary-toggle-active;
}
@ -280,6 +283,21 @@
-webkit-mask-image: url("/static/icons/table.svg");
}
.module-icon-diagram {
mask-image: url("/static/icons/diagram.svg");
-webkit-mask-image: url("/static/icons/diagram.svg");
}
.module-icon-cube {
mask-image: url("/static/icons/cube.svg");
-webkit-mask-image: url("/static/icons/cube.svg");
}
.module-icon-settings {
mask-image: url("/static/icons/settings.svg");
-webkit-mask-image: url("/static/icons/settings.svg");
}
.module-icon-controller {
mask-image: url("/static/icons/controller.svg");
-webkit-mask-image: url("/static/icons/controller.svg");

View file

@ -1,6 +1,46 @@
// Rôle : gère les contrôles partagés des outils en deux colonnes et leurs états.
@use "../mixins";
.module-content select {
min-height: 36px;
overflow: hidden;
padding-right: 34px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
appearance: none;
-webkit-appearance: none;
color: var(--color-text-primary);
text-overflow: ellipsis;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23c4b5fd' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(139, 92, 246, 0.04), rgba(31, 41, 78, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.38), rgba(21, 26, 48, 0.38));
background-position:
calc(100% - 10px) 50%,
0 0,
0 0;
background-size:
11px 11px,
auto,
auto;
background-repeat: no-repeat;
}
.module-content select:hover,
.module-content select:focus {
border-color: rgba(196, 181, 253, 0.22);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f6c453' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(246, 196, 83, 0.06), rgba(139, 92, 246, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.58), rgba(21, 26, 48, 0.58));
color: var(--color-text-primary);
}
.module-content select option {
background: #11182f;
color: var(--color-text-primary);
}
.tool-split-results {
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-3);

View file

@ -22,10 +22,6 @@
gap: var(--space-3);
}
.task-planner-add-stack .text-import-actions {
padding-top: var(--space-2);
}
.task-planner-global-controls {
display: flex;
flex-wrap: wrap;
@ -121,17 +117,21 @@
text-transform: uppercase;
}
.task-planner-settings select,
.task-planner-settings input,
.task-planner-reset-override input,
.task-planner-settings select,
.task-planner-add-form select,
.task-planner-type-select,
.task-planner-reset-override select,
.task-planner-reset-override input,
.task-planner-relations select,
.combos-device-row select,
.equipment-planner-socket-links select,
.equipment-planner-socket-controls select {
width: 100%;
}
.task-planner-settings input,
.task-planner-reset-override input {
min-height: 36px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
@ -139,18 +139,6 @@
color: var(--color-text-primary);
}
.task-planner-settings select option,
.task-planner-add-form select option,
.task-planner-type-select option,
.task-planner-reset-override select option,
.task-planner-relations select option,
.combos-device-row select option,
.equipment-planner-socket-links select option,
.equipment-planner-socket-controls select option {
background: #11182f;
color: var(--color-text-primary);
}
.task-planner-add-form {
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.36fr) auto;
}
@ -445,42 +433,12 @@
}
.task-planner-type-select {
appearance: none;
-webkit-appearance: none;
width: 100%;
min-height: 36px;
overflow: hidden;
padding: 0 30px 0 10px;
border-color: rgba(165, 180, 252, 0.09);
text-overflow: ellipsis;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23c4b5fd' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(139, 92, 246, 0.04), rgba(31, 41, 78, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.38), rgba(21, 26, 48, 0.38));
background-position:
calc(100% - 10px) 50%,
0 0,
0 0;
background-size:
11px 11px,
auto,
auto;
background-repeat: no-repeat;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 900;
}
.task-planner-type-select:hover,
.task-planner-type-select:focus {
border-color: rgba(196, 181, 253, 0.22);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f6c453' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(246, 196, 83, 0.06), rgba(139, 92, 246, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.58), rgba(21, 26, 48, 0.58));
color: var(--color-text-primary);
}
.task-planner-warning {
display: inline-flex;
align-items: center;
@ -744,6 +702,5 @@
}
.task-planner-relations select {
min-height: 36px;
padding-inline: 10px;
padding-left: 10px;
}

View file

@ -35,6 +35,13 @@
padding: 0;
}
.module-text-exchange-panel {
display: grid;
grid-column: 1 / -1;
gap: var(--space-3);
padding: var(--space-4);
}
.text-import-actions {
display: flex;
flex-wrap: wrap;

View file

@ -47,90 +47,6 @@
width: 100%;
}
.timer-countdown-form select {
appearance: none;
-webkit-appearance: none;
overflow: hidden;
padding-right: 34px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
text-overflow: ellipsis;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(31, 41, 78, 0.12)),
linear-gradient(rgba(15, 23, 42, 0.72), rgba(15, 23, 42, 0.72));
background-position:
calc(100% - 14px) 50%,
0 0,
0 0;
background-size:
12px 12px,
auto,
auto;
background-repeat: no-repeat;
}
.timer-countdown-form select option {
background: #11182f;
color: var(--color-text-primary);
}
.timer-countdown-form select,
.task-planner-settings select,
.task-planner-add-form select,
.task-planner-type-select,
.task-planner-reset-override select,
.task-planner-relations select,
.combos-device-row select,
.equipment-planner-socket-links select,
.equipment-planner-socket-controls select {
appearance: none;
-webkit-appearance: none;
overflow: hidden;
padding-right: 34px;
text-overflow: ellipsis;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23c4b5fd' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(139, 92, 246, 0.04), rgba(31, 41, 78, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.38), rgba(21, 26, 48, 0.38));
background-position:
calc(100% - 10px) 50%,
0 0,
0 0;
background-size:
11px 11px,
auto,
auto;
background-repeat: no-repeat;
}
.timer-countdown-form select:hover,
.timer-countdown-form select:focus,
.task-planner-settings select:hover,
.task-planner-settings select:focus,
.task-planner-add-form select:hover,
.task-planner-add-form select:focus,
.task-planner-type-select:hover,
.task-planner-type-select:focus,
.task-planner-reset-override select:hover,
.task-planner-reset-override select:focus,
.task-planner-relations select:hover,
.task-planner-relations select:focus,
.combos-device-row select:hover,
.combos-device-row select:focus,
.equipment-planner-socket-links select:hover,
.equipment-planner-socket-links select:focus,
.equipment-planner-socket-controls select:hover,
.equipment-planner-socket-controls select:focus {
border-color: rgba(196, 181, 253, 0.22);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f6c453' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(246, 196, 83, 0.06), rgba(139, 92, 246, 0.08)),
linear-gradient(rgba(21, 26, 48, 0.58), rgba(21, 26, 48, 0.58));
color: var(--color-text-primary);
}
.time-parts-input {
display: grid;
grid-template-columns: minmax(34px, 1fr) 4px minmax(34px, 1fr) 4px minmax(34px, 1fr);