Move guide checklist controls into a collapsible header toggle
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-24 08:26:48 +02:00
parent ef0c592c11
commit 14f1b79cbc
12 changed files with 144 additions and 89 deletions

View file

@ -822,9 +822,16 @@ Date picker et menus compacts :
### Liaison des guides aux checklists ### Liaison des guides aux checklists
Les guides à cartes partagent un panneau `guide-checklist-controls` dans la toolbox Dans la toolbox latérale, chaque checklist compatible avec le guide ouvert affiche
latérale du jeu, au-dessus de ses outils : panneau NDS, select avec label, bouton de dissociation et switch un bouton icône de lien à côté du « + » dans son en-tête. Ce toggle NDS affiche
« Masquer les cochés ». Le label et le sélecteur occupent toute la largeur ; les actions sont centrées et reviennent à la ligne sur mobile. Le panneau ne concerne que le guide à cartes actuellement ouvert. ou masque la section `guide-checklist-controls`, masquée par défaut, sans désactiver
le suivi. Il expose `aria-expanded`, `aria-pressed` et `aria-controls` ; son icône
reste dorée lorsque la liste est associée. Le bouton NDS
« Associer à la page » / « Dissocier » expose `aria-pressed` ; activer une liste
dissocie la précédente. Le switch « Masquer les cochés » est désactivé sur les listes
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é.
L'état terminé désature et atténue uniquement les portraits (ou les icônes de L'état terminé désature et atténue uniquement les portraits (ou les icônes de
catégorie Diablo), avec une coche accessible « Coché ». Les textes restent catégorie Diablo), avec une coche accessible « Coché ». Les textes restent
lisibles et les indicateurs absolus ne modifient pas la taille des cartes. lisibles et les indicateurs absolus ne modifient pas la taille des cartes.

View file

@ -52,7 +52,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier le bouton toolbox et le panneau latéral. - Vérifier le bouton toolbox et le panneau latéral.
- Vérifier le breadcrumb. - Vérifier le breadcrumb.
- Vérifier les textes avec retours à la ligne. - Vérifier les textes avec retours à la ligne.
- Pour les guides à cartes, vérifier le sélecteur de checklist dans le panneau latéral de la toolbox liée, les listes partielles et la mise à jour immédiate depuis le panneau latéral. Les guides ne doivent jamais modifier la progression. - 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. Les guides ne doivent jamais modifier la progression.
- Vérifier le changement de checklist, la dissociation, la restauration de session, la suppression d'un module et le changement de toolbox. La réinitialisation des filtres conserve la liaison ; une liste devenue sans correspondance reste sélectionnée avec un message. - Vérifier le changement de checklist, la dissociation, la restauration de session, la suppression d'un module et le changement de toolbox. La réinitialisation des filtres conserve la liaison ; une liste devenue sans correspondance reste sélectionnée avec un message.
- Pour Aniimo, vérifier la copie des espèces ou des formes selon l'affichage, les noms français/anglais, la priorité des formes et le masquage avant regroupement. Compteurs et copie doivent correspondre aux résultats affichés. - Pour Aniimo, vérifier la copie des espèces ou des formes selon l'affichage, les noms français/anglais, la priorité des formes et le masquage avant regroupement. Compteurs et copie doivent correspondre aux résultats affichés.

View file

@ -211,9 +211,14 @@ Stockage compact :
### Association temporaire aux guides ### Association temporaire aux guides
Les pages Aniimo, monstres MHW, faune MHW et affixes Diablo peuvent refléter une Les pages Aniimo, monstres MHW, faune MHW et affixes Diablo peuvent refléter une
checklist de la toolbox actuellement associée au jeu. Le sélecteur, la dissociation checklist de la toolbox actuellement associée au jeu. Chaque checklist compatible
et le masquage se trouvent dans le panneau latéral de cette toolbox, au-dessus des outils. Cette liaison ne modifie affiche un toggle de lien à côté du « + » de son en-tête dans le panneau latéral.
ni les données de checklist ni les liens persistants jeu/toolbox. Il ouvre une section masquée par défaut en haut du contenu :
bouton toggle d'association/dissociation et switch de masquage, actif seulement pour
la liste associée. Associer une autre liste remplace la précédente et conserve le
réglage de masquage ; dissocier réaffiche tous les éléments. Replier les commandes
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 `sessionStorage["sokkog:guide-checklists"]` contient un objet indexé par
`aniimo/aniimos`, `mhwilds/monsters`, `mhwilds/endemic` ou `diablo4/affixes`. `aniimo/aniimos`, `mhwilds/monsters`, `mhwilds/endemic` ou `diablo4/affixes`.

