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.
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
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 ;
- 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 ;
- 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 ;
- les barres sont sticky sous la topbar sur desktop, reviennent à la ligne et redeviennent statiques sur mobile ;
- reset avec bouton icône `rubber`.
@ -616,9 +623,13 @@ Modales :
- panneau centre ;
- fermeture au clic exterieur quand l'action le permet ;
- 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 :
- 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 ;
- 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 ;
@ -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 ;
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é.
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
catégorie Diablo). Le recto utilise uniquement la checkbox interactive ; la coche
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/<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 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 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.

View file

@ -236,7 +236,7 @@ Stockage compact :
## Outil Checklist
### Association temporaire aux guides
### Association persistante aux guides
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
@ -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.
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`.
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.
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.
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
lignes exactes qui lui correspondent : cocher porte `qtyCurrent` à `qtyTarget`,
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
affichage séparé. Les noms et formes français et anglais sont reconnus. Une ligne
@ -298,6 +299,7 @@ Format avec catégories :
```json
{
"hideCompletedItems": true,
"hideCompletedSections": true,
"hideCompletedSectionsFully": false,
"sections": [
@ -323,6 +325,7 @@ Stockage compact :
- `qtyTarget` est omis si la valeur vaut `1`.
- `qtyCurrent` est omis si la valeur vaut `0`.
- `hideCompletedItems` est omis si `false`.
- `hideCompletedSections` est omis si `false`.
- `hideCompletedSectionsFully` est omis si `false`.
- `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 |
| 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` |
| 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` |
| 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` |

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");
});
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 }) => {
const tool = await setup(page, "checklist", { sections: [] });
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 tool.getByRole("radio", { name: "Visible quand terminée", exact: true }).click();
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 page.reload();
await expect(quantity).toHaveValue("2");
@ -88,3 +122,63 @@ test("checklist quantities are clamped and completed categories hide without los
await page.reload();
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 }) => {
const seeded = await setup(page);
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");
await expect(drawer.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
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(page.locator(".game-filters")).toHaveCSS("position", width <= 760 ? "static" : "sticky");
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);
});
}

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 { readFileSync } from "node:fs";
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";
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 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");
@ -125,7 +125,7 @@ test("Aniimo copies visible species or forms and round-trips through checklist i
});
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);
await page.setViewportSize({ width, height: 900 });
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);
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
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.locator(".aniimo-front .guide-checklist-mark")).toHaveCount(0);
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 card.getByRole("checkbox", { name: "Cocher dans la checklist" }).check();
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).toHaveAttribute("data-form", "mountain_woods_form");
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 closeDrawer(page);
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 assertLink(page, "capture", true);
await page.getByRole("searchbox").fill("emberpup");
@ -198,16 +213,15 @@ for (const width of [1280, 390]) {
await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true");
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 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(activeButtons(page)).toHaveCount(0);
await expect(hiddenSwitch(page)).toHaveCount(0);
await closeDrawer(page);
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);
});
}
@ -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 expect(activeButtons(page)).toHaveCount(0);
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 }) => {
@ -276,20 +290,20 @@ test("deleting a selected checklist clears its link immediately", async ({ page
await expect(hiddenSwitch(page)).toHaveCount(0);
await page.reload();
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 page.addInitScript((key) => {
for (const method of ["getItem", "setItem", "removeItem"]) {
const original = globalThis.Storage.prototype[method];
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);
};
}
}, sessionKey);
}, storageKey);
await page.reload();
await page.goto(route);
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 page.goto(route);
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 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 }) => {
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");
await importToolbox(page, "Linked cover", drawer);
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(picker(page)).toHaveCount(0);
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();
const dialog = page.getByRole("dialog", { name: "Lier une toolbox", exact: true });
await dialog.getByLabel("Toolbox associée").selectOption("");

View file

@ -2,7 +2,10 @@
import { test, expect } from "@playwright/test";
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");
await expect(drawer.locator(".drawer-panel")).toBeVisible();
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 drawer.getByRole("button", { name: "Fermer", exact: true }).click();
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).toHaveCSS("width", "248px");
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 }) => {
await page.goto("/#/games/mhwilds");
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");
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.locator("[data-module-type='memos']")).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 }) => {

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 test from "node:test";
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);
});
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 association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true };
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.quantityLabel",
"toolboxes.modules.checklist.addButton",
"toolboxes.modules.checklist.emptyItems",
"toolboxes.modules.checklist.importPlaceholder",
"toolboxes.modules.checklist.importOpenButton",
"toolboxes.modules.checklist.importModalTitle",
"toolboxes.modules.checklist.importButton",
"toolboxes.modules.checklist.hideCompletedSectionsTitle",
"toolboxes.modules.checklist.hideCompletedSectionsFullyTitle",
"toolboxes.modules.checklist.hideCompletedItemsTitle",
"toolboxes.modules.checklist.completedSectionsModeTitle",
"toolboxes.modules.checklist.completedSectionsVisibleLabel",
"toolboxes.modules.checklist.completedSectionsReducedLabel",
@ -343,12 +345,20 @@ export function validateSiteContent(site) {
"toolboxes.modules.checklist.editTitle",
"toolboxes.modules.checklist.deleteTitle",
"toolboxes.modules.images.addImages",
"toolboxes.modules.images.emptyImages",
"toolboxes.modules.images.pastePlaceholder",
"toolboxes.modules.images.pasteAriaLabel",
"toolboxes.modules.images.imageAlt",
"toolboxes.modules.images.labelPlaceholder",
"toolboxes.modules.images.labelAriaLabel",
"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.annotateTitle",
"toolboxes.modules.images.deleteAriaLabel",
@ -356,6 +366,7 @@ export function validateSiteContent(site) {
"toolboxes.modules.links.titlePlaceholder",
"toolboxes.modules.links.urlPlaceholder",
"toolboxes.modules.links.addButton",
"toolboxes.modules.links.emptyLinks",
"toolboxes.modules.links.importPlaceholder",
"toolboxes.modules.links.importOpenButton",
"toolboxes.modules.links.importModalTitle",
@ -365,6 +376,7 @@ export function validateSiteContent(site) {
"toolboxes.modules.links.deleteTitle",
"toolboxes.modules.counters.labelPlaceholder",
"toolboxes.modules.counters.addButton",
"toolboxes.modules.counters.emptyCounters",
"toolboxes.modules.counters.decrementLabel",
"toolboxes.modules.counters.incrementLabel",
"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 textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "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 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");
@ -228,6 +230,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(addToolControls, /tool-add-quick-toggle/);
assert.match(moduleMetadataRegistry, /scrollable: true/);
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-collapse-button/);
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-delete-button nds-icon-button/);
assert.match(checklistModule, /useGroupedReorder/);
assert.match(checklistModule, /hideCompletedItems/);
assert.match(checklistParts, /visibleItems/);
assert.match(checklistBundle, /getItemProps/);
assert.match(checklistParts, /getGroupProps/);
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, /TextExchangeActions/);
assert.match(imagesModule, /image-annotate-button/);
assert.match(imagesModule, /kind: "collection"/);
assert.match(imagesModule, /galleryButton/);
assert.match(imagesModule, /dataTransfer\.files/);
assert.match(imageAnnotationModule, /export function ImageAnnotationModule/);
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, /<Icon name="zoom"/);
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", () => {

View file

@ -1193,6 +1193,33 @@ test("checklist data drops empty titled sections", () => {
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", () => {
const context = { uid: (prefix) => `${prefix}-new` };
const sections = [

View file

@ -1991,6 +1991,7 @@
"sectionPlaceholder": "Catégorie",
"quantityLabel": "Quantité cible",
"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",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
@ -2003,6 +2004,7 @@
"importError": "Format de checklist invalide.",
"hideCompletedSectionsTitle": "Réduire 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",
"completedSectionsVisibleLabel": "Visible quand terminée",
"completedSectionsReducedLabel": "Réduite quand terminée",
@ -2033,12 +2035,20 @@
"importError": "Format d'images invalide.",
"exportInfo": "Les images sont exportées en base64 complet. Le texte peut donc être très long.",
"addImages": "Ajouter des images",
"emptyImages": "Aucune image.",
"pastePlaceholder": "Coller une image ici",
"pasteAriaLabel": "Coller une image depuis le presse-papiers",
"imageAlt": "Image",
"labelPlaceholder": "Libellé de l'image",
"labelAriaLabel": "Libellé de 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",
"reorderTitle": "Déplacer",
"annotateAriaLabel": "Annoter l'image",
@ -2051,6 +2061,7 @@
"urlPlaceholder": "https://...",
"categoryPlaceholder": "Catégorie",
"addButton": "Ajouter",
"emptyLinks": "Aucun lien.",
"reorderCategoryTitle": "Déplacer la catégorie",
"showCategoryTitle": "Afficher la catégorie",
"hideCategoryTitle": "Réduire la catégorie",
@ -2072,6 +2083,7 @@
"counters": {
"labelPlaceholder": "Nom du compteur",
"addButton": "Ajouter",
"emptyCounters": "Aucun compteur.",
"importPlaceholder": "Victoire: 12\nDéfaite: -2",
"importOpenButton": "Importer du texte",
"exportOpenButton": "Exporter en format texte",
@ -2759,7 +2771,7 @@
"associate": "Associer à la page",
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
"matches": "correspondances",
"unlink": "Dissocier",
"unlink": "Dissocier la checklist",
"hideCompleted": "Masquer les cochés",
"completed": "Coché",
"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 { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx";
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
import { DRAWING_COLORS, DRAWING_WIDTHS } from "../utils/drawingOptions.js";
import { Icon } from "./Icon.jsx";
import { ImageGallery } from "./ImageGallery.jsx";
import { Tooltip } from "./Tooltip.jsx";
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()}` }) {
useEffect(() => lockBodyScroll(), []);
const canAnnotate = Boolean(image.canAnnotate && image.onChangeMarkers);
const canDraw = Boolean(canAnnotate && image.onChangeDrawings);
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(image.markers) ? image.markers : []);
const [viewerDrawings, setViewerDrawings] = useState(() => image.drawings?.strokes ? image.drawings : { strokes: [] });
const [drawingMode, setDrawingMode] = useState(image.drawingMode === "temporary" ? "temporary" : "permanent");
const isCollection = image.kind === "collection" && Array.isArray(image.images) && image.images.length > 0;
const collection = isCollection ? image.images : [image];
const initialIndex = isCollection ? Math.min(collection.length - 1, Math.max(0, Number(image.activeIndex) || 0)) : 0;
const initialView = isCollection && image.initialView === "gallery" ? "gallery" : "detail";
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 [eraseActive, setEraseActive] = useState(false);
const [drawingColor, setDrawingColor] = useState("#f6c453");
const [drawingWidth, setDrawingWidth] = useState(4);
const [previousImage, setPreviousImage] = useState(image);
const [previousImage, setPreviousImage] = useState(activeImage);
const viewerRef = useRef(null);
const mediaRef = useRef(null);
const hasMarkers = viewerMarkers.length > 0;
if (previousImage !== image) {
setPreviousImage(image);
setViewerMarkers(Array.isArray(image.markers) ? image.markers : []);
setViewerDrawings(image.drawings?.strokes ? image.drawings : { strokes: [] });
setDrawingMode(image.drawingMode === "temporary" ? "temporary" : "permanent");
if (previousImage !== activeImage) {
setPreviousImage(activeImage);
setViewerMarkers(Array.isArray(activeImage.markers) ? activeImage.markers : []);
setViewerDrawings(activeImage.drawings?.strokes ? activeImage.drawings : { strokes: [] });
setDrawingMode(activeImage.drawingMode === "temporary" ? "temporary" : "permanent");
setDrawingActive(false);
setEraseActive(false);
}
@ -76,31 +99,43 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
observer.disconnect();
window.removeEventListener("resize", syncImageHeight);
};
}, [image.dataUrl, canAnnotate]);
}, [activeImage.dataUrl, canAnnotate, view]);
useEffect(() => {
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);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose]);
}, [activeIndex, collection.length, isCollection, onClose, view]);
function updateViewerMarkers(nextMarkers) {
setViewerMarkers(nextMarkers);
image.onChangeMarkers?.(nextMarkers);
activeImage.onChangeMarkers?.(nextMarkers);
}
function updateViewerDrawings(nextStrokes) {
const nextDrawings = { strokes: nextStrokes };
setViewerDrawings(nextDrawings);
image.onChangeDrawings?.(nextDrawings, drawingMode);
activeImage.onChangeDrawings?.(nextDrawings, drawingMode);
}
function toggleDrawingMode() {
const nextMode = drawingMode === "permanent" ? "temporary" : "permanent";
setDrawingMode(nextMode);
image.onChangeDrawingMode?.(nextMode, viewerDrawings);
activeImage.onChangeDrawingMode?.(nextMode, viewerDrawings);
}
function getMediaSurfaceSize() {
@ -126,6 +161,19 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
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) {
updateViewerMarkers(viewerMarkers.map((marker) => marker.id === markerId ? updater(marker) : marker));
}
@ -137,17 +185,32 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
return (
<div className="image-viewer-root">
<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>
{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">
{!canAnnotate && !hasMarkers && (
<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>
{isCollection && view === "detail" && (
<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>
</div>
</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
ref={mediaRef}
@ -155,9 +218,9 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
onClick={addViewerMarker}
role={canAnnotate ? "button" : 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
active={drawingActive}
erase={eraseActive}
@ -166,12 +229,12 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
width={drawingWidth}
onChange={updateViewerDrawings}
getSurfaceSize={getMediaSurfaceSize}
createStrokeId={image.createStrokeId}
createStrokeId={activeImage.createStrokeId}
coordinateMode="percent"
/>
{canDraw && (
<DrawingControls
textContent={image}
textContent={activeImage}
active={drawingActive}
onToggleActive={() => setDrawingActive((value) => !value)}
eraseActive={eraseActive}
@ -193,7 +256,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
as="span"
className="annotation-marker image-viewer-marker"
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"
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
>
@ -202,6 +265,11 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
))}
</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 && (
<aside className="image-viewer-side">
<strong>{viewerMarkers.length} {viewerMarkers.length > 1 ? "marqueurs" : "marqueur"}</strong>
@ -215,11 +283,11 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
data-size="sm"
type="text"
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 }))}
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" />
</button>
</li>
@ -230,7 +298,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
)}
</aside>
)}
</div>
</div>}
</section>
</div>
);

View file

@ -3,12 +3,12 @@ import { Icon } from "./Icon.jsx";
import { ImportButton } from "./ImportButton.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 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 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);
return (
@ -48,15 +48,16 @@ export function Shell({ route, content, games, toolboxes, links, drawerGameId =
{game && (
<div className="topbar-actions">
<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-variant="primary"
onClick={() => actions.setDrawerGameId(drawerGameId === game.id ? "" : game.id)}
onClick={() => actions.setDrawerGameId(toolboxOpen ? "" : game.id)}
aria-label={toolboxLabel}
aria-pressed={drawerGameId === game.id}
aria-pressed={toolboxOpen}
title={toolboxLabel}
>
<img src="/static/icons/toolbox.svg" alt="" aria-hidden="true" />
<span>{toolboxLabel}</span>
</button>
</div>
)}

View file

@ -30,6 +30,21 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
</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 }) {
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.
import { useMemo } from "react";
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
import { GuideChecklistPageControls } from "../GuideChecklistControls.jsx";
import { useGuideChecklist } from "../useGuideChecklist.js";
import { Icon } from "../../../components/Icon.jsx";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
@ -31,7 +32,10 @@ function AniimoListing({ game, aniimo, filters, setFilters, content }) {
</div>
<p>{content.description}</p>
</div>
<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 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>
</div>
</section>
<section className="game-layout aniimo-layout">
<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 { useMemo } from "react";
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
import { GuideChecklistPageControls } from "../GuideChecklistControls.jsx";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
import { Diablo4AffixCard } from "./Diablo4AffixCard.jsx";
import { Diablo4Filters } from "./Diablo4Filters.jsx";
@ -30,6 +31,9 @@ export function Diablo4Listing({ game, diablo4, filters, setFilters }) {
</div>
<p>Filtrez les affixes par nom et catégories pour retrouver rapidement les statistiques utiles à votre build.</p>
</div>
<div className="actions game-heading-actions">
<GuideChecklistPageControls checklist={checklist} />
</div>
</section>
<section className="game-layout diablo4-layout" data-game-category="diablo4-affixes">
<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.
export const GUIDE_CHECKLIST_SESSION_KEY = "sokkog:guide-checklists";
// Rôle : reconnaît les lignes de checklist et valide les associations persistantes des guides.
export const GUIDE_CHECKLIST_STORAGE_KEY = "sokkog:guide-checklists";
const GUIDES = new Set(["aniimo/aniimos", "mhwilds/monsters", "mhwilds/endemic", "diablo4/affixes"]);
export function normalizeGuideLabel(value) {

View file

@ -5,6 +5,7 @@ import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
import { EndemicCard } from "./cards/EndemicCard.jsx";
import { MonsterCard } from "./cards/MonsterCard.jsx";
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
import { GuideChecklistPageControls } from "../GuideChecklistControls.jsx";
import { MhwildsFilters } from "./MhwildsFilters.jsx";
import { getFilteredMhwildsItems, getMhwildsFilterKey, getUniqueConditionValues } from "./utils.js";
@ -32,9 +33,12 @@ export function MhwildsListing({ category, game, mhwilds, filters, setFilters, t
</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>
</div>
<div className="actions">
<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>
<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/endemic">Faune</a>
</div>
</div>
</section>
<section className="game-layout" data-mhwilds-category={category}>

View file

@ -12,13 +12,13 @@ export function useGuideChecklistPanel(checklist, module) {
}
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 entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]);
const gameId = guideId.split("/")[0];
const content = siteContent.guideChecklist;
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) => ({
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,
lists: lists.filter((list) => list.count || list.id === selected?.id),
isVisible: (key) => !hideCompleted || !isCompleted(key),
associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),
toggleHidden: () => guideChecklistSession.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted })
associate: (moduleId) => guideChecklistStorage.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),
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 moduleText = siteContent.toolboxes.modules;
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
const [pendingModuleId, setPendingModuleId] = useState("");
const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId);
const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested });
// 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,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
});
setPendingModuleId(module.id);
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
}
@ -257,6 +259,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId)
});
setPendingModuleId(moduleId);
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
}
@ -320,6 +323,8 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
toolbox={toolbox}
moduleColumns={moduleColumns}
context={moduleContext}
scrollToModuleId={pendingModuleId}
onModuleScrolled={() => setPendingModuleId("")}
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) })}
onModuleOrderChange={(moduleOrder) => updateToolbox({ ...toolbox, moduleOrder })}

View file

@ -4,6 +4,7 @@ import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompl
import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js";
import { ChecklistSection } from "./ChecklistParts.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
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")} />
</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"}>
{[
{ mode: "visible", label: textContent.completedSectionsVisibleLabel || "Visible", icon: "eye-open" },
@ -149,8 +162,9 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing, textExc
</button>
))}
</div>
</div>
)}
)}
</div>
{!data.sections.length && <ModuleEmptyMessage>{textContent.emptyItems || "Aucun élément dans cette checklist."}</ModuleEmptyMessage>}
<ul className="checklist">
{visibleSections.map((section, index) => {
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 isAutoCollapsed = sectionComplete && hideWhenComplete;
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 className = [
"checklist-section",
@ -106,7 +109,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
)}
{!isCollapsed && (
<ul className="checklist-section-items">
{section.items.map((item) => (
{visibleItems.map((item) => (
<ChecklistItem
key={item.id}
item={item}

View file

@ -3,6 +3,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
export function CountersModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
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")} />
</div>
)}
{!data.counters.length && <ModuleEmptyMessage>{textContent.emptyCounters || "Aucun compteur."}</ModuleEmptyMessage>}
<div className="counters-grid">
{data.counters.map((counter) => {
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 { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
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 (
<>
{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")} />
</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">
{data.images.map((image) => {
{data.images.map((image, index) => {
const className = [
isItemDragging(image.id) ? "is-dragging" : "",
isItemDropTarget(image.id) ? "is-drop-target" : "",
@ -114,7 +134,7 @@ export function ImagesModule({ toolboxId, moduleId, context, editing, textExchan
>
<Icon name="drag" />
</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"} />
</button>
{editing ? (

View file

@ -6,6 +6,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { ModuleEmptyMessage } from "./ModuleEmptyMessage.jsx";
function getLinkCategory(link) {
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")} />
</div>
)}
{!data.links.length && <ModuleEmptyMessage>{textContent.emptyLinks || "Aucun lien."}</ModuleEmptyMessage>}
<div className="links-category-list">
{getLinkEntries(data).map((entry) => {
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.
import { Suspense, useState } from "react";
import { Suspense, useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx";
@ -8,12 +8,13 @@ import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
import { supportsTextExchange } from "./textImport.js";
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 Component = definition.Component;
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const moduleRef = useRef(null);
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
@ -40,6 +41,15 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
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() {
if (!collapsed) {
setEditing(false);
@ -58,8 +68,10 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
return (
<article
ref={moduleRef}
className={className}
data-module-type={module.type}
data-module-id={module.id}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>

View file

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

View file

@ -30,15 +30,16 @@ function normalizeChecklistSection(section, fallbackTitle = "") {
}
export function normalizeChecklistData(data) {
const hideCompletedItems = Boolean(data?.hideCompletedItems);
const hideCompletedSections = Boolean(data?.hideCompletedSections);
const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully);
const sections = Array.isArray(data?.sections)
? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length)
: [];
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);
return { hideCompletedSections, hideCompletedSectionsFully, sections, items };
return { hideCompletedItems, hideCompletedSections, hideCompletedSectionsFully, sections, items };
}
function compactChecklistItemForStorage(item) {
@ -64,11 +65,12 @@ export function compactChecklistSectionForStorage(section) {
export function compactChecklistDataForStorage(value) {
const normalized = normalizeChecklistData(value);
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length);
if (!sections.length) return null;
const settings = {
...(normalized.hideCompletedItems ? { hideCompletedItems: true } : {}),
...(normalized.hideCompletedSections ? { hideCompletedSections: 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 };
return { ...settings, sections };
}

View file

@ -191,16 +191,19 @@ function remapModuleDataForExport(type, data, nextId) {
const compact = compactModuleDataForStorage(type, data);
if (!compact) return null;
if (type === "checklist") {
const { sections, items, ...settings } = compact;
if (compact.sections) {
return {
sections: compact.sections.map((section) => ({
...settings,
sections: sections.map((section) => ({
...section,
id: nextId("section"),
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") {
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 { 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 {
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 : {};
} catch { return {}; }
} catch {
return {};
}
}
export function useGuideChecklistSession({ toolboxes, links, ready }) {
const [associations, setAssociations] = useState(readSession);
export function useGuideChecklistStorage({ toolboxes, links, ready }) {
const [associations, setAssociations] = useState(readStoredAssociations);
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);
useEffect(() => {
if (!ready) return;
try {
if (Object.keys(valid).length) sessionStorage.setItem(GUIDE_CHECKLIST_SESSION_KEY, JSON.stringify(valid));
else sessionStorage.removeItem(GUIDE_CHECKLIST_SESSION_KEY);
} catch { /* La session reste disponible en mémoire si le navigateur refuse le stockage. */ }
if (Object.keys(valid).length) localStorage.setItem(GUIDE_CHECKLIST_STORAGE_KEY, JSON.stringify(valid));
else localStorage.removeItem(GUIDE_CHECKLIST_STORAGE_KEY);
} catch { /* Les liaisons restent disponibles en mémoire si le stockage local est indisponible. */ }
}, [ready, valid]);
function setAssociation(guideId, association) {
@ -30,5 +32,6 @@ export function useGuideChecklistSession({ toolboxes, links, ready }) {
return next;
});
}
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 { useAppData } from "./hooks/useAppData.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 { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
import { RouteContent } from "./router/RouteContent.jsx";
@ -39,6 +39,7 @@ function App() {
const [autoBackupModalOpen, setAutoBackupModalOpen] = useState(false);
const [linkModalGameId, setLinkModalGameId] = useState("");
const [drawerGameId, setDrawerGameId] = useState("");
const [drawerRoute, setDrawerRoute] = useState(route);
const [image, setImage] = useState(null);
const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState("");
const [notifications, setNotifications] = useState([]);
@ -48,7 +49,13 @@ function App() {
const store = useIndexedToolboxes(handleStorageError);
const autoBackup = useAutoBackup(store);
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 id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
@ -154,7 +161,7 @@ function App() {
}
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}>
<AppErrorBoundary route={route}>
<RouteContent

View file

@ -91,6 +91,16 @@
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 {
padding-left: 0;
}
@ -236,6 +246,34 @@
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 {
display: contents;
}

View file

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

View file

@ -173,9 +173,9 @@
}
.nds-filter-search {
flex: 0 1 280px;
width: min(100%, 280px);
max-width: 280px;
flex: 0 1 260px;
width: min(100%, 260px);
max-width: 260px;
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); }
}
.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 {
position: absolute;
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 {
position: fixed;
inset: 0;
@ -38,6 +38,13 @@
gap: var(--space-2);
}
.image-viewer-heading {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.image-viewer-title {
overflow: hidden;
color: var(--color-text);
@ -46,6 +53,17 @@
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 {
display: flex;
justify-content: flex-end;
@ -69,6 +87,24 @@
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 {
display: flex;
min-width: 0;
@ -158,3 +194,73 @@
overscroll-behavior: contain;
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 {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) var(--space-4) 0;
}
.checklist-hide-completed-toggle {
flex: 0 0 auto;
}
.checklist-complete-mode-group {
@include controls.radio-button-group;
width: fit-content;

View file

@ -194,6 +194,21 @@
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));

View file

@ -145,6 +145,15 @@
-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 {
mask-image: url("/static/icons/notepad.svg");
-webkit-mask-image: url("/static/icons/notepad.svg");