Add checklist creation for filtered game results
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-29 23:27:10 +02:00
parent 0acf5ecf21
commit 962816bbb9
15 changed files with 414 additions and 40 deletions

View file

@ -890,6 +890,11 @@ masquage. La colonne d'actions occupe la hauteur existante du
header afin que cet encart n'ajoute pas de ligne ou d'espace vertical. Cette 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 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. si la checklist, la toolbox liée ou la liaison jeu/toolbox disparaît.
Le bouton checklist des pages de résultats filtrables ouvre une modale NDS qui
rappelle que les filtres, le regroupement et le masquage déterminent les éléments
exportés. La copie texte reste disponible sans toolbox liée ; l'action de création
est alors désactivée et accompagnée d'une notice inline. Avec une toolbox liée, la
modale affiche son nom et crée la checklist dans son espace actif.
L'état terminé désature et atténue uniquement les portraits (ou les icônes de L'état terminé désature et atténue uniquement les portraits (ou les icônes de
catégorie Diablo). Le recto utilise uniquement la checkbox interactive ; la coche catégorie Diablo). Le recto utilise uniquement la checkbox interactive ; la coche
accessible « Coché » reste sur le verso des cartes retournables. Les textes restent accessible « Coché » reste sur le verso des cartes retournables. Les textes restent

View file

@ -58,7 +58,8 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier les textes avec retours à la ligne. - Vérifier les textes avec retours à la ligne.
- Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. La checkbox des cartes met à jour toutes les lignes exactes correspondantes, respecte les quantités cibles et ne retourne pas les cartes. - Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. La checkbox des cartes met à jour toutes les lignes exactes correspondantes, respecte les quantités cibles et ne retourne pas les cartes.
- 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 les résultats filtrables Aniimo, MHW et Diablo, vérifier que l'action checklist ouvre une modale rappelant que seuls les éléments visibles sont utilisés. La copie reste disponible sans toolbox liée ; la création est alors désactivée avec une indication explicite. Avec une toolbox liée, la création ajoute une nouvelle checklist dans son espace actif sans remplacer l'association guide/checklist existante.
- Pour Aniimo, vérifier la copie et la création des espèces ou des formes selon l'affichage, les noms français/anglais, la priorité des formes et le masquage avant regroupement. Compteurs, copie et création doivent correspondre aux résultats affichés.
## Listes de jeu ## Listes de jeu

View file

@ -298,7 +298,11 @@ affichage séparé. Les noms et formes français et anglais sont reconnus. Une l
sans forme couvre toutes les variantes ; une ligne spécifique à une forme prend sans forme couvre toutes les variantes ; une ligne spécifique à une forme prend
priorité sur la ligne générale. Le masquage des cochés précède le regroupement ; priorité sur la ligne générale. Le masquage des cochés précède le regroupement ;
le style d'une carte groupée dépend de la variante active. La copie et les le style d'une carte groupée dépend de la variante active. La copie et les
compteurs portent sur les résultats effectivement affichés. compteurs portent sur les résultats effectivement affichés. Les pages de résultats
filtrables peuvent aussi créer une nouvelle checklist dans l'espace actif de la
toolbox liée au jeu. Cette création reprend uniquement les éléments visibles,
initialisés à `qtyTarget: 1` et `qtyCurrent: 0`, et ne modifie pas l'association
guide/checklist conservée dans `localStorage`.
### Données persistantes ### Données persistantes

View file

@ -58,6 +58,35 @@ async function readModule(page, moduleId) {
}), moduleId); }), moduleId);
} }
async function readCreatedChecklist(page, title) {
return page.evaluate(async (title) => new Promise((resolve, reject) => {
const request = globalThis.indexedDB.open("sokkog", 1);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(["kv", "modules"], "readonly");
const toolboxesRequest = tx.objectStore("kv").get("toolboxes");
const modulesRequest = tx.objectStore("modules").getAll();
tx.oncomplete = () => {
const toolbox = toolboxesRequest.result.find((item) => item.id === "guide");
const module = toolbox.modules.find((item) => item.type === "checklist" && item.title === title);
const data = modulesRequest.result.find((entry) => entry.key === `guide:${module?.id}`)?.data;
const activeSpace = toolbox.spaces.find((space) => space.id === toolbox.activeSpaceId);
db.close();
resolve({ module, data, activeModuleIds: activeSpace?.moduleOrder.one || [] });
};
tx.onerror = () => reject(tx.error);
};
request.onerror = () => reject(request.error);
}), title);
}
async function copyVisibleResults(page) {
await page.locator(".results-copy-button").click();
const dialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" });
await dialog.getByRole("button", { name: "Copier le format texte", exact: true }).click();
await expect(dialog).toHaveCount(0);
}
async function openDrawer(page) { async function openDrawer(page) {
const drawer = page.locator("#toolbox-drawer"); const drawer = page.locator("#toolbox-drawer");
if (!await drawer.locator(".drawer-panel").isVisible()) await page.locator(".topbar-actions button").click(); if (!await drawer.locator(".drawer-panel").isVisible()) await page.locator(".topbar-actions button").click();
@ -105,25 +134,62 @@ test("Aniimo copies visible species or forms and round-trips through checklist i
const copy = page.locator(".results-copy-button"); const copy = page.locator(".results-copy-button");
await expect(copy).toHaveAccessibleName(/sans les variantes/); await expect(copy).toHaveAccessibleName(/sans les variantes/);
await expect(page.locator('[data-copy-target="true"]')).toHaveText("1 / 88 Aniimos"); await expect(page.locator('[data-copy-target="true"]')).toHaveText("1 / 88 Aniimos");
await copy.click(); await copyVisibleResults(page);
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("Loufeuteau:1"); await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("Loufeuteau:1");
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click(); await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
await expect(copy).toHaveAccessibleName(/toutes les variantes/); await expect(copy).toHaveAccessibleName(/toutes les variantes/);
await expect(page.locator('[data-copy-target="true"]')).toHaveText("3 / 208 variantes"); await expect(page.locator('[data-copy-target="true"]')).toHaveText("3 / 208 variantes");
await copy.click(); await copyVisibleResults(page);
const text = await page.evaluate(() => globalThis.navigator.clipboard.readText()); const text = await page.evaluate(() => globalThis.navigator.clipboard.readText());
const imported = importChecklistText(text, { uid: (prefix) => `${prefix}-${Math.random()}`, normalizeChecklistData }); const imported = importChecklistText(text, { uid: (prefix) => `${prefix}-${Math.random()}`, normalizeChecklistData });
expect(imported.items.map((item) => item.label)).toEqual(["Loufeuteau — Forme de base", "Loufeuteau — Forme des montagnes boisées", "Loufeuteau — Hautes-plaines"]); expect(imported.items.map((item) => item.label)).toEqual(["Loufeuteau — Forme de base", "Loufeuteau — Forme des montagnes boisées", "Loufeuteau — Hautes-plaines"]);
expect(imported.items.every((item) => item.qtyTarget === 1 && item.qtyCurrent === 0)).toBe(true); expect(imported.items.every((item) => item.qtyTarget === 1 && item.qtyCurrent === 0)).toBe(true);
await choose(page, "forms"); await choose(page, "forms");
await toggleHidden(page); await toggleHidden(page);
await copy.click(); await copyVisibleResults(page);
expect(await page.evaluate(() => globalThis.navigator.clipboard.readText())).not.toContain("Forme de base"); expect(await page.evaluate(() => globalThis.navigator.clipboard.readText())).not.toContain("Forme de base");
await choose(page, "perfect"); await choose(page, "perfect");
await expect(copy).toBeDisabled(); await expect(copy).toBeDisabled();
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
}); });
test("filtered results explain unavailable creation when no toolbox is linked", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.setViewportSize({ width: 390, height: 844 });
await resetSokkoStorage(page);
await page.goto(route);
await page.getByRole("searchbox").fill("emberpup");
await page.locator(".results-copy-button").click();
const dialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" });
await expect(dialog.getByText("Liez une toolbox à cette page pour créer directement la checklist.")).toBeVisible();
await expect(dialog.getByRole("button", { name: "Créer dans la toolbox", exact: true })).toBeDisabled();
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
await dialog.getByRole("button", { name: "Copier le format texte", exact: true }).click();
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("Loufeuteau:1");
});
test("filtered results create a checklist in the linked active space without replacing the guide association", async ({ page }) => {
await seed(page);
await page.goto(route);
await choose(page, "forms");
await page.getByRole("searchbox").fill("emberpup");
const associationBefore = await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey);
await page.locator(".results-copy-button").click();
const dialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" });
await expect(dialog.getByText("Guide toolbox", { exact: true })).toBeVisible();
await expect(dialog.getByText("Aniimos", { exact: true })).toBeVisible();
await dialog.getByRole("button", { name: "Créer dans la toolbox", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText("Checklist créée dans la toolbox associée.")).toBeVisible();
await expect.poll(async () => (await readCreatedChecklist(page, "Aniimos")).module?.id).toBeTruthy();
const created = await readCreatedChecklist(page, "Aniimos");
expect(created.activeModuleIds).toContain(created.module.id);
expect(normalizeChecklistData(created.data).items.map((item) => item.label)).toEqual(["Loufeuteau"]);
expect(normalizeChecklistData(created.data).items.every((item) => item.qtyTarget === 1 && item.qtyCurrent === 0)).toBe(true);
expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBe(associationBefore);
});
for (const width of [1280, 390]) { for (const width of [1280, 390]) {
test(`drawer linking, page controls and persistent restoration at ${width}px`, async ({ page }) => { test(`drawer linking, page controls and persistent restoration at ${width}px`, async ({ page }) => {
test.setTimeout(60000); test.setTimeout(60000);
@ -239,10 +305,10 @@ for (const width of [1280, 390]) {
}); });
} }
for (const [gameId, category, cardClass, dataPath, dataKey] of [ for (const [gameId, category, cardClass, dataPath, dataKey, checklistTitle] of [
["mhwilds", "monsters", ".monster-card", "mhwilds/monsters.json", "monsters"], ["mhwilds", "monsters", ".monster-card", "mhwilds/monsters.json", "monsters", "Monstres"],
["mhwilds", "endemic", ".endemic-card", "mhwilds/endemic_life.json", "endemicLife"], ["mhwilds", "endemic", ".endemic-card", "mhwilds/endemic_life.json", "endemicLife", "Faune endémique"],
["diablo4", "affixes", ".diablo4-affix-card", "diablo4/affixes_types.json", "affixes"] ["diablo4", "affixes", ".diablo4-affix-card", "diablo4/affixes_types.json", "affixes", "Affixes"]
]) { ]) {
test(`${gameId}/${category} links from its drawer and hides completed cards`, async ({ page }) => { test(`${gameId}/${category} links from its drawer and hides completed cards`, async ({ page }) => {
const item = json(dataPath)[dataKey][0]; const item = json(dataPath)[dataKey][0];
@ -250,6 +316,10 @@ for (const [gameId, category, cardClass, dataPath, dataKey] of [
await seed(page, gameId, { progress: [row(label, 1)] }); await seed(page, gameId, { progress: [row(label, 1)] });
await page.goto(`/#/games/${gameId}/${category}`); await page.goto(`/#/games/${gameId}/${category}`);
await expect(page.locator(cardClass).first()).toBeVisible(); await expect(page.locator(cardClass).first()).toBeVisible();
await page.locator(".results-copy-button").click();
const resultsDialog = page.getByRole("dialog", { name: "Utiliser les résultats visibles" });
await expect(resultsDialog.getByText(checklistTitle, { exact: true })).toBeVisible();
await resultsDialog.getByRole("button", { name: "Fermer", exact: true }).click();
const count = await page.locator(cardClass).count(); const count = await page.locator(cardClass).count();
await choose(page, "progress"); await choose(page, "progress");
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1); await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1);

View file

@ -100,8 +100,21 @@ export function validateSiteContent(site) {
"guideChecklist.completed", "guideChecklist.completed",
"guideChecklist.checkCard", "guideChecklist.checkCard",
"guideChecklist.uncheckCard", "guideChecklist.uncheckCard",
"guideChecklist.resultsModalTitle",
"guideChecklist.resultsModalDescription",
"guideChecklist.resultsChecklistLabel",
"guideChecklist.resultsTargetLabel",
"guideChecklist.resultsNoToolbox",
"guideChecklist.resultsCopyLabel",
"guideChecklist.resultsCreateLabel",
"guideChecklist.resultsCreatingLabel",
"guideChecklist.resultsClose",
"guideChecklist.resultsCopied",
"guideChecklist.resultsCopyError",
"guideChecklist.resultsCreateError",
"aniimo.copyChecklist", "aniimo.copyChecklist",
"aniimo.copyVariantsChecklist", "aniimo.copyVariantsChecklist",
"aniimo.variantsChecklistTitle",
"aniimo.statsTitle", "aniimo.statsTitle",
"aniimo.statsTotal", "aniimo.statsTotal",
"aniimo.stats.hp", "aniimo.stats.hp",

View file

@ -49,6 +49,10 @@ test("generic game routes and shared game components are wired", async () => {
assert.match(checklistCopyButton, /# \$\{title\}/); assert.match(checklistCopyButton, /# \$\{title\}/);
assert.match(checklistCopyButton, /navigator\.clipboard\.writeText/); assert.match(checklistCopyButton, /navigator\.clipboard\.writeText/);
assert.match(checklistCopyButton, /results-copy-button/); assert.match(checklistCopyButton, /results-copy-button/);
assert.match(checklistCopyButton, /results-checklist-modal/);
assert.match(checklistCopyButton, /filteredResults/);
assert.match(checklistCopyButton, /Créer dans la toolbox/);
assert.match(checklistCopyButton, /resultsNoToolbox/);
assert.match(gameListsPage, /export function GameListsPage/); assert.match(gameListsPage, /export function GameListsPage/);
assert.match(gameListsPage, /game-list-selector/); assert.match(gameListsPage, /game-list-selector/);
assert.match(gameListsPage, /game-list-create-button/); assert.match(gameListsPage, /game-list-create-button/);
@ -78,6 +82,8 @@ test("mhwilds and diablo pages expose expected route pieces", async () => {
assert.match(diablo4Overview, /export function Diablo4Overview/); assert.match(diablo4Overview, /export function Diablo4Overview/);
assert.match(diablo4Listing, /export function Diablo4Listing/); assert.match(diablo4Listing, /export function Diablo4Listing/);
assert.match(diablo4Listing, /CopyChecklistItemsButton/); assert.match(diablo4Listing, /CopyChecklistItemsButton/);
assert.match(diablo4Listing, /checklistTitle="Affixes"/);
assert.match(diablo4Listing, /filteredResults/);
assert.match(diablo4Filters, /export function Diablo4Filters/); assert.match(diablo4Filters, /export function Diablo4Filters/);
assert.match(diablo4Filters, /GameFiltersPanel/); assert.match(diablo4Filters, /GameFiltersPanel/);
assert.match(diablo4Filters, /variant="horizontal-menu"/); assert.match(diablo4Filters, /variant="horizontal-menu"/);
@ -92,11 +98,15 @@ test("mhwilds and diablo pages expose expected route pieces", async () => {
assert.match(mhwildsListing, /export function MhwildsListing/); assert.match(mhwildsListing, /export function MhwildsListing/);
assert.match(mhwildsListing, /GameBreadcrumb/); assert.match(mhwildsListing, /GameBreadcrumb/);
assert.match(mhwildsListing, /CopyChecklistItemsButton/); assert.match(mhwildsListing, /CopyChecklistItemsButton/);
assert.match(mhwildsListing, /checklistTitle=\{label\}/);
assert.match(mhwildsListing, /filteredResults/);
assert.match(mhwildsListing, /endemic_life/); assert.match(mhwildsListing, /endemic_life/);
assert.match(mhwildsLists, /export function MhwildsLists/); assert.match(mhwildsLists, /export function MhwildsLists/);
assert.match(mhwildsLists, /GameListsPage/); assert.match(mhwildsLists, /GameListsPage/);
assert.match(mhwildsLists, /selectedListId/); assert.match(mhwildsLists, /selectedListId/);
assert.match(aniimoPage, /AniimoLists/); assert.match(aniimoPage, /AniimoLists/);
assert.match(aniimoPage, /content\.variantsChecklistTitle/);
assert.match(aniimoPage, /filteredResults/);
assert.match(aniimoPage, /games\/aniimo\/lists/); assert.match(aniimoPage, /games\/aniimo\/lists/);
assert.match(aniimoFilters, /data-variant="horizontal-menu"/); assert.match(aniimoFilters, /data-variant="horizontal-menu"/);
assert.match(aniimoFilters, /GameFilterDropdown/); assert.match(aniimoFilters, /GameFilterDropdown/);

View file

@ -34,6 +34,9 @@ test("toolbox cards and pages retain their public UI conventions", async () => {
assert.match(toolboxActions, /type: "memos"/); assert.match(toolboxActions, /type: "memos"/);
assert.match(toolboxActions, /type: "links"/); assert.match(toolboxActions, /type: "links"/);
assert.match(toolboxActions, /#\/library/); assert.match(toolboxActions, /#\/library/);
assert.match(toolboxActions, /async function createChecklistFromResults/);
assert.match(toolboxActions, /persistToolboxSnapshot/);
assert.match(toolboxActions, /createChecklistFromList/);
assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/); assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/);
assert.match(toolboxStorage, /export function normalizeToolboxIcon/); assert.match(toolboxStorage, /export function normalizeToolboxIcon/);
assert.match(toolboxStorage, /export function normalizeToolboxCustomCover/); assert.match(toolboxStorage, /export function normalizeToolboxCustomCover/);

View file

@ -2808,14 +2808,27 @@
"hideCompleted": "Masquer les cochés", "hideCompleted": "Masquer les cochés",
"completed": "Coché", "completed": "Coché",
"checkCard": "Cocher dans la checklist", "checkCard": "Cocher dans la checklist",
"uncheckCard": "Décocher dans la checklist" "uncheckCard": "Décocher dans la checklist",
"resultsModalTitle": "Utiliser les résultats visibles",
"resultsModalDescription": "Seuls les éléments actuellement visibles sur la page seront utilisés, après application des filtres, du regroupement et du masquage des éléments cochés.",
"resultsChecklistLabel": "Nouvelle checklist",
"resultsTargetLabel": "Toolbox cible",
"resultsNoToolbox": "Liez une toolbox à cette page pour créer directement la checklist.",
"resultsCopyLabel": "Copier le format texte",
"resultsCreateLabel": "Créer dans la toolbox",
"resultsCreatingLabel": "Création…",
"resultsClose": "Fermer",
"resultsCopied": "Copié dans le presse-papiers.",
"resultsCopyError": "Impossible de copier les résultats dans le presse-papiers.",
"resultsCreateError": "Impossible de créer la checklist."
}, },
"aniimo": { "aniimo": {
"title": "Aniimos", "title": "Aniimos",
"description": "Filtrez les variantes par nom, éléments, rôle et compétences de logis, puis retournez les cartes pour consulter leurs capacités.", "description": "Filtrez les variantes par nom, éléments, rôle et compétences de logis, puis retournez les cartes pour consulter leurs capacités.",
"homeDescription": "Recherche par nom, rôle, éléments et compétences de logis.", "homeDescription": "Recherche par nom, rôle, éléments et compétences de logis.",
"copyChecklist": "Copier les Aniimos visibles sans les variantes pour checklist", "copyChecklist": "Utiliser les Aniimos visibles sans les variantes pour une checklist",
"copyVariantsChecklist": "Copier toutes les variantes visibles pour checklist", "copyVariantsChecklist": "Utiliser toutes les variantes visibles pour une checklist",
"variantsChecklistTitle": "Variantes Aniimo",
"statsTitle": "Statistiques", "statsTitle": "Statistiques",
"statsTotal": "Total", "statsTotal": "Total",
"stats": { "hp": "PV", "atk": "Attaque", "p_def": "Déf. phys.", "m_def": "Déf. mag.", "break": "Rupture", "regen": "Régén." }, "stats": { "hp": "PV", "atk": "Attaque", "p_def": "Déf. phys.", "m_def": "Déf. mag.", "break": "Rupture", "regen": "Régén." },

View file

@ -1,7 +1,9 @@
// Rôle : copie des données filtrées au format compatible avec l'import checklist. // Rôle : copie ou crée en checklist les données visibles des pages de jeu.
/* eslint-disable react-refresh/only-export-components -- expose aussi les formatters publics testés pour l'import checklist. */ /* eslint-disable react-refresh/only-export-components -- expose aussi les formatters publics testés pour l'import checklist. */
import { useState } from "react"; import { useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../components/Icon.jsx"; import { Icon } from "../../components/Icon.jsx";
import { lockBodyScroll } from "../../utils/bodyScrollLock.js";
async function copyText(value) { async function copyText(value) {
try { try {
@ -44,8 +46,105 @@ export function formatChecklistImportSections(sections = []) {
.join("\n\n"); .join("\n\n");
} }
export function CopyChecklistItemsButton({ labels, sections, title = "Copier pour checklist" }) { function ChecklistResultsModal({ checklistTitle, content, linkedToolbox, text, onCreate, onClose }) {
const titleId = useId();
const descriptionId = useId();
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
function handleKeyDown(event) {
if (event.key === "Escape" && !creating) onClose();
}
const unlock = lockBodyScroll();
document.addEventListener("keydown", handleKeyDown);
return () => {
unlock();
document.removeEventListener("keydown", handleKeyDown);
};
}, [creating, onClose]);
async function copyItems() {
setError("");
if (!await copyText(text)) {
setError(content.resultsCopyError || "Impossible de copier les résultats dans le presse-papiers.");
return;
}
notify(content.resultsCopied || "Copié dans le presse-papiers.");
onClose();
}
async function createChecklist() {
if (!linkedToolbox || creating) return;
setCreating(true);
setError("");
try {
if (await onCreate()) onClose();
else setError(content.resultsCreateError || "Impossible de créer la checklist.");
} catch {
setError(content.resultsCreateError || "Impossible de créer la checklist.");
} finally {
setCreating(false);
}
}
return createPortal(
<div className="confirm-modal-root results-checklist-modal-root">
<div className="confirm-backdrop nds-modal-backdrop" onClick={() => !creating && onClose()} />
<section className="confirm-modal nds-modal results-checklist-modal" role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId}>
<header className="results-checklist-modal-header">
<h2 id={titleId}>{content.resultsModalTitle || "Utiliser les résultats visibles"}</h2>
<button className="nds-icon-button" data-size="sm" type="button" disabled={creating} onClick={onClose} aria-label={content.resultsClose || "Fermer"} title={content.resultsClose || "Fermer"}>
<Icon name="close" />
</button>
</header>
<p id={descriptionId} className="results-checklist-modal-description">
{content.resultsModalDescription || "Seuls les éléments actuellement visibles sur la page seront utilisés, après application des filtres."}
</p>
<div className="results-checklist-modal-summary">
<span>{content.resultsChecklistLabel || "Checklist"}</span>
<strong>{checklistTitle}</strong>
</div>
{linkedToolbox ? (
<div className="results-checklist-modal-target">
<span>{content.resultsTargetLabel || "Toolbox cible"}</span>
<strong>{linkedToolbox.name}</strong>
</div>
) : (
<p className="results-checklist-modal-notice nds-inline-notice" data-variant="warning" role="status">
<Icon name="link" />
<span>{content.resultsNoToolbox || "Liez une toolbox à cette page pour créer directement la checklist."}</span>
</p>
)}
{error && <p className="results-checklist-modal-error nds-inline-notice" data-variant="danger" role="alert"><Icon name="close" /><span>{error}</span></p>}
<footer className="nds-modal-footer results-checklist-modal-actions">
<button className="nds-button" data-size="sm" type="button" disabled={creating} onClick={() => void copyItems()} autoFocus>
<Icon name="copy" />{content.resultsCopyLabel || "Copier le format texte"}
</button>
<button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={!linkedToolbox || creating} onClick={() => void createChecklist()}>
<span className="module-icon" aria-hidden="true"><span className="module-icon-svg module-icon-checklist" /></span>
{creating ? content.resultsCreatingLabel || "Création…" : content.resultsCreateLabel || "Créer dans la toolbox"}
</button>
</footer>
</section>
</div>,
document.body
);
}
export function CopyChecklistItemsButton({
labels,
sections,
title = "Copier pour checklist",
filteredResults = false,
checklistTitle = "Checklist",
linkedToolbox,
content = {},
onCreateChecklist
}) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [open, setOpen] = useState(false);
const text = sections ? formatChecklistImportSections(sections) : formatChecklistImportItems(labels); const text = sections ? formatChecklistImportSections(sections) : formatChecklistImportItems(labels);
async function copyItems() { async function copyItems() {
@ -55,17 +154,37 @@ export function CopyChecklistItemsButton({ labels, sections, title = "Copier pou
window.setTimeout(() => setCopied(false), 1400); window.setTimeout(() => setCopied(false), 1400);
} }
async function createChecklist() {
if (!onCreateChecklist) return false;
return onCreateChecklist({
title: checklistTitle,
...(sections ? { sections } : { items: labels || [] })
});
}
return ( return (
<button <>
className="filter-reset-button results-copy-button nds-icon-button" <button
data-size="xs" className="filter-reset-button results-copy-button nds-icon-button"
type="button" data-size="xs"
onClick={copyItems} type="button"
disabled={!text} onClick={filteredResults ? () => setOpen(true) : copyItems}
aria-label={title} disabled={!text}
title={copied ? "Copié" : title} aria-label={title}
> title={copied ? "Copié" : title}
<Icon name="copy" /> >
</button> <Icon name="copy" />
</button>
{filteredResults && open && (
<ChecklistResultsModal
checklistTitle={checklistTitle}
content={content}
linkedToolbox={linkedToolbox}
text={text}
onCreate={createChecklist}
onClose={() => setOpen(false)}
/>
)}
</>
); );
} }

View file

@ -28,7 +28,15 @@ function AniimoListing({ game, aniimo, filters, setFilters, content }) {
<div className="game-title-row"> <div className="game-title-row">
<h1>{content.title}</h1> <h1>{content.title}</h1>
<span className="results-count aniimo-results-count" role="status"><span data-copy-target={active.grouped || undefined}>{groups.length} / {totalAniimos} {content.title}</span>{" · "}<span data-copy-target={!active.grouped || undefined}>{visible.length} / {aniimo.variants.length} {content.variants.toLowerCase()}</span></span> <span className="results-count aniimo-results-count" role="status"><span data-copy-target={active.grouped || undefined}>{groups.length} / {totalAniimos} {content.title}</span>{" · "}<span data-copy-target={!active.grouped || undefined}>{visible.length} / {aniimo.variants.length} {content.variants.toLowerCase()}</span></span>
<CopyChecklistItemsButton labels={active.grouped ? groups.map(([variant]) => t(variant.name)) : visible.map((variant) => `${t(variant.name)} — ${t(variant.form)}`)} title={active.grouped ? content.copyChecklist : content.copyVariantsChecklist} /> <CopyChecklistItemsButton
labels={active.grouped ? groups.map(([variant]) => t(variant.name)) : visible.map((variant) => `${t(variant.name)} — ${t(variant.form)}`)}
title={active.grouped ? content.copyChecklist : content.copyVariantsChecklist}
filteredResults
checklistTitle={active.grouped ? content.title : content.variantsChecklistTitle}
linkedToolbox={checklist.toolbox}
content={checklist.content}
onCreateChecklist={checklist.createChecklistFromResults}
/>
</div> </div>
<p>{content.description}</p> <p>{content.description}</p>
</div> </div>

View file

@ -27,7 +27,15 @@ export function Diablo4Listing({ game, diablo4, filters, setFilters }) {
<div className="game-title-row"> <div className="game-title-row">
<h1>Affixes</h1> <h1>Affixes</h1>
<span className="results-count">{visible.length} / {diablo4.affixes.length}</span> <span className="results-count">{visible.length} / {diablo4.affixes.length}</span>
<CopyChecklistItemsButton labels={visible.map((affix) => affix.label)} title="Copier les affixes visibles pour checklist" /> <CopyChecklistItemsButton
labels={visible.map((affix) => affix.label)}
title="Utiliser les affixes visibles pour une checklist"
filteredResults
checklistTitle="Affixes"
linkedToolbox={checklist.toolbox}
content={checklist.content}
onCreateChecklist={checklist.createChecklistFromResults}
/>
</div> </div>
<p>Filtrez les affixes par nom et catégories pour retrouver rapidement les statistiques utiles à votre build.</p> <p>Filtrez les affixes par nom et catégories pour retrouver rapidement les statistiques utiles à votre build.</p>
</div> </div>

View file

@ -29,7 +29,15 @@ export function MhwildsListing({ category, game, mhwilds, filters, setFilters, t
<div className="game-title-row"> <div className="game-title-row">
<h1>{label}</h1> <h1>{label}</h1>
<span className="results-count">{visible.length} / {items.length}</span> <span className="results-count">{visible.length} / {items.length}</span>
<CopyChecklistItemsButton labels={visible.map((item) => t(item.name, { capitalize: true }))} title={`Copier les ${label.toLowerCase()} visibles pour checklist`} /> <CopyChecklistItemsButton
labels={visible.map((item) => t(item.name, { capitalize: true }))}
title={`Utiliser les ${label.toLowerCase()} visibles pour une checklist`}
filteredResults
checklistTitle={label}
linkedToolbox={checklist.toolbox}
content={checklist.content}
onCreateChecklist={checklist.createChecklistFromResults}
/>
</div> </div>
<p>{isMonsters ? "Filtrez les monstres par nom et faiblesses, puis consultez leurs dégâts détaillés." : "Filtrez la faune par nom et zones d'apparition."}</p> <p>{isMonsters ? "Filtrez les monstres par nom et faiblesses, puis consultez leurs dégâts détaillés." : "Filtrez la faune par nom et zones d'apparition."}</p>
</div> </div>

View file

@ -38,6 +38,7 @@ export function useGuideChecklist(requestedGuideId) {
content, toolbox, selected, hideCompleted, isCompleted, canToggle, toggleCompleted, guideId, available: entries !== null, content, toolbox, selected, hideCompleted, isCompleted, canToggle, toggleCompleted, guideId, available: entries !== null,
lists: lists.filter((list) => list.count || list.id === selected?.id), lists: lists.filter((list) => list.count || list.id === selected?.id),
isVisible: (key) => !hideCompleted || !isCompleted(key), isVisible: (key) => !hideCompleted || !isCompleted(key),
createChecklistFromResults: (checklist) => actions.createChecklistFromResults(gameId, checklist),
associate: (moduleId) => guideChecklistStorage.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null), associate: (moduleId) => guideChecklistStorage.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),
toggleHidden: () => guideChecklistStorage.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted }) toggleHidden: () => guideChecklistStorage.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted })
}; };

View file

@ -116,37 +116,65 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
}); });
} }
function createChecklistFromList(gameId, list) { async function createChecklistFromResults(gameId, checklist) {
const toolboxId = store.links[gameId] || ""; const toolboxId = store.links[gameId] || "";
const toolbox = store.toolboxes.find((item) => item.id === toolboxId); const toolbox = store.toolboxes.find((item) => item.id === toolboxId);
if (!toolbox || !list) return false; if (!toolbox || !checklist) return false;
const moduleId = uid("mod"); const moduleId = uid("mod");
const sections = (list.categories || []).map((category) => ({ const sourceSections = Array.isArray(checklist.sections)
? checklist.sections
: [{ title: "", items: checklist.items || [] }];
const sections = sourceSections.map((category) => ({
id: uid("section"), id: uid("section"),
title: category.title || "", title: category.title || "",
items: (category.items || []).map((item) => ({ items: (category.items || []).map((item) => ({
id: uid("item"), id: uid("item"),
label: item.name, label: typeof item === "object" && item !== null ? String(item.label || item.name || "").trim() : String(item || "").trim(),
qtyTarget: Math.max(1, Number.parseInt(item.quantity, 10) || 1), qtyTarget: Math.max(1, Number.parseInt(item?.quantity ?? item?.qtyTarget, 10) || 1),
qtyCurrent: 0 qtyCurrent: 0
})).filter((item) => item.label) })).filter((item) => item.label)
})).filter((section) => section.items.length); })).filter((section) => section.items.length);
if (!sections.length) return false; if (!sections.length) return false;
const title = String(checklist.title || "Checklist").trim() || "Checklist";
const updatedAt = new Date().toISOString();
const data = compactModuleDataForStorage("checklist", { sections });
let created = false;
const saved = await store.persistToolboxSnapshot((current) => {
if (current.links[gameId] !== toolboxId || !current.toolboxes.some((item) => item.id === toolboxId)) return current;
created = true;
return {
...current,
toolboxes: current.toolboxes.map((item) => item.id === toolboxId
? { ...addModuleToToolboxSpace(item, { id: moduleId, type: "checklist", title }, item.activeSpaceId), updatedAt }
: item),
moduleData: { ...current.moduleData, [globalModuleKey(toolboxId, moduleId)]: data }
};
});
if (saved && created) notify("Checklist créée dans la toolbox associée.");
return Boolean(saved && created);
}
function createChecklistFromList(gameId, list) {
const toolboxId = store.links[gameId] || "";
const toolbox = store.toolboxes.find((item) => item.id === toolboxId);
if (!toolbox || !list) return false;
setConfirmModal({ setConfirmModal({
title: "Créer une checklist", title: "Créer une checklist",
message: `Créer la checklist "${list.title || "Checklist"}" dans la toolbox "${toolbox.name}" ?`, message: `Créer la checklist "${list.title || "Checklist"}" dans la toolbox "${toolbox.name}" ?`,
confirmLabel: "Créer", confirmLabel: "Créer",
onResolve: (confirmed) => { onResolve: (confirmed) => {
if (!confirmed) return; if (!confirmed) return;
const updatedAt = new Date().toISOString(); void createChecklistFromResults(gameId, {
store.setToolboxes((toolboxes) => toolboxes.map((item) => item.id === toolboxId title: list.title || "Checklist",
? { ...addModuleToToolboxSpace(item, { id: moduleId, type: "checklist", title: list.title || "Checklist" }, item.activeSpaceId), updatedAt } sections: (list.categories || []).map((category) => ({
: item)); title: category.title || "",
store.updateModuleData(toolboxId, moduleId, { sections }, "checklist"); items: category.items || []
notify("Checklist créée dans la toolbox associée."); }))
});
} }
}); });
return true; return true;
@ -263,6 +291,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
setLinkModalGameId, setLinkModalGameId,
setDrawerGameId, setDrawerGameId,
setImage, setImage,
createChecklistFromResults,
createChecklistFromList, createChecklistFromList,
addImageFiles, addImageFiles,
ensureModuleData: store.ensureModuleData, ensureModuleData: store.ensureModuleData,

View file

@ -19,6 +19,88 @@
min-height: 32px; min-height: 32px;
} }
.results-checklist-modal.nds-modal {
width: min(560px, calc(100vw - 32px));
}
.results-checklist-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.results-checklist-modal-header h2,
.results-checklist-modal .results-checklist-modal-description,
.results-checklist-modal .results-checklist-modal-notice,
.results-checklist-modal .results-checklist-modal-error {
margin-bottom: 0;
}
.results-checklist-modal-description {
margin-top: var(--space-3);
color: var(--color-text-secondary);
line-height: 1.55;
}
.results-checklist-modal-summary,
.results-checklist-modal-target {
display: grid;
gap: 4px;
margin-top: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.32);
}
.results-checklist-modal-summary span,
.results-checklist-modal-target span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 800;
text-transform: uppercase;
}
.results-checklist-modal-summary strong,
.results-checklist-modal-target strong {
color: var(--color-text-primary);
overflow-wrap: anywhere;
}
.results-checklist-modal-notice,
.results-checklist-modal-error {
margin-top: var(--space-4);
}
.results-checklist-modal-actions {
margin-top: var(--space-5);
}
.results-checklist-modal-actions .module-icon {
width: 20px;
height: 20px;
border: 0;
background: transparent;
box-shadow: none;
}
.results-checklist-modal-actions .module-icon-svg {
width: 18px;
height: 18px;
}
@media (max-width: 600px) {
.results-checklist-modal-actions {
align-items: stretch;
flex-direction: column;
}
.results-checklist-modal-actions .nds-button {
width: 100%;
}
}
.game-list-help-link { .game-list-help-link {
width: fit-content; width: fit-content;
margin-top: var(--space-3); margin-top: var(--space-3);