Improve toolbox checklists, image browsing, and guide integration
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-27 22:26:39 +02:00
parent 00e73f0062
commit 386212041f
47 changed files with 744 additions and 112 deletions

View file

@ -192,6 +192,12 @@ Style courant :
Le mot visible cote utilisateur est **outil**, pas **module**. Le terme module reste acceptable uniquement dans le code. Le mot visible cote utilisateur est **outil**, pas **module**. Le terme module reste acceptable uniquement dans le code.
Les outils qui gèrent une collection affichent un message vide compact et discret tant
qu'aucun élément n'existe. Ce message réutilise le traitement visuel en pointillés du
planificateur de tâches, sans remplacer les états vides métier plus détaillés déjà
présents. Lorsqu'un outil vient d'être ajouté, la page défile doucement jusqu'à son
panneau afin qu'il reste immédiatement visible, y compris s'il a été inséré hors cadre.
### Outils En Deux Colonnes ### Outils En Deux Colonnes
Les outils avec controles a gauche et resultats a droite doivent reutiliser les classes generiques `tool-split-*`. Les outils avec controles a gauche et resultats a droite doivent reutiliser les classes generiques `tool-split-*`.
@ -563,6 +569,7 @@ Filtres :
- lorsque la condition ne concerne qu’un groupe avancé, comme les catégories Diablo IV, son sélecteur est placé dans le popover correspondant avec 12 px d’espace avant les options ; - lorsque la condition ne concerne qu’un groupe avancé, comme les catégories Diablo IV, son sélecteur est placé dans le popover correspondant avec 12 px d’espace avant les options ;
- toute option réduite à son icône doit utiliser le composant commun `Tooltip` et conserver un nom accessible (`aria-label`) ; l’attribut HTML `title` ne remplace pas le tooltip ; - toute option réduite à son icône doit utiliser le composant commun `Tooltip` et conserver un nom accessible (`aria-label`) ; l’attribut HTML `title` ne remplace pas le tooltip ;
- les options directes avec libellé suivent la largeur naturelle de leur contenu ; lorsqu’elles passent en icône seule, elles retrouvent une largeur compacte commune ; - les options directes avec libellé suivent la largeur naturelle de leur contenu ; lorsqu’elles passent en icône seule, elles retrouvent une largeur compacte commune ;
- la recherche des barres horizontales est plafonnée à `260px` afin de ne pas étirer inutilement les filtres ;
- `nds-active-filters` forme une seconde section compacte dans la barre principale, séparée sur toute la largeur par un trait doré semi-transparent sans ombre ; la section haute conserve les 10 px de padding vertical de la barre, tandis que la section basse se limite à 4 px ; elle affiche un badge fermable par recherche ou critère actif, dont le bouton retire uniquement le filtre correspondant ; - `nds-active-filters` forme une seconde section compacte dans la barre principale, séparée sur toute la largeur par un trait doré semi-transparent sans ombre ; la section haute conserve les 10 px de padding vertical de la barre, tandis que la section basse se limite à 4 px ; elle affiche un badge fermable par recherche ou critère actif, dont le bouton retire uniquement le filtre correspondant ;
- les barres sont sticky sous la topbar sur desktop, reviennent à la ligne et redeviennent statiques sur mobile ; - les barres sont sticky sous la topbar sur desktop, reviennent à la ligne et redeviennent statiques sur mobile ;
- reset avec bouton icône `rubber`. - reset avec bouton icône `rubber`.
@ -616,9 +623,13 @@ Modales :
- panneau centre ; - panneau centre ;
- fermeture au clic exterieur quand l'action le permet ; - fermeture au clic exterieur quand l'action le permet ;
- boutons icones pour fermer/zoomer quand approprie. - boutons icones pour fermer/zoomer quand approprie.
- la visionneuse d'un outil Images peut recevoir la collection du bloc courant : sa galerie utilise de grandes vignettes non recadrees dans une grille responsive et scrollable ;
- la vue detaillee expose la position, un retour explicite a la galerie et des boutons precedent/suivant bloques aux extremites ; les fleches gauche/droite reprennent cette navigation ;
- une visionneuse d'image seule, notamment pour les annotations, ne doit afficher ni galerie ni navigation de collection.
Drawer toolbox : Drawer toolbox :
- le bouton de topbar associe icone et texte visible : « Afficher la toolbox » quand le drawer est ferme, puis « Masquer la toolbox » lorsqu'il est ouvert ;
- panneau lateral ancre a droite, ouvert a `700px` et redimensionnable uniquement vers une largeur superieure ; - panneau lateral ancre a droite, ouvert a `700px` et redimensionnable uniquement vers une largeur superieure ;
- le shell reserve toujours `700px` au panneau : toute largeur supplementaire recouvre la partie droite de la page sans la reduire davantage ; - le shell reserve toujours `700px` au panneau : toute largeur supplementaire recouvre la partie droite de la page sans la reduire davantage ;
- la page reste visible et interactive, sans backdrop bloquant ni fermeture au clic exterieur ; - la page reste visible et interactive, sans backdrop bloquant ni fermeture au clic exterieur ;
@ -849,6 +860,12 @@ dissocie la précédente. Le switch « Masquer les cochés » est désactivé su
non associées. Les commandes sont centrées et reviennent à la ligne sur mobile ; non associées. Les commandes sont centrées et reviennent à la ligne sur mobile ;
le nombre de correspondances figure dessous. Une liste active devenue incompatible le nombre de correspondances figure dessous. Une liste active devenue incompatible
garde ses commandes avec un message explicite. Aucun sélecteur global n'est affiché. garde ses commandes avec un message explicite. Aucun sélecteur global n'est affiché.
Quand une liste est associée, un encart compact placé en haut à droite du header
du guide expose directement le switch « Masquer les cochés » et l'action
« Dissocier la checklist ». La colonne d'actions occupe la hauteur existante du
header afin que cet encart n'ajoute pas de ligne ou d'espace vertical. Cette
association est conservée dans `localStorage` entre les sessions ; elle est retirée
si la checklist, la toolbox liée ou la liaison jeu/toolbox disparaît.
L'état terminé désature et atténue uniquement les portraits (ou les icônes de L'état terminé désature et atténue uniquement les portraits (ou les icônes de
catégorie Diablo). Le recto utilise uniquement la checkbox interactive ; la coche catégorie Diablo). Le recto utilise uniquement la checkbox interactive ; la coche
accessible « Coché » reste sur le verso des cartes retournables. Les textes restent accessible « Coché » reste sur le verso des cartes retournables. Les textes restent

View file

@ -50,7 +50,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier la page `/games`. - Vérifier la page `/games`.
- Vérifier la page `/games/<gameId>`. - Vérifier la page `/games/<gameId>`.
- Vérifier le chargement à la première visite, la conservation des données pendant la navigation et les erreurs réseau ; sous StrictMode, les requêtes abandonnées ne doivent pas remplacer les données actuelles ni afficher d'erreur d'annulation. - Vérifier le chargement à la première visite, la conservation des données pendant la navigation et les erreurs réseau ; sous StrictMode, les requêtes abandonnées ne doivent pas remplacer les données actuelles ni afficher d'erreur d'annulation.
- Vérifier le bouton toolbox et le panneau latéral. - Vérifier que le bouton texte « Afficher la toolbox » devient « Masquer la toolbox » avec le panneau latéral ouvert.
- Vérifier le breadcrumb. - Vérifier le breadcrumb.
- Vérifier les textes avec retours à la ligne. - Vérifier les textes avec retours à la ligne.
- Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. La checkbox des cartes met à jour toutes les lignes exactes correspondantes, respecte les quantités cibles et ne retourne pas les cartes. - Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. La checkbox des cartes met à jour toutes les lignes exactes correspondantes, respecte les quantités cibles et ne retourne pas les cartes.

View file

@ -236,7 +236,7 @@ Stockage compact :
## Outil Checklist ## Outil Checklist
### Association temporaire aux guides ### Association persistante aux guides
Les pages Aniimo, monstres MHW, faune MHW et affixes Diablo peuvent refléter une Les pages Aniimo, monstres MHW, faune MHW et affixes Diablo peuvent refléter une
checklist de la toolbox actuellement associée au jeu. Chaque checklist compatible checklist de la toolbox actuellement associée au jeu. Chaque checklist compatible
@ -248,12 +248,13 @@ réglage de masquage ; dissocier réaffiche tous les éléments. Replier les com
ne modifie pas l'association ; cet état d'affichage reste local au composant. ne modifie pas l'association ; cet état d'affichage reste local au composant.
Cette liaison ne modifie ni les données de checklist ni les liens persistants jeu/toolbox. Cette liaison ne modifie ni les données de checklist ni les liens persistants jeu/toolbox.
`sessionStorage["sokkog:guide-checklists"]` contient un objet indexé par `localStorage["sokkog:guide-checklists"]` contient un objet indexé par
`aniimo/aniimos`, `mhwilds/monsters`, `mhwilds/endemic` ou `diablo4/affixes`. `aniimo/aniimos`, `mhwilds/monsters`, `mhwilds/endemic` ou `diablo4/affixes`.
Chaque entrée contient `{ toolboxId, moduleId, hideCompleted }`. Aucune liaison Chaque entrée contient `{ toolboxId, moduleId, hideCompleted }`. Aucune liaison
n'est choisie automatiquement. La navigation et le rechargement la conservent ; n'est choisie automatiquement. La navigation, le rechargement et une nouvelle
session du navigateur la conservent ;
la dissociation, la suppression du module ou le changement de toolbox la retirent. la dissociation, la suppression du module ou le changement de toolbox la retirent.
Sans accès au stockage de session, l'état reste en mémoire dans l'application. Sans accès au stockage local, l'état reste en mémoire dans l'application.
Ces associations ne sont pas exportées et ne se synchronisent pas entre onglets. Ces associations ne sont pas exportées et ne se synchronisent pas entre onglets.
La reconnaissance compare les libellés complets sans tenir compte de la casse, La reconnaissance compare les libellés complets sans tenir compte de la casse,
@ -266,7 +267,7 @@ sélectionnée mais sans correspondance reste liée, avec un message explicite.
Une checkbox sur la carte d'un guide peut modifier la progression de toutes les Une checkbox sur la carte d'un guide peut modifier la progression de toutes les
lignes exactes qui lui correspondent : cocher porte `qtyCurrent` à `qtyTarget`, lignes exactes qui lui correspondent : cocher porte `qtyCurrent` à `qtyTarget`,
décocher le remet à `0`. Les données passent par le flux React/IndexedDB existant ; décocher le remet à `0`. Les données passent par le flux React/IndexedDB existant ;
aucune copie de progression n'est conservée dans l'état de session du guide. aucune copie de progression n'est conservée dans l'association du guide.
La copie Aniimo utilise `Nom:1` en affichage regroupé et `Nom — Forme:1` en La copie Aniimo utilise `Nom:1` en affichage regroupé et `Nom — Forme:1` en
affichage séparé. Les noms et formes français et anglais sont reconnus. Une ligne affichage séparé. Les noms et formes français et anglais sont reconnus. Une ligne
@ -298,6 +299,7 @@ Format avec catégories :
```json ```json
{ {
"hideCompletedItems": true,
"hideCompletedSections": true, "hideCompletedSections": true,
"hideCompletedSectionsFully": false, "hideCompletedSectionsFully": false,
"sections": [ "sections": [
@ -323,6 +325,7 @@ Stockage compact :
- `qtyTarget` est omis si la valeur vaut `1`. - `qtyTarget` est omis si la valeur vaut `1`.
- `qtyCurrent` est omis si la valeur vaut `0`. - `qtyCurrent` est omis si la valeur vaut `0`.
- `hideCompletedItems` est omis si `false`.
- `hideCompletedSections` est omis si `false`. - `hideCompletedSections` est omis si `false`.
- `hideCompletedSectionsFully` est omis si `false`. - `hideCompletedSectionsFully` est omis si `false`.
- `title` est omis si vide. - `title` est omis si vide.

View file

@ -21,7 +21,9 @@ not bugs, missing scenarios or a percentage of work remaining.
| Toolbox creation, import/export, text exchange | `e2e/toolbox.spec.js`, `text-exchange.spec.js`; Node toolbox module tests | | Toolbox creation, import/export, text exchange | `e2e/toolbox.spec.js`, `text-exchange.spec.js`; Node toolbox module tests |
| Links: creation, copy, category removal preserving links, deletion and reload | `e2e/basic-tools.spec.js` | | Links: creation, copy, category removal preserving links, deletion and reload | `e2e/basic-tools.spec.js` |
| Counters: creation, signed values, reset, deletion and reload | `e2e/basic-tools.spec.js` | | Counters: creation, signed values, reset, deletion and reload | `e2e/basic-tools.spec.js` |
| Checklist: quantities, clamping, completed category visibility and persistence | `e2e/basic-tools.spec.js` | | Checklist: quantities, clamping, completed item/category visibility and persistence | `e2e/basic-tools.spec.js` |
| Images: gallery, detail navigation, keyboard controls and focus restoration | `e2e/basic-tools.spec.js` |
| Guide checklists: persistent association, page controls, drawer scope and live completion | `e2e/guide-checklist.spec.js` |
| Planner: editing, recurrence, prerequisites, subtree reorder and persistence | `e2e/task-planner.spec.js` | | Planner: editing, recurrence, prerequisites, subtree reorder and persistence | `e2e/task-planner.spec.js` |
| Representative visual states and responsive layouts | `e2e/style-layouts.spec.js`, `tool-style-states.spec.js`, `card-surfaces.spec.js`, `nds-controls.spec.js`, `nds-patterns.spec.js` | | Representative visual states and responsive layouts | `e2e/style-layouts.spec.js`, `tool-style-states.spec.js`, `card-surfaces.spec.js`, `nds-controls.spec.js`, `nds-patterns.spec.js` |
| Production onboarding | `e2e/production/onboarding.spec.js` | | Production onboarding | `e2e/production/onboarding.spec.js` |

View file

@ -64,6 +64,32 @@ test("counters persist signed values and reset without affecting other counters"
await expect(tool.locator(".counter-value span")).toHaveText("Other"); await expect(tool.locator(".counter-value span")).toHaveText("Other");
}); });
test("empty collection tools explain that they have no content", async ({ page }) => {
await page.goto("/#/toolboxes");
await page.getByText("Importer une toolbox", { exact: true }).locator('input[type="file"]').setInputFiles({
name: "empty-tools.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({
toolbox: {
id: "empty-tools",
name: "Empty tools",
modules: [
{ id: "checklist", type: "checklist", title: "Checklist" },
{ id: "images", type: "images", title: "Images" },
{ id: "links", type: "links", title: "Links" },
{ id: "counters", type: "counters", title: "Counters" }
]
},
modules: {}
}))
});
await page.getByRole("link", { name: "Ouvrir Empty tools", exact: true }).first().click();
await expect(page.getByText("Aucun élément dans cette checklist.", { exact: true })).toBeVisible();
await expect(page.getByText("Aucune image.", { exact: true })).toBeVisible();
await expect(page.getByText("Aucun lien.", { exact: true })).toBeVisible();
await expect(page.getByText("Aucun compteur.", { exact: true })).toBeVisible();
});
test("checklist quantities are clamped and completed categories hide without losing data", async ({ page }) => { test("checklist quantities are clamped and completed categories hide without losing data", async ({ page }) => {
const tool = await setup(page, "checklist", { sections: [] }); const tool = await setup(page, "checklist", { sections: [] });
await tool.getByPlaceholder("Catégorie", { exact: true }).fill("Craft"); await tool.getByPlaceholder("Catégorie", { exact: true }).fill("Craft");
@ -81,6 +107,14 @@ test("checklist quantities are clamped and completed categories hide without los
await expect(tool.getByRole("radio", { name: "Cachée quand terminée", exact: true })).toHaveAttribute("aria-checked", "true"); await expect(tool.getByRole("radio", { name: "Cachée quand terminée", exact: true })).toHaveAttribute("aria-checked", "true");
await tool.getByRole("radio", { name: "Visible quand terminée", exact: true }).click(); await tool.getByRole("radio", { name: "Visible quand terminée", exact: true }).click();
await expect(quantity).toHaveValue("3"); await expect(quantity).toHaveValue("3");
const hideCompleted = tool.getByRole("button", { name: "Masquer les éléments terminés", exact: true });
await hideCompleted.click();
await expect(hideCompleted).toHaveAttribute("aria-pressed", "true");
await expect(quantity).toHaveCount(0);
await page.reload();
await expect(hideCompleted).toHaveAttribute("aria-pressed", "true");
await hideCompleted.click();
await expect(quantity).toHaveValue("3");
await tool.getByRole("button", { name: "Retirer une quantité", exact: true }).click(); await tool.getByRole("button", { name: "Retirer une quantité", exact: true }).click();
await page.reload(); await page.reload();
await expect(quantity).toHaveValue("2"); await expect(quantity).toHaveValue("2");
@ -88,3 +122,63 @@ test("checklist quantities are clamped and completed categories hide without los
await page.reload(); await page.reload();
await expect(quantity).toHaveCount(0); await expect(quantity).toHaveCount(0);
}); });
test("a simple checklist can hide and restore checked rows", async ({ page }) => {
const tool = await setup(page, "checklist", {
items: [
{ id: "done", label: "Done", qtyCurrent: 1 },
{ id: "pending", label: "Pending" }
]
});
const hideCompleted = tool.getByRole("button", { name: "Masquer les éléments terminés", exact: true });
await hideCompleted.click();
await expect(tool.getByRole("checkbox", { name: "Terminer Done", exact: true })).toHaveCount(0);
await expect(tool.getByRole("checkbox", { name: "Terminer Pending", exact: true })).toBeVisible();
await hideCompleted.click();
await expect(tool.getByRole("checkbox", { name: "Terminer Done", exact: true })).toBeVisible();
});
test("images open a gallery and navigate the current collection without looping", async ({ page }) => {
const makeImage = (color) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="${color}"/></svg>`)}`;
const tool = await setup(page, "images", {
images: [
{ id: "one", label: "First", dataUrl: makeImage("#8b5cf6") },
{ id: "two", label: "Second", dataUrl: makeImage("#22d3ee") },
{ id: "three", dataUrl: makeImage("#f6c453") }
]
});
await tool.getByRole("button", { name: "Galerie", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Galerie d'images", exact: true });
await expect(dialog).toBeVisible();
await expect(dialog.locator(".image-viewer-gallery-item")).toHaveCount(3);
await dialog.getByRole("button", { name: "Afficher en grand : Second", exact: true }).click();
await expect(page.locator(".image-viewer-title")).toHaveText("Second");
await expect(page.locator(".image-viewer-position")).toHaveText("2 / 3");
await page.keyboard.press("ArrowRight");
await expect(page.locator(".image-viewer-title")).toHaveText("Image");
await expect(page.getByRole("button", { name: "Image suivante", exact: true })).toBeDisabled();
await page.keyboard.press("ArrowLeft");
await expect(page.locator(".image-viewer-title")).toHaveText("Second");
await page.getByRole("button", { name: "Revenir à la galerie", exact: true }).click();
const secondThumbnail = dialog.getByRole("button", { name: "Afficher en grand : Second", exact: true });
await expect(secondThumbnail).toBeFocused();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
await tool.getByRole("button", { name: "Galerie", exact: true }).click();
const galleryBox = await dialog.boundingBox();
expect(galleryBox.x).toBeGreaterThanOrEqual(0);
expect(galleryBox.y).toBeGreaterThanOrEqual(0);
expect(galleryBox.x + galleryBox.width).toBeLessThanOrEqual(390);
expect(galleryBox.y + galleryBox.height).toBeLessThanOrEqual(844);
await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
await tool.locator(".image-preview").nth(2).click();
await expect(page.locator(".image-viewer-position")).toHaveText("3 / 3");
await expect(page.getByRole("button", { name: "Image suivante", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Fermer", exact: true }).click();
});

View file

@ -138,7 +138,7 @@ test("opening and editing one toolbox only reads its media and reuses it on retu
test("the game drawer loads only the linked toolbox media", async ({ page }) => { test("the game drawer loads only the linked toolbox media", async ({ page }) => {
const seeded = await setup(page); const seeded = await setup(page);
await page.goto("/#/games/mhwilds"); await page.goto("/#/games/mhwilds");
await page.getByRole("button", { name: "Ouvrir la toolbox Alpha", exact: true }).click(); await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click();
const drawer = page.locator("#toolbox-drawer .drawer-panel"); const drawer = page.locator("#toolbox-drawer .drawer-panel");
await expect(drawer.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); await expect(drawer.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
await expect(drawer.locator(".annotation-marker")).toHaveCount(1); await expect(drawer.locator(".annotation-marker")).toHaveCount(1);

View file

@ -38,7 +38,7 @@ for (const width of [1920, 900]) {
await expect(first).toHaveAttribute("aria-pressed", "false"); await expect(first).toHaveAttribute("aria-pressed", "false");
await expect(page.locator(".game-filters")).toHaveCSS("position", width <= 760 ? "static" : "sticky"); await expect(page.locator(".game-filters")).toHaveCSS("position", width <= 760 ? "static" : "sticky");
await expect(page.locator(".game-filters")).toHaveCSS("top", width <= 760 ? "auto" : "68px"); await expect(page.locator(".game-filters")).toHaveCSS("top", width <= 760 ? "auto" : "68px");
expect((await filters.locator(".nds-filter-search").boundingBox())?.width).toBeLessThanOrEqual(280); expect((await filters.locator(".nds-filter-search").boundingBox())?.width).toBeLessThanOrEqual(260);
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true); expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
}); });
} }

View file

@ -1,4 +1,4 @@
// Rôle : vérifie copie, liaison de session et progression des checklists dans les guides. // Rôle : vérifie copie, liaison persistante et progression des checklists dans les guides.
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { resetSokkoStorage } from "./helpers/storage.js"; import { resetSokkoStorage } from "./helpers/storage.js";
@ -6,7 +6,7 @@ import { importChecklistText } from "../website/src/features/toolboxes/modules/t
import { normalizeChecklistData } from "../website/src/features/toolboxes/storage/modules/checklist.js"; import { normalizeChecklistData } from "../website/src/features/toolboxes/storage/modules/checklist.js";
const route = "/#/games/aniimo/aniimos"; const route = "/#/games/aniimo/aniimos";
const sessionKey = "sokkog:guide-checklists"; const storageKey = "sokkog:guide-checklists";
const controls = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).locator(".guide-checklist-controls"); const controls = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).locator(".guide-checklist-controls");
const controlsToggle = (page, id) => page.getByRole("button", { name: `Lien avec le guide — ${id}`, exact: true }); const controlsToggle = (page, id) => page.getByRole("button", { name: `Lien avec le guide — ${id}`, exact: true });
const linkButton = (page, id) => controls(page, id).locator(".guide-checklist-link"); const linkButton = (page, id) => controls(page, id).locator(".guide-checklist-link");
@ -125,7 +125,7 @@ test("Aniimo copies visible species or forms and round-trips through checklist i
}); });
for (const width of [1280, 390]) { for (const width of [1280, 390]) {
test(`drawer linking, live completion and session restoration at ${width}px`, async ({ page }) => { test(`drawer linking, page controls and persistent restoration at ${width}px`, async ({ page }) => {
test.setTimeout(60000); test.setTimeout(60000);
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
await seed(page); await seed(page);
@ -164,6 +164,19 @@ for (const width of [1280, 390]) {
expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0); expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0);
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") }); await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
await closeDrawer(page); await closeDrawer(page);
const pageControls = page.locator(".guide-checklist-page-controls");
await expect(pageControls).toBeVisible();
const pageControlsBounds = await pageControls.boundingBox();
const pageHeadingBounds = await page.locator(".page-heading").boundingBox();
const headingActionsBounds = await page.locator(".game-heading-actions").boundingBox();
expect(pageControlsBounds.y).toBeGreaterThanOrEqual(pageHeadingBounds.y);
expect(pageControlsBounds.y + pageControlsBounds.height).toBeLessThanOrEqual(pageHeadingBounds.y + pageHeadingBounds.height);
if (width > 760) {
expect(headingActionsBounds.height).toBeCloseTo(pageHeadingBounds.height, 0);
expect(pageControlsBounds.y).toBeCloseTo(pageHeadingBounds.y, 0);
}
await expect(pageControls.getByRole("button", { name: "Masquer les cochés", exact: true })).toHaveAttribute("aria-pressed", "false");
await expect(pageControls.getByRole("button", { name: "Dissocier la checklist", exact: true })).toBeVisible();
await expect(card).toHaveAttribute("data-checklist-complete", "true"); await expect(card).toHaveAttribute("data-checklist-complete", "true");
await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveCount(0); await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveCount(0);
await card.getByRole("button", { name: /Afficher les capacités/ }).click(); await card.getByRole("button", { name: /Afficher les capacités/ }).click();
@ -177,7 +190,7 @@ for (const width of [1280, 390]) {
await expect.poll(async () => (await readModule(page, "forms")).items.find((item) => item.label.includes("Forme de base")).qtyCurrent || 0).toBe(0); await expect.poll(async () => (await readModule(page, "forms")).items.find((item) => item.label.includes("Forme de base")).qtyCurrent || 0).toBe(0);
await card.getByRole("checkbox", { name: "Cocher dans la checklist" }).check(); await card.getByRole("checkbox", { name: "Cocher dans la checklist" }).check();
await expect(card).toHaveAttribute("data-checklist-complete", "true"); await expect(card).toHaveAttribute("data-checklist-complete", "true");
await toggleHidden(page); await pageControls.getByRole("button", { name: "Masquer les cochés", exact: true }).click();
await expect(card.getByRole("tab")).toHaveCount(2); await expect(card.getByRole("tab")).toHaveCount(2);
await expect(card).toHaveAttribute("data-form", "mountain_woods_form"); await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
expect(normalizeChecklistData(await readModule(page, "forms")).items).toEqual(normalizeChecklistData(before).items); expect(normalizeChecklistData(await readModule(page, "forms")).items).toEqual(normalizeChecklistData(before).items);
@ -188,6 +201,8 @@ for (const width of [1280, 390]) {
await drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) }).getByRole("checkbox", { name: "Terminer Loufeuteau", exact: true }).check(); await drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) }).getByRole("checkbox", { name: "Terminer Loufeuteau", exact: true }).check();
await closeDrawer(page); await closeDrawer(page);
await expect(card).toHaveCount(0); await expect(card).toHaveCount(0);
await expect.poll(() => page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).not.toBeNull();
await page.evaluate(() => globalThis.sessionStorage.clear());
await page.reload(); await page.reload();
await assertLink(page, "capture", true); await assertLink(page, "capture", true);
await page.getByRole("searchbox").fill("emberpup"); await page.getByRole("searchbox").fill("emberpup");
@ -198,16 +213,15 @@ for (const width of [1280, 390]) {
await closeDrawer(page); await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true"); await expect(card).toHaveAttribute("data-checklist-complete", "true");
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click(); await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
await page.locator(".guide-checklist-page-controls").getByRole("button", { name: "Dissocier la checklist", exact: true }).click();
await openDrawer(page); await openDrawer(page);
await openControls(page, "capture"); await openControls(page, "capture");
await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "true");
await linkButton(page, "capture").click();
await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "false"); await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "false");
await expect(activeButtons(page)).toHaveCount(0); await expect(activeButtons(page)).toHaveCount(0);
await expect(hiddenSwitch(page)).toHaveCount(0); await expect(hiddenSwitch(page)).toHaveCount(0);
await closeDrawer(page); await closeDrawer(page);
await expect(page.locator('[data-checklist-complete="true"]')).toHaveCount(0); await expect(page.locator('[data-checklist-complete="true"]')).toHaveCount(0);
expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull(); expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull();
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true); expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
}); });
} }
@ -261,7 +275,7 @@ test("renames retain a zero-match selection; changing toolbox clears the associa
await dialog.getByRole("button", { name: "Valider", exact: true }).click(); await dialog.getByRole("button", { name: "Valider", exact: true }).click();
await expect(activeButtons(page)).toHaveCount(0); await expect(activeButtons(page)).toHaveCount(0);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(1); await expect(page.locator(".guide-checklist-controls")).toHaveCount(1);
expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull(); expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull();
}); });
test("deleting a selected checklist clears its link immediately", async ({ page }) => { test("deleting a selected checklist clears its link immediately", async ({ page }) => {
@ -276,20 +290,20 @@ test("deleting a selected checklist clears its link immediately", async ({ page
await expect(hiddenSwitch(page)).toHaveCount(0); await expect(hiddenSwitch(page)).toHaveCount(0);
await page.reload(); await page.reload();
await assertLink(page, "", false); await assertLink(page, "", false);
expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull(); expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull();
}); });
test("unavailable session storage retains links across navigation; overview has no guide controls", async ({ page }) => { test("unavailable local storage retains links in memory across navigation; overview has no guide controls", async ({ page }) => {
await seed(page); await seed(page);
await page.addInitScript((key) => { await page.addInitScript((key) => {
for (const method of ["getItem", "setItem", "removeItem"]) { for (const method of ["getItem", "setItem", "removeItem"]) {
const original = globalThis.Storage.prototype[method]; const original = globalThis.Storage.prototype[method];
globalThis.Storage.prototype[method] = function (name, ...args) { globalThis.Storage.prototype[method] = function (name, ...args) {
if (name === key) throw new Error("Session storage unavailable"); if (name === key) throw new Error("Local storage unavailable");
return original.call(this, name, ...args); return original.call(this, name, ...args);
}; };
} }
}, sessionKey); }, storageKey);
await page.reload(); await page.reload();
await page.goto(route); await page.goto(route);
await choose(page, "perfect"); await choose(page, "perfect");
@ -304,4 +318,8 @@ test("unavailable session storage retains links across navigation; overview has
await expect(page.locator(".module-guide-button")).toHaveCount(0); await expect(page.locator(".module-guide-button")).toHaveCount(0);
await page.goto(route); await page.goto(route);
await assertLink(page, "perfect", true); await assertLink(page, "perfect", true);
await openDrawer(page);
await page.goto("/#/toolboxes");
await expect(page.locator("#toolbox-drawer .drawer-panel")).toHaveCount(0);
await expect(page.locator(".app-shell")).not.toHaveClass(/toolbox-drawer-open/);
}); });

View file

@ -63,3 +63,17 @@ test("cross-column drops and empty column boundaries preserve every module and t
await page.getByRole("button", { name: "Afficher en lignes", exact: true }).click(); await page.getByRole("button", { name: "Afficher en lignes", exact: true }).click();
await expect(page.locator(".module-title")).toHaveText(["A", "B", "C", "D"]); await expect(page.locator(".module-title")).toHaveText(["A", "B", "C", "D"]);
}); });
test("adding an off-screen tool scrolls it into view", async ({ page }) => {
await page.setViewportSize({ width: 900, height: 420 });
await setup(page, 1);
await page.getByRole("button", { name: "Afficher l'ajout rapide", exact: true }).click();
await page.getByRole("button", { name: "Ajouter Compteurs", exact: true }).click();
const added = page.locator('article[data-module-type="counters"]').last();
await expect(page.locator("article.module")).toHaveCount(5);
await expect.poll(async () => added.evaluate((element) => {
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.top < globalThis.window.innerHeight && rect.bottom > 0;
})).toBe(true);
expect(await page.evaluate(() => globalThis.window.scrollY)).toBeGreaterThan(0);
});

View file

@ -97,7 +97,7 @@ test("cancelled and unreadable replacements preserve the cover and allow selecti
test("game association takes precedence without discarding the custom cover", async ({ page }) => { test("game association takes precedence without discarding the custom cover", async ({ page }) => {
await page.goto("/#/games/mhwilds"); await page.goto("/#/games/mhwilds");
await page.getByRole("button", { name: "Associer une toolbox", exact: true }).click(); await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click();
const drawer = page.locator("#toolbox-drawer .drawer-panel"); const drawer = page.locator("#toolbox-drawer .drawer-panel");
await importToolbox(page, "Linked cover", drawer); await importToolbox(page, "Linked cover", drawer);
await expect(drawer.getByRole("heading", { name: "Linked cover" })).toBeVisible(); await expect(drawer.getByRole("heading", { name: "Linked cover" })).toBeVisible();
@ -111,7 +111,7 @@ test("game association takes precedence without discarding the custom cover", as
await expect(page.locator(".toolbox-game-icon img")).toHaveAttribute("src", gameCover); await expect(page.locator(".toolbox-game-icon img")).toHaveAttribute("src", gameCover);
await expect(picker(page)).toHaveCount(0); await expect(picker(page)).toHaveCount(0);
await page.goto("/#/games/mhwilds"); await page.goto("/#/games/mhwilds");
await page.getByRole("button", { name: "Ouvrir la toolbox Linked cover", exact: true }).click(); await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click();
await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click(); await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Lier une toolbox", exact: true }); const dialog = page.getByRole("dialog", { name: "Lier une toolbox", exact: true });
await dialog.getByLabel("Toolbox associée").selectOption(""); await dialog.getByLabel("Toolbox associée").selectOption("");

View file

@ -2,7 +2,10 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
async function openDrawer(page) { async function openDrawer(page) {
await page.locator(".drawer-button").click(); const trigger = page.getByRole("button", { name: "Afficher la toolbox", exact: true });
await expect(trigger).toBeVisible();
await trigger.click();
await expect(page.getByRole("button", { name: "Masquer la toolbox", exact: true })).toHaveAttribute("aria-pressed", "true");
const drawer = page.locator("#toolbox-drawer"); const drawer = page.locator("#toolbox-drawer");
await expect(drawer.locator(".drawer-panel")).toBeVisible(); await expect(drawer.locator(".drawer-panel")).toBeVisible();
return drawer; return drawer;
@ -61,6 +64,7 @@ test("drawer reserves 700px while additional width overlays the page", async ({
await expect.poll(async () => (await panel.boundingBox()).width).toBeCloseTo(800, 0); await expect.poll(async () => (await panel.boundingBox()).width).toBeCloseTo(800, 0);
await drawer.getByRole("button", { name: "Fermer", exact: true }).click(); await drawer.getByRole("button", { name: "Fermer", exact: true }).click();
await expect(drawer).toHaveCount(0); await expect(drawer).toHaveCount(0);
await expect(page.getByRole("button", { name: "Afficher la toolbox", exact: true })).toHaveAttribute("aria-pressed", "false");
await expect(sidebar).not.toHaveAttribute("data-variant"); await expect(sidebar).not.toHaveAttribute("data-variant");
await expect(sidebar).toHaveCSS("width", "248px"); await expect(sidebar).toHaveCSS("width", "248px");
const reopenedDrawer = await openDrawer(page); const reopenedDrawer = await openDrawer(page);

View file

@ -204,7 +204,7 @@ test("loads randomizer tabs after module split", async ({ page }) => {
test("creates a game-linked toolbox from the drawer", async ({ page }) => { test("creates a game-linked toolbox from the drawer", async ({ page }) => {
await page.goto("/#/games/mhwilds"); await page.goto("/#/games/mhwilds");
await expect(page.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible();
await page.getByRole("button", { name: "Associer une toolbox" }).click(); await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click();
const drawer = page.locator("#toolbox-drawer .drawer-panel"); const drawer = page.locator("#toolbox-drawer .drawer-panel");
await expect(drawer).toBeVisible(); await expect(drawer).toBeVisible();
@ -216,7 +216,7 @@ test("creates a game-linked toolbox from the drawer", async ({ page }) => {
await expect(drawer.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible(); await expect(drawer.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible();
await expect(drawer.locator("[data-module-type='memos']")).toBeVisible(); await expect(drawer.locator("[data-module-type='memos']")).toBeVisible();
await expect(drawer.locator("[data-module-type='links']")).toBeVisible(); await expect(drawer.locator("[data-module-type='links']")).toBeVisible();
await expect(page.getByRole("button", { name: /Ouvrir la toolbox Monster Hunter.*Wilds/ })).toBeVisible(); await expect(page.getByRole("button", { name: "Masquer la toolbox", exact: true })).toBeVisible();
}); });
test("imports and exports a toolbox payload", async ({ page }) => { test("imports and exports a toolbox payload", async ({ page }) => {

View file

@ -1,4 +1,4 @@
// Rôle : vérifie les correspondances des guides et la validité des associations de session. // Rôle : vérifie les correspondances des guides et la validité des associations persistantes.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { aniimoChecklistEntries, matchGuideChecklist, normalizeGuideLabel, toggleGuideChecklistRows, validGuideAssociations } from "../website/src/features/games/guideChecklist.js"; import { aniimoChecklistEntries, matchGuideChecklist, normalizeGuideLabel, toggleGuideChecklistRows, validGuideAssociations } from "../website/src/features/games/guideChecklist.js";
@ -58,7 +58,7 @@ test("card toggles update every exact matching row to its target or zero", () =>
assert.equal(data.sections[0].items[0].qtyCurrent, 1); assert.equal(data.sections[0].items[0].qtyCurrent, 1);
}); });
test("session associations require the current linked toolbox and an existing checklist", () => { test("persistent associations require the current linked toolbox and an existing checklist", () => {
const toolbox = { id: "tb", modules: [{ id: "capture", type: "checklist" }, { id: "notes", type: "notepad" }] }; const toolbox = { id: "tb", modules: [{ id: "capture", type: "checklist" }, { id: "notes", type: "notepad" }] };
const association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true }; const association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true };
const state = { "aniimo/aniimos": association, "mhwilds/monsters": { ...association, moduleId: "notes" }, unknown: association }; const state = { "aniimo/aniimos": association, "mhwilds/monsters": { ...association, moduleId: "notes" }, unknown: association };

View file

@ -320,12 +320,14 @@ export function validateSiteContent(site) {
"toolboxes.modules.checklist.sectionPlaceholder", "toolboxes.modules.checklist.sectionPlaceholder",
"toolboxes.modules.checklist.quantityLabel", "toolboxes.modules.checklist.quantityLabel",
"toolboxes.modules.checklist.addButton", "toolboxes.modules.checklist.addButton",
"toolboxes.modules.checklist.emptyItems",
"toolboxes.modules.checklist.importPlaceholder", "toolboxes.modules.checklist.importPlaceholder",
"toolboxes.modules.checklist.importOpenButton", "toolboxes.modules.checklist.importOpenButton",
"toolboxes.modules.checklist.importModalTitle", "toolboxes.modules.checklist.importModalTitle",
"toolboxes.modules.checklist.importButton", "toolboxes.modules.checklist.importButton",
"toolboxes.modules.checklist.hideCompletedSectionsTitle", "toolboxes.modules.checklist.hideCompletedSectionsTitle",
"toolboxes.modules.checklist.hideCompletedSectionsFullyTitle", "toolboxes.modules.checklist.hideCompletedSectionsFullyTitle",
"toolboxes.modules.checklist.hideCompletedItemsTitle",
"toolboxes.modules.checklist.completedSectionsModeTitle", "toolboxes.modules.checklist.completedSectionsModeTitle",
"toolboxes.modules.checklist.completedSectionsVisibleLabel", "toolboxes.modules.checklist.completedSectionsVisibleLabel",
"toolboxes.modules.checklist.completedSectionsReducedLabel", "toolboxes.modules.checklist.completedSectionsReducedLabel",
@ -343,12 +345,20 @@ export function validateSiteContent(site) {
"toolboxes.modules.checklist.editTitle", "toolboxes.modules.checklist.editTitle",
"toolboxes.modules.checklist.deleteTitle", "toolboxes.modules.checklist.deleteTitle",
"toolboxes.modules.images.addImages", "toolboxes.modules.images.addImages",
"toolboxes.modules.images.emptyImages",
"toolboxes.modules.images.pastePlaceholder", "toolboxes.modules.images.pastePlaceholder",
"toolboxes.modules.images.pasteAriaLabel", "toolboxes.modules.images.pasteAriaLabel",
"toolboxes.modules.images.imageAlt", "toolboxes.modules.images.imageAlt",
"toolboxes.modules.images.labelPlaceholder", "toolboxes.modules.images.labelPlaceholder",
"toolboxes.modules.images.labelAriaLabel", "toolboxes.modules.images.labelAriaLabel",
"toolboxes.modules.images.previewAriaLabel", "toolboxes.modules.images.previewAriaLabel",
"toolboxes.modules.images.galleryButton",
"toolboxes.modules.images.galleryTitle",
"toolboxes.modules.images.galleryImageAriaLabel",
"toolboxes.modules.images.returnGalleryTitle",
"toolboxes.modules.images.previousImageTitle",
"toolboxes.modules.images.nextImageTitle",
"toolboxes.modules.images.positionLabel",
"toolboxes.modules.images.annotateAriaLabel", "toolboxes.modules.images.annotateAriaLabel",
"toolboxes.modules.images.annotateTitle", "toolboxes.modules.images.annotateTitle",
"toolboxes.modules.images.deleteAriaLabel", "toolboxes.modules.images.deleteAriaLabel",
@ -356,6 +366,7 @@ export function validateSiteContent(site) {
"toolboxes.modules.links.titlePlaceholder", "toolboxes.modules.links.titlePlaceholder",
"toolboxes.modules.links.urlPlaceholder", "toolboxes.modules.links.urlPlaceholder",
"toolboxes.modules.links.addButton", "toolboxes.modules.links.addButton",
"toolboxes.modules.links.emptyLinks",
"toolboxes.modules.links.importPlaceholder", "toolboxes.modules.links.importPlaceholder",
"toolboxes.modules.links.importOpenButton", "toolboxes.modules.links.importOpenButton",
"toolboxes.modules.links.importModalTitle", "toolboxes.modules.links.importModalTitle",
@ -365,6 +376,7 @@ export function validateSiteContent(site) {
"toolboxes.modules.links.deleteTitle", "toolboxes.modules.links.deleteTitle",
"toolboxes.modules.counters.labelPlaceholder", "toolboxes.modules.counters.labelPlaceholder",
"toolboxes.modules.counters.addButton", "toolboxes.modules.counters.addButton",
"toolboxes.modules.counters.emptyCounters",
"toolboxes.modules.counters.decrementLabel", "toolboxes.modules.counters.decrementLabel",
"toolboxes.modules.counters.incrementLabel", "toolboxes.modules.counters.incrementLabel",
"toolboxes.modules.counters.resetTitle", "toolboxes.modules.counters.resetTitle",

View file

@ -170,6 +170,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const textImportPlanning = await readFile("website/src/features/toolboxes/modules/textImport/planning.js", "utf8"); const textImportPlanning = await readFile("website/src/features/toolboxes/modules/textImport/planning.js", "utf8");
const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8"); const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8");
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8"); const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
const imageGallery = await readFile("website/src/components/ImageGallery.jsx", "utf8");
const moduleEmptyMessage = await readFile("website/src/features/toolboxes/modules/ModuleEmptyMessage.jsx", "utf8");
const randomizerModule = await readFile("website/src/features/toolboxes/modules/RandomizerModule.jsx", "utf8"); const randomizerModule = await readFile("website/src/features/toolboxes/modules/RandomizerModule.jsx", "utf8");
const randomizerDice = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerDice.jsx", "utf8"); const randomizerDice = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerDice.jsx", "utf8");
const randomizerWheel = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerWheel.jsx", "utf8"); const randomizerWheel = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerWheel.jsx", "utf8");
@ -228,6 +230,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(addToolControls, /tool-add-quick-toggle/); assert.match(addToolControls, /tool-add-quick-toggle/);
assert.match(moduleMetadataRegistry, /scrollable: true/); assert.match(moduleMetadataRegistry, /scrollable: true/);
assert.match(moduleShell, /module-content/); assert.match(moduleShell, /module-content/);
assert.match(moduleShell, /scrollIntoView/);
assert.match(moduleShell, /data-module-id/);
assert.match(moduleShell, /module-scroll-button/); assert.match(moduleShell, /module-scroll-button/);
assert.match(moduleShell, /module-collapse-button/); assert.match(moduleShell, /module-collapse-button/);
assert.match(moduleShell, /aria-expanded=\{!collapsed\}/); assert.match(moduleShell, /aria-expanded=\{!collapsed\}/);
@ -395,6 +399,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(checklistParts, /checklist-qty-current/); assert.match(checklistParts, /checklist-qty-current/);
assert.match(checklistParts, /checklist-delete-button nds-icon-button/); assert.match(checklistParts, /checklist-delete-button nds-icon-button/);
assert.match(checklistModule, /useGroupedReorder/); assert.match(checklistModule, /useGroupedReorder/);
assert.match(checklistModule, /hideCompletedItems/);
assert.match(checklistParts, /visibleItems/);
assert.match(checklistBundle, /getItemProps/); assert.match(checklistBundle, /getItemProps/);
assert.match(checklistParts, /getGroupProps/); assert.match(checklistParts, /getGroupProps/);
assert.match(checklistModule, /getGroupBoundaryProps/); assert.match(checklistModule, /getGroupBoundaryProps/);
@ -411,6 +417,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(imagesModule, /createImageAnnotationModule/); assert.match(imagesModule, /createImageAnnotationModule/);
assert.match(imagesModule, /TextExchangeActions/); assert.match(imagesModule, /TextExchangeActions/);
assert.match(imagesModule, /image-annotate-button/); assert.match(imagesModule, /image-annotate-button/);
assert.match(imagesModule, /kind: "collection"/);
assert.match(imagesModule, /galleryButton/);
assert.match(imagesModule, /dataTransfer\.files/); assert.match(imagesModule, /dataTransfer\.files/);
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/); assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
assert.match(imageAnnotationModule, /annotation-marker/); assert.match(imageAnnotationModule, /annotation-marker/);
@ -455,6 +463,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(imageViewer, /!hasMarkers &&/); assert.match(imageViewer, /!hasMarkers &&/);
assert.match(imageViewer, /<Icon name="zoom"/); assert.match(imageViewer, /<Icon name="zoom"/);
assert.match(imageViewer, /annotation-marker-list themed-scrollbar/); assert.match(imageViewer, /annotation-marker-list themed-scrollbar/);
assert.match(imageViewer, /ArrowLeft/);
assert.match(imageViewer, /ArrowRight/);
assert.match(imageViewer, /<ImageGallery/);
assert.match(imageGallery, /image-viewer-gallery-item/);
assert.match(moduleEmptyMessage, /toolbox-module-empty/);
}); });
test("task planner helpers keep hierarchy, reorder and reset behavior stable", () => { test("task planner helpers keep hierarchy, reorder and reset behavior stable", () => {

View file

@ -1193,6 +1193,33 @@ test("checklist data drops empty titled sections", () => {
assert.deepEqual(compact.sections.map((section) => section.id), ["filled"]); assert.deepEqual(compact.sections.map((section) => section.id), ["filled"]);
}); });
test("checklist completed-item visibility survives compact storage and toolbox export", () => {
const data = {
hideCompletedItems: true,
hideCompletedSections: true,
hideCompletedSectionsFully: false,
sections: [{ id: "section", title: "Craft", items: [{ id: "item", label: "Ore", qtyTarget: 3, qtyCurrent: 3 }] }]
};
const normalized = normalizeChecklistData(data);
assert.equal(normalized.hideCompletedItems, true);
const compact = compactModuleDataForStorage("checklist", data);
assert.equal(compact.hideCompletedItems, true);
assert.equal(compact.hideCompletedSections, true);
assert.equal("hideCompletedSectionsFully" in compact, false);
assert.deepEqual(compactModuleDataForStorage("checklist", { hideCompletedItems: true, sections: [] }), { hideCompletedItems: true });
const payload = createToolboxExportPayload({
id: "source",
name: "Checklist export",
modules: [{ id: "checklist", type: "checklist", title: "Checklist" }]
}, { [moduleStorageKey("source", "checklist")]: data });
const exported = Object.values(payload.modules)[0];
assert.equal(exported.hideCompletedItems, true);
assert.equal(exported.hideCompletedSections, true);
assert.equal(exported.sections[0].items[0].qtyCurrent, 3);
});
test("checklist helpers merge categories and move items deterministically", () => { test("checklist helpers merge categories and move items deterministically", () => {
const context = { uid: (prefix) => `${prefix}-new` }; const context = { uid: (prefix) => `${prefix}-new` };
const sections = [ const sections = [

View file

@ -1991,6 +1991,7 @@
"sectionPlaceholder": "Catégorie", "sectionPlaceholder": "Catégorie",
"quantityLabel": "Quantité cible", "quantityLabel": "Quantité cible",
"addButton": "Ajouter", "addButton": "Ajouter",
"emptyItems": "Aucun élément dans cette checklist.",
"importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1", "importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1",
"importOpenButton": "Importer du texte", "importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte", "exportOpenButton": "Exporter en format texte",
@ -2003,6 +2004,7 @@
"importError": "Format de checklist invalide.", "importError": "Format de checklist invalide.",
"hideCompletedSectionsTitle": "Réduire les catégories terminées", "hideCompletedSectionsTitle": "Réduire les catégories terminées",
"hideCompletedSectionsFullyTitle": "Cacher les catégories terminées", "hideCompletedSectionsFullyTitle": "Cacher les catégories terminées",
"hideCompletedItemsTitle": "Masquer les éléments terminés",
"completedSectionsModeTitle": "Affichage des catégories terminées", "completedSectionsModeTitle": "Affichage des catégories terminées",
"completedSectionsVisibleLabel": "Visible quand terminée", "completedSectionsVisibleLabel": "Visible quand terminée",
"completedSectionsReducedLabel": "Réduite quand terminée", "completedSectionsReducedLabel": "Réduite quand terminée",
@ -2033,12 +2035,20 @@
"importError": "Format d'images invalide.", "importError": "Format d'images invalide.",
"exportInfo": "Les images sont exportées en base64 complet. Le texte peut donc être très long.", "exportInfo": "Les images sont exportées en base64 complet. Le texte peut donc être très long.",
"addImages": "Ajouter des images", "addImages": "Ajouter des images",
"emptyImages": "Aucune image.",
"pastePlaceholder": "Coller une image ici", "pastePlaceholder": "Coller une image ici",
"pasteAriaLabel": "Coller une image depuis le presse-papiers", "pasteAriaLabel": "Coller une image depuis le presse-papiers",
"imageAlt": "Image", "imageAlt": "Image",
"labelPlaceholder": "Libellé de l'image", "labelPlaceholder": "Libellé de l'image",
"labelAriaLabel": "Libellé de l'image", "labelAriaLabel": "Libellé de l'image",
"previewAriaLabel": "Agrandir l'image", "previewAriaLabel": "Agrandir l'image",
"galleryButton": "Galerie",
"galleryTitle": "Galerie d'images",
"galleryImageAriaLabel": "Afficher en grand",
"returnGalleryTitle": "Revenir à la galerie",
"previousImageTitle": "Image précédente",
"nextImageTitle": "Image suivante",
"positionLabel": "Image {current} sur {total}",
"reorderAriaLabel": "Déplacer l'image", "reorderAriaLabel": "Déplacer l'image",
"reorderTitle": "Déplacer", "reorderTitle": "Déplacer",
"annotateAriaLabel": "Annoter l'image", "annotateAriaLabel": "Annoter l'image",
@ -2051,6 +2061,7 @@
"urlPlaceholder": "https://...", "urlPlaceholder": "https://...",
"categoryPlaceholder": "Catégorie", "categoryPlaceholder": "Catégorie",
"addButton": "Ajouter", "addButton": "Ajouter",
"emptyLinks": "Aucun lien.",
"reorderCategoryTitle": "Déplacer la catégorie", "reorderCategoryTitle": "Déplacer la catégorie",
"showCategoryTitle": "Afficher la catégorie", "showCategoryTitle": "Afficher la catégorie",
"hideCategoryTitle": "Réduire la catégorie", "hideCategoryTitle": "Réduire la catégorie",
@ -2072,6 +2083,7 @@
"counters": { "counters": {
"labelPlaceholder": "Nom du compteur", "labelPlaceholder": "Nom du compteur",
"addButton": "Ajouter", "addButton": "Ajouter",
"emptyCounters": "Aucun compteur.",
"importPlaceholder": "Victoire: 12\nDéfaite: -2", "importPlaceholder": "Victoire: 12\nDéfaite: -2",
"importOpenButton": "Importer du texte", "importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte", "exportOpenButton": "Exporter en format texte",
@ -2759,7 +2771,7 @@
"associate": "Associer à la page", "associate": "Associer à la page",
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.", "noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
"matches": "correspondances", "matches": "correspondances",
"unlink": "Dissocier", "unlink": "Dissocier la checklist",
"hideCompleted": "Masquer les cochés", "hideCompleted": "Masquer les cochés",
"completed": "Coché", "completed": "Coché",
"checkCard": "Cocher dans la checklist", "checkCard": "Cocher dans la checklist",

View file

@ -0,0 +1,42 @@
// Rôle : affiche la collection d'un outil Images dans la galerie de la visionneuse.
import { useEffect, useRef } from "react";
export function ImageGallery({ images, activeIndex, focusIndex = -1, onSelect, textContent = {} }) {
const galleryRef = useRef(null);
useEffect(() => {
if (focusIndex < 0) return;
const frame = window.requestAnimationFrame(() => {
galleryRef.current?.querySelector(`[data-image-index="${focusIndex}"]`)?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [focusIndex]);
return (
<div ref={galleryRef} className="image-viewer-gallery themed-scrollbar">
{images.map((image, index) => {
const position = `${index + 1} / ${images.length}`;
const label = image.label || `${textContent.imageAlt || "Image"} ${index + 1}`;
return (
<button
className={`image-viewer-gallery-item nds-card ${index === activeIndex ? "is-active" : ""}`}
data-variant="interactive"
data-image-index={index}
type="button"
key={image.id || `${image.dataUrl}-${index}`}
onClick={() => onSelect(index)}
aria-label={`${textContent.galleryImageAriaLabel || "Afficher en grand"} : ${label}`}
>
<span className="image-viewer-gallery-frame">
<img src={image.dataUrl} alt="" loading="lazy" decoding="async" />
</span>
<span className="image-viewer-gallery-caption">
<strong>{label}</strong>
<small>{position}</small>
</span>
</button>
);
})}
</div>
);
}

View file

@ -1,10 +1,11 @@
// Rôle : affiche les images en modale, avec support des annotations. // Rôle : affiche en modale une image annotable ou une collection navigable avec galerie.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx"; import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx";
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx"; import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
import { lockBodyScroll } from "../utils/bodyScrollLock.js"; import { lockBodyScroll } from "../utils/bodyScrollLock.js";
import { DRAWING_COLORS, DRAWING_WIDTHS } from "../utils/drawingOptions.js"; import { DRAWING_COLORS, DRAWING_WIDTHS } from "../utils/drawingOptions.js";
import { Icon } from "./Icon.jsx"; import { Icon } from "./Icon.jsx";
import { ImageGallery } from "./ImageGallery.jsx";
import { Tooltip } from "./Tooltip.jsx"; import { Tooltip } from "./Tooltip.jsx";
function clampPercent(value) { function clampPercent(value) {
@ -32,27 +33,49 @@ function openImageInNewTab(dataUrl) {
} }
} }
function formatPositionLabel(template, current, total) {
return String(template || "Image {current} sur {total}")
.replace("{current}", current)
.replace("{total}", total);
}
export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.crypto?.randomUUID?.() || `marker-${Date.now()}` }) { export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.crypto?.randomUUID?.() || `marker-${Date.now()}` }) {
useEffect(() => lockBodyScroll(), []); useEffect(() => lockBodyScroll(), []);
const canAnnotate = Boolean(image.canAnnotate && image.onChangeMarkers); const isCollection = image.kind === "collection" && Array.isArray(image.images) && image.images.length > 0;
const canDraw = Boolean(canAnnotate && image.onChangeDrawings); const collection = isCollection ? image.images : [image];
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(image.markers) ? image.markers : []); const initialIndex = isCollection ? Math.min(collection.length - 1, Math.max(0, Number(image.activeIndex) || 0)) : 0;
const [viewerDrawings, setViewerDrawings] = useState(() => image.drawings?.strokes ? image.drawings : { strokes: [] }); const initialView = isCollection && image.initialView === "gallery" ? "gallery" : "detail";
const [drawingMode, setDrawingMode] = useState(image.drawingMode === "temporary" ? "temporary" : "permanent"); const [activeIndex, setActiveIndex] = useState(initialIndex);
const [view, setView] = useState(initialView);
const [galleryFocusIndex, setGalleryFocusIndex] = useState(-1);
const [previousRequest, setPreviousRequest] = useState(image);
if (previousRequest !== image) {
setPreviousRequest(image);
setActiveIndex(initialIndex);
setView(initialView);
setGalleryFocusIndex(-1);
}
const activeImage = collection[activeIndex] || collection[0] || image;
const textContent = isCollection ? image.textContent || {} : {};
const canAnnotate = Boolean(activeImage.canAnnotate && activeImage.onChangeMarkers);
const canDraw = Boolean(canAnnotate && activeImage.onChangeDrawings);
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(activeImage.markers) ? activeImage.markers : []);
const [viewerDrawings, setViewerDrawings] = useState(() => activeImage.drawings?.strokes ? activeImage.drawings : { strokes: [] });
const [drawingMode, setDrawingMode] = useState(activeImage.drawingMode === "temporary" ? "temporary" : "permanent");
const [drawingActive, setDrawingActive] = useState(false); const [drawingActive, setDrawingActive] = useState(false);
const [eraseActive, setEraseActive] = useState(false); const [eraseActive, setEraseActive] = useState(false);
const [drawingColor, setDrawingColor] = useState("#f6c453"); const [drawingColor, setDrawingColor] = useState("#f6c453");
const [drawingWidth, setDrawingWidth] = useState(4); const [drawingWidth, setDrawingWidth] = useState(4);
const [previousImage, setPreviousImage] = useState(image); const [previousImage, setPreviousImage] = useState(activeImage);
const viewerRef = useRef(null); const viewerRef = useRef(null);
const mediaRef = useRef(null); const mediaRef = useRef(null);
const hasMarkers = viewerMarkers.length > 0; const hasMarkers = viewerMarkers.length > 0;
if (previousImage !== image) { if (previousImage !== activeImage) {
setPreviousImage(image); setPreviousImage(activeImage);
setViewerMarkers(Array.isArray(image.markers) ? image.markers : []); setViewerMarkers(Array.isArray(activeImage.markers) ? activeImage.markers : []);
setViewerDrawings(image.drawings?.strokes ? image.drawings : { strokes: [] }); setViewerDrawings(activeImage.drawings?.strokes ? activeImage.drawings : { strokes: [] });
setDrawingMode(image.drawingMode === "temporary" ? "temporary" : "permanent"); setDrawingMode(activeImage.drawingMode === "temporary" ? "temporary" : "permanent");
setDrawingActive(false); setDrawingActive(false);
setEraseActive(false); setEraseActive(false);
} }
@ -76,31 +99,43 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
observer.disconnect(); observer.disconnect();
window.removeEventListener("resize", syncImageHeight); window.removeEventListener("resize", syncImageHeight);
}; };
}, [image.dataUrl, canAnnotate]); }, [activeImage.dataUrl, canAnnotate, view]);
useEffect(() => { useEffect(() => {
const onKeyDown = (event) => { const onKeyDown = (event) => {
if (event.key === "Escape") onClose(); if (event.key === "Escape") {
onClose();
return;
}
if (!isCollection || view !== "detail") return;
if (event.key === "ArrowLeft" && activeIndex > 0) {
event.preventDefault();
setActiveIndex((index) => index - 1);
}
if (event.key === "ArrowRight" && activeIndex < collection.length - 1) {
event.preventDefault();
setActiveIndex((index) => index + 1);
}
}; };
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose]); }, [activeIndex, collection.length, isCollection, onClose, view]);
function updateViewerMarkers(nextMarkers) { function updateViewerMarkers(nextMarkers) {
setViewerMarkers(nextMarkers); setViewerMarkers(nextMarkers);
image.onChangeMarkers?.(nextMarkers); activeImage.onChangeMarkers?.(nextMarkers);
} }
function updateViewerDrawings(nextStrokes) { function updateViewerDrawings(nextStrokes) {
const nextDrawings = { strokes: nextStrokes }; const nextDrawings = { strokes: nextStrokes };
setViewerDrawings(nextDrawings); setViewerDrawings(nextDrawings);
image.onChangeDrawings?.(nextDrawings, drawingMode); activeImage.onChangeDrawings?.(nextDrawings, drawingMode);
} }
function toggleDrawingMode() { function toggleDrawingMode() {
const nextMode = drawingMode === "permanent" ? "temporary" : "permanent"; const nextMode = drawingMode === "permanent" ? "temporary" : "permanent";
setDrawingMode(nextMode); setDrawingMode(nextMode);
image.onChangeDrawingMode?.(nextMode, viewerDrawings); activeImage.onChangeDrawingMode?.(nextMode, viewerDrawings);
} }
function getMediaSurfaceSize() { function getMediaSurfaceSize() {
@ -126,6 +161,19 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
updateViewerMarkers(nextMarkers); updateViewerMarkers(nextMarkers);
} }
function openGallery() {
setGalleryFocusIndex(activeIndex);
setView("gallery");
}
function openGalleryImage(index) {
setGalleryFocusIndex(-1);
setActiveIndex(index);
setView("detail");
}
const positionLabel = formatPositionLabel(textContent.positionLabel, activeIndex + 1, collection.length);
function updateViewerMarker(markerId, updater) { function updateViewerMarker(markerId, updater) {
updateViewerMarkers(viewerMarkers.map((marker) => marker.id === markerId ? updater(marker) : marker)); updateViewerMarkers(viewerMarkers.map((marker) => marker.id === markerId ? updater(marker) : marker));
} }
@ -137,17 +185,32 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
return ( return (
<div className="image-viewer-root"> <div className="image-viewer-root">
<div className="image-viewer-backdrop" onClick={onClose} /> <div className="image-viewer-backdrop" onClick={onClose} />
<section ref={viewerRef} className="image-viewer" role="dialog" aria-modal="true" aria-label="Image"> <section ref={viewerRef} className={`image-viewer ${view === "gallery" ? "is-gallery" : ""}`} role="dialog" aria-modal="true" aria-label={view === "gallery" ? textContent.galleryTitle || "Galerie d'images" : "Image"}>
<header> <header>
{image.label && <strong className="image-viewer-title">{image.label}</strong>} <div className="image-viewer-heading">
{(view === "gallery" || isCollection || activeImage.label) && (
<strong className="image-viewer-title">{view === "gallery" ? textContent.galleryTitle || "Galerie d'images" : activeImage.label || textContent.imageAlt || "Image"}</strong>
)}
{isCollection && view === "detail" && <span className="image-viewer-position" aria-label={positionLabel} title={positionLabel} aria-live="polite">{activeIndex + 1} / {collection.length}</span>}
</div>
<div className="image-viewer-actions"> <div className="image-viewer-actions">
{!canAnnotate && !hasMarkers && ( {isCollection && view === "detail" && (
<button className="image-viewer-button nds-icon-button" data-size="sm" onClick={() => openImageInNewTab(image.dataUrl)} aria-label="Ouvrir l'image en taille réelle" title="Taille réelle"><Icon name="zoom" /></button> <button className="image-viewer-button nds-icon-button" data-size="sm" onClick={openGallery} aria-label={textContent.returnGalleryTitle || "Revenir à la galerie"} title={textContent.returnGalleryTitle || "Revenir à la galerie"}><Icon name="picture" /></button>
)}
{view === "detail" && !canAnnotate && !hasMarkers && (
<button className="image-viewer-button nds-icon-button" data-size="sm" onClick={() => openImageInNewTab(activeImage.dataUrl)} aria-label="Ouvrir l'image en taille réelle" title="Taille réelle"><Icon name="zoom" /></button>
)} )}
<button className="image-viewer-button nds-icon-button" data-size="sm" onClick={onClose} aria-label="Fermer" title="Fermer"><Icon name="close" /></button> <button className="image-viewer-button nds-icon-button" data-size="sm" onClick={onClose} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</div> </div>
</header> </header>
<div className={`image-viewer-body ${canAnnotate ? "has-annotation-side" : ""}`}> {view === "gallery" ? (
<ImageGallery images={collection} activeIndex={activeIndex} focusIndex={galleryFocusIndex} onSelect={openGalleryImage} textContent={textContent} />
) : <div className={`image-viewer-body ${canAnnotate ? "has-annotation-side" : ""} ${isCollection && collection.length > 1 ? "has-navigation" : ""}`}>
{isCollection && collection.length > 1 && (
<button className="image-viewer-nav image-viewer-nav-previous nds-icon-button" data-size="md" type="button" disabled={activeIndex === 0} onClick={() => setActiveIndex((index) => index - 1)} aria-label={textContent.previousImageTitle || "Image précédente"} title={textContent.previousImageTitle || "Image précédente"}>
<Icon name="chevron-down" />
</button>
)}
<div className={`image-viewer-media ${canAnnotate ? "can-annotate" : ""}`}> <div className={`image-viewer-media ${canAnnotate ? "can-annotate" : ""}`}>
<div <div
ref={mediaRef} ref={mediaRef}
@ -155,9 +218,9 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
onClick={addViewerMarker} onClick={addViewerMarker}
role={canAnnotate ? "button" : undefined} role={canAnnotate ? "button" : undefined}
tabIndex={canAnnotate ? 0 : undefined} tabIndex={canAnnotate ? 0 : undefined}
aria-label={canAnnotate ? image.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined} aria-label={canAnnotate ? activeImage.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined}
> >
<img src={image.dataUrl} alt={image.alt || "Image"} /> <img src={activeImage.dataUrl} alt={activeImage.alt || activeImage.label || textContent.imageAlt || "Image"} />
<DrawingOverlay <DrawingOverlay
active={drawingActive} active={drawingActive}
erase={eraseActive} erase={eraseActive}
@ -166,12 +229,12 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
width={drawingWidth} width={drawingWidth}
onChange={updateViewerDrawings} onChange={updateViewerDrawings}
getSurfaceSize={getMediaSurfaceSize} getSurfaceSize={getMediaSurfaceSize}
createStrokeId={image.createStrokeId} createStrokeId={activeImage.createStrokeId}
coordinateMode="percent" coordinateMode="percent"
/> />
{canDraw && ( {canDraw && (
<DrawingControls <DrawingControls
textContent={image} textContent={activeImage}
active={drawingActive} active={drawingActive}
onToggleActive={() => setDrawingActive((value) => !value)} onToggleActive={() => setDrawingActive((value) => !value)}
eraseActive={eraseActive} eraseActive={eraseActive}
@ -193,7 +256,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
as="span" as="span"
className="annotation-marker image-viewer-marker" className="annotation-marker image-viewer-marker"
key={marker.id || `${marker.x}-${marker.y}-${index}`} key={marker.id || `${marker.x}-${marker.y}-${index}`}
content={marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`} content={marker.label || `${activeImage.markerPrefix || "Marqueur"} ${index + 1}`}
position="top" position="top"
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }} style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
> >
@ -202,6 +265,11 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
))} ))}
</div> </div>
</div> </div>
{isCollection && collection.length > 1 && (
<button className="image-viewer-nav image-viewer-nav-next nds-icon-button" data-size="md" type="button" disabled={activeIndex === collection.length - 1} onClick={() => setActiveIndex((index) => index + 1)} aria-label={textContent.nextImageTitle || "Image suivante"} title={textContent.nextImageTitle || "Image suivante"}>
<Icon name="chevron-down" />
</button>
)}
{canAnnotate && ( {canAnnotate && (
<aside className="image-viewer-side"> <aside className="image-viewer-side">
<strong>{viewerMarkers.length} {viewerMarkers.length > 1 ? "marqueurs" : "marqueur"}</strong> <strong>{viewerMarkers.length} {viewerMarkers.length > 1 ? "marqueurs" : "marqueur"}</strong>
@ -215,11 +283,11 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
data-size="sm" data-size="sm"
type="text" type="text"
value={marker.label} value={marker.label}
placeholder={image.markerPlaceholder || "Libellé du marqueur"} placeholder={activeImage.markerPlaceholder || "Libellé du marqueur"}
onChange={(event) => updateViewerMarker(marker.id, (item) => ({ ...item, label: event.target.value }))} onChange={(event) => updateViewerMarker(marker.id, (item) => ({ ...item, label: event.target.value }))}
aria-label={`Libellé du marqueur ${index + 1}`} aria-label={`Libellé du marqueur ${index + 1}`}
/> />
<button className="tool-split-action-button nds-icon-button" data-size="sm" data-variant="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 nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={() => removeViewerMarker(marker.id)} aria-label={`${activeImage.deleteMarkerTitle || "Supprimer"} ${marker.label || `${activeImage.markerPrefix || "Marqueur"} ${index + 1}`}`} title={activeImage.deleteMarkerTitle || "Supprimer"}>
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</li> </li>
@ -230,7 +298,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
)} )}
</aside> </aside>
)} )}
</div> </div>}
</section> </section>
</div> </div>
); );

View file

@ -3,12 +3,12 @@ import { Icon } from "./Icon.jsx";
import { ImportButton } from "./ImportButton.jsx"; import { ImportButton } from "./ImportButton.jsx";
import { Tooltip } from "./Tooltip.jsx"; import { Tooltip } from "./Tooltip.jsx";
export function Shell({ route, content, games, toolboxes, links, drawerGameId = "", actions, children }) { export function Shell({ route, content, games, drawerGameId = "", actions, children }) {
const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : ""; const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : "";
const game = games.find((item) => item.id === gameId); const game = games.find((item) => item.id === gameId);
const linkedToolbox = game ? toolboxes.find((item) => item.id === links[game.id]) : null;
const topbarLabel = route === "/design-system" && import.meta.env.DEV ? "Design system" : route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : route === "/library" ? content.topbar.library : content.topbar.dashboard; const topbarLabel = route === "/design-system" && import.meta.env.DEV ? "Design system" : route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : route === "/library" ? content.topbar.library : content.topbar.dashboard;
const toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox"; const toolboxOpen = Boolean(game && drawerGameId === game.id);
const toolboxLabel = toolboxOpen ? "Masquer la toolbox" : "Afficher la toolbox";
const compactSidebar = Boolean(drawerGameId); const compactSidebar = Boolean(drawerGameId);
return ( return (
@ -48,15 +48,16 @@ export function Shell({ route, content, games, toolboxes, links, drawerGameId =
{game && ( {game && (
<div className="topbar-actions"> <div className="topbar-actions">
<button <button
className={`drawer-button toolbox-icon-button nds-icon-button ${drawerGameId === game.id ? "active" : ""}`} className={`drawer-button toolbox-button nds-button ${toolboxOpen ? "active" : ""}`}
data-size="sm" data-size="sm"
data-variant="primary" data-variant="primary"
onClick={() => actions.setDrawerGameId(drawerGameId === game.id ? "" : game.id)} onClick={() => actions.setDrawerGameId(toolboxOpen ? "" : game.id)}
aria-label={toolboxLabel} aria-label={toolboxLabel}
aria-pressed={drawerGameId === game.id} aria-pressed={toolboxOpen}
title={toolboxLabel} title={toolboxLabel}
> >
<img src="/static/icons/toolbox.svg" alt="" aria-hidden="true" /> <img src="/static/icons/toolbox.svg" alt="" aria-hidden="true" />
<span>{toolboxLabel}</span>
</button> </button>
</div> </div>
)} )}

View file

@ -30,6 +30,21 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
</div>; </div>;
} }
export function GuideChecklistPageControls({ checklist }) {
if (!checklist.selected) return null;
const { content, hideCompleted } = checklist;
return (
<aside className="guide-checklist-page-controls nds-tool-panel" aria-label={content.label}>
<button className={`nds-switch ${hideCompleted ? "active" : ""}`} data-size="sm" type="button" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}>
<Icon name="hide" />
<span>{content.hideCompleted}</span>
<i aria-hidden="true" />
</button>
<button className="nds-button" data-size="xs" data-variant="secondary" type="button" onClick={() => checklist.associate("")}>{content.unlink}</button>
</aside>
);
}
export function GuideChecklistMark({ completed, label }) { export function GuideChecklistMark({ completed, label }) {
return completed ? <span className="guide-checklist-mark nds-badge" data-variant="neutral" role="img" aria-label={label} title={label}>✓</span> : null; return completed ? <span className="guide-checklist-mark nds-badge" data-variant="neutral" role="img" aria-label={label} title={label}>✓</span> : null;
} }

View file

@ -1,6 +1,7 @@
// Rôle : assemble l'accueil Aniimo et la grille filtrable de ses variantes. // Rôle : assemble l'accueil Aniimo et la grille filtrable de ses variantes.
import { useMemo } from "react"; import { useMemo } from "react";
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx"; import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
import { GuideChecklistPageControls } from "../GuideChecklistControls.jsx";
import { useGuideChecklist } from "../useGuideChecklist.js"; import { useGuideChecklist } from "../useGuideChecklist.js";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx"; import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
@ -31,7 +32,10 @@ function AniimoListing({ game, aniimo, filters, setFilters, content }) {
</div> </div>
<p>{content.description}</p> <p>{content.description}</p>
</div> </div>
<div className="actions game-heading-actions">
<GuideChecklistPageControls checklist={checklist} />
<button className="nds-switch" type="button" aria-pressed={active.grouped} onClick={() => update({ ...active, grouped: !active.grouped })}><Icon name="multiple" /><span>{content.groupVariants}</span><i aria-hidden="true" /></button> <button className="nds-switch" type="button" aria-pressed={active.grouped} onClick={() => update({ ...active, grouped: !active.grouped })}><Icon name="multiple" /><span>{content.groupVariants}</span><i aria-hidden="true" /></button>
</div>
</section> </section>
<section className="game-layout aniimo-layout"> <section className="game-layout aniimo-layout">
<aside className="game-filters"><AniimoFilters filters={active} onChange={update} options={aniimo.filterOptions} t={t} content={content} /></aside> <aside className="game-filters"><AniimoFilters filters={active} onChange={update} options={aniimo.filterOptions} t={t} content={content} /></aside>

View file

@ -2,6 +2,7 @@
import { useGuideChecklist } from "../useGuideChecklist.js"; import { useGuideChecklist } from "../useGuideChecklist.js";
import { useMemo } from "react"; import { useMemo } from "react";
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx"; import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
import { GuideChecklistPageControls } from "../GuideChecklistControls.jsx";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx"; import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
import { Diablo4AffixCard } from "./Diablo4AffixCard.jsx"; import { Diablo4AffixCard } from "./Diablo4AffixCard.jsx";
import { Diablo4Filters } from "./Diablo4Filters.jsx"; import { Diablo4Filters } from "./Diablo4Filters.jsx";
@ -30,6 +31,9 @@ export function Diablo4Listing({ game, diablo4, filters, setFilters }) {
</div> </div>
<p>Filtrez les affixes par nom et catégories pour retrouver rapidement les statistiques utiles à votre build.</p> <p>Filtrez les affixes par nom et catégories pour retrouver rapidement les statistiques utiles à votre build.</p>
</div> </div>
<div className="actions game-heading-actions">
<GuideChecklistPageControls checklist={checklist} />
</div>
</section> </section>
<section className="game-layout diablo4-layout" data-game-category="diablo4-affixes"> <section className="game-layout diablo4-layout" data-game-category="diablo4-affixes">
<aside className="game-filters"> <aside className="game-filters">

View file

@ -1,5 +1,5 @@
// Rôle : reconnaît les lignes de checklist et valide les associations temporaires des guides. // Rôle : reconnaît les lignes de checklist et valide les associations persistantes des guides.
export const GUIDE_CHECKLIST_SESSION_KEY = "sokkog:guide-checklists"; export const GUIDE_CHECKLIST_STORAGE_KEY = "sokkog:guide-checklists";
const GUIDES = new Set(["aniimo/aniimos", "mhwilds/monsters", "mhwilds/endemic", "diablo4/affixes"]); const GUIDES = new Set(["aniimo/aniimos", "mhwilds/monsters", "mhwilds/endemic", "diablo4/affixes"]);
export function normalizeGuideLabel(value) { export function normalizeGuideLabel(value) {

View file

@ -5,6 +5,7 @@ import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
import { EndemicCard } from "./cards/EndemicCard.jsx"; import { EndemicCard } from "./cards/EndemicCard.jsx";
import { MonsterCard } from "./cards/MonsterCard.jsx"; import { MonsterCard } from "./cards/MonsterCard.jsx";
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx"; import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
import { GuideChecklistPageControls } from "../GuideChecklistControls.jsx";
import { MhwildsFilters } from "./MhwildsFilters.jsx"; import { MhwildsFilters } from "./MhwildsFilters.jsx";
import { getFilteredMhwildsItems, getMhwildsFilterKey, getUniqueConditionValues } from "./utils.js"; import { getFilteredMhwildsItems, getMhwildsFilterKey, getUniqueConditionValues } from "./utils.js";
@ -32,10 +33,13 @@ export function MhwildsListing({ category, game, mhwilds, filters, setFilters, t
</div> </div>
<p>{isMonsters ? "Filtrez les monstres par nom et faiblesses, puis consultez leurs dégâts détaillés." : "Filtrez la faune par nom et zones d'apparition."}</p> <p>{isMonsters ? "Filtrez les monstres par nom et faiblesses, puis consultez leurs dégâts détaillés." : "Filtrez la faune par nom et zones d'apparition."}</p>
</div> </div>
<div className="actions"> <div className="actions game-heading-actions">
<GuideChecklistPageControls checklist={checklist} />
<div className="game-heading-navigation">
<a className="nds-button" data-size="sm" data-variant={isMonsters ? "primary" : "secondary"} href="#/games/mhwilds/monsters">Monstres</a> <a className="nds-button" data-size="sm" data-variant={isMonsters ? "primary" : "secondary"} href="#/games/mhwilds/monsters">Monstres</a>
<a className="nds-button" data-size="sm" data-variant={!isMonsters ? "primary" : "secondary"} href="#/games/mhwilds/endemic">Faune</a> <a className="nds-button" data-size="sm" data-variant={!isMonsters ? "primary" : "secondary"} href="#/games/mhwilds/endemic">Faune</a>
</div> </div>
</div>
</section> </section>
<section className="game-layout" data-mhwilds-category={category}> <section className="game-layout" data-mhwilds-category={category}>
<aside className="game-filters"> <aside className="game-filters">

View file

@ -12,13 +12,13 @@ export function useGuideChecklistPanel(checklist, module) {
} }
export function useGuideChecklist(requestedGuideId) { export function useGuideChecklist(requestedGuideId) {
const { toolboxes, links, moduleData, guideChecklistSession, actions, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext); const { toolboxes, links, moduleData, guideChecklistStorage, actions, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
const guideId = requestedGuideId || route.replace(/^\/games\//, ""); const guideId = requestedGuideId || route.replace(/^\/games\//, "");
const entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]); const entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]);
const gameId = guideId.split("/")[0]; const gameId = guideId.split("/")[0];
const content = siteContent.guideChecklist; const content = siteContent.guideChecklist;
const toolbox = toolboxes.find((item) => item.id === links[gameId]); const toolbox = toolboxes.find((item) => item.id === links[gameId]);
const association = guideChecklistSession.associations[guideId]; const association = guideChecklistStorage.associations[guideId];
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({ const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({
id: module.id, id: module.id,
...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`]) ...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`])
@ -37,7 +37,7 @@ export function useGuideChecklist(requestedGuideId) {
content, toolbox, selected, hideCompleted, isCompleted, canToggle, toggleCompleted, guideId, available: entries !== null, content, toolbox, selected, hideCompleted, isCompleted, canToggle, toggleCompleted, guideId, available: entries !== null,
lists: lists.filter((list) => list.count || list.id === selected?.id), lists: lists.filter((list) => list.count || list.id === selected?.id),
isVisible: (key) => !hideCompleted || !isCompleted(key), isVisible: (key) => !hideCompleted || !isCompleted(key),
associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null), associate: (moduleId) => guideChecklistStorage.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),
toggleHidden: () => guideChecklistSession.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted }) toggleHidden: () => guideChecklistStorage.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted })
}; };
} }

View file

@ -190,6 +190,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
const toolboxGameCover = getGameCardCover(toolboxGame); const toolboxGameCover = getGameCardCover(toolboxGame);
const moduleText = siteContent.toolboxes.modules; const moduleText = siteContent.toolboxes.modules;
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId)); const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
const [pendingModuleId, setPendingModuleId] = useState("");
const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId); const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId);
const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested }); const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested });
// Consommer la demande de lancement ne doit pas interrompre la visite en cours. // Consommer la demande de lancement ne doit pas interrompre la visite en cours.
@ -245,6 +246,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
modules, modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id) moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
}); });
setPendingModuleId(module.id);
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type); if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
} }
@ -257,6 +259,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
modules, modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId) moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId)
}); });
setPendingModuleId(moduleId);
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation"); updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
} }
@ -320,6 +323,8 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
toolbox={toolbox} toolbox={toolbox}
moduleColumns={moduleColumns} moduleColumns={moduleColumns}
context={moduleContext} context={moduleContext}
scrollToModuleId={pendingModuleId}
onModuleScrolled={() => setPendingModuleId("")}
onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })} onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })}
onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })} onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })}
onModuleOrderChange={(moduleOrder) => updateToolbox({ ...toolbox, moduleOrder })} onModuleOrderChange={(moduleOrder) => updateToolbox({ ...toolbox, moduleOrder })}

View file

@ -4,6 +4,7 @@ import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompl
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js"; import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
import { ChecklistSection } from "./ChecklistParts.jsx"; import { ChecklistSection } from "./ChecklistParts.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { import {
appendChecklistItems, appendChecklistItems,
@ -127,8 +128,20 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
<TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} /> <TextExchangeActions type="checklist" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "checklist")} />
</div> </div>
)} )}
{namedSections.length > 0 && (
<div className="checklist-toolbar"> <div className="checklist-toolbar">
<button
className={`checklist-hide-completed-toggle nds-switch ${data.hideCompletedItems ? "active" : ""}`}
data-size="lg"
type="button"
onClick={() => save(data.sections, { hideCompletedItems: !data.hideCompletedItems })}
aria-pressed={data.hideCompletedItems}
aria-label={textContent.hideCompletedItemsTitle || "Masquer les éléments terminés"}
title={textContent.hideCompletedItemsTitle || "Masquer les éléments terminés"}
>
<Icon name={data.hideCompletedItems ? "eye-closed" : "eye-open"} />
<i aria-hidden="true" />
</button>
{namedSections.length > 0 && (
<div className="checklist-complete-mode-group nds-radio-button-group" data-size="md" role="radiogroup" aria-label={textContent.completedSectionsModeTitle || "Affichage des catégories terminées"}> <div className="checklist-complete-mode-group nds-radio-button-group" data-size="md" role="radiogroup" aria-label={textContent.completedSectionsModeTitle || "Affichage des catégories terminées"}>
{[ {[
{ mode: "visible", label: textContent.completedSectionsVisibleLabel || "Visible", icon: "eye-open" }, { mode: "visible", label: textContent.completedSectionsVisibleLabel || "Visible", icon: "eye-open" },
@ -149,8 +162,9 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
</button> </button>
))} ))}
</div> </div>
</div>
)} )}
</div>
{!data.sections.length && <ModuleEmptyMessage>{textContent.emptyItems || "Aucun élément dans cette checklist."}</ModuleEmptyMessage>}
<ul className="checklist"> <ul className="checklist">
{visibleSections.map((section, index) => { {visibleSections.map((section, index) => {
const isLastSection = index === visibleSections.length - 1; const isLastSection = index === visibleSections.length - 1;

View file

@ -15,6 +15,9 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections); const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
const isAutoCollapsed = sectionComplete && hideWhenComplete; const isAutoCollapsed = sectionComplete && hideWhenComplete;
const isCollapsed = Boolean(section.collapsed) || isAutoCollapsed; const isCollapsed = Boolean(section.collapsed) || isAutoCollapsed;
const visibleItems = data.hideCompletedItems
? section.items.filter((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) < item.qtyTarget)
: section.items;
const textContent = context.moduleText?.checklist || {}; const textContent = context.moduleText?.checklist || {};
const className = [ const className = [
"checklist-section", "checklist-section",
@ -106,7 +109,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
)} )}
{!isCollapsed && ( {!isCollapsed && (
<ul className="checklist-section-items"> <ul className="checklist-section-items">
{section.items.map((item) => ( {visibleItems.map((item) => (
<ChecklistItem <ChecklistItem
key={item.id} key={item.id}
item={item} item={item}

View file

@ -3,6 +3,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) { export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] })); const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] }));
@ -57,6 +58,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing, textExch
<TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} /> <TextExchangeActions type="counters" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "counters")} />
</div> </div>
)} )}
{!data.counters.length && <ModuleEmptyMessage>{textContent.emptyCounters || "Aucun compteur."}</ModuleEmptyMessage>}
<div className="counters-grid"> <div className="counters-grid">
{data.counters.map((counter) => { {data.counters.map((counter) => {
const className = [ const className = [

View file

@ -1,7 +1,8 @@
// Rôle : fournit l'outil images avec import fichier/texte, collage, libellés et annotation rapide. // Rôle : fournit l'outil images avec import, galerie, libellés et annotation rapide.
import { useState } from "react"; import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
export function ImagesModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) { export function ImagesModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
@ -41,6 +42,16 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
}); });
} }
function openCollection(initialView, activeIndex = 0) {
context.setImage({
kind: "collection",
images: data.images,
activeIndex,
initialView,
textContent
});
}
return ( return (
<> <>
{editing && ( {editing && (
@ -93,8 +104,17 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
<TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} /> <TextExchangeActions type="images" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "images")} />
</div> </div>
)} )}
{data.images.length > 0 && (
<div className="images-toolbar">
<button className="nds-button" data-size="sm" type="button" onClick={() => openCollection("gallery")}>
<Icon name="picture" />
<span>{textContent.galleryButton || "Galerie"}</span>
</button>
</div>
)}
{!data.images.length && <ModuleEmptyMessage>{textContent.emptyImages || "Aucune image."}</ModuleEmptyMessage>}
<div className="images"> <div className="images">
{data.images.map((image) => { {data.images.map((image, index) => {
const className = [ const className = [
isItemDragging(image.id) ? "is-dragging" : "", isItemDragging(image.id) ? "is-dragging" : "",
isItemDropTarget(image.id) ? "is-drop-target" : "", isItemDropTarget(image.id) ? "is-drop-target" : "",
@ -114,7 +134,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
> >
<Icon name="drag" /> <Icon name="drag" />
</button> </button>
<button className="image-preview" onClick={() => context.setImage(image)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}> <button className="image-preview" onClick={() => openCollection("detail", index)} aria-label={textContent.previewAriaLabel || "Agrandir l'image"}>
<img src={image.dataUrl} alt={textContent.imageAlt || "Image"} /> <img src={image.dataUrl} alt={textContent.imageAlt || "Image"} />
</button> </button>
{editing ? ( {editing ? (

View file

@ -6,6 +6,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
function getLinkCategory(link) { function getLinkCategory(link) {
return String(link?.category || "").trim(); return String(link?.category || "").trim();
@ -173,6 +174,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
<TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} /> <TextExchangeActions type="links" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "links")} />
</div> </div>
)} )}
{!data.links.length && <ModuleEmptyMessage>{textContent.emptyLinks || "Aucun lien."}</ModuleEmptyMessage>}
<div className="links-category-list"> <div className="links-category-list">
{getLinkEntries(data).map((entry) => { {getLinkEntries(data).map((entry) => {
if (entry.type === "group") { if (entry.type === "group") {

View file

@ -0,0 +1,4 @@
// Rôle : rend le message compact commun aux outils sans contenu.
export function ModuleEmptyMessage({ children }) {
return <p className="toolbox-module-empty">{children}</p>;
}

View file

@ -1,5 +1,5 @@
// Rôle : rend l'en-tête, les contrôles et le contenu repliable d'un outil toolbox. // Rôle : rend l'en-tête, les contrôles et le contenu repliable d'un outil toolbox.
import { Suspense, useState } from "react"; import { Suspense, useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx"; import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx";
@ -8,12 +8,13 @@ import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
import { supportsTextExchange } from "./textImport.js"; import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx"; import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) { export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad; const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component; const Component = definition.Component;
const label = definition.label || module.type; const label = definition.label || module.type;
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false); const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const moduleRef = useRef(null);
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module); const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
const hasAddPanel = definition.editable && definition.addable !== false; const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type); const hasTextExchange = supportsTextExchange(module.type);
@ -40,6 +41,15 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
textExchangeOpen textExchangeOpen
}; };
useEffect(() => {
if (!scrollIntoView) return undefined;
const frame = window.requestAnimationFrame(() => {
moduleRef.current?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
onScrolledIntoView?.();
});
return () => window.cancelAnimationFrame(frame);
}, [onScrolledIntoView, scrollIntoView]);
function toggleCollapsed() { function toggleCollapsed() {
if (!collapsed) { if (!collapsed) {
setEditing(false); setEditing(false);
@ -58,8 +68,10 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
return ( return (
<article <article
ref={moduleRef}
className={className} className={className}
data-module-type={module.type} data-module-type={module.type}
data-module-id={module.id}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })} {...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
> >
<header> <header>

View file

@ -53,7 +53,7 @@ function moveModuleInTwoColumnOrder(order, operation) {
return { ...order, two: columns }; return { ...order, two: columns };
} }
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) { export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]); const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]); const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
const columns = useMemo(() => [ const columns = useMemo(() => [
@ -101,6 +101,8 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUp
context={context} context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }} reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup="" itemGroup=""
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}
onRename={onRename} onRename={onRename}
onUpdateModule={onUpdateModule} onUpdateModule={onUpdateModule}
onDelete={onDelete} onDelete={onDelete}
@ -128,6 +130,8 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUp
context={context} context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }} reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup={MODULE_COLUMN_IDS[index]} itemGroup={MODULE_COLUMN_IDS[index]}
scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled}
onRename={onRename} onRename={onRename}
onUpdateModule={onUpdateModule} onUpdateModule={onUpdateModule}
onDelete={onDelete} onDelete={onDelete}

View file

@ -30,15 +30,16 @@ function normalizeChecklistSection(section, fallbackTitle = "") {
} }
export function normalizeChecklistData(data) { export function normalizeChecklistData(data) {
const hideCompletedItems = Boolean(data?.hideCompletedItems);
const hideCompletedSections = Boolean(data?.hideCompletedSections); const hideCompletedSections = Boolean(data?.hideCompletedSections);
const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully); const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully);
const sections = Array.isArray(data?.sections) const sections = Array.isArray(data?.sections)
? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length) ? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length)
: []; : [];
const simpleItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label); const simpleItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label);
if (!sections.length && simpleItems.length) return { hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: simpleItems }], items: simpleItems }; if (!sections.length && simpleItems.length) return { hideCompletedItems, hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: simpleItems }], items: simpleItems };
const items = sections.flatMap((section) => section.items); const items = sections.flatMap((section) => section.items);
return { hideCompletedSections, hideCompletedSectionsFully, sections, items }; return { hideCompletedItems, hideCompletedSections, hideCompletedSectionsFully, sections, items };
} }
function compactChecklistItemForStorage(item) { function compactChecklistItemForStorage(item) {
@ -64,11 +65,12 @@ export function compactChecklistSectionForStorage(section) {
export function compactChecklistDataForStorage(value) { export function compactChecklistDataForStorage(value) {
const normalized = normalizeChecklistData(value); const normalized = normalizeChecklistData(value);
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length); const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length);
if (!sections.length) return null;
const settings = { const settings = {
...(normalized.hideCompletedItems ? { hideCompletedItems: true } : {}),
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}), ...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),
...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {}) ...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {})
}; };
if (!sections.length) return Object.keys(settings).length ? settings : null;
if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items }; if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items };
return { ...settings, sections }; return { ...settings, sections };
} }

View file

@ -191,16 +191,19 @@ function remapModuleDataForExport(type, data, nextId) {
const compact = compactModuleDataForStorage(type, data); const compact = compactModuleDataForStorage(type, data);
if (!compact) return null; if (!compact) return null;
if (type === "checklist") { if (type === "checklist") {
const { sections, items, ...settings } = compact;
if (compact.sections) { if (compact.sections) {
return { return {
sections: compact.sections.map((section) => ({ ...settings,
sections: sections.map((section) => ({
...section, ...section,
id: nextId("section"), id: nextId("section"),
items: section.items.map((item) => ({ ...item, id: nextId("item") })) items: section.items.map((item) => ({ ...item, id: nextId("item") }))
})) }))
}; };
} }
return { items: compact.items.map((item) => ({ ...item, id: nextId("item") })) }; if (items) return { ...settings, items: items.map((item) => ({ ...item, id: nextId("item") })) };
return settings;
} }
if (type === "images") { if (type === "images") {
return { images: compact.images.map((image) => ({ ...image, id: nextId("image") })) }; return { images: compact.images.map((image) => ({ ...image, id: nextId("image") })) };

View file

@ -1,25 +1,27 @@
// Rôle : conserve les liaisons guide/checklist pendant la session et retire les références supprimées. // Rôle : persiste les liaisons guide/checklist et retire les références devenues invalides.
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { GUIDE_CHECKLIST_SESSION_KEY, validGuideAssociations } from "../features/games/guideChecklist.js"; import { GUIDE_CHECKLIST_STORAGE_KEY, validGuideAssociations } from "../features/games/guideChecklist.js";
function readSession() { function readStoredAssociations() {
try { try {
const value = JSON.parse(sessionStorage.getItem(GUIDE_CHECKLIST_SESSION_KEY) || "{}"); const value = JSON.parse(localStorage.getItem(GUIDE_CHECKLIST_STORAGE_KEY) || "{}");
return value && typeof value === "object" && !Array.isArray(value) ? value : {}; return value && typeof value === "object" && !Array.isArray(value) ? value : {};
} catch { return {}; } } catch {
return {};
}
} }
export function useGuideChecklistSession({ toolboxes, links, ready }) { export function useGuideChecklistStorage({ toolboxes, links, ready }) {
const [associations, setAssociations] = useState(readSession); const [associations, setAssociations] = useState(readStoredAssociations);
const valid = useMemo(() => ready ? validGuideAssociations(associations, toolboxes, links) : associations, [associations, toolboxes, links, ready]); const valid = useMemo(() => ready ? validGuideAssociations(associations, toolboxes, links) : associations, [associations, toolboxes, links, ready]);
if (ready && Object.keys(valid).length !== Object.keys(associations).length) setAssociations(valid); if (ready && Object.keys(valid).length !== Object.keys(associations).length) setAssociations(valid);
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
try { try {
if (Object.keys(valid).length) sessionStorage.setItem(GUIDE_CHECKLIST_SESSION_KEY, JSON.stringify(valid)); if (Object.keys(valid).length) localStorage.setItem(GUIDE_CHECKLIST_STORAGE_KEY, JSON.stringify(valid));
else sessionStorage.removeItem(GUIDE_CHECKLIST_SESSION_KEY); else localStorage.removeItem(GUIDE_CHECKLIST_STORAGE_KEY);
} catch { /* La session reste disponible en mémoire si le navigateur refuse le stockage. */ } } catch { /* Les liaisons restent disponibles en mémoire si le stockage local est indisponible. */ }
}, [ready, valid]); }, [ready, valid]);
function setAssociation(guideId, association) { function setAssociation(guideId, association) {
@ -30,5 +32,6 @@ export function useGuideChecklistSession({ toolboxes, links, ready }) {
return next; return next;
}); });
} }
return { associations: valid, setAssociation }; return { associations: valid, setAssociation };
} }

View file

@ -11,7 +11,7 @@ import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedTool
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js"; import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
import { useAppData } from "./hooks/useAppData.js"; import { useAppData } from "./hooks/useAppData.js";
import { GuideChecklistContext } from "./features/games/useGuideChecklist.js"; import { GuideChecklistContext } from "./features/games/useGuideChecklist.js";
import { useGuideChecklistSession } from "./hooks/useGuideChecklistSession.js"; import { useGuideChecklistStorage } from "./hooks/useGuideChecklistStorage.js";
import { useGameFilters } from "./hooks/useGameFilters.js"; import { useGameFilters } from "./hooks/useGameFilters.js";
import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js"; import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
import { RouteContent } from "./router/RouteContent.jsx"; import { RouteContent } from "./router/RouteContent.jsx";
@ -39,6 +39,7 @@ function App() {
const [autoBackupModalOpen, setAutoBackupModalOpen] = useState(false); const [autoBackupModalOpen, setAutoBackupModalOpen] = useState(false);
const [linkModalGameId, setLinkModalGameId] = useState(""); const [linkModalGameId, setLinkModalGameId] = useState("");
const [drawerGameId, setDrawerGameId] = useState(""); const [drawerGameId, setDrawerGameId] = useState("");
const [drawerRoute, setDrawerRoute] = useState(route);
const [image, setImage] = useState(null); const [image, setImage] = useState(null);
const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState(""); const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState("");
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
@ -48,7 +49,13 @@ function App() {
const store = useIndexedToolboxes(handleStorageError); const store = useIndexedToolboxes(handleStorageError);
const autoBackup = useAutoBackup(store); const autoBackup = useAutoBackup(store);
const { backupNeeded, requestPermission: requestBackupPermission } = autoBackup; const { backupNeeded, requestPermission: requestBackupPermission } = autoBackup;
const guideChecklistSession = useGuideChecklistSession(store); const guideChecklistStorage = useGuideChecklistStorage(store);
if (drawerRoute !== route) {
setDrawerRoute(route);
const activeGameId = route.match(/^\/games\/([^/]+)/)?.[1] || "";
if (activeGameId !== drawerGameId) setDrawerGameId("");
}
const notify = useCallback((message, options = {}) => { const notify = useCallback((message, options = {}) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
@ -154,7 +161,7 @@ function App() {
} }
return ( return (
<GuideChecklistContext.Provider value={{ route, aniimo, mhwilds, diablo4, siteContent, toolboxes: store.toolboxes, links: store.links, moduleData: store.moduleData, guideChecklistSession, actions }}> <GuideChecklistContext.Provider value={{ route, aniimo, mhwilds, diablo4, siteContent, toolboxes: store.toolboxes, links: store.links, moduleData: store.moduleData, guideChecklistStorage, actions }}>
<Shell route={route} content={siteContent} games={games} toolboxes={store.toolboxes} links={store.links} drawerGameId={drawerGameId} actions={actions}> <Shell route={route} content={siteContent} games={games} toolboxes={store.toolboxes} links={store.links} drawerGameId={drawerGameId} actions={actions}>
<AppErrorBoundary route={route}> <AppErrorBoundary route={route}>
<RouteContent <RouteContent

View file

@ -91,6 +91,16 @@
width: 100%; width: 100%;
} }
.game-heading-actions,
.guide-checklist-page-controls {
width: 100%;
align-items: stretch;
}
.guide-checklist-page-controls {
justify-content: space-between;
}
.home-onboarding-steps-action { .home-onboarding-steps-action {
padding-left: 0; padding-left: 0;
} }
@ -236,6 +246,34 @@
height: min(280px, 32vh); height: min(280px, 32vh);
} }
.image-viewer-root {
padding: 8px;
}
.image-viewer {
width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
padding: var(--space-3);
}
.image-viewer.is-gallery {
height: calc(100vh - 16px);
}
.image-viewer-body.has-navigation {
gap: 6px;
}
.image-viewer-gallery {
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
gap: var(--space-3);
}
.image-viewer-gallery-frame {
min-height: 180px;
height: min(42vh, 260px);
}
.module-column { .module-column {
display: contents; display: contents;
} }

View file

@ -407,13 +407,14 @@
gap: 10px; gap: 10px;
} }
.toolbox-icon-button { .toolbox-button {
padding: 0; white-space: nowrap;
} }
.toolbox-icon-button img { .toolbox-button img {
width: 21px; width: 21px;
height: 21px; height: 21px;
flex: 0 0 auto;
object-fit: contain; object-fit: contain;
} }

View file

@ -173,9 +173,9 @@
} }
.nds-filter-search { .nds-filter-search {
flex: 0 1 280px; flex: 0 1 260px;
width: min(100%, 280px); width: min(100%, 260px);
max-width: 280px; max-width: 260px;
min-width: min(100%, 190px); min-width: min(100%, 190px);
} }

View file

@ -10,6 +10,28 @@
.guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-2); padding-top: var(--space-2); } .guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-2); padding-top: var(--space-2); }
} }
.module-guide-button[data-linked] .ui-icon { color: var(--color-accent-gold); } .module-guide-button[data-linked] .ui-icon { color: var(--color-accent-gold); }
.game-heading-actions {
display: flex;
align-self: stretch;
align-items: flex-end;
flex-direction: column;
justify-content: space-between;
}
.guide-checklist-page-controls {
display: flex;
align-self: flex-end;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
width: fit-content;
padding: 8px;
}
.guide-checklist-page-controls .nds-switch { max-width: 100%; }
.game-heading-navigation {
display: flex;
align-items: center;
gap: var(--space-2);
}
.guide-checklist-checkbox { .guide-checklist-checkbox {
position: absolute; position: absolute;
right: 10px; right: 10px;

View file

@ -1,4 +1,4 @@
// Rôle : stylise le viewer d'image et son panneau d'annotation. // Rôle : stylise la visionneuse d'image, sa galerie et son panneau d'annotation.
.image-viewer-root { .image-viewer-root {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -38,6 +38,13 @@
gap: var(--space-2); gap: var(--space-2);
} }
.image-viewer-heading {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.image-viewer-title { .image-viewer-title {
overflow: hidden; overflow: hidden;
color: var(--color-text); color: var(--color-text);
@ -46,6 +53,17 @@
white-space: nowrap; white-space: nowrap;
} }
.image-viewer-position {
flex: 0 0 auto;
padding: 3px 9px;
border: 1px solid rgba(246, 196, 83, 0.22);
border-radius: var(--radius-pill);
background: rgba(246, 196, 83, 0.08);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
}
.image-viewer-actions { .image-viewer-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@ -69,6 +87,24 @@
justify-items: stretch; justify-items: stretch;
} }
.image-viewer-body.has-navigation {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-3);
}
.image-viewer-nav {
align-self: center;
}
.image-viewer-nav .ui-icon {
transform: rotate(90deg);
}
.image-viewer-nav-next .ui-icon {
transform: rotate(-90deg);
}
.image-viewer-media { .image-viewer-media {
display: flex; display: flex;
min-width: 0; min-width: 0;
@ -158,3 +194,73 @@
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 4px 4px 4px 0; padding: 4px 4px 4px 0;
} }
.image-viewer.is-gallery {
height: min(860px, calc(100vh - 48px));
grid-template-rows: auto minmax(0, 1fr);
}
.image-viewer-gallery {
display: grid;
min-height: 0;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
align-content: start;
gap: var(--space-4);
overflow-y: auto;
overscroll-behavior: contain;
padding: 2px 6px 2px 2px;
}
.image-viewer-gallery-item {
display: grid;
min-width: 0;
gap: 10px;
padding: 10px;
color: var(--color-text);
text-align: left;
cursor: pointer;
}
.image-viewer-gallery-item.is-active {
border-color: rgba(246, 196, 83, 0.52);
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.16);
}
.image-viewer-gallery-frame {
display: grid;
min-height: 200px;
height: clamp(200px, 24vh, 280px);
place-items: center;
overflow: hidden;
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.72);
}
.image-viewer-gallery-frame img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.image-viewer-gallery-caption {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
}
.image-viewer-gallery-caption strong {
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.image-viewer-gallery-caption small {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 800;
}

View file

@ -11,10 +11,16 @@
.checklist-toolbar { .checklist-toolbar {
display: flex; display: flex;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) var(--space-4) 0; padding: var(--space-4) var(--space-4) 0;
} }
.checklist-hide-completed-toggle {
flex: 0 0 auto;
}
.checklist-complete-mode-group { .checklist-complete-mode-group {
@include controls.radio-button-group; @include controls.radio-button-group;
width: fit-content; width: fit-content;

View file

@ -194,6 +194,21 @@
padding: var(--space-4); padding: var(--space-4);
} }
.images-toolbar {
display: flex;
justify-content: flex-end;
padding: var(--space-4) var(--space-4) 0;
}
.images-toolbar .ui-icon {
width: 18px;
height: 18px;
}
.images-toolbar + .images {
padding-top: var(--space-3);
}
.images { .images {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));

View file

@ -145,6 +145,15 @@
-webkit-mask-size: contain; -webkit-mask-size: contain;
} }
.toolbox-module-empty {
margin: var(--space-4);
padding: var(--space-4);
border: 1px dashed rgba(165, 180, 252, 0.18);
border-radius: var(--radius-md);
color: var(--color-text-muted);
text-align: center;
}
.module-icon-notepad { .module-icon-notepad {
mask-image: url("/static/icons/notepad.svg"); mask-image: url("/static/icons/notepad.svg");
-webkit-mask-image: url("/static/icons/notepad.svg"); -webkit-mask-image: url("/static/icons/notepad.svg");