Improve toolbox checklists, image browsing, and guide integration
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
This commit is contained in:
parent
00e73f0062
commit
386212041f
47 changed files with 744 additions and 112 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
42
website/src/components/ImageGallery.jsx
Normal file
42
website/src/components/ImageGallery.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,10 +33,13 @@ 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">
|
||||
<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}>
|
||||
<aside className="game-filters">
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") })) };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue