new tool timer & alert revamp
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s
|
|
@ -150,6 +150,11 @@
|
|||
"icon": "calculator",
|
||||
"name": "Calculateur",
|
||||
"description": "Un calculateur avec résultats enregistrables et arborescence pour préparer des chaînes de craft ou de ressources."
|
||||
},
|
||||
{
|
||||
"icon": "clock",
|
||||
"name": "Timer",
|
||||
"description": "Un espace pour préparer chronomètres à étapes et comptes à rebours multiples pendant une session."
|
||||
}
|
||||
],
|
||||
"limitsTitle": "À retenir",
|
||||
|
|
@ -183,6 +188,10 @@
|
|||
"importAll": "Importer tout",
|
||||
"exportAll": "Exporter tout",
|
||||
"importOne": "Importer une toolbox",
|
||||
"preferencesLabel": "Préférences d'interface",
|
||||
"toastPositionLabel": "Positionnement des alertes",
|
||||
"toastPositionLeft": "Afficher les alertes en bas à gauche",
|
||||
"toastPositionRight": "Afficher les alertes en bas à droite",
|
||||
"summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.",
|
||||
"storageHelp": {
|
||||
"title": "Sauvegardes locales",
|
||||
|
|
@ -272,6 +281,47 @@
|
|||
"renameTitle": "Renommer",
|
||||
"deleteTitle": "Supprimer"
|
||||
},
|
||||
"timer": {
|
||||
"stopwatchTab": "Chronomètre",
|
||||
"countdownTab": "Compte à rebours",
|
||||
"stopwatchTitle": "Chronomètre",
|
||||
"countdownTitle": "Compte à rebours",
|
||||
"startButton": "Lancer",
|
||||
"pauseButton": "Pause",
|
||||
"lapButton": "Étape",
|
||||
"resetTitle": "Réinitialiser",
|
||||
"lapLabelAriaLabel": "Libellé de l'étape",
|
||||
"countdownLabel": "Libellé",
|
||||
"countdownLabelPlaceholder": "Boss, event, craft...",
|
||||
"countdownTypeLabel": "Type",
|
||||
"durationType": "Durée",
|
||||
"dailyTimeType": "Heure précise",
|
||||
"timePatternType": "Horaire spécifique",
|
||||
"intervalType": "Intervalle",
|
||||
"durationLabel": "Durée",
|
||||
"dailyTimeLabel": "Heure",
|
||||
"timePatternLabel": "Heure / minute / seconde",
|
||||
"intervalLabel": "Toutes les",
|
||||
"addCountdownButton": "Ajouter",
|
||||
"defaultCountdownLabel": "Timer",
|
||||
"finishedLabel": "Terminé",
|
||||
"targetLabel": "Prochaine occurrence",
|
||||
"stepLabel": "Étape",
|
||||
"stepsTitle": "Étapes",
|
||||
"countdownsTitle": "Temps restants",
|
||||
"scrollableTitle": "Liste scrollable",
|
||||
"sortTitle": "Trier par temps restant",
|
||||
"alertModeTitle": "Mode d'alerte",
|
||||
"alertOffTitle": "Pas d'alerte",
|
||||
"alertVisibleTitle": "Alerte quand la toolbox est affichée",
|
||||
"alertSiteTitle": "Alerte sur tout le site",
|
||||
"alertTooSoonTitle": "Alerte indisponible à moins de 5 minutes",
|
||||
"alertMessage": "{label} est terminé dans {toolbox}.",
|
||||
"emptySteps": "Aucune étape enregistrée.",
|
||||
"emptyCountdowns": "Aucun compte à rebours configuré.",
|
||||
"renameTitle": "Renommer",
|
||||
"deleteTitle": "Supprimer"
|
||||
},
|
||||
"imageAnnotation": {
|
||||
"addImage": "Ajouter une image",
|
||||
"replaceImage": "Remplacer l'image",
|
||||
|
|
|
|||
2
website/public/static/icons/hourglass.svg
Normal 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" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg"><path d="M17,24H7.005a4.014,4.014,0,0,1-3.044-1.4,3.94,3.94,0,0,1-.917-3.158A12.522,12.522,0,0,1,7.445,12a12.522,12.522,0,0,1-4.4-7.444A3.94,3.94,0,0,1,3.961,1.4,4.014,4.014,0,0,1,7.005,0H17a4.017,4.017,0,0,1,3.044,1.4,3.943,3.943,0,0,1,.918,3.155A12.556,12.556,0,0,1,16.551,12a12.557,12.557,0,0,1,4.406,7.448,3.944,3.944,0,0,1-.918,3.156A4.017,4.017,0,0,1,17,24ZM17,2H7.005a2.015,2.015,0,0,0-1.528.7,1.921,1.921,0,0,0-.456,1.556c.376,2.5,1.924,4.84,4.6,6.957a1,1,0,0,1,0,1.568C6.945,14.9,5.4,17.242,5.021,19.741A1.921,1.921,0,0,0,5.477,21.3a2.015,2.015,0,0,0,1.528.7H17a2.014,2.014,0,0,0,1.528-.7,1.917,1.917,0,0,0,.456-1.554c-.373-2.487-1.92-4.829-4.6-6.962a1,1,0,0,1,0-1.564c2.681-2.133,4.228-4.475,4.6-6.963A1.916,1.916,0,0,0,18.523,2.7,2.014,2.014,0,0,0,17,2ZM15.681,20H8.318a1,1,0,0,1-.927-1.374,11.185,11.185,0,0,1,3.471-4.272l.518-.412a1,1,0,0,1,1.245,0l.509.406a11.3,11.3,0,0,1,3.473,4.276A1,1,0,0,1,15.681,20Zm-5.647-2h3.928A11.57,11.57,0,0,0,12,16,11.3,11.3,0,0,0,10.034,18Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
6
website/public/static/icons/pause-circle.svg
Normal 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" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<path d="M8 9.5C8 9.03406 8 8.80109 8.07612 8.61732C8.17761 8.37229 8.37229 8.17761 8.61732 8.07612C8.80109 8 9.03406 8 9.5 8C9.96594 8 10.1989 8 10.3827 8.07612C10.6277 8.17761 10.8224 8.37229 10.9239 8.61732C11 8.80109 11 9.03406 11 9.5V14.5C11 14.9659 11 15.1989 10.9239 15.3827C10.8224 15.6277 10.6277 15.8224 10.3827 15.9239C10.1989 16 9.96594 16 9.5 16C9.03406 16 8.80109 16 8.61732 15.9239C8.37229 15.8224 8.17761 15.6277 8.07612 15.3827C8 15.1989 8 14.9659 8 14.5V9.5Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<path d="M13 9.5C13 9.03406 13 8.80109 13.0761 8.61732C13.1776 8.37229 13.3723 8.17761 13.6173 8.07612C13.8011 8 14.0341 8 14.5 8C14.9659 8 15.1989 8 15.3827 8.07612C15.6277 8.17761 15.8224 8.37229 15.9239 8.61732C16 8.80109 16 9.03406 16 9.5V14.5C16 14.9659 16 15.1989 15.9239 15.3827C15.8224 15.6277 15.6277 15.8224 15.3827 15.9239C15.1989 16 14.9659 16 14.5 16C14.0341 16 13.8011 16 13.6173 15.9239C13.3723 15.8224 13.1776 15.6277 13.0761 15.3827C13 15.1989 13 14.9659 13 14.5V9.5Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
5
website/public/static/icons/play-circle.svg
Normal 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 fill-rule="evenodd" clip-rule="evenodd" d="M11.0748 7.50835C9.74622 6.72395 8.25 7.79065 8.25 9.21316V14.7868C8.25 16.2093 9.74622 17.276 11.0748 16.4916L15.795 13.7048C17.0683 12.953 17.0683 11.047 15.795 10.2952L11.0748 7.50835ZM9.75 9.21316C9.75 9.01468 9.84615 8.87585 9.95947 8.80498C10.0691 8.73641 10.1919 8.72898 10.3122 8.80003L15.0324 11.5869C15.165 11.6652 15.25 11.8148 15.25 12C15.25 12.1852 15.165 12.3348 15.0324 12.4131L10.3122 15.2C10.1919 15.271 10.0691 15.2636 9.95947 15.195C9.84615 15.1242 9.75 14.9853 9.75 14.7868V9.21316Z" fill="#1C274C"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.25C6.06294 1.25 1.25 6.06294 1.25 12C1.25 17.9371 6.06294 22.75 12 22.75C17.9371 22.75 22.75 17.9371 22.75 12C22.75 6.06294 17.9371 1.25 12 1.25ZM2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12Z" fill="#1C274C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
7
website/public/static/icons/record-circle.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?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="M18.5 12C18.5 13.3807 17.3807 14.5 16 14.5C14.6193 14.5 13.5 13.3807 13.5 12C13.5 10.6193 14.6193 9.5 16 9.5C17.3807 9.5 18.5 10.6193 18.5 12Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<path d="M10.5 12C10.5 13.3807 9.38071 14.5 8 14.5C6.61929 14.5 5.5 13.3807 5.5 12C5.5 10.6193 6.61929 9.5 8 9.5C9.38071 9.5 10.5 10.6193 10.5 12Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<path d="M8 14.5H16" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 854 B |
4
website/public/static/icons/refresh.svg
Normal 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="M13 2L11 3.99545L11.0592 4.05474M11 18.0001L13 19.9108L12.9703 19.9417M11.0592 4.05474L13 6M11.0592 4.05474C11.3677 4.01859 11.6817 4 12 4C16.4183 4 20 7.58172 20 12C20 14.5264 18.8289 16.7793 17 18.2454M7 5.75463C5.17107 7.22075 4 9.47362 4 12C4 16.4183 7.58172 20 12 20C12.3284 20 12.6523 19.9802 12.9703 19.9417M11 22.0001L12.9703 19.9417" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 665 B |
8
website/public/static/icons/sort-time.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?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 7L2 7" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 12H2" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M10 17H2" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="17" cy="12" r="5" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<path d="M17 10V11.8462L18 13" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 658 B |
6
website/public/static/icons/sound-max.svg
Normal 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" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.15838 13.9306C2.44537 12.7423 2.44537 11.2577 3.15838 10.0694V10.0694C3.37596 9.70674 3.73641 9.45272 4.1511 9.36978L5.84413 9.03117C5.94499 9.011 6.03591 8.95691 6.10176 8.87788L8.17085 6.39498C9.3534 4.97592 9.94468 4.26638 10.4723 4.45742C11 4.64846 11 5.57207 11 7.41928L11 16.5807C11 18.4279 11 19.3515 10.4723 19.5426C9.94468 19.7336 9.3534 19.0241 8.17085 17.605L6.10176 15.1221C6.03591 15.0431 5.94499 14.989 5.84413 14.9688L4.1511 14.6302C3.73641 14.5473 3.37596 14.2933 3.15838 13.9306V13.9306Z" stroke="#33363F" stroke-width="2"/>
|
||||
<path d="M15.5355 8.46447C16.4684 9.39732 16.9948 10.6611 17 11.9803C17.0052 13.2996 16.4888 14.5674 15.5633 15.5076" stroke="#33363F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M19.6569 6.34314C21.1494 7.83572 21.9916 9.85769 21.9999 11.9685C22.0083 14.0793 21.182 16.1078 19.7012 17.6121" stroke="#33363F" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
5
website/public/static/icons/sound-min.svg
Normal 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="M3.15838 13.9306C2.44537 12.7423 2.44537 11.2577 3.15838 10.0694V10.0694C3.37596 9.70674 3.73641 9.45272 4.1511 9.36978L5.84413 9.03117C5.94499 9.011 6.03591 8.95691 6.10176 8.87788L8.17085 6.39498C9.3534 4.97592 9.94468 4.26638 10.4723 4.45742C11 4.64846 11 5.57207 11 7.41928L11 16.5807C11 18.4279 11 19.3515 10.4723 19.5426C9.94468 19.7336 9.3534 19.0241 8.17085 17.605L6.10176 15.1221C6.03591 15.0431 5.94499 14.989 5.84413 14.9688L4.1511 14.6302C3.73641 14.5473 3.37596 14.2933 3.15838 13.9306V13.9306Z" stroke="#33363F" stroke-width="2"/>
|
||||
<path d="M15.5355 8.46447C16.4684 9.39732 16.9948 10.6611 17 11.9803C17.0052 13.2996 16.4888 14.5674 15.5633 15.5076" stroke="#33363F" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 961 B |
6
website/public/static/icons/sound-mute.svg
Normal 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" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.15838 13.9306C2.44537 12.7423 2.44537 11.2577 3.15838 10.0694V10.0694C3.37596 9.70674 3.73641 9.45272 4.1511 9.36978L5.84413 9.03117C5.94499 9.011 6.03591 8.95691 6.10176 8.87788L8.17085 6.39498C9.3534 4.97592 9.94468 4.26638 10.4723 4.45742C11 4.64846 11 5.57207 11 7.41928L11 16.5807C11 18.4279 11 19.3515 10.4723 19.5426C9.94468 19.7336 9.3534 19.0241 8.17085 17.605L6.10176 15.1221C6.03591 15.0431 5.94499 14.989 5.84413 14.9688L4.1511 14.6302C3.73641 14.5473 3.37596 14.2933 3.15838 13.9306V13.9306Z" stroke="#33363F" stroke-width="2"/>
|
||||
<path d="M15 15L21 9" stroke="#33363F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M21 15L15 9" stroke="#33363F" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 947 B |
10
website/public/static/icons/stop-circle.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?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">
|
||||
<g id="Media / Stop_Circle">
|
||||
<g id="Vector">
|
||||
<path d="M3 12C3 16.9706 7.02944 21 12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 13.3996V10.5996C15 10.0396 14.9996 9.75981 14.8906 9.5459C14.7948 9.35774 14.6423 9.20487 14.4542 9.10899C14.2403 9 13.9597 9 13.3996 9H10.5996C10.0396 9 9.75981 9 9.5459 9.10899C9.35774 9.20487 9.20487 9.35774 9.10899 9.5459C9 9.75981 9 10.0396 9 10.5996V13.3996C9 13.9597 9 14.2403 9.10899 14.4542C9.20487 14.6423 9.35774 14.7948 9.5459 14.8906C9.75981 14.9996 10.0396 15 10.5996 15H13.3996C13.9597 15 14.2403 14.9996 14.4542 14.8906C14.6423 14.7948 14.7948 14.6423 14.8906 14.4542C14.9996 14.2403 15 13.9597 15 13.3996Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
2
website/public/static/icons/stopwatch.svg
Normal 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" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg"><path d="M14,13a2,2,0,1,1-3-1.723V7a1,1,0,0,1,2,0v4.277A1.994,1.994,0,0,1,14,13Zm6.06-7.461a11,11,0,1,1-16.12,0l-.672-.747A3.672,3.672,0,0,1,2,5,1,1,0,0,1,2,3c.785,0,1-.215,1-1A1,1,0,0,1,5,2a3.513,3.513,0,0,1-.28,1.417l.706.784A10.923,10.923,0,0,1,11,2.051V1a1,1,0,0,1,2,0V2.051A10.923,10.923,0,0,1,18.574,4.2l.706-.784A3.513,3.513,0,0,1,19,2a1,1,0,0,1,2,0c0,.785.215,1,1,1a1,1,0,0,1,0,2,3.672,3.672,0,0,1-1.268-.208ZM21,13a9,9,0,1,0-9,9A9.01,9.01,0,0,0,21,13Z"/></svg>
|
||||
|
After Width: | Height: | Size: 726 B |
4
website/public/static/icons/toast-left.svg
Normal 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="M21 6H13M9 6V18M21 10H13M21 14H13M21 18H13M3 10L5 12L3 14" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
4
website/public/static/icons/toast-right.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 6H11M15 6V18M3 10H11M3 14H11M3 18H11M21 10L19 12L21 14" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 299 B |
|
|
@ -19,6 +19,8 @@ export function AppOverlays({
|
|||
image,
|
||||
setImage,
|
||||
notification,
|
||||
setNotification,
|
||||
toastPosition,
|
||||
getGame,
|
||||
toolboxes,
|
||||
links
|
||||
|
|
@ -47,7 +49,7 @@ export function AppOverlays({
|
|||
}} />}
|
||||
{storageError && <ConfirmModal title="Quota local atteint" message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
|
||||
{image && <ImageViewer image={image} onClose={() => setImage(null)} createMarkerId={() => uid("marker")} />}
|
||||
{notification && <NotificationToast key={notification.id} message={notification.message} />}
|
||||
{notification && <NotificationToast key={notification.id} message={notification.message} position={toastPosition} onClose={() => setNotification(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
|||
onChange={(event) => updateViewerMarker(marker.id, (item) => ({ ...item, label: event.target.value }))}
|
||||
aria-label={`Libellé du marqueur ${index + 1}`}
|
||||
/>
|
||||
<button className="calculator-action-button danger" type="button" onClick={() => removeViewerMarker(marker.id)} aria-label={`${image.deleteMarkerTitle || "Supprimer"} ${marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}`} title={image.deleteMarkerTitle || "Supprimer"}>
|
||||
<button className="tool-split-action-button danger" type="button" onClick={() => removeViewerMarker(marker.id)} aria-label={`${image.deleteMarkerTitle || "Supprimer"} ${marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}`} title={image.deleteMarkerTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</li>
|
||||
|
|
|
|||
25
website/src/components/Tabs.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Rôle : fournit un contrôle d'onglets réutilisable avec rôles ARIA.
|
||||
export function Tabs({ tabs, activeTab, onChange, className = "", iconOnly = false }) {
|
||||
return (
|
||||
<div className={`tabs ${className}`.trim()} role="tablist">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={active ? "active" : ""}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-label={tab.label}
|
||||
title={tab.label}
|
||||
onClick={() => onChange(tab.id)}
|
||||
>
|
||||
{tab.icon && <span className={`module-icon-svg module-icon-${tab.icon}`} aria-hidden="true" />}
|
||||
{!iconOnly && <span>{tab.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
website/src/components/ToastPositionSwitch.jsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Rôle : affiche le switch global de position des notifications internes.
|
||||
import React from "react";
|
||||
import { Icon } from "./Icon.jsx";
|
||||
|
||||
export function ToastPositionSwitch({ position, labels, onChange }) {
|
||||
const normalizedPosition = position === "left" ? "left" : "right";
|
||||
return (
|
||||
<button
|
||||
className="toast-position-switch"
|
||||
type="button"
|
||||
data-position={normalizedPosition}
|
||||
onClick={() => onChange(normalizedPosition === "left" ? "right" : "left")}
|
||||
aria-label={normalizedPosition === "left" ? labels.left : labels.right}
|
||||
title={labels.title}
|
||||
>
|
||||
<span><Icon name="toast-left" /></span>
|
||||
<span><Icon name="toast-right" /></span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// Rôle : fournit les modales globales liées aux toolboxes et confirmations.
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "./Icon.jsx";
|
||||
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
|
||||
|
||||
function useModalScrollLock() {
|
||||
|
|
@ -23,10 +24,13 @@ export function ConfirmModal({ title, message, confirmLabel = "Confirmer", cance
|
|||
);
|
||||
}
|
||||
|
||||
export function NotificationToast({ message }) {
|
||||
export function NotificationToast({ message, position = "right", onClose }) {
|
||||
return (
|
||||
<div className="notification-toast" role="status" aria-live="polite">
|
||||
{message}
|
||||
<div className={`notification-toast is-${position === "left" ? "left" : "right"}`} role="status" aria-live="polite">
|
||||
<span>{message}</span>
|
||||
<button type="button" onClick={onClose} aria-label="Fermer l'alerte" title="Fermer">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from "react";
|
|||
import { Icon } from "../../components/Icon.jsx";
|
||||
import { ImportButton } from "../../components/ImportButton.jsx";
|
||||
import { StorageQuota } from "../../components/StorageQuota.jsx";
|
||||
import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
|
||||
import { usePointerReorder } from "../../hooks/usePointerReorder.js";
|
||||
import { compressImage } from "../../utils/imageCompression.js";
|
||||
import { getSetting as dbGetSetting, setSetting as dbSetSetting } from "../../utils/indexedDbStorage.js";
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
normalizeCountersData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeLinksData,
|
||||
normalizeTimerData,
|
||||
normalizeUrl,
|
||||
uid
|
||||
} from "./storage/toolboxStorage.js";
|
||||
|
|
@ -31,7 +33,7 @@ async function copyText(value) {
|
|||
}
|
||||
}
|
||||
|
||||
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage }) {
|
||||
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage, toastPosition, setToastPosition }) {
|
||||
const content = siteContent.toolboxes;
|
||||
const {
|
||||
draggingId: draggingToolboxId,
|
||||
|
|
@ -71,6 +73,21 @@ export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions,
|
|||
<ImportButton className="import-button" label={content.importAll} onFile={actions.importAllToolboxes} />
|
||||
</div>
|
||||
</section>
|
||||
<section className="toolbox-preferences-row" aria-label={content.preferencesLabel}>
|
||||
<div>
|
||||
<p className="eyebrow">{content.preferencesLabel}</p>
|
||||
<strong>{content.toastPositionLabel}</strong>
|
||||
</div>
|
||||
<ToastPositionSwitch
|
||||
position={toastPosition}
|
||||
onChange={setToastPosition}
|
||||
labels={{
|
||||
title: content.toastPositionLabel,
|
||||
left: content.toastPositionLeft,
|
||||
right: content.toastPositionRight
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<section className="cards">
|
||||
<StorageHelpCard help={content.storageHelp} />
|
||||
{toolboxes.length ? toolboxes.map((toolbox) => (
|
||||
|
|
@ -128,6 +145,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
normalizeCountersData,
|
||||
normalizeCalculatorData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeTimerData,
|
||||
normalizeUrl,
|
||||
hostnameFromUrl,
|
||||
copyText,
|
||||
|
|
|
|||
|
|
@ -136,23 +136,23 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
|
|||
</div>
|
||||
</form>
|
||||
<div
|
||||
className={`calculator-saved ${data.scrollResults ? "is-scrollable" : ""}`}
|
||||
className={`tool-split-results calculator-saved ${data.scrollResults ? "is-scrollable" : ""}`}
|
||||
style={data.scrollResults && calculatorHeight ? { "--calculator-scroll-height": `${calculatorHeight}px` } : undefined}
|
||||
>
|
||||
<div className="calculator-result-actions">
|
||||
<div className="tool-split-actions">
|
||||
{activeParent && (
|
||||
<button className="calculator-root-button" type="button" onClick={() => setActiveParentId("")}>
|
||||
<button className="tool-split-root-button" type="button" onClick={() => setActiveParentId("")}>
|
||||
{textContent.rootButton || "Revenir à la racine"}
|
||||
</button>
|
||||
)}
|
||||
<button className="calculator-action-button danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
|
||||
<button className="tool-split-action-button danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
|
||||
<Icon name="rubber" />
|
||||
</button>
|
||||
<button className="calculator-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label={textContent.copyTitle || "Copier pour checklist"} title={copied ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier pour checklist"}>
|
||||
<button className="tool-split-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label={textContent.copyTitle || "Copier pour checklist"} title={copied ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier pour checklist"}>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
<button
|
||||
className={`calculator-scroll-toggle ${data.scrollResults ? "active" : ""}`}
|
||||
className={`tool-split-scroll-toggle ${data.scrollResults ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setScrollResults(!data.scrollResults)}
|
||||
aria-pressed={data.scrollResults}
|
||||
|
|
@ -163,7 +163,7 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
|
|||
<i aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
|
||||
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
|
||||
{data.entries.length ? (
|
||||
<CalculatorEntries entries={data.entries} parentId="" activeParentId={activeParentId} textContent={textContent} onUse={useEntry} onRename={renameEntry} onDelete={deleteEntry} />
|
||||
) : (
|
||||
|
|
@ -181,19 +181,19 @@ function CalculatorEntries({ entries, parentId, activeParentId, textContent, onU
|
|||
if (!children.length) return null;
|
||||
|
||||
return (
|
||||
<ul className="calculator-entry-list">
|
||||
<ul className="tool-split-entry-list calculator-entry-list">
|
||||
{children.map((entry) => (
|
||||
<li className={`${entry.id === activeParentId ? "active" : ""} ${entry.id === editingId ? "is-editing" : ""}`} key={entry.id}>
|
||||
<div className="calculator-entry">
|
||||
<div className="tool-split-entry calculator-entry">
|
||||
{entry.id === editingId ? (
|
||||
<>
|
||||
<span className="calculator-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
|
||||
<span className="tool-split-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
|
||||
<strong>{formatResult(entry.value)}</strong>
|
||||
</span>
|
||||
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} onDone={() => setEditingId("")} />
|
||||
</>
|
||||
) : (
|
||||
<button className="calculator-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
|
||||
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
|
||||
<span><strong>{formatResult(entry.value)}</strong> {entry.label}</span>
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -228,7 +228,7 @@ function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
|
|||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="calculator-entry-label"
|
||||
className="tool-split-entry-label"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
onBlur={saveLabel}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
|||
<img src={data.image} alt={textContent.imageAlt || "Image annotée"} draggable="false" />
|
||||
<div className="annotation-preview-actions">
|
||||
<button
|
||||
className="calculator-action-button"
|
||||
className="tool-split-action-button"
|
||||
type="button"
|
||||
onClick={() => context.setImage({
|
||||
dataUrl: data.image,
|
||||
|
|
@ -103,7 +103,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
|||
<Icon name="zoom" />
|
||||
</button>
|
||||
{editing && (
|
||||
<button className="calculator-action-button danger" type="button" onClick={removeImage} aria-label={textContent.deleteImageTitle || "Supprimer l'image"} title={textContent.deleteImageTitle || "Supprimer l'image"}>
|
||||
<button className="tool-split-action-button danger" type="button" onClick={removeImage} aria-label={textContent.deleteImageTitle || "Supprimer l'image"} title={textContent.deleteImageTitle || "Supprimer l'image"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
572
website/src/features/toolboxes/modules/TimerModule.jsx
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
// Rôle : fournit l'outil timer avec chronomètre à étapes et comptes à rebours multiples.
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { Tabs } from "../../../components/Tabs.jsx";
|
||||
import {
|
||||
formatDuration,
|
||||
formatDurationWithCentiseconds,
|
||||
formatTargetTime,
|
||||
getCentiseconds,
|
||||
getCountdownTargetMs,
|
||||
getDailyTargetMs,
|
||||
getNowMs,
|
||||
getTimePatternTargetMs,
|
||||
timePartsToDurationMs,
|
||||
timePartsToString
|
||||
} from "./timerUtils.js";
|
||||
|
||||
const TIMER_TABS = [
|
||||
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
|
||||
{ id: "countdown", label: "Compte à rebours", icon: "hourglass" }
|
||||
];
|
||||
|
||||
const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
|
||||
const ALERT_MODES = [
|
||||
{ mode: "off", icon: "sound-mute" },
|
||||
{ mode: "visible", icon: "sound-min" },
|
||||
{ mode: "site", icon: "sound-max" }
|
||||
];
|
||||
const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
|
||||
|
||||
function TimerValue({ ms, compact = false, showCentiseconds = true }) {
|
||||
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
|
||||
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
|
||||
|
||||
return (
|
||||
<span className="timer-value is-compact">
|
||||
<span>{formatDuration(ms)}</span>
|
||||
<small>{getCentiseconds(ms)}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getCountdownMeta(countdown, textContent) {
|
||||
if (countdown.type === "duration") return `${textContent.durationType || "Durée"} · ${formatDuration(countdown.durationMs || 0)}`;
|
||||
if (countdown.type === "daily_time") return `${textContent.dailyTimeType || "Heure précise"} · ${countdown.time}`;
|
||||
if (countdown.type === "time_pattern") return `${textContent.timePatternType || "Heure / minute / seconde"} · ${countdown.pattern}`;
|
||||
if (countdown.type === "interval") return `${textContent.intervalType || "Intervalle"} · ${formatDuration(countdown.intervalMs || 0)}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function handleEditableKeyDown(event, callback) {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
callback();
|
||||
}
|
||||
|
||||
export function TimerModule({ toolboxId, moduleId, context }) {
|
||||
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
|
||||
const textContent = context.moduleText?.timer || {};
|
||||
const activeTab = data.activeTab;
|
||||
const [nowMs, setNowMs] = useState(getNowMs);
|
||||
const [countdownForm, setCountdownForm] = useState({
|
||||
label: "",
|
||||
type: "duration",
|
||||
duration: { hours: "", minutes: "", seconds: "" },
|
||||
time: { hours: "", minutes: "", seconds: "" },
|
||||
pattern: { hours: "", minutes: "", seconds: "" },
|
||||
interval: { hours: "", minutes: "", seconds: "" }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => setNowMs(getNowMs()), 50);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const stopwatchElapsedMs = data.stopwatch.startedAt
|
||||
? data.stopwatch.elapsedMs + Math.max(0, nowMs - data.stopwatch.startedAt)
|
||||
: data.stopwatch.elapsedMs;
|
||||
const visibleCountdowns = useMemo(() => {
|
||||
const countdowns = data.countdowns.map((countdown) => ({ ...countdown, targetMs: getCountdownTargetMs(countdown, nowMs) }));
|
||||
if (!data.sortResults) return countdowns;
|
||||
return [...countdowns].sort((a, b) => (a.targetMs || Number.POSITIVE_INFINITY) - (b.targetMs || Number.POSITIVE_INFINITY));
|
||||
}, [data.countdowns, data.sortResults, nowMs]);
|
||||
|
||||
function save(nextData) {
|
||||
context.setModuleData(toolboxId, moduleId, nextData);
|
||||
}
|
||||
|
||||
function setActiveTab(nextActiveTab) {
|
||||
save({ ...data, activeTab: nextActiveTab });
|
||||
}
|
||||
|
||||
function setScrollResults(scrollResults) {
|
||||
save({ ...data, scrollResults });
|
||||
}
|
||||
|
||||
function setSortResults(sortResults) {
|
||||
save({ ...data, sortResults });
|
||||
}
|
||||
|
||||
function updateStopwatch(stopwatch) {
|
||||
save({ ...data, stopwatch });
|
||||
}
|
||||
|
||||
function startStopwatch() {
|
||||
if (data.stopwatch.startedAt) return;
|
||||
updateStopwatch({ ...data.stopwatch, startedAt: getNowMs() });
|
||||
}
|
||||
|
||||
function pauseStopwatch() {
|
||||
if (!data.stopwatch.startedAt) return;
|
||||
updateStopwatch({
|
||||
...data.stopwatch,
|
||||
startedAt: 0,
|
||||
elapsedMs: stopwatchElapsedMs
|
||||
});
|
||||
}
|
||||
|
||||
function resetStopwatch() {
|
||||
updateStopwatch({ ...data.stopwatch, elapsedMs: 0, startedAt: 0 });
|
||||
}
|
||||
|
||||
function addLap() {
|
||||
if (!stopwatchElapsedMs) return;
|
||||
updateStopwatch({
|
||||
...data.stopwatch,
|
||||
laps: [
|
||||
{
|
||||
id: context.uid("timer"),
|
||||
label: "",
|
||||
elapsedMs: stopwatchElapsedMs
|
||||
},
|
||||
...data.stopwatch.laps
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function deleteLap(lapId) {
|
||||
updateStopwatch({ ...data.stopwatch, laps: data.stopwatch.laps.filter((lap) => lap.id !== lapId) });
|
||||
}
|
||||
|
||||
function renameLap(lapId, label) {
|
||||
updateStopwatch({
|
||||
...data.stopwatch,
|
||||
laps: data.stopwatch.laps.map((lap) => lap.id === lapId ? { ...lap, label: label.trim() } : lap)
|
||||
});
|
||||
}
|
||||
|
||||
function updateCountdownForm(patch) {
|
||||
setCountdownForm((current) => ({ ...current, ...patch }));
|
||||
}
|
||||
|
||||
function createCountdown(event) {
|
||||
event.preventDefault();
|
||||
const type = COUNTDOWN_TYPES.includes(countdownForm.type) ? countdownForm.type : "duration";
|
||||
const label = countdownForm.label.trim() || textContent.defaultCountdownLabel || "Timer";
|
||||
const now = getNowMs();
|
||||
let countdown = null;
|
||||
|
||||
if (type === "duration") {
|
||||
const durationMs = timePartsToDurationMs(countdownForm.duration);
|
||||
if (durationMs > 0) countdown = { id: context.uid("timer"), label, type, durationMs, targetAt: now + durationMs };
|
||||
}
|
||||
if (type === "daily_time") {
|
||||
const time = timePartsToString(countdownForm.time);
|
||||
const targetAt = getDailyTargetMs(time, now);
|
||||
if (time && targetAt) countdown = { id: context.uid("timer"), label, type, time, targetAt };
|
||||
}
|
||||
if (type === "time_pattern") {
|
||||
const pattern = timePartsToString(countdownForm.pattern, { allowWildcard: true });
|
||||
if (pattern && getTimePatternTargetMs(pattern, now)) countdown = { id: context.uid("timer"), label, type, pattern };
|
||||
}
|
||||
if (type === "interval") {
|
||||
const intervalMs = timePartsToDurationMs(countdownForm.interval);
|
||||
if (intervalMs > 0) countdown = { id: context.uid("timer"), label, type, intervalMs, anchorAt: now };
|
||||
}
|
||||
|
||||
if (!countdown) return;
|
||||
save({ ...data, countdowns: [...data.countdowns, countdown] });
|
||||
setCountdownForm((current) => ({ ...current, label: "" }));
|
||||
}
|
||||
|
||||
function deleteCountdown(countdownId) {
|
||||
save({ ...data, countdowns: data.countdowns.filter((countdown) => countdown.id !== countdownId) });
|
||||
}
|
||||
|
||||
function resetCountdown(countdown) {
|
||||
if (countdown.type !== "duration" && countdown.type !== "daily_time" && countdown.type !== "interval") return;
|
||||
const now = getNowMs();
|
||||
let nextCountdown = null;
|
||||
if (countdown.type === "duration") nextCountdown = { ...countdown, targetAt: now + countdown.durationMs };
|
||||
if (countdown.type === "daily_time") nextCountdown = { ...countdown, targetAt: getDailyTargetMs(countdown.time, now) };
|
||||
if (countdown.type === "interval") nextCountdown = { ...countdown, anchorAt: now };
|
||||
if (!nextCountdown) return;
|
||||
save({ ...data, countdowns: data.countdowns.map((item) => item.id === countdown.id ? nextCountdown : item) });
|
||||
}
|
||||
|
||||
function renameCountdown(countdownId, label) {
|
||||
save({
|
||||
...data,
|
||||
countdowns: data.countdowns.map((countdown) => countdown.id === countdownId ? { ...countdown, label: label.trim() } : countdown)
|
||||
});
|
||||
}
|
||||
|
||||
function setCountdownAlertMode(countdownId, alertMode) {
|
||||
const countdown = data.countdowns.find((item) => item.id === countdownId);
|
||||
const targetMs = countdown ? getCountdownTargetMs(countdown, nowMs) : 0;
|
||||
if (alertMode !== "off" && targetMs && targetMs - nowMs < ALERT_MIN_DELAY_MS) {
|
||||
context.notify?.(textContent.alertTooSoonTitle || "Alerte indisponible à moins de 5 minutes");
|
||||
return;
|
||||
}
|
||||
save({
|
||||
...data,
|
||||
countdowns: data.countdowns.map((countdown) => countdown.id === countdownId ? { ...countdown, alertMode } : countdown)
|
||||
});
|
||||
}
|
||||
|
||||
function resetActiveResults() {
|
||||
if (activeTab === "stopwatch") {
|
||||
resetStopwatch();
|
||||
return;
|
||||
}
|
||||
save({ ...data, countdowns: [] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="timer-module">
|
||||
<section className="timer-control-card">
|
||||
<Tabs
|
||||
className="timer-tabs"
|
||||
tabs={[
|
||||
{ ...TIMER_TABS[0], label: textContent.stopwatchTab || TIMER_TABS[0].label },
|
||||
{ ...TIMER_TABS[1], label: textContent.countdownTab || TIMER_TABS[1].label }
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
iconOnly
|
||||
/>
|
||||
{activeTab === "stopwatch" ? (
|
||||
<StopwatchControls
|
||||
textContent={textContent}
|
||||
elapsedMs={stopwatchElapsedMs}
|
||||
running={Boolean(data.stopwatch.startedAt)}
|
||||
onStart={startStopwatch}
|
||||
onPause={pauseStopwatch}
|
||||
onReset={resetStopwatch}
|
||||
onLap={addLap}
|
||||
/>
|
||||
) : (
|
||||
<CountdownControls
|
||||
textContent={textContent}
|
||||
form={countdownForm}
|
||||
onChange={updateCountdownForm}
|
||||
onSubmit={createCountdown}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<section className={`tool-split-results timer-results-card ${data.scrollResults ? "is-scrollable" : ""}`}>
|
||||
<div className="tool-split-actions">
|
||||
<button className="tool-split-action-button danger" type="button" onClick={resetActiveResults} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
|
||||
<Icon name="rubber" />
|
||||
</button>
|
||||
{activeTab === "countdown" && (
|
||||
<button
|
||||
className={`tool-split-scroll-toggle timer-sort-toggle ${data.sortResults ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setSortResults(!data.sortResults)}
|
||||
aria-pressed={data.sortResults}
|
||||
title={textContent.sortTitle || "Trier par temps restant"}
|
||||
aria-label={textContent.sortTitle || "Trier par temps restant"}
|
||||
>
|
||||
<Icon name="sort-time" />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`tool-split-scroll-toggle timer-scroll-toggle ${data.scrollResults ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setScrollResults(!data.scrollResults)}
|
||||
aria-pressed={data.scrollResults}
|
||||
title={textContent.scrollableTitle || "Liste scrollable"}
|
||||
aria-label={textContent.scrollableTitle || "Liste scrollable"}
|
||||
>
|
||||
<Icon name="scrollable" />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`tool-split-tree timer-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
|
||||
{activeTab === "stopwatch" ? (
|
||||
<StopwatchLapList laps={data.stopwatch.laps} textContent={textContent} onRename={renameLap} onDelete={deleteLap} />
|
||||
) : (
|
||||
<CountdownList countdowns={visibleCountdowns} nowMs={nowMs} textContent={textContent} onRename={renameCountdown} onAlertModeChange={setCountdownAlertMode} onReset={resetCountdown} onDelete={deleteCountdown} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
|
||||
const labels = {
|
||||
off: textContent.alertOffTitle || "Pas d'alerte",
|
||||
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
|
||||
site: textContent.alertSiteTitle || "Alerte sur tout le site"
|
||||
};
|
||||
const blockedTitle = textContent.alertTooSoonTitle || "Alerte indisponible à moins de 5 minutes";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
|
||||
role="group"
|
||||
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
|
||||
title={blocked ? blockedTitle : undefined}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{ALERT_MODES.map((option) => {
|
||||
const label = labels[option.mode];
|
||||
const disabled = blocked && option.mode !== "off";
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onChange(option.mode)}
|
||||
disabled={disabled}
|
||||
aria-pressed={mode === option.mode}
|
||||
aria-label={disabled ? blockedTitle : label}
|
||||
title={disabled ? blockedTitle : label}
|
||||
>
|
||||
<Icon name={option.icon} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
|
||||
return (
|
||||
<div className="timer-control-panel" role="tabpanel">
|
||||
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
|
||||
<strong><TimerValue ms={elapsedMs} compact /></strong>
|
||||
<div className="timer-actions">
|
||||
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
|
||||
<Icon name={running ? "pause-circle" : "play-circle"} />
|
||||
</button>
|
||||
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
|
||||
<Icon name="record-circle" />
|
||||
</button>
|
||||
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
|
||||
<Icon name="stop-circle" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountdownControls({ textContent, form, onChange, onSubmit }) {
|
||||
return (
|
||||
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit}>
|
||||
<label>
|
||||
<span>{textContent.countdownLabel || "Libellé"}</span>
|
||||
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{textContent.countdownTypeLabel || "Type"}</span>
|
||||
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
|
||||
<option value="duration">{textContent.durationType || "Durée"}</option>
|
||||
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
|
||||
<option value="time_pattern">{textContent.timePatternType || "Horaire spécifique"}</option>
|
||||
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.type === "duration" && (
|
||||
<label>
|
||||
<span>{textContent.durationLabel || "Durée"}</span>
|
||||
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
|
||||
</label>
|
||||
)}
|
||||
{form.type === "daily_time" && (
|
||||
<label>
|
||||
<span>{textContent.dailyTimeLabel || "Heure"}</span>
|
||||
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
|
||||
</label>
|
||||
)}
|
||||
{form.type === "time_pattern" && (
|
||||
<label>
|
||||
<span>{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
|
||||
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
|
||||
</label>
|
||||
)}
|
||||
{form.type === "interval" && (
|
||||
<label>
|
||||
<span>{textContent.intervalLabel || "Toutes les"}</span>
|
||||
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
|
||||
</label>
|
||||
)}
|
||||
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TimePartsInput({ value, onChange, allowWildcard = false }) {
|
||||
function updatePart(part, nextValue) {
|
||||
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
|
||||
onChange({ ...value, [part]: cleanValue });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
|
||||
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||
<span>:</span>
|
||||
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||
<span>:</span>
|
||||
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
|
||||
const [editingId, setEditingId] = useState("");
|
||||
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
|
||||
return (
|
||||
<ul className="tool-split-entry-list timer-list">
|
||||
{laps.map((lap, index) => (
|
||||
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
|
||||
<div className="tool-split-entry timer-entry has-single-action">
|
||||
{editingId === lap.id ? (
|
||||
<EditableTimerLabel
|
||||
value={lap.label}
|
||||
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
|
||||
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
|
||||
onRename={(label) => onRename(lap.id, label)}
|
||||
onDone={() => setEditingId("")}
|
||||
>
|
||||
<TimerValue ms={lap.elapsedMs} />
|
||||
</EditableTimerLabel>
|
||||
) : (
|
||||
<span
|
||||
className="tool-split-entry-summary timer-entry-summary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setEditingId(lap.id)}
|
||||
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
|
||||
>
|
||||
<span className="timer-entry-line">
|
||||
<TimerValue ms={lap.elapsedMs} />
|
||||
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function CountdownList({ countdowns, nowMs, textContent, onRename, onAlertModeChange, onReset, onDelete }) {
|
||||
const [editingId, setEditingId] = useState("");
|
||||
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
|
||||
return (
|
||||
<ul className="tool-split-entry-list timer-list">
|
||||
{countdowns.map((countdown) => {
|
||||
const remainingMs = countdown.targetMs - nowMs;
|
||||
const expired = remainingMs <= 0;
|
||||
const canReset = countdown.type === "duration"
|
||||
|| countdown.type === "interval"
|
||||
|| (countdown.type === "daily_time" && expired);
|
||||
return (
|
||||
<li key={countdown.id} className={`timer-list-item ${expired ? "is-expired" : ""} ${editingId === countdown.id ? "is-editing" : ""}`}>
|
||||
<div className={`tool-split-entry timer-entry has-single-action ${editingId !== countdown.id ? "has-inline-controls" : ""} ${canReset && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
|
||||
{editingId === countdown.id ? (
|
||||
<EditableTimerLabel
|
||||
value={countdown.label}
|
||||
placeholder={textContent.defaultCountdownLabel || "Timer"}
|
||||
ariaLabel={textContent.countdownLabel || "Libellé"}
|
||||
onRename={(label) => onRename(countdown.id, label)}
|
||||
onDone={() => setEditingId("")}
|
||||
>
|
||||
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
||||
</EditableTimerLabel>
|
||||
) : (
|
||||
<span
|
||||
className="tool-split-entry-summary timer-entry-summary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setEditingId(countdown.id)}
|
||||
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
|
||||
>
|
||||
<span className="timer-entry-line">
|
||||
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
||||
<span>{countdown.label}</span>
|
||||
<span className="timer-inline-controls">
|
||||
{canReset && (
|
||||
<button
|
||||
className="timer-inline-reset"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onReset(countdown);
|
||||
}}
|
||||
aria-label={textContent.resetTitle || "Réinitialiser"}
|
||||
title={textContent.resetTitle || "Réinitialiser"}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
)}
|
||||
<TimerAlertModeSwitch
|
||||
mode={countdown.alertMode}
|
||||
blocked={countdown.targetMs && countdown.targetMs - nowMs < ALERT_MIN_DELAY_MS}
|
||||
textContent={textContent}
|
||||
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="timer-entry-meta">
|
||||
<span>{getCountdownMeta(countdown, textContent)}</span>
|
||||
<em>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} · ${formatTargetTime(countdown.targetMs)}`}</em>
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
|
||||
const [label, setLabel] = useState(value);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
function saveLabel() {
|
||||
onRename(label);
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="tool-split-entry-label timer-lap-label"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
onBlur={saveLabel}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") event.currentTarget.blur();
|
||||
if (event.key === "Escape") {
|
||||
setLabel(value);
|
||||
onDone();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
|
|||
import { LinksModule } from "./LinksModule.jsx";
|
||||
import { NotepadModule } from "./NotepadModule.jsx";
|
||||
import { ImagesModule } from "./ImagesModule.jsx";
|
||||
import { TimerModule } from "./TimerModule.jsx";
|
||||
|
||||
const MODULE_COMPONENTS = {
|
||||
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
|
||||
|
|
@ -19,6 +20,7 @@ const MODULE_COMPONENTS = {
|
|||
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
|
||||
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
|
||||
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
|
||||
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },
|
||||
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
|
||||
};
|
||||
|
||||
|
|
|
|||
108
website/src/features/toolboxes/modules/timerUtils.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Rôle : regroupe les calculs purs de formatage et d'échéance des timers.
|
||||
export function getNowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function pad(value) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function formatDuration(ms) {
|
||||
const safeMs = Math.max(0, Math.floor(ms));
|
||||
const totalSeconds = Math.floor(safeMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
export function getCentiseconds(ms) {
|
||||
return pad(Math.floor((Math.max(0, Math.floor(ms)) % 1000) / 10));
|
||||
}
|
||||
|
||||
export function formatDurationWithCentiseconds(ms) {
|
||||
return `${formatDuration(ms)}:${getCentiseconds(ms)}`;
|
||||
}
|
||||
|
||||
export function formatTargetTime(ms) {
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
}).format(new Date(ms));
|
||||
}
|
||||
|
||||
export function normalizeTimePart(value, max, allowWildcard = false) {
|
||||
const cleanValue = String(value || "").trim().toUpperCase();
|
||||
if (allowWildcard && !cleanValue) return "X";
|
||||
if (allowWildcard && /^X+$/.test(cleanValue)) return "X";
|
||||
const parsed = cleanValue ? Number.parseInt(cleanValue, 10) : 0;
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > max) return "";
|
||||
return pad(parsed);
|
||||
}
|
||||
|
||||
export function normalizeTimeParts(parts, { allowWildcard = false, allowLargeHours = false } = {}) {
|
||||
const hours = normalizeTimePart(parts?.hours, allowLargeHours ? 999 : 23, allowWildcard);
|
||||
const minutes = normalizeTimePart(parts?.minutes, 59, allowWildcard);
|
||||
const seconds = normalizeTimePart(parts?.seconds, 59, allowWildcard);
|
||||
if (!hours || !minutes || !seconds) return null;
|
||||
return { hours, minutes, seconds };
|
||||
}
|
||||
|
||||
export function timePartsToDurationMs(parts) {
|
||||
const normalized = normalizeTimeParts(parts, { allowLargeHours: true });
|
||||
if (!normalized) return 0;
|
||||
return ((Number(normalized.hours) * 3600) + (Number(normalized.minutes) * 60) + Number(normalized.seconds)) * 1000;
|
||||
}
|
||||
|
||||
export function timePartsToString(parts, options) {
|
||||
const normalized = normalizeTimeParts(parts, options);
|
||||
return normalized ? `${normalized.hours}:${normalized.minutes}:${normalized.seconds}` : "";
|
||||
}
|
||||
|
||||
export function parseTimeString(value, options) {
|
||||
const [hours = "", minutes = "", seconds = ""] = String(value || "").split(":");
|
||||
return normalizeTimeParts({ hours, minutes, seconds }, options);
|
||||
}
|
||||
|
||||
export function getDailyTargetMs(time, nowMs) {
|
||||
const parsed = parseTimeString(time);
|
||||
if (!parsed) return 0;
|
||||
const target = new Date(nowMs);
|
||||
target.setHours(Number(parsed.hours), Number(parsed.minutes), Number(parsed.seconds), 0);
|
||||
if (target.getTime() <= nowMs) target.setDate(target.getDate() + 1);
|
||||
return target.getTime();
|
||||
}
|
||||
|
||||
export function getTimePatternTargetMs(pattern, nowMs) {
|
||||
const parsed = parseTimeString(pattern, { allowWildcard: true });
|
||||
if (!parsed) return 0;
|
||||
const now = new Date(nowMs);
|
||||
const candidate = new Date(nowMs);
|
||||
candidate.setMilliseconds(0);
|
||||
|
||||
for (let offset = 1; offset <= 86400; offset += 1) {
|
||||
candidate.setTime(now.getTime() + offset * 1000);
|
||||
const hours = parsed.hours === "X" || Number(parsed.hours) === candidate.getHours();
|
||||
const minutes = parsed.minutes === "X" || Number(parsed.minutes) === candidate.getMinutes();
|
||||
const seconds = parsed.seconds === "X" || Number(parsed.seconds) === candidate.getSeconds();
|
||||
if (hours && minutes && seconds) return candidate.getTime();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getIntervalTargetMs(intervalMs, anchorAt, nowMs) {
|
||||
const safeIntervalMs = Math.max(1000, Number(intervalMs) || 0);
|
||||
const safeAnchor = Number.isFinite(anchorAt) && anchorAt > 0 ? anchorAt : nowMs;
|
||||
if (safeAnchor > nowMs) return safeAnchor;
|
||||
return safeAnchor + Math.ceil((nowMs - safeAnchor + 1) / safeIntervalMs) * safeIntervalMs;
|
||||
}
|
||||
|
||||
export function getCountdownTargetMs(countdown, nowMs) {
|
||||
if (countdown.type === "duration") return Number(countdown.targetAt) || 0;
|
||||
if (countdown.type === "daily_time") return Number(countdown.targetAt) || 0;
|
||||
if (countdown.type === "time_pattern") return getTimePatternTargetMs(countdown.pattern, nowMs);
|
||||
if (countdown.type === "interval") return getIntervalTargetMs(countdown.intervalMs, countdown.anchorAt, nowMs);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
|
||||
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k" };
|
||||
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z" };
|
||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const TIMER_TABS = new Set(["stopwatch", "countdown"]);
|
||||
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
|
||||
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
|
||||
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
|
||||
const TOOLBOX_ICON_FILES = [
|
||||
"toolbox.png",
|
||||
|
|
@ -30,6 +33,7 @@ const DEFAULT_MODULE_TITLES = {
|
|||
links: "Liens",
|
||||
counters: "Compteurs",
|
||||
calculator: "Calculateur",
|
||||
timer: "Timer",
|
||||
imageAnnotation: "Annotation d'images"
|
||||
};
|
||||
|
||||
|
|
@ -210,6 +214,16 @@ function normalizeCalculatorValue(value) {
|
|||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function isTimeString(value, allowWildcard = false) {
|
||||
const pattern = allowWildcard ? /^(?:\d{2}|X):(?:\d{2}|X):(?:\d{2}|X)$/ : /^\d{2}:\d{2}:\d{2}$/;
|
||||
if (!pattern.test(String(value || ""))) return false;
|
||||
return String(value).split(":").every((part, index) => {
|
||||
if (part === "X") return allowWildcard;
|
||||
const number = Number(part);
|
||||
return Number.isInteger(number) && number >= 0 && number <= (index === 0 ? 23 : 59);
|
||||
});
|
||||
}
|
||||
|
||||
export function labelFromFileName(name) {
|
||||
if (!name || name === "image.png") return "";
|
||||
return name.replace(/\.[^.]+$/, "").trim();
|
||||
|
|
@ -230,6 +244,62 @@ export function normalizeCalculatorData(data) {
|
|||
};
|
||||
}
|
||||
|
||||
export function normalizeTimerData(data) {
|
||||
const stopwatch = data?.stopwatch || {};
|
||||
const legacyAlertMode = TIMER_ALERT_MODES.has(data?.alertMode) ? data.alertMode : "off";
|
||||
const laps = (Array.isArray(stopwatch.laps) ? stopwatch.laps : [])
|
||||
.map((lap) => ({
|
||||
id: lap?.id || uid("timer"),
|
||||
label: String(lap?.label || "").trim(),
|
||||
elapsedMs: Math.max(0, Number(lap?.elapsedMs) || 0)
|
||||
}))
|
||||
.filter((lap) => lap.elapsedMs > 0);
|
||||
const countdowns = (Array.isArray(data?.countdowns) ? data.countdowns : [])
|
||||
.map((countdown) => {
|
||||
const type = COUNTDOWN_TYPES.has(countdown?.type) ? countdown.type : "duration";
|
||||
const normalized = {
|
||||
id: countdown?.id || uid("timer"),
|
||||
label: String(countdown?.label || "Timer").trim() || "Timer",
|
||||
type,
|
||||
alertMode: TIMER_ALERT_MODES.has(countdown?.alertMode) ? countdown.alertMode : legacyAlertMode
|
||||
};
|
||||
if (type === "duration") {
|
||||
const durationMs = Math.max(0, Number(countdown?.durationMs) || 0);
|
||||
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
|
||||
return durationMs > 0 && targetAt > 0 ? { ...normalized, durationMs, targetAt } : null;
|
||||
}
|
||||
if (type === "daily_time") {
|
||||
const time = String(countdown?.time || "").trim();
|
||||
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
|
||||
return isTimeString(time) && targetAt > 0 ? { ...normalized, time, targetAt } : null;
|
||||
}
|
||||
if (type === "time_pattern") {
|
||||
const pattern = String(countdown?.pattern || "").trim().toUpperCase();
|
||||
return isTimeString(pattern, true) ? { ...normalized, pattern } : null;
|
||||
}
|
||||
if (type === "interval") {
|
||||
const legacyIntervalMs = (Number.parseInt(countdown?.intervalMinutes, 10) || 0) * 60000;
|
||||
const intervalMs = Math.max(0, Number(countdown?.intervalMs) || legacyIntervalMs);
|
||||
const anchorAt = Math.max(0, Number(countdown?.anchorAt) || 0);
|
||||
return intervalMs > 0 ? { ...normalized, intervalMs, anchorAt } : null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
activeTab: TIMER_TABS.has(data?.activeTab) ? data.activeTab : "stopwatch",
|
||||
scrollResults: data?.scrollResults === true,
|
||||
sortResults: data?.sortResults === true,
|
||||
stopwatch: {
|
||||
elapsedMs: Math.max(0, Number(stopwatch.elapsedMs) || 0),
|
||||
startedAt: Math.max(0, Number(stopwatch.startedAt) || 0),
|
||||
laps
|
||||
},
|
||||
countdowns
|
||||
};
|
||||
}
|
||||
|
||||
function clampPercent(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
|
|
@ -339,6 +409,30 @@ export function compactModuleDataForStorage(type, value) {
|
|||
if (!entries.length && !normalized.scrollResults) return null;
|
||||
return normalized.scrollResults ? { entries, scrollResults: true } : { entries };
|
||||
}
|
||||
if (type === "timer") {
|
||||
const normalized = normalizeTimerData(value);
|
||||
const compact = {};
|
||||
if (normalized.activeTab !== "stopwatch") compact.activeTab = normalized.activeTab;
|
||||
if (normalized.scrollResults) compact.scrollResults = true;
|
||||
if (normalized.sortResults) compact.sortResults = true;
|
||||
if (normalized.stopwatch.elapsedMs || normalized.stopwatch.startedAt || normalized.stopwatch.laps.length) {
|
||||
compact.stopwatch = {
|
||||
...(normalized.stopwatch.elapsedMs ? { elapsedMs: normalized.stopwatch.elapsedMs } : {}),
|
||||
...(normalized.stopwatch.startedAt ? { startedAt: normalized.stopwatch.startedAt } : {}),
|
||||
...(normalized.stopwatch.laps.length ? { laps: normalized.stopwatch.laps } : {})
|
||||
};
|
||||
}
|
||||
if (normalized.countdowns.length) {
|
||||
compact.countdowns = normalized.countdowns.map((countdown) => {
|
||||
if (countdown.alertMode === "off") {
|
||||
const { alertMode, ...compactCountdown } = countdown;
|
||||
return compactCountdown;
|
||||
}
|
||||
return countdown;
|
||||
});
|
||||
}
|
||||
return Object.keys(compact).length ? compact : null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
|
@ -403,6 +497,20 @@ function remapModuleDataForExport(type, data, nextId) {
|
|||
return remapped;
|
||||
}
|
||||
|
||||
if (type === "timer") {
|
||||
const remapped = { ...compact };
|
||||
if (compact.stopwatch?.laps) {
|
||||
remapped.stopwatch = {
|
||||
...compact.stopwatch,
|
||||
laps: compact.stopwatch.laps.map((lap) => ({ ...lap, id: nextId("timer") }))
|
||||
};
|
||||
}
|
||||
if (compact.countdowns) {
|
||||
remapped.countdowns = compact.countdowns.map((countdown) => ({ ...countdown, id: nextId("timer") }));
|
||||
}
|
||||
return remapped;
|
||||
}
|
||||
|
||||
return compact;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ import { useEffect, useState } from "react";
|
|||
import {
|
||||
getAllModuleData as dbGetAllModuleData,
|
||||
getLinks as dbGetLinks,
|
||||
getSetting as dbGetSetting,
|
||||
getStorageEstimate,
|
||||
getToolboxes as dbGetToolboxes,
|
||||
removeModuleData as dbRemoveModuleData,
|
||||
removeModuleDataKeys as dbRemoveModuleDataKeys,
|
||||
setLinks as dbSetLinks,
|
||||
setModuleData as dbSetModuleData,
|
||||
setSetting as dbSetSetting,
|
||||
setToolboxes as dbSetToolboxes
|
||||
} from "../../../utils/indexedDbStorage.js";
|
||||
import {
|
||||
|
|
@ -23,6 +25,7 @@ export function useIndexedToolboxes(onError) {
|
|||
const [toolboxes, setToolboxesState] = useState([]);
|
||||
const [links, setLinksState] = useState({});
|
||||
const [moduleData, setModuleDataState] = useState({});
|
||||
const [toastPosition, setToastPositionState] = useState("right");
|
||||
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
||||
|
||||
async function refreshQuota() {
|
||||
|
|
@ -40,15 +43,17 @@ export function useIndexedToolboxes(onError) {
|
|||
let cancelled = false;
|
||||
async function loadStore() {
|
||||
try {
|
||||
const [storedToolboxes, storedLinks, storedModules] = await Promise.all([
|
||||
const [storedToolboxes, storedLinks, storedModules, storedToastPosition] = await Promise.all([
|
||||
dbGetToolboxes(),
|
||||
dbGetLinks(),
|
||||
dbGetAllModuleData()
|
||||
dbGetAllModuleData(),
|
||||
dbGetSetting("toastPosition", "right")
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setToolboxesState((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
|
||||
setLinksState(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
|
||||
setModuleDataState(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])));
|
||||
setToastPositionState(storedToastPosition === "left" ? "left" : "right");
|
||||
setReady(true);
|
||||
refreshQuota();
|
||||
} catch (error) {
|
||||
|
|
@ -118,11 +123,20 @@ export function useIndexedToolboxes(onError) {
|
|||
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => onError(error.message));
|
||||
}
|
||||
|
||||
function setToastPosition(position) {
|
||||
const normalized = position === "left" ? "left" : "right";
|
||||
setToastPositionState(normalized);
|
||||
dbSetSetting("toastPosition", normalized)
|
||||
.then(refreshQuota)
|
||||
.catch((error) => onError(error.message));
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
toolboxes,
|
||||
links,
|
||||
moduleData,
|
||||
toastPosition,
|
||||
storageUsage,
|
||||
setToolboxes: persistToolboxes,
|
||||
setLinks: persistLinks,
|
||||
|
|
@ -130,6 +144,7 @@ export function useIndexedToolboxes(onError) {
|
|||
updateModuleData,
|
||||
removeModuleData,
|
||||
removeToolboxModuleData,
|
||||
setToastPosition,
|
||||
refreshQuota
|
||||
};
|
||||
}
|
||||
|
|
|
|||
63
website/src/hooks/useTimerAlerts.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Rôle : surveille globalement les comptes à rebours actifs et déclenche les alertes internes autorisées.
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getCountdownTargetMs } from "../features/toolboxes/modules/timerUtils.js";
|
||||
import { moduleStorageKey } from "../features/toolboxes/storage/toolboxStorage.js";
|
||||
|
||||
function getVisibleToolboxIds(route, drawerGameId, links) {
|
||||
const visibleIds = new Set();
|
||||
const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
|
||||
if (toolboxMatch?.[1]) visibleIds.add(toolboxMatch[1]);
|
||||
const drawerToolboxId = drawerGameId ? links[drawerGameId] : "";
|
||||
if (drawerToolboxId) visibleIds.add(drawerToolboxId);
|
||||
return visibleIds;
|
||||
}
|
||||
|
||||
function getAlertMessage(template, toolbox, countdown) {
|
||||
return template
|
||||
.replaceAll("{label}", countdown.label)
|
||||
.replaceAll("{toolbox}", toolbox.name);
|
||||
}
|
||||
|
||||
export function useTimerAlerts({ toolboxes, moduleData, links, route, drawerGameId, normalizeTimerData, textContent, notify }) {
|
||||
const previousTargetsRef = useRef(new Map());
|
||||
const lastTickRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const visibleToolboxIds = getVisibleToolboxIds(route, drawerGameId, links);
|
||||
const alertMessageTemplate = textContent?.alertMessage || "Compte à rebours terminé : {label}";
|
||||
|
||||
function tick() {
|
||||
const nowMs = Date.now();
|
||||
const lastTickMs = lastTickRef.current;
|
||||
const nextTargets = new Map();
|
||||
|
||||
toolboxes.forEach((toolbox) => {
|
||||
toolbox.modules.forEach((module) => {
|
||||
if (module.type !== "timer") return;
|
||||
|
||||
const data = normalizeTimerData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
|
||||
|
||||
data.countdowns.forEach((countdown) => {
|
||||
if (countdown.alertMode === "off") return;
|
||||
if (countdown.alertMode === "visible" && !visibleToolboxIds.has(toolbox.id)) return;
|
||||
|
||||
const key = `${toolbox.id}:${module.id}:${countdown.id}`;
|
||||
const targetMs = getCountdownTargetMs(countdown, nowMs);
|
||||
const previousTargetMs = previousTargetsRef.current.get(key) || targetMs;
|
||||
nextTargets.set(key, targetMs);
|
||||
|
||||
if (!targetMs || previousTargetMs <= lastTickMs || previousTargetMs > nowMs) return;
|
||||
notify(getAlertMessage(alertMessageTemplate, toolbox, countdown));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
previousTargetsRef.current = nextTargets;
|
||||
lastTickRef.current = nowMs;
|
||||
}
|
||||
|
||||
tick();
|
||||
const intervalId = window.setInterval(tick, 500);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [drawerGameId, links, moduleData, normalizeTimerData, notify, route, textContent, toolboxes]);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// Rôle : point d'entrée React, assemble données, routes, shell et overlays.
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles/main.scss";
|
||||
import { AppOverlays } from "./components/AppOverlays.jsx";
|
||||
|
|
@ -9,9 +9,10 @@ import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedTool
|
|||
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
|
||||
import { useAppData } from "./hooks/useAppData.js";
|
||||
import { useGameFilters } from "./hooks/useGameFilters.js";
|
||||
import { useTimerAlerts } from "./hooks/useTimerAlerts.js";
|
||||
import { RouteContent } from "./router/RouteContent.jsx";
|
||||
import { useHashRoute } from "./router/hashRouter.js";
|
||||
import { moduleStorageKey } from "./features/toolboxes/storage/toolboxStorage.js";
|
||||
import { normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
|
||||
|
||||
function App() {
|
||||
const route = useHashRoute();
|
||||
|
|
@ -26,9 +27,9 @@ function App() {
|
|||
const [storageError, setStorageError] = useState("");
|
||||
const store = useIndexedToolboxes((message) => setStorageError(message));
|
||||
|
||||
function notify(message) {
|
||||
const notify = useCallback((message) => {
|
||||
setNotification({ id: Date.now(), message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleNotification(event) {
|
||||
|
|
@ -37,11 +38,11 @@ function App() {
|
|||
|
||||
window.addEventListener("sokkog:notify", handleNotification);
|
||||
return () => window.removeEventListener("sokkog:notify", handleNotification);
|
||||
}, []);
|
||||
}, [notify]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notification) return undefined;
|
||||
const timeoutId = window.setTimeout(() => setNotification(null), 1800);
|
||||
const timeoutId = window.setTimeout(() => setNotification(null), 120000);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [notification]);
|
||||
|
||||
|
|
@ -63,6 +64,17 @@ function App() {
|
|||
setImage
|
||||
});
|
||||
|
||||
useTimerAlerts({
|
||||
toolboxes: store.toolboxes,
|
||||
moduleData: store.moduleData,
|
||||
links: store.links,
|
||||
route,
|
||||
drawerGameId,
|
||||
normalizeTimerData,
|
||||
textContent: siteContent?.toolboxes?.modules?.timer,
|
||||
notify
|
||||
});
|
||||
|
||||
if (!siteContent) {
|
||||
return (
|
||||
<main className="startup-error">
|
||||
|
|
@ -92,6 +104,8 @@ function App() {
|
|||
getToolboxGame={getToolboxGame}
|
||||
actions={actions}
|
||||
storageUsage={store.storageUsage}
|
||||
toastPosition={store.toastPosition}
|
||||
setToastPosition={store.setToastPosition}
|
||||
getModuleData={store.getModuleData}
|
||||
updateToolbox={actions.updateToolbox}
|
||||
updateModuleData={store.updateModuleData}
|
||||
|
|
@ -126,6 +140,8 @@ function App() {
|
|||
image={image}
|
||||
setImage={setImage}
|
||||
notification={notification}
|
||||
setNotification={setNotification}
|
||||
toastPosition={store.toastPosition}
|
||||
getGame={getGame}
|
||||
toolboxes={store.toolboxes}
|
||||
links={store.links}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,41 @@
|
|||
-webkit-mask-image: url("/static/icons/calculator.svg");
|
||||
}
|
||||
|
||||
.ui-icon-clock {
|
||||
mask-image: url("/static/icons/clock.svg");
|
||||
-webkit-mask-image: url("/static/icons/clock.svg");
|
||||
}
|
||||
|
||||
.ui-icon-stopwatch {
|
||||
mask-image: url("/static/icons/stopwatch.svg");
|
||||
-webkit-mask-image: url("/static/icons/stopwatch.svg");
|
||||
}
|
||||
|
||||
.ui-icon-hourglass {
|
||||
mask-image: url("/static/icons/hourglass.svg");
|
||||
-webkit-mask-image: url("/static/icons/hourglass.svg");
|
||||
}
|
||||
|
||||
.ui-icon-play-circle {
|
||||
mask-image: url("/static/icons/play-circle.svg");
|
||||
-webkit-mask-image: url("/static/icons/play-circle.svg");
|
||||
}
|
||||
|
||||
.ui-icon-pause-circle {
|
||||
mask-image: url("/static/icons/pause-circle.svg");
|
||||
-webkit-mask-image: url("/static/icons/pause-circle.svg");
|
||||
}
|
||||
|
||||
.ui-icon-record-circle {
|
||||
mask-image: url("/static/icons/record-circle.svg");
|
||||
-webkit-mask-image: url("/static/icons/record-circle.svg");
|
||||
}
|
||||
|
||||
.ui-icon-stop-circle {
|
||||
mask-image: url("/static/icons/stop-circle.svg");
|
||||
-webkit-mask-image: url("/static/icons/stop-circle.svg");
|
||||
}
|
||||
|
||||
.ui-icon-map {
|
||||
mask-image: url("/static/icons/map.svg");
|
||||
-webkit-mask-image: url("/static/icons/map.svg");
|
||||
|
|
@ -82,6 +117,36 @@
|
|||
-webkit-mask-image: url("/static/icons/scrollable.svg");
|
||||
}
|
||||
|
||||
.ui-icon-sort-time {
|
||||
mask-image: url("/static/icons/sort-time.svg");
|
||||
-webkit-mask-image: url("/static/icons/sort-time.svg");
|
||||
}
|
||||
|
||||
.ui-icon-sound-mute {
|
||||
mask-image: url("/static/icons/sound-mute.svg");
|
||||
-webkit-mask-image: url("/static/icons/sound-mute.svg");
|
||||
}
|
||||
|
||||
.ui-icon-sound-min {
|
||||
mask-image: url("/static/icons/sound-min.svg");
|
||||
-webkit-mask-image: url("/static/icons/sound-min.svg");
|
||||
}
|
||||
|
||||
.ui-icon-sound-max {
|
||||
mask-image: url("/static/icons/sound-max.svg");
|
||||
-webkit-mask-image: url("/static/icons/sound-max.svg");
|
||||
}
|
||||
|
||||
.ui-icon-toast-left {
|
||||
mask-image: url("/static/icons/toast-left.svg");
|
||||
-webkit-mask-image: url("/static/icons/toast-left.svg");
|
||||
}
|
||||
|
||||
.ui-icon-toast-right {
|
||||
mask-image: url("/static/icons/toast-right.svg");
|
||||
-webkit-mask-image: url("/static/icons/toast-right.svg");
|
||||
}
|
||||
|
||||
.ui-icon-hide {
|
||||
mask-image: url("/static/icons/hide.svg");
|
||||
-webkit-mask-image: url("/static/icons/hide.svg");
|
||||
|
|
@ -142,6 +207,11 @@
|
|||
-webkit-mask-image: url("/static/icons/rubber.svg");
|
||||
}
|
||||
|
||||
.ui-icon-refresh {
|
||||
mask-image: url("/static/icons/refresh.svg");
|
||||
-webkit-mask-image: url("/static/icons/refresh.svg");
|
||||
}
|
||||
|
||||
.ui-icon-trash {
|
||||
mask-image: url("/static/icons/trashcan.svg");
|
||||
-webkit-mask-image: url("/static/icons/trashcan.svg");
|
||||
|
|
|
|||
|
|
@ -321,25 +321,135 @@ body.is-resizing-drawer * {
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
@property --notification-toast-angle {
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
.notification-toast {
|
||||
--notification-toast-angle: 0deg;
|
||||
position: fixed;
|
||||
right: var(--space-5);
|
||||
bottom: var(--space-5);
|
||||
z-index: 360;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
padding: 11px 14px;
|
||||
border: 1px solid rgba(246, 196, 83, 0.34);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 34px;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
max-width: min(460px, calc(100vw - 32px));
|
||||
min-width: min(360px, calc(100vw - 32px));
|
||||
padding: 14px 14px 14px 18px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(16, 20, 38, 0.94), rgba(24, 20, 42, 0.94)) padding-box,
|
||||
linear-gradient(135deg, rgba(246, 196, 83, 0.48), rgba(139, 92, 246, 0.28)) border-box;
|
||||
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.12), transparent 38%) padding-box,
|
||||
linear-gradient(135deg, rgba(16, 20, 38, 0.98), rgba(24, 20, 42, 0.98)) padding-box,
|
||||
conic-gradient(
|
||||
from var(--notification-toast-angle),
|
||||
rgba(246, 196, 83, 0.95),
|
||||
rgba(139, 92, 246, 0.8),
|
||||
rgba(56, 189, 248, 0.62),
|
||||
rgba(246, 196, 83, 0.95)
|
||||
) border-box;
|
||||
color: var(--color-text-primary);
|
||||
box-shadow:
|
||||
var(--shadow-sm),
|
||||
0 0 18px rgba(139, 92, 246, 0.16);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 800;
|
||||
pointer-events: none;
|
||||
var(--shadow-md),
|
||||
0 0 24px rgba(139, 92, 246, 0.22),
|
||||
0 0 18px rgba(246, 196, 83, 0.1);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 900;
|
||||
pointer-events: auto;
|
||||
animation:
|
||||
notification-toast-border-spin 2.4s linear infinite,
|
||||
notification-toast-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.notification-toast.is-right {
|
||||
right: var(--space-5);
|
||||
animation:
|
||||
notification-toast-enter-right 320ms var(--ease-standard),
|
||||
notification-toast-border-spin 2.4s linear infinite,
|
||||
notification-toast-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.notification-toast.is-left {
|
||||
left: var(--space-5);
|
||||
animation:
|
||||
notification-toast-enter-left 320ms var(--ease-standard),
|
||||
notification-toast-border-spin 2.4s linear infinite,
|
||||
notification-toast-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.notification-toast span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-toast button {
|
||||
display: inline-grid;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border-color: rgba(165, 180, 252, 0.12);
|
||||
background: rgba(5, 7, 17, 0.28);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.notification-toast button:hover {
|
||||
border-color: rgba(196, 181, 253, 0.26);
|
||||
background: rgba(21, 26, 48, 0.72);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.notification-toast button .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@keyframes notification-toast-border-spin {
|
||||
to { --notification-toast-angle: 360deg; }
|
||||
}
|
||||
|
||||
@keyframes notification-toast-enter-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(calc(100% + var(--space-5)));
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-toast-enter-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(calc(-100% - var(--space-5)));
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-toast-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
var(--shadow-md),
|
||||
0 0 24px rgba(139, 92, 246, 0.22),
|
||||
0 0 18px rgba(246, 196, 83, 0.1);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow:
|
||||
var(--shadow-md),
|
||||
0 0 34px rgba(139, 92, 246, 0.3),
|
||||
0 0 26px rgba(246, 196, 83, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.tool-add-modal {
|
||||
|
|
|
|||
|
|
@ -200,7 +200,8 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.calculator-module {
|
||||
.calculator-module,
|
||||
.timer-module {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
|
@ -218,6 +219,11 @@
|
|||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toolbox-preferences-row {
|
||||
max-width: none;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.tool-add-modal {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@
|
|||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
min-height: 68px;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -296,6 +296,116 @@
|
|||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toolbox-preferences-row {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
align-items: center;
|
||||
justify-self: end;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.08), transparent 34%),
|
||||
linear-gradient(135deg, rgba(16, 20, 38, 0.84), rgba(5, 7, 17, 0.76));
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 800;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.toolbox-preferences-row::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.38), transparent 30%),
|
||||
linear-gradient(135deg, rgba(246, 196, 83, 0.26), rgba(139, 92, 246, 0.16), rgba(56, 189, 248, 0.18));
|
||||
@include mixins.border-mask;
|
||||
}
|
||||
|
||||
.toolbox-preferences-row .eyebrow {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.toolbox-preferences-row strong {
|
||||
display: block;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.toolbox-preferences-row + .cards {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.toast-position-switch {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(2, 32px);
|
||||
width: 74px;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
gap: 0;
|
||||
padding: 3px 4px;
|
||||
border-color: rgba(165, 180, 252, 0.12);
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(7, 10, 24, 0.32);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.toast-position-switch::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
left: 4px;
|
||||
width: 30px;
|
||||
border: 1px solid rgba(246, 196, 83, 0.3);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(246, 196, 83, 0.1), rgba(139, 92, 246, 0.08)),
|
||||
rgba(21, 26, 48, 0.58);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
transition: transform var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.toast-position-switch[data-position="right"]::before {
|
||||
transform: translateX(32px);
|
||||
}
|
||||
|
||||
.toast-position-switch span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toast-position-switch .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.toast-position-switch:hover {
|
||||
border-color: rgba(196, 181, 253, 0.24);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modules {
|
||||
align-items: start;
|
||||
}
|
||||
|
|
@ -606,11 +716,71 @@
|
|||
-webkit-mask-image: url("/static/icons/calculator.svg");
|
||||
}
|
||||
|
||||
.module-icon-clock {
|
||||
mask-image: url("/static/icons/clock.svg");
|
||||
-webkit-mask-image: url("/static/icons/clock.svg");
|
||||
}
|
||||
|
||||
.module-icon-stopwatch {
|
||||
mask-image: url("/static/icons/stopwatch.svg");
|
||||
-webkit-mask-image: url("/static/icons/stopwatch.svg");
|
||||
}
|
||||
|
||||
.module-icon-hourglass {
|
||||
mask-image: url("/static/icons/hourglass.svg");
|
||||
-webkit-mask-image: url("/static/icons/hourglass.svg");
|
||||
}
|
||||
|
||||
.module-icon-map {
|
||||
mask-image: url("/static/icons/map.svg");
|
||||
-webkit-mask-image: url("/static/icons/map.svg");
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
border: 1px solid rgba(165, 180, 252, 0.12);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(5, 7, 17, 0.34);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
border-color: rgba(246, 196, 83, 0.28);
|
||||
background: rgba(246, 196, 83, 0.08);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
border-color: rgba(246, 196, 83, 0.56);
|
||||
background: rgba(246, 196, 83, 0.12);
|
||||
color: var(--color-accent-gold);
|
||||
box-shadow: 0 0 18px rgba(246, 196, 83, 0.08);
|
||||
}
|
||||
|
||||
.tabs .module-icon-svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.notepad {
|
||||
display: block;
|
||||
width: calc(100% - 32px);
|
||||
|
|
@ -809,7 +979,15 @@ textarea:focus {
|
|||
font-weight: 800;
|
||||
}
|
||||
|
||||
.checklist-complete-mode-button {
|
||||
.timer-alert-mode-switch {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.checklist-complete-mode-button,
|
||||
.timer-alert-mode-button {
|
||||
display: inline-grid;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
|
|
@ -824,23 +1002,41 @@ textarea:focus {
|
|||
font-weight: 800;
|
||||
}
|
||||
|
||||
.checklist-complete-mode-button:first-child {
|
||||
.checklist-complete-mode-button:first-child,
|
||||
.timer-alert-mode-button:first-child {
|
||||
border-radius: var(--radius-pill) 0 0 var(--radius-pill);
|
||||
}
|
||||
|
||||
.checklist-complete-mode-button:last-child {
|
||||
.checklist-complete-mode-button:last-child,
|
||||
.timer-alert-mode-button:last-child {
|
||||
border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
|
||||
}
|
||||
|
||||
.checklist-complete-mode-button + .checklist-complete-mode-button {
|
||||
.checklist-complete-mode-button + .checklist-complete-mode-button,
|
||||
.timer-alert-mode-button + .timer-alert-mode-button {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.checklist-complete-mode-button .ui-icon {
|
||||
.checklist-complete-mode-button .ui-icon,
|
||||
.timer-alert-mode-button .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.timer-alert-mode-button {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
min-height: 26px;
|
||||
border-color: rgba(165, 180, 252, 0.08);
|
||||
background: rgba(5, 7, 17, 0.26);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.timer-alert-mode-button .ui-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.checklist-section-collapse-button {
|
||||
display: inline-grid;
|
||||
width: 30px;
|
||||
|
|
@ -1026,7 +1222,8 @@ textarea:focus {
|
|||
font-weight: 900;
|
||||
}
|
||||
|
||||
.calculator-module {
|
||||
.calculator-module,
|
||||
.timer-module {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 35fr) minmax(0, 65fr);
|
||||
column-gap: var(--space-6);
|
||||
|
|
@ -1046,7 +1243,9 @@ textarea:focus {
|
|||
}
|
||||
|
||||
.calculator-card,
|
||||
.calculator-saved {
|
||||
.tool-split-results,
|
||||
.timer-control-card,
|
||||
.timer-results-card {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
|
@ -1061,6 +1260,316 @@ textarea:focus {
|
|||
rgba(7, 10, 24, 0.3);
|
||||
}
|
||||
|
||||
.timer-control-card,
|
||||
.timer-results-card {
|
||||
align-self: start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.timer-tabs {
|
||||
width: fit-content;
|
||||
grid-template-columns: repeat(2, 40px);
|
||||
}
|
||||
|
||||
.timer-tabs button {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timer-control-panel {
|
||||
display: grid;
|
||||
min-height: 170px;
|
||||
align-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.08), transparent 36%),
|
||||
rgba(5, 7, 17, 0.5);
|
||||
}
|
||||
|
||||
.timer-countdown-form {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.timer-countdown-form label,
|
||||
.timer-control-panel > label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.timer-countdown-form input,
|
||||
.timer-countdown-form select,
|
||||
.timer-control-panel input {
|
||||
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);
|
||||
}
|
||||
|
||||
.time-parts-input {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(34px, 1fr) 4px minmax(34px, 1fr) 4px minmax(34px, 1fr);
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(15, 23, 42, 0.64);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time-parts-input:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
|
||||
}
|
||||
|
||||
.time-parts-input input {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.time-parts-input input:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.time-parts-input input:focus-visible {
|
||||
outline: 0;
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.time-parts-input span {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timer-control-panel > span,
|
||||
.timer-control-panel label span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.timer-control-panel strong {
|
||||
color: var(--color-text-primary);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-weight: 950;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.timer-value {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 0.16em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timer-value > span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timer-control-panel .timer-value.is-compact {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.timer-value small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: inherit;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.timer-value.is-compact small {
|
||||
display: inline-block;
|
||||
font-size: 0.5em;
|
||||
transform: translateY(-0.08em);
|
||||
}
|
||||
|
||||
.timer-value.is-compact {
|
||||
font-size: clamp(1.45rem, 2.25vw, 2rem);
|
||||
}
|
||||
|
||||
.timer-control-panel p,
|
||||
.timer-results-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.timer-action-button,
|
||||
.timer-actions .danger {
|
||||
display: inline-flex;
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timer-list-item.is-expired {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.timer-entry-meta {
|
||||
display: flex;
|
||||
grid-column: 1 / -2;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
gap: 4px 8px;
|
||||
margin: 0;
|
||||
padding: 1px 10px 0;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.timer-entry-summary .timer-value,
|
||||
.timer-entry-value .timer-value {
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.timer-entry-summary {
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timer-entry-line {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timer-entry.has-inline-reset .timer-entry-line {
|
||||
grid-template-columns: max-content minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.timer-entry.has-inline-controls .timer-entry-line {
|
||||
grid-template-columns: max-content minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.timer-entry.has-single-action {
|
||||
grid-template-columns: minmax(0, 1fr) 34px;
|
||||
}
|
||||
|
||||
.tool-split-entry-list li.is-editing > .timer-entry.has-single-action {
|
||||
grid-template-columns: auto minmax(0, 1fr) 34px;
|
||||
}
|
||||
|
||||
.timer-inline-reset {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
min-height: 26px;
|
||||
padding: 0;
|
||||
border-color: rgba(165, 180, 252, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(5, 7, 17, 0.26);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.timer-inline-reset .ui-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.timer-inline-controls {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.timer-entry-line > span:nth-child(2) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timer-lap-label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timer-lap-label::placeholder {
|
||||
color: rgba(229, 235, 255, 0.72);
|
||||
}
|
||||
|
||||
.timer-entry-meta span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timer-list-item em {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timer-results-card {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.timer-results-card.is-scrollable {
|
||||
max-height: 450px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calculator-card {
|
||||
gap: 10px;
|
||||
align-self: start;
|
||||
|
|
@ -1084,7 +1593,7 @@ textarea:focus {
|
|||
|
||||
.calculator-form label span,
|
||||
.calculator-result span,
|
||||
.calculator-root-button {
|
||||
.tool-split-root-button {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
|
|
@ -1109,7 +1618,7 @@ textarea:focus {
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
.calculator-saved {
|
||||
.tool-split-results {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
overflow: visible;
|
||||
|
|
@ -1124,7 +1633,7 @@ textarea:focus {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calculator-result-actions {
|
||||
.tool-split-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
|
|
@ -1133,7 +1642,7 @@ textarea:focus {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.calculator-root-button {
|
||||
.tool-split-root-button {
|
||||
width: fit-content;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
|
|
@ -1142,8 +1651,8 @@ textarea:focus {
|
|||
background: rgba(5, 7, 17, 0.24);
|
||||
}
|
||||
|
||||
.calculator-action-button,
|
||||
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value),
|
||||
.tool-split-action-button,
|
||||
.tool-split-entry > button:not(.tool-split-entry-summary, .tool-split-entry-value),
|
||||
.counter-actions button,
|
||||
.link-item button {
|
||||
display: inline-flex;
|
||||
|
|
@ -1159,17 +1668,17 @@ textarea:focus {
|
|||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.calculator-action-button:disabled {
|
||||
.tool-split-action-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.calculator-action-button .ui-icon {
|
||||
.tool-split-action-button .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.calculator-scroll-toggle {
|
||||
.tool-split-scroll-toggle {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 18px 34px;
|
||||
width: fit-content;
|
||||
|
|
@ -1185,12 +1694,12 @@ textarea:focus {
|
|||
font-weight: 800;
|
||||
}
|
||||
|
||||
.calculator-scroll-toggle .ui-icon {
|
||||
.tool-split-scroll-toggle .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.calculator-scroll-toggle i {
|
||||
.tool-split-scroll-toggle i {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 34px;
|
||||
|
|
@ -1200,7 +1709,7 @@ textarea:focus {
|
|||
background: rgba(7, 10, 24, 0.72);
|
||||
}
|
||||
|
||||
.calculator-scroll-toggle i::before {
|
||||
.tool-split-scroll-toggle i::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
|
@ -1216,20 +1725,20 @@ textarea:focus {
|
|||
box-shadow var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.calculator-root-button {
|
||||
.tool-split-root-button {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.calculator-root-button:hover,
|
||||
.calculator-action-button:hover:not(:disabled),
|
||||
.tool-split-root-button:hover,
|
||||
.tool-split-action-button:hover:not(:disabled),
|
||||
.checklist-qty-controls button:hover,
|
||||
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value, .danger):hover,
|
||||
.tool-split-entry > button:not(.tool-split-entry-summary, .tool-split-entry-value, .danger):hover,
|
||||
.counter-actions button:not(.danger):hover,
|
||||
.link-item button:not(.danger):hover,
|
||||
.module-scroll-button:hover,
|
||||
.module-scroll-button.active,
|
||||
.calculator-scroll-toggle:hover,
|
||||
.calculator-scroll-toggle.active,
|
||||
.tool-split-scroll-toggle:hover,
|
||||
.tool-split-scroll-toggle.active,
|
||||
.checklist-complete-mode-button:hover,
|
||||
.checklist-complete-mode-button.active {
|
||||
border-color: rgba(196, 181, 253, 0.32);
|
||||
|
|
@ -1242,6 +1751,52 @@ textarea:focus {
|
|||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.timer-alert-mode-button:hover,
|
||||
.timer-alert-mode-button.active {
|
||||
border-color: rgba(196, 181, 253, 0.2);
|
||||
background: rgba(21, 26, 48, 0.42);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.timer-alert-mode-button.active {
|
||||
border-color: rgba(246, 196, 83, 0.26);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(246, 196, 83, 0.08), rgba(139, 92, 246, 0.08)),
|
||||
rgba(21, 26, 48, 0.36);
|
||||
color: var(--color-accent-gold);
|
||||
}
|
||||
|
||||
.timer-alert-mode-button:disabled,
|
||||
.timer-alert-mode-button:disabled:hover,
|
||||
.timer-alert-mode-button.active:disabled {
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(165, 180, 252, 0.025);
|
||||
background: rgba(5, 7, 17, 0.08);
|
||||
color: rgba(148, 163, 184, 0.22);
|
||||
opacity: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.timer-alert-mode-button:disabled .ui-icon {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.timer-alert-mode-switch.is-blocked {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button:disabled,
|
||||
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button:disabled:hover,
|
||||
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button.active:disabled {
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(165, 180, 252, 0.035);
|
||||
background: rgba(5, 7, 17, 0.1);
|
||||
color: rgba(148, 163, 184, 0.32);
|
||||
box-shadow: none;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.checklist-section-collapse-button:hover {
|
||||
border-color: rgba(246, 196, 83, 0.58);
|
||||
background:
|
||||
|
|
@ -1253,8 +1808,8 @@ textarea:focus {
|
|||
0 0 16px rgba(139, 92, 246, 0.08);
|
||||
}
|
||||
|
||||
.calculator-action-button.danger,
|
||||
.calculator-entry > button.danger,
|
||||
.tool-split-action-button.danger,
|
||||
.tool-split-entry > button.danger,
|
||||
.counter-actions button.danger,
|
||||
.link-item button.danger {
|
||||
border-color: rgba(251, 113, 133, 0.2);
|
||||
|
|
@ -1262,8 +1817,8 @@ textarea:focus {
|
|||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.calculator-action-button.danger:hover:not(:disabled),
|
||||
.calculator-entry > button.danger:hover,
|
||||
.tool-split-action-button.danger:hover:not(:disabled),
|
||||
.tool-split-entry > button.danger:hover,
|
||||
.counter-actions button.danger:hover,
|
||||
.link-item button.danger:hover {
|
||||
border-color: rgba(251, 113, 133, 0.56);
|
||||
|
|
@ -1276,8 +1831,8 @@ textarea:focus {
|
|||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.calculator-action-button.danger:hover .ui-icon,
|
||||
.calculator-entry > button.danger:hover .ui-icon,
|
||||
.tool-split-action-button.danger:hover .ui-icon,
|
||||
.tool-split-entry > button.danger:hover .ui-icon,
|
||||
.counter-actions button.danger:hover .ui-icon,
|
||||
.link-item button.danger:hover .ui-icon {
|
||||
background-color: #fff;
|
||||
|
|
@ -1285,7 +1840,7 @@ textarea:focus {
|
|||
}
|
||||
|
||||
.module-scroll-button.active,
|
||||
.calculator-scroll-toggle.active,
|
||||
.tool-split-scroll-toggle.active,
|
||||
.checklist-complete-mode-button.active {
|
||||
border-color: rgba(246, 196, 83, 0.34);
|
||||
background:
|
||||
|
|
@ -1295,13 +1850,13 @@ textarea:focus {
|
|||
}
|
||||
|
||||
.module-scroll-button.active i,
|
||||
.calculator-scroll-toggle.active i {
|
||||
.tool-split-scroll-toggle.active i {
|
||||
border-color: rgba(246, 196, 83, 0.36);
|
||||
background: rgba(246, 196, 83, 0.12);
|
||||
}
|
||||
|
||||
.module-scroll-button.active i::before,
|
||||
.calculator-scroll-toggle.active i::before {
|
||||
.tool-split-scroll-toggle.active i::before {
|
||||
left: 17px;
|
||||
background: var(--color-accent-gold);
|
||||
box-shadow: 0 0 10px rgba(246, 196, 83, 0.28);
|
||||
|
|
@ -1311,7 +1866,7 @@ textarea:focus {
|
|||
left: 15px;
|
||||
}
|
||||
|
||||
.calculator-tree {
|
||||
.tool-split-tree {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
|
|
@ -1319,12 +1874,12 @@ textarea:focus {
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
.calculator-tree.is-scrollable {
|
||||
.tool-split-tree.is-scrollable {
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.calculator-entry-list {
|
||||
.tool-split-entry-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
|
|
@ -1332,13 +1887,13 @@ textarea:focus {
|
|||
list-style: none;
|
||||
}
|
||||
|
||||
.calculator-entry-list .calculator-entry-list {
|
||||
.tool-split-entry-list .tool-split-entry-list {
|
||||
position: relative;
|
||||
margin-top: 6px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.calculator-entry-list .calculator-entry-list::before {
|
||||
.tool-split-entry-list .tool-split-entry-list::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
|
|
@ -1348,11 +1903,11 @@ textarea:focus {
|
|||
background: linear-gradient(180deg, rgba(246, 196, 83, 0.52), rgba(139, 92, 246, 0.2));
|
||||
}
|
||||
|
||||
.calculator-entry-list .calculator-entry-list > li {
|
||||
.tool-split-entry-list .tool-split-entry-list > li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calculator-entry-list .calculator-entry-list > li::before {
|
||||
.tool-split-entry-list .tool-split-entry-list > li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
|
|
@ -1362,25 +1917,26 @@ textarea:focus {
|
|||
background: rgba(246, 196, 83, 0.52);
|
||||
}
|
||||
|
||||
.calculator-entry {
|
||||
.tool-split-entry {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 34px 34px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.calculator-entry-list li.is-editing > .calculator-entry {
|
||||
.tool-split-entry-list li.is-editing > .tool-split-entry {
|
||||
grid-template-columns: auto minmax(0, 1fr) 34px 34px;
|
||||
}
|
||||
|
||||
.calculator-entry > button {
|
||||
.tool-split-entry > button {
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.calculator-entry-summary,
|
||||
.calculator-entry-value {
|
||||
.tool-split-entry-summary,
|
||||
.tool-split-entry-value {
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border: 1px solid rgba(165, 180, 252, 0.09);
|
||||
|
|
@ -1391,15 +1947,17 @@ textarea:focus {
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.calculator-entry-summary {
|
||||
.tool-split-entry-summary {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.calculator-entry-value {
|
||||
.tool-split-entry-value {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.calculator-entry-value.is-readonly {
|
||||
.tool-split-entry-value.is-readonly {
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
background: rgba(21, 26, 48, 0.38);
|
||||
|
|
@ -1407,25 +1965,26 @@ textarea:focus {
|
|||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.calculator-entry-summary:hover,
|
||||
.calculator-entry-value:not(.is-readonly):hover {
|
||||
.tool-split-entry-summary:hover,
|
||||
.tool-split-entry-value:not(.is-readonly):hover {
|
||||
border-color: rgba(196, 181, 253, 0.2);
|
||||
background: rgba(21, 26, 48, 0.72);
|
||||
}
|
||||
|
||||
.calculator-entry-summary span {
|
||||
.tool-split-entry-summary > span {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calculator-entry-summary strong,
|
||||
.calculator-entry-value strong {
|
||||
.tool-split-entry-summary strong,
|
||||
.tool-split-entry-value strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.calculator-entry-label {
|
||||
.tool-split-entry-label {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
|
|
@ -1438,26 +1997,26 @@ textarea:focus {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calculator-entry-label:hover,
|
||||
.calculator-entry-label:focus {
|
||||
.tool-split-entry-label:hover,
|
||||
.tool-split-entry-label:focus {
|
||||
border-color: rgba(196, 181, 253, 0.22);
|
||||
background: rgba(21, 26, 48, 0.58);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.calculator-entry .ui-icon {
|
||||
.tool-split-entry .ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value) {
|
||||
.tool-split-entry > button:not(.tool-split-entry-summary, .tool-split-entry-value) {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.calculator-entry-list li.active > .calculator-entry > .calculator-entry-summary,
|
||||
.calculator-entry-list li.active > .calculator-entry > .calculator-entry-value,
|
||||
.calculator-entry-list li.active > .calculator-entry > .calculator-entry-label {
|
||||
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-summary,
|
||||
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-value,
|
||||
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-label {
|
||||
border-color: var(--color-accent-gold);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(246, 196, 83, 0.09), rgba(139, 92, 246, 0.1)),
|
||||
|
|
@ -1602,7 +2161,7 @@ textarea:focus {
|
|||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.annotation-preview-actions .calculator-action-button {
|
||||
.annotation-preview-actions .tool-split-action-button {
|
||||
background: rgba(21, 26, 48, 0.94);
|
||||
box-shadow:
|
||||
0 6px 18px rgba(5, 7, 17, 0.28),
|
||||
|
|
|
|||