View file

@ -7,8 +7,11 @@ import { normalizeChecklistData } from "../website/src/features/toolboxes/storag
const route = "/#/games/aniimo/aniimos"; const route = "/#/games/aniimo/aniimos";
const sessionKey = "sokkog:guide-checklists"; const sessionKey = "sokkog:guide-checklists";
const selector = (page) => page.getByRole("combobox", { name: "Checklist associée", exact: true }); 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 hiddenSwitch = (page) => page.getByRole("button", { name: "Masquer les cochés", exact: true }); const controlsToggle = (page, id) => page.getByRole("button", { name: `Lien avec le guide — ${id}`, exact: true });
const linkButton = (page, id) => controls(page, id).locator(".guide-checklist-link");
const activeButtons = (page) => page.locator('.guide-checklist-link[aria-pressed="true"]');
const hiddenSwitch = (page) => page.locator('.guide-checklist-controls[data-linked="true"]').getByRole("button", { name: "Masquer les cochés", exact: true });
const json = (path) => JSON.parse(readFileSync(`website/public/data/${path}`, "utf8")); const json = (path) => JSON.parse(readFileSync(`website/public/data/${path}`, "utf8"));
const row = (label, qtyCurrent = 0) => ({ id: label, label, qtyCurrent }); const row = (label, qtyCurrent = 0) => ({ id: label, label, qtyCurrent });
@ -64,19 +67,30 @@ async function openDrawer(page) {
async function closeDrawer(page) { async function closeDrawer(page) {
await page.locator("#toolbox-drawer").getByRole("button", { name: "Fermer", exact: true }).click(); await page.locator("#toolbox-drawer").getByRole("button", { name: "Fermer", exact: true }).click();
} }
async function openControls(page, id) {
const toggle = id ? controlsToggle(page, id) : page.locator('.module-guide-button[data-linked="true"]');
await expect(toggle).toBeVisible();
if (await toggle.getAttribute("aria-expanded") === "false") await toggle.click();
}
async function choose(page, id) { async function choose(page, id) {
await openDrawer(page); await openDrawer(page);
await selector(page).selectOption(id); await openControls(page, id);
await linkButton(page, id).click();
await expect(linkButton(page, id)).toHaveAttribute("aria-pressed", "true");
await expect(activeButtons(page)).toHaveCount(1);
await closeDrawer(page); await closeDrawer(page);
} }
async function toggleHidden(page) { async function toggleHidden(page) {
await openDrawer(page); await openDrawer(page);
await openControls(page);
await hiddenSwitch(page).click(); await hiddenSwitch(page).click();
await closeDrawer(page); await closeDrawer(page);
} }
async function assertLink(page, id, hidden) { async function assertLink(page, id, hidden) {
await openDrawer(page); await openDrawer(page);
await expect(selector(page)).toHaveValue(id); await expect(activeButtons(page)).toHaveCount(id ? 1 : 0);
if (id) await openControls(page, id);
if (id) await expect(linkButton(page, id)).toHaveAttribute("aria-pressed", "true");
if (id) await expect(hiddenSwitch(page)).toHaveAttribute("aria-pressed", String(hidden)); if (id) await expect(hiddenSwitch(page)).toHaveAttribute("aria-pressed", String(hidden));
else await expect(hiddenSwitch(page)).toHaveCount(0); else await expect(hiddenSwitch(page)).toHaveCount(0);
await closeDrawer(page); await closeDrawer(page);
@ -118,14 +132,35 @@ for (const width of [1280, 390]) {
await page.getByRole("searchbox").fill("emberpup"); await page.getByRole("searchbox").fill("emberpup");
const card = page.locator(".aniimo-result"); const card = page.locator(".aniimo-result");
await openDrawer(page); await openDrawer(page);
await expect(selector(page)).toHaveValue(""); await expect(activeButtons(page)).toHaveCount(0);
await expect(selector(page).locator("option")).toHaveCount(4); await expect(page.locator(".guide-checklist-controls")).toHaveCount(3);
await expect(page.locator(".guide-checklist-controls select")).toHaveCount(0);
await expect(controls(page, "unrelated")).toHaveCount(0);
await expect(controlsToggle(page, "unrelated")).toHaveCount(0);
await expect(controls(page, "capture")).toBeHidden();
await openControls(page, "capture");
await expect(controls(page, "capture").getByRole("button", { name: "Masquer les cochés" })).toBeDisabled();
const before = await readModule(page, "forms"); const before = await readModule(page, "forms");
await selector(page).selectOption("forms"); const heading = controlsToggle(page, "forms");
const panel = page.locator(".guide-checklist-controls"); await expect(heading).toHaveAttribute("aria-expanded", "false");
expect(await heading.evaluate((button) => button.previousElementSibling.classList.contains("module-edit-button"))).toBe(true);
await heading.press("Enter");
await expect(heading).toHaveAttribute("aria-pressed", "true");
await expect(controls(page, "forms")).toHaveAttribute("id", await heading.getAttribute("aria-controls"));
await linkButton(page, "forms").press("Enter");
await expect(linkButton(page, "forms")).toHaveAttribute("aria-pressed", "true");
const panel = controls(page, "forms");
await heading.press("Space");
await expect(heading).toHaveAttribute("aria-expanded", "false");
await expect(heading).toHaveAttribute("aria-pressed", "false");
await expect(heading).toHaveAttribute("data-linked", "true");
await expect(linkButton(page, "forms")).toBeHidden();
await expect(card).toHaveAttribute("data-checklist-complete", "true");
await heading.press("Enter");
await expect(linkButton(page, "forms")).toBeVisible();
const bounds = await panel.boundingBox(); const bounds = await panel.boundingBox();
const selectBounds = await selector(page).boundingBox(); const actionsBounds = await panel.locator(".guide-checklist-actions").boundingBox();
expect(selectBounds.x + selectBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0); expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0);
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") }); await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
await closeDrawer(page); await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true"); await expect(card).toHaveAttribute("data-checklist-complete", "true");
@ -146,14 +181,17 @@ for (const width of [1280, 390]) {
await page.getByRole("searchbox").fill("emberpup"); await page.getByRole("searchbox").fill("emberpup");
await expect(card).toHaveCount(0); await expect(card).toHaveCount(0);
await openDrawer(page); await openDrawer(page);
await openControls(page, "capture");
await hiddenSwitch(page).press("Space"); await hiddenSwitch(page).press("Space");
await closeDrawer(page); await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true"); await expect(card).toHaveAttribute("data-checklist-complete", "true");
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click(); await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
await openDrawer(page); await openDrawer(page);
await expect(selector(page)).toHaveValue("capture"); await openControls(page, "capture");
await page.getByRole("button", { name: "Dissocier", exact: true }).click(); await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "true");
await expect(selector(page)).toHaveValue(""); 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 expect(hiddenSwitch(page)).toHaveCount(0);
await closeDrawer(page); await closeDrawer(page);
await expect(page.locator('[data-checklist-complete="true"]')).toHaveCount(0); await expect(page.locator('[data-checklist-complete="true"]')).toHaveCount(0);
@ -192,17 +230,18 @@ test("renames retain a zero-match selection; changing toolbox clears the associa
await choose(page, "capture"); await choose(page, "capture");
const drawer = await openDrawer(page); const drawer = await openDrawer(page);
const checklist = drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) }); const checklist = drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) });
await openControls(page, "capture");
await checklist.getByRole("button", { name: "Modifier Loufeuteau", exact: true }).click(); await checklist.getByRole("button", { name: "Modifier Loufeuteau", exact: true }).click();
await checklist.getByRole("textbox", { name: "Libellé", exact: true }).fill("Unrelated now"); await checklist.getByRole("textbox", { name: "Libellé", exact: true }).fill("Unrelated now");
await checklist.getByRole("textbox", { name: "Libellé", exact: true }).press("Enter"); await checklist.getByRole("textbox", { name: "Libellé", exact: true }).press("Enter");
await expect(selector(page)).toHaveValue("capture"); await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByText("Cette checklist ne contient plus de correspondance avec le guide.")).toBeVisible(); await expect(page.getByText("Cette checklist ne contient plus de correspondance avec le guide.")).toBeVisible();
await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click(); await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Lier une toolbox" }); const dialog = page.getByRole("dialog", { name: "Lier une toolbox" });
await dialog.getByRole("combobox").selectOption("other"); await dialog.getByRole("combobox").selectOption("other");
await dialog.getByRole("button", { name: "Valider", exact: true }).click(); await dialog.getByRole("button", { name: "Valider", exact: true }).click();
await expect(selector(page)).toHaveValue(""); await expect(activeButtons(page)).toHaveCount(0);
await expect(selector(page).locator("option")).toHaveCount(2); 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.sessionStorage.getItem(key), sessionKey)).toBeNull();
}); });
@ -214,7 +253,7 @@ test("deleting a selected checklist clears its link immediately", async ({ page
const drawer = await openDrawer(page); const drawer = await openDrawer(page);
await drawer.getByRole("button", { name: "Retirer capture", exact: true }).click(); await drawer.getByRole("button", { name: "Retirer capture", exact: true }).click();
await page.getByRole("dialog").getByRole("button", { name: "Retirer", exact: true }).click(); await page.getByRole("dialog").getByRole("button", { name: "Retirer", exact: true }).click();
await expect(selector(page)).toHaveValue(""); await expect(activeButtons(page)).toHaveCount(0);
await expect(hiddenSwitch(page)).toHaveCount(0); await expect(hiddenSwitch(page)).toHaveCount(0);
await page.reload(); await page.reload();
await assertLink(page, "", false); await assertLink(page, "", false);
@ -240,6 +279,10 @@ test("unavailable session storage retains links across navigation; overview has
await openDrawer(page); await openDrawer(page);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0); await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
await closeDrawer(page); await closeDrawer(page);
await page.goto("/#/toolbox/guide");
await expect(page.locator('[data-module-type="checklist"]')).toHaveCount(4);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
await expect(page.locator(".module-guide-button")).toHaveCount(0);
await page.goto(route); await page.goto(route);
await assertLink(page, "perfect", true); await assertLink(page, "perfect", true);
}); });

View file

@ -90,10 +90,7 @@ export function validateSiteContent(site) {
"gamesPage.emptyTitle", "gamesPage.emptyTitle",
"gamesPage.emptyText", "gamesPage.emptyText",
"guideChecklist.label", "guideChecklist.label",
"guideChecklist.untitled", "guideChecklist.associate",
"guideChecklist.none",
"guideChecklist.linkToolbox",
"guideChecklist.noCompatible",
"guideChecklist.noMatches", "guideChecklist.noMatches",
"guideChecklist.matches", "guideChecklist.matches",
"guideChecklist.unlink", "guideChecklist.unlink",

View file

@ -2720,11 +2720,8 @@
"emptyText": "Créez une première toolbox pour stocker vos outils dans ce navigateur." "emptyText": "Créez une première toolbox pour stocker vos outils dans ce navigateur."
}, },
"guideChecklist": { "guideChecklist": {
"label": "Checklist associée", "label": "Lien avec le guide",
"untitled": "Checklist sans titre", "associate": "Associer à la page",
"none": "Aucune checklist",
"linkToolbox": "Associer une toolbox pour suivre une checklist",
"noCompatible": "Aucune checklist de cette toolbox ne correspond aux éléments du guide.",
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.", "noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
"matches": "correspondances", "matches": "correspondances",
"unlink": "Dissocier", "unlink": "Dissocier",

View file

@ -1,32 +1,32 @@
// Rôle : affiche le sélecteur de checklist et le masquage commun aux pages guides. // Rôle : expose le suivi du guide dans chaque checklist compatible et marque les cartes terminées.
import { Icon } from "../../components/Icon.jsx"; import { Icon } from "../../components/Icon.jsx";
import { useGuideChecklist } from "./useGuideChecklist.js";
export function CurrentGuideChecklistControls({ gameId }) { export function GuideChecklistToggle({ checklist, panel, label }) {
const checklist = useGuideChecklist(); if (!panel.list) return null;
return checklist.available && checklist.guideId.split("/")[0] === gameId ? <GuideChecklistControls checklist={checklist} /> : null; return <button
className={`module-guide-button nds-icon-button ${panel.open ? "active" : ""}`}
data-size="md"
data-linked={checklist.selected?.id === panel.list.id || undefined}
type="button"
aria-label={`${checklist.content.label} — ${label}`}
title={checklist.content.label}
aria-expanded={panel.open}
aria-pressed={panel.open}
aria-controls={panel.id}
onClick={panel.toggle}
><Icon name="link" /></button>;
} }
export function GuideChecklistControls({ checklist }) { export function GuideChecklistControls({ checklist, list, id, open }) {
const { content, toolbox, selected, lists, hideCompleted } = checklist; if (!list) return null;
return <div className="guide-checklist-controls nds-panel" aria-label={content.label}> const { content, selected, hideCompleted } = checklist;
{!toolbox ? <button type="button" className="nds-button" data-size="sm" onClick={checklist.linkToolbox}>{content.linkToolbox}</button> : <> const active = selected?.id === list.id;
<label className="field compact"> return <div id={id} hidden={!open} className="guide-checklist-controls" data-linked={active || undefined}>
<span>{content.label}</span> <div className="guide-checklist-actions">
<select className="nds-select" data-size="sm" value={selected?.id || ""} disabled={!lists.length} onChange={(event) => checklist.associate(event.target.value)}> <button type="button" className="guide-checklist-link nds-button" data-size="sm" data-variant={active ? "primary" : "secondary"} aria-pressed={active} onClick={() => checklist.associate(active ? "" : list.id)}>{active ? content.unlink : content.associate}</button>
<option value="">{content.none}</option> <button type="button" className="nds-switch" disabled={!active} aria-pressed={active && hideCompleted} onClick={checklist.toggleHidden}><Icon name="hide" /><span>{content.hideCompleted}</span><i aria-hidden="true" /></button>
{lists.map((list) => <option key={list.id} value={list.id}>{list.title} · {list.count} {content.matches}</option>)} </div>
</select> <p>{list.count ? `${list.count} ${content.matches}` : content.noMatches}</p>
</label>
{!lists.length && <p>{content.noCompatible}</p>}
{selected && <>
<div className="guide-checklist-actions">
<button type="button" className="nds-button" data-size="sm" onClick={() => checklist.associate("")}>{content.unlink}</button>
<button type="button" className="nds-switch" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}><Icon name="hide" /><span>{content.hideCompleted}</span><i aria-hidden="true" /></button>
</div>
{!selected.count && <p>{content.noMatches}</p>}
</>}
</>}
</div>; </div>;
} }

View file

@ -1,21 +1,28 @@
// Rôle : partage la progression réactive et les commandes de liaison entre les guides. // Rôle : partage la progression réactive et les commandes de liaison entre les guides.
import { createContext, useContext, useMemo } from "react"; import { createContext, useContext, useId, useMemo, useState } from "react";
import { getGuideChecklistEntries, matchGuideChecklist } from "./guideChecklist.js"; import { getGuideChecklistEntries, matchGuideChecklist } from "./guideChecklist.js";
export const GuideChecklistContext = createContext(null); export const GuideChecklistContext = createContext(null);
export function useGuideChecklistPanel(checklist, module) {
const id = useId();
const [open, setOpen] = useState(false);
const list = module.type === "checklist" ? checklist?.lists.find((item) => item.id === module.id) : undefined;
return { id, open, list, toggle: () => setOpen((value) => !value) };
}
export function useGuideChecklist(requestedGuideId) { export function useGuideChecklist(requestedGuideId) {
const { toolboxes, links, moduleData, guideChecklistSession, actions, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext); const { toolboxes, links, moduleData, guideChecklistSession, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
const guideId = requestedGuideId || route.replace(/^\/games\//, ""); const guideId = requestedGuideId || route.replace(/^\/games\//, "");
const entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]); const entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]);
const gameId = guideId.split("/")[0]; const gameId = guideId.split("/")[0];
const content = siteContent.guideChecklist; const content = siteContent.guideChecklist;
const toolbox = toolboxes.find((item) => item.id === links[gameId]); const toolbox = toolboxes.find((item) => item.id === links[gameId]);
const association = guideChecklistSession.associations[guideId]; const association = guideChecklistSession.associations[guideId];
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module, index) => ({ const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({
id: module.id, title: module.title || `${content.untitled} ${index + 1}`, id: module.id,
...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`]) ...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`])
})), [entries, toolbox, moduleData, content.untitled]); })), [entries, toolbox, moduleData]);
const selected = lists.find((list) => list.id === association?.moduleId); const selected = lists.find((list) => list.id === association?.moduleId);
const hideCompleted = Boolean(selected && association.hideCompleted); const hideCompleted = Boolean(selected && association.hideCompleted);
const isCompleted = (key) => Boolean(selected?.completed.has(key)); const isCompleted = (key) => Boolean(selected?.completed.has(key));
@ -24,7 +31,6 @@ export function useGuideChecklist(requestedGuideId) {
lists: lists.filter((list) => list.count || list.id === selected?.id), lists: lists.filter((list) => list.count || list.id === selected?.id),
isVisible: (key) => !hideCompleted || !isCompleted(key), isVisible: (key) => !hideCompleted || !isCompleted(key),
associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null), associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),
toggleHidden: () => guideChecklistSession.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted }), toggleHidden: () => guideChecklistSession.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted })
linkToolbox: () => actions.setLinkModalGameId(gameId)
}; };
} }

View file

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx"; import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx";
import { Icon } from "../../components/Icon.jsx"; import { Icon } from "../../components/Icon.jsx";
import { ImportButton } from "../../components/ImportButton.jsx"; import { ImportButton } from "../../components/ImportButton.jsx";
import { CurrentGuideChecklistControls } from "../games/GuideChecklistControls.jsx"; import { useGuideChecklist } from "../games/useGuideChecklist.js";
import { StorageQuota } from "../../components/StorageQuota.jsx"; import { StorageQuota } from "../../components/StorageQuota.jsx";
import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx"; import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js"; import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
@ -180,7 +180,7 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
return toolbox?.tutorial === true || pendingTutorialToolboxId === toolbox?.id; return toolbox?.tutorial === true || pendingTutorialToolboxId === toolbox?.id;
} }
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) { function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId, guideChecklist }) {
const toolboxId = toolbox.id; const toolboxId = toolbox.id;
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2; const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const toolboxGameCover = getGameCardCover(toolboxGame); const toolboxGameCover = getGameCardCover(toolboxGame);
@ -194,6 +194,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
if (previousTutorial.toolboxId !== toolboxId || tutorialRequested) setShowTutorial(tutorialRequested); if (previousTutorial.toolboxId !== toolboxId || tutorialRequested) setShowTutorial(tutorialRequested);
} }
const moduleContext = { const moduleContext = {
guideChecklist,
getModuleData, getModuleData,
ensureModuleData: actions.ensureModuleData, ensureModuleData: actions.ensureModuleData,
isModuleDataReady: actions.isModuleDataReady, isModuleDataReady: actions.isModuleDataReady,
@ -341,6 +342,8 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
} }
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles }) { export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
const checklist = useGuideChecklist();
const guideChecklist = checklist.available && checklist.guideId.split("/")[0] === gameId ? checklist : undefined;
const selectedId = links[gameId] || ""; const selectedId = links[gameId] || "";
const toolbox = toolboxes.find((item) => item.id === selectedId); const toolbox = toolboxes.find((item) => item.id === selectedId);
const [width, setWidth] = useState(""); const [width, setWidth] = useState("");
@ -400,8 +403,7 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
</div> </div>
<button className="drawer-close-button nds-icon-button" data-size="sm" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button> <button className="drawer-close-button nds-icon-button" data-size="sm" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</header> </header>
<CurrentGuideChecklistControls gameId={gameId} /> {toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} storageReady={storageReady} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} guideChecklist={guideChecklist} /> : (
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} storageReady={storageReady} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} /> : (
<div className="empty nds-empty-state"><p>Aucune toolbox associée à cette page jeu.</p><button className="nds-button" data-size="sm" data-variant="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div> <div className="empty nds-empty-state"><p>Aucune toolbox associée à cette page jeu.</p><button className="nds-button" data-size="sm" data-variant="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div>
)} )}
<StorageQuota usage={storageUsage} /> <StorageQuota usage={storageUsage} />

View file

@ -1,7 +1,11 @@
// Rôle : attend les médias persistés avant de monter un outil éditable et propose une reprise locale en cas d'échec. // Rôle : affiche le chargement des outils et attend leurs médias persistés, avec reprise locale en cas d'échec.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
export function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
export function ModuleDataGate({ context, toolboxId, moduleId, children }) { export function ModuleDataGate({ context, toolboxId, moduleId, children }) {
const ready = context.isModuleDataReady?.(toolboxId, moduleId) ?? true; const ready = context.isModuleDataReady?.(toolboxId, moduleId) ?? true;
const load = context.ensureModuleData; const load = context.ensureModuleData;

View file

@ -1,20 +1,18 @@
// Rôle : rend les modules d'une toolbox, leur shell commun et leur réorganisation. // Rôle : rend les modules d'une toolbox, leur shell commun et leur réorganisation.
import { Suspense, useMemo, useState } from "react"; import { Suspense, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx";
import { useGuideChecklistPanel } from "../../games/useGuideChecklist.js";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { supportsTextExchange } from "./textImport.js"; import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx"; import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
import { ModuleDataGate } from "./ModuleDataGate.jsx"; import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
const MODULE_COLUMN_IDS = ["left", "right"]; const MODULE_COLUMN_IDS = ["left", "right"];
function getModuleMap(modules) {
return new Map(modules.map((module) => [module.id, module]));
}
function getModulesByIds(modules, ids) { function getModulesByIds(modules, ids) {
const moduleMap = getModuleMap(modules); const moduleMap = new Map(modules.map((module) => [module.id, module]));
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean); return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
} }
@ -157,6 +155,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
const label = definition.label || module.type; const label = definition.label || module.type;
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false); const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
const hasAddPanel = definition.editable && definition.addable !== false; const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type); const hasTextExchange = supportsTextExchange(module.type);
const scrollable = definition.scrollable && module.scrollable === true; const scrollable = definition.scrollable && module.scrollable === true;
@ -242,6 +241,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
<Icon name="add" /> <Icon name="add" />
</button> </button>
)} )}
<GuideChecklistToggle checklist={context.guideChecklist} panel={guidePanel} label={module.title || label} />
{hasTextExchange && ( {hasTextExchange && (
<button <button
className={`module-exchange-button nds-icon-button ${textExchangeOpen ? "active" : ""}`} className={`module-exchange-button nds-icon-button ${textExchangeOpen ? "active" : ""}`}
@ -264,6 +264,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
</div> </div>
</header> </header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}> <div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<GuideChecklistControls checklist={context.guideChecklist} {...guidePanel} />
<Suspense fallback={<ModuleLoadingFallback label={label} />}> <Suspense fallback={<ModuleLoadingFallback label={label} />}>
<ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}> <ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}>
<Component {...componentProps} /> <Component {...componentProps} />
@ -274,10 +275,6 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
); );
} }
function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) { function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({ const inlineEdit = useInlineEdit({
value, value,

View file

@ -1,18 +1,15 @@
// Rôle : présente la liaison checklist et l'état terminé communs aux cartes de guides. // Rôle : présente la liaison checklist et l'état terminé communs aux cartes de guides.
.guide-checklist-controls { .guide-checklist-controls {
display: grid;
align-items: center;
text-align: center; text-align: center;
gap: var(--space-3); margin-bottom: var(--space-3);
margin-bottom: var(--space-4); padding-bottom: var(--space-3);
padding: var(--space-3); border-bottom: 1px solid var(--color-border);
min-width: 0; min-width: 0;
.field { min-width: 0; margin: 0; } p { margin: var(--space-2) 0 0; color: var(--color-text-muted); font-size: var(--font-size-xs); }
select { width: 100%; min-width: 0; }
p { margin: 0; color: var(--color-text-muted); font-size: var(--font-size-sm); }
.nds-switch { max-width: 100%; } .nds-switch { max-width: 100%; }
.guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-3); } .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); }
.guide-checklist-mark { .guide-checklist-mark {
position: absolute; position: absolute;
top: 10px; top: 10px;