Link guide cards to checklists and refine Aniimo ability filters
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s

This commit is contained in:
Shinuwa 2026-09-24 23:55:06 +02:00
parent b5901b31e3
commit 7b23a8601a
23 changed files with 193 additions and 51 deletions

View file

@ -33,3 +33,12 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
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;
}
export function GuideChecklistCheckbox({ checklist, itemKey }) {
if (!checklist.canToggle(itemKey)) return null;
const completed = checklist.isCompleted(itemKey);
const label = completed ? checklist.content.uncheckCard : checklist.content.checkCard;
return <label className="guide-checklist-checkbox" title={label} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={completed} onChange={() => checklist.toggleCompleted(itemKey)} aria-label={label} />
</label>;
}

View file

@ -1,6 +1,6 @@
// Rôle : affiche une carte Aniimo retournable et les onglets des variantes correspondantes.
import { useId, useLayoutEffect, useRef, useState } from "react";
import { GuideChecklistMark } from "../GuideChecklistControls.jsx";
import { GuideChecklistCheckbox, GuideChecklistMark } from "../GuideChecklistControls.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { ANIIMO_STATS, aniimoAsset } from "./utils.js";
@ -15,7 +15,8 @@ function shortForm(label) {
return (words.length === 1 ? words[0].slice(0, 3) : words.map((word) => word[0]).join("")).toLocaleUpperCase("fr");
}
function CardFaces({ variant, t, content, completed, completedLabel, statsMax }) {
function CardFaces({ variant, t, content, checklist, statsMax }) {
const completed = checklist.isCompleted(variant.key);
const [flipped, setFlipped] = useState(false);
const frontButton = useRef(null);
const backButton = useRef(null);
@ -35,8 +36,7 @@ function CardFaces({ variant, t, content, completed, completedLabel, statsMax })
<div className="aniimo-card-inner">
<div className="aniimo-face aniimo-front nds-card" aria-hidden={flipped} inert={flipped}>
{flipButton(false, frontButton)}
<GuideChecklistMark completed={completed} label={completedLabel} />
<div className="aniimo-portrait"><img src={variant.image} alt={t(variant.name)} width="512" height="840" loading="lazy" /></div>
<div className="aniimo-portrait"><img src={variant.image} alt={t(variant.name)} width="512" height="840" loading="lazy" /><GuideChecklistCheckbox checklist={checklist} itemKey={variant.key} /></div>
<div className="aniimo-card-body">
<h2 title={t(variant.name)}>{t(variant.name)}</h2>
<p className="aniimo-form" title={t(variant.form)}>{t(variant.form)}</p>
@ -45,7 +45,7 @@ function CardFaces({ variant, t, content, completed, completedLabel, statsMax })
</div>
<div className="aniimo-face aniimo-back nds-card" aria-hidden={!flipped} inert={!flipped}>
{flipButton(true, backButton)}
<GuideChecklistMark completed={completed} label={completedLabel} />
<GuideChecklistMark completed={completed} label={checklist.content.completed} />
<div className="aniimo-card-body aniimo-back-heading">
<h2 title={t(variant.name)}>{t(variant.name)}</h2>
<p className="aniimo-form" title={t(variant.form)}>{t(variant.form)}</p>
@ -79,7 +79,7 @@ function CardFaces({ variant, t, content, completed, completedLabel, statsMax })
);
}
export function AniimoCard({ variants, t, content, isCompleted, completedLabel, statsMax }) {
export function AniimoCard({ variants, t, content, checklist, statsMax }) {
const id = useId();
const [selectedKey, setSelectedKey] = useState(variants[0].key);
const variant = variants.find((item) => item.key === selectedKey) || variants[0];
@ -91,7 +91,7 @@ export function AniimoCard({ variants, t, content, isCompleted, completedLabel,
target.parentElement.children[index].focus();
}
return (
<article className="aniimo-result" data-checklist-complete={isCompleted(variant.key) || undefined} data-aniimo={variant.aniimoId} data-form={variant.form} aria-label={t(variant.name)}>
<article className="aniimo-result" data-checklist-complete={checklist.isCompleted(variant.key) || undefined} data-aniimo={variant.aniimoId} data-form={variant.form} aria-label={t(variant.name)}>
<div className="aniimo-tabs-slot">
{multiple && <div className="aniimo-tabs nds-radio-button-group" data-size="xs" role="tablist" aria-label={`${content.variants} — ${t(variant.name)}`}>
{variants.map((item, index) => <Tooltip as="button" content={t(item.form)} aria-label={t(item.form)} key={item.key} type="button" role="tab" data-form={item.form} id={`${id}-${item.form}`} aria-controls={`${id}-panel`} aria-selected={item.key === variant.key} tabIndex={item.key === variant.key ? 0 : -1} className={item.key === variant.key ? "active" : ""} onClick={() => setSelectedKey(item.key)} onKeyDown={(event) => {
@ -103,7 +103,7 @@ export function AniimoCard({ variants, t, content, isCompleted, completedLabel,
</div>}
</div>
<div id={`${id}-panel`} role={multiple ? "tabpanel" : undefined} aria-labelledby={multiple ? `${id}-${variant.form}` : undefined}>
<CardFaces variant={variant} t={t} content={content} completed={isCompleted(variant.key)} completedLabel={completedLabel} statsMax={statsMax} />
<CardFaces variant={variant} t={t} content={content} checklist={checklist} statsMax={statsMax} />
</div>
</article>
);

View file

@ -14,6 +14,16 @@ function LogicChoice({ label, value, onChange, content }) {
);
}
function LevelOperatorIcon({ exact = false }) {
return (
<svg className="aniimo-level-operator-icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
{exact
? <path d="M4.5 7h11M4.5 13h11" />
: <path d="m4.5 3.75 11 4.75-11 4.75M4.5 16.5h11" />}
</svg>
);
}
export function AniimoFilters({ filters, onChange, options, t, content }) {
const [collapsed, setCollapsed] = useState({});
const abilityOptions = [...options.abilities].sort((a, b) => Number(!options.elements.includes(a)) - Number(!options.elements.includes(b)));
@ -46,8 +56,14 @@ export function AniimoFilters({ filters, onChange, options, t, content }) {
<Icon name={collapsed[key] ? "chevron-down" : "chevron-up"} />
</button></legend>
<div id={`aniimo-filter-${key}`} hidden={collapsed[key]}>
{key !== "roles" && <LogicChoice label={content[key]} content={content} value={filters[`${key}Logic`]} onChange={(logic) => onChange({ ...filters, [`${key}Logic`]: logic })} />}
{key === "abilities" && <p className="aniimo-levels-hint" id="aniimo-levels-hint">{content.abilityLevelsHint}</p>}
{key === "elements" && <LogicChoice label={content[key]} content={content} value={filters.elementsLogic} onChange={(logic) => onChange({ ...filters, elementsLogic: logic })} />}
{key === "abilities" && <div className="filter-logic">
<span>{content.levelMode}</span>
<div className="filter-logic-group nds-radio-button-group" data-size="xs" role="group" aria-label={content.levelMode}>
<button type="button" className={!filters.abilityExact ? "active" : ""} aria-label={content.minimum} title={content.minimum} aria-pressed={!filters.abilityExact} onClick={() => onChange({ ...filters, abilityExact: false })}><LevelOperatorIcon /></button>
<button type="button" className={filters.abilityExact ? "active" : ""} aria-label={content.exactLevel} title={content.exactLevel} aria-pressed={filters.abilityExact} onClick={() => onChange({ ...filters, abilityExact: true })}><LevelOperatorIcon exact /></button>
</div>
</div>}
<div className={`aniimo-filter-options ${key === "abilities" ? "aniimo-ability-options" : "aniimo-chip-options"}`} role="region" aria-label={content[key]}>
{(key === "abilities" ? abilityOptions : options[key]).map((option) => {
const isAbility = key === "abilities";
@ -56,7 +72,7 @@ export function AniimoFilters({ filters, onChange, options, t, content }) {
const level = filters.abilities[option] || 0;
return <div key={option} className="aniimo-ability-filter" data-ability-group={options.elements.includes(option) ? "elements" : "other"} data-active={level > 0 || undefined}>
<label htmlFor={rangeId}><img src={aniimoAsset(option)} alt="" width="18" height="18" /><span>{t(option)}</span></label>
<input id={rangeId} className="nds-range" data-size="sm" type="range" min="0" max="4" step="1" value={level} aria-label={`${t(option)} — ${content.minimum}`} aria-describedby="aniimo-levels-hint" aria-valuetext={level ? `${content.minimum} ${level}` : content.abilityDisabled} onChange={(event) => {
<input id={rangeId} className="nds-range" data-size="sm" type="range" min="0" max="4" step="1" value={level} aria-label={`${t(option)} — ${filters.abilityExact ? content.exactLevel : content.minimum}`} aria-valuetext={level ? `${filters.abilityExact ? content.exactLevel : content.minimum} ${level}` : content.abilityDisabled} onChange={(event) => {
const abilities = { ...filters.abilities };
const minimum = Number(event.target.value);
if (minimum > 0) abilities[option] = minimum;

View file

@ -36,7 +36,7 @@ function AniimoListing({ game, aniimo, filters, setFilters, content }) {
<section className="game-layout aniimo-layout">
<aside className="game-filters"><AniimoFilters filters={active} onChange={update} options={aniimo.filterOptions} t={t} content={content} /></aside>
<section className="game-results" aria-label={content.results}>
{cards.length ? <div className="game-grid aniimo-grid">{cards.map((variants) => <AniimoCard key={active.grouped ? variants[0].aniimoId : variants[0].key} variants={variants} t={t} content={content} isCompleted={checklist.isCompleted} completedLabel={checklist.content.completed} statsMax={statsMax} />)}</div> : <div className="empty nds-empty-state"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>}
{cards.length ? <div className="game-grid aniimo-grid">{cards.map((variants) => <AniimoCard key={active.grouped ? variants[0].aniimoId : variants[0].key} variants={variants} t={t} content={content} checklist={checklist} statsMax={statsMax} />)}</div> : <div className="empty nds-empty-state"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>}
</section>
</section>
</>;

View file

@ -1,6 +1,6 @@
// Rôle : prépare les variantes Aniimo et applique les critères avant leur regroupement.
export function createAniimoFilters() {
return { name: "", prismanaOnly: false, elements: [], roles: [], abilities: {}, elementsLogic: "and", abilitiesLogic: "and", grouped: true };
return { name: "", prismanaOnly: false, elements: [], roles: [], abilities: {}, elementsLogic: "and", abilityExact: false, grouped: true };
}
export const ANIIMO_STATS = ["hp", "atk", "p_def", "m_def", "break", "regen"];
@ -54,7 +54,7 @@ export function filterAniimoVariants(variants, filters) {
&& (!filters.prismanaOnly || variant.form === "prismana_form")
&& matchesSelection(filters.elements, filters.elementsLogic, (element) => variant.elements.includes(element))
&& (!filters.roles.length || filters.roles.includes(variant.role))
&& matchesSelection(abilities, filters.abilitiesLogic, ([type, minimum]) => variant.homeland_abilities.some((ability) => ability.type === type && ability.level >= minimum))
&& abilities.every(([type, level]) => variant.homeland_abilities.some((ability) => ability.type === type && (filters.abilityExact ? ability.level === level : ability.level >= level)))
));
}

View file

@ -1,11 +1,12 @@
// Rôle : affiche une carte d'affixe Diablo IV et ses catégories.
import { GuideChecklistMark } from "../GuideChecklistControls.jsx";
import { GuideChecklistCheckbox } from "../GuideChecklistControls.jsx";
import { getCategoryIconStyle, getCategoryLabel } from "./utils.js";
export function Diablo4AffixCard({ affix, categoryMap, completed, completedLabel }) {
export function Diablo4AffixCard({ affix, categoryMap, checklist }) {
const completed = checklist.isCompleted(affix.id);
return (
<article className="compact-data-card nds-card diablo4-affix-card" data-variant="interactive" data-checklist-complete={completed || undefined}>
<GuideChecklistMark completed={completed} label={completedLabel} />
<GuideChecklistCheckbox checklist={checklist} itemKey={affix.id} />
<div className="diablo4-affix-card-body">
<h2>{affix.label}</h2>
<div className="diablo4-category-list" aria-label="Catégories">

View file

@ -38,7 +38,7 @@ export function Diablo4Listing({ game, diablo4, filters, setFilters }) {
<section className="game-results" aria-live="polite">
<div className="game-grid diablo4-affix-grid">
{visible.length ? visible.map((affix) => (
<Diablo4AffixCard key={affix.id} affix={affix} categoryMap={diablo4.categoryMap} completed={checklist.isCompleted(affix.id)} completedLabel={checklist.content.completed} />
<Diablo4AffixCard key={affix.id} affix={affix} categoryMap={diablo4.categoryMap} checklist={checklist} />
)) : (
<div className="empty nds-empty-state">
<h2>Aucun résultat</h2>

View file

@ -19,14 +19,29 @@ export function matchGuideChecklist(entries, data) {
const findRows = (labels = []) => [...new Set(labels.map(normalizeGuideLabel))].flatMap((label) => byLabel.get(label) || []);
const matched = new Set();
const completed = new Set();
const rowIds = new Map();
for (const entry of entries) {
const specific = findRows(entry.labels);
const matches = specific.length ? specific : findRows(entry.fallbackLabels);
if (!matches.length) continue;
matched.add(entry.key);
rowIds.set(entry.key, [...new Set(matches.map((row) => row.id).filter(Boolean))]);
if (matches.every((row) => Number(row.qtyCurrent || 0) >= Math.max(1, Number(row.qtyTarget || 1)))) completed.add(entry.key);
}
return { count: matched.size, completed };
return { count: matched.size, completed, rowIds };
}
export function toggleGuideChecklistRows(data, ids, completed) {
const targetIds = new Set(ids || []);
if (!targetIds.size) return data;
const update = (items = []) => items.map((item) => targetIds.has(item.id)
? { ...item, qtyCurrent: completed ? Math.max(1, Number(item.qtyTarget || 1)) : 0 }
: item);
return {
...data,
...(Array.isArray(data?.items) ? { items: update(data.items) } : {}),
...(Array.isArray(data?.sections) ? { sections: data.sections.map((section) => ({ ...section, items: update(section.items) })) } : {})
};
}
export function validGuideAssociations(value, toolboxes, links) {

View file

@ -45,8 +45,8 @@ export function MhwildsListing({ category, game, mhwilds, filters, setFilters, t
<div className={`game-grid ${isMonsters ? "monster-grid" : "endemic-grid"}`}>
{visible.length ? visible.map((item) => (
isMonsters
? <MonsterCard key={item.name} monster={item} t={t} completed={checklist.isCompleted(item.name)} completedLabel={checklist.content.completed} />
: <EndemicCard key={item.name} item={item} t={t} completed={checklist.isCompleted(item.name)} completedLabel={checklist.content.completed} />
? <MonsterCard key={item.name} monster={item} t={t} checklist={checklist} />
: <EndemicCard key={item.name} item={item} t={t} checklist={checklist} />
)) : (
<div className="empty nds-empty-state">
<h2>Aucun résultat</h2>

View file

@ -1,9 +1,10 @@
// Rôle : affiche une carte de faune endémique Monster Hunter Wilds.
import { GuideChecklistMark } from "../../GuideChecklistControls.jsx";
import { GuideChecklistCheckbox } from "../../GuideChecklistControls.jsx";
import { IconRow } from "./IconRow.jsx";
import { assetPath, getConditionValues, normalizeText } from "../utils.js";
export function EndemicCard({ item, t, completed, completedLabel }) {
export function EndemicCard({ item, t, checklist }) {
const completed = checklist.isCompleted(item.name);
const locations = getConditionValues(item.locations);
const description = t(item.description);
const normalizedDescription = normalizeText(description);
@ -13,9 +14,9 @@ export function EndemicCard({ item, t, completed, completedLabel }) {
return (
<article className="mhwilds-card endemic-card" data-checklist-complete={completed || undefined}>
<GuideChecklistMark completed={completed} label={completedLabel} />
<div className="mhwilds-card-art">
<img src={assetPath(item.name)} alt={t(item.name, { capitalize: true })} loading="lazy" />
<GuideChecklistCheckbox checklist={checklist} itemKey={item.name} />
</div>
<div className="mhwilds-card-body">
<h2>{t(item.name, { capitalize: true })}</h2>

View file

@ -1,11 +1,12 @@
// Rôle : affiche une carte monstre retournable avec faiblesses et hitzones.
import { GuideChecklistMark } from "../../GuideChecklistControls.jsx";
import { GuideChecklistCheckbox, GuideChecklistMark } from "../../GuideChecklistControls.jsx";
import { useState } from "react";
import { DamageTable } from "./DamageTable.jsx";
import { IconRow } from "./IconRow.jsx";
import { assetPath, getConditionValues } from "../utils.js";
export function MonsterCard({ monster, t, completed, completedLabel }) {
export function MonsterCard({ monster, t, checklist }) {
const completed = checklist.isCompleted(monster.name);
const [flipped, setFlipped] = useState(false);
const weaknesses = getConditionValues(monster.weaknesses);
const ailments = getConditionValues(monster.ailments).filter((value) => value !== "none");
@ -23,16 +24,16 @@ export function MonsterCard({ monster, t, completed, completedLabel }) {
tabIndex="0"
role="button"
aria-pressed={flipped}
aria-label={`Afficher les dégâts de ${t(monster.name, { capitalize: true })}${completed ? ` — ${completedLabel}` : ""}`}
aria-label={`Afficher les dégâts de ${t(monster.name, { capitalize: true })}${completed ? ` — ${checklist.content.completed}` : ""}`}
>
<div className="mhwilds-card-inner">
<div className="mhwilds-card-face mhwilds-card-front">
<GuideChecklistMark completed={completed} label={completedLabel} />
<span className="mhwilds-flip-indicator" aria-hidden="true">
<span className="ui-icon ui-icon-flip" />
</span>
<div className="mhwilds-card-art">
<img src={assetPath(monster.name)} alt={t(monster.name, { capitalize: true })} loading="lazy" />
<GuideChecklistCheckbox checklist={checklist} itemKey={monster.name} />
</div>
<div className="mhwilds-card-body">
<p className="eyebrow">{t(monster.type, { capitalize: true })}</p>
@ -42,7 +43,7 @@ export function MonsterCard({ monster, t, completed, completedLabel }) {
</div>
</div>
<div className="mhwilds-card-face mhwilds-card-back">
<GuideChecklistMark completed={completed} label={completedLabel} />
<GuideChecklistMark completed={completed} label={checklist.content.completed} />
<span className="mhwilds-flip-indicator" aria-hidden="true">
<span className="ui-icon ui-icon-flip" />
</span>

View file

@ -1,6 +1,6 @@
// Rôle : partage la progression réactive et les commandes de liaison entre les guides.
import { createContext, useContext, useId, useMemo, useState } from "react";
import { getGuideChecklistEntries, matchGuideChecklist } from "./guideChecklist.js";
import { getGuideChecklistEntries, matchGuideChecklist, toggleGuideChecklistRows } from "./guideChecklist.js";
export const GuideChecklistContext = createContext(null);
@ -12,7 +12,7 @@ export function useGuideChecklistPanel(checklist, module) {
}
export function useGuideChecklist(requestedGuideId) {
const { toolboxes, links, moduleData, guideChecklistSession, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
const { toolboxes, links, moduleData, guideChecklistSession, 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];
@ -26,8 +26,15 @@ export function useGuideChecklist(requestedGuideId) {
const selected = lists.find((list) => list.id === association?.moduleId);
const hideCompleted = Boolean(selected && association.hideCompleted);
const isCompleted = (key) => Boolean(selected?.completed.has(key));
const canToggle = (key) => Boolean(selected?.rowIds.get(key)?.length);
function toggleCompleted(key) {
const ids = selected?.rowIds.get(key);
if (!ids?.length) return;
const data = moduleData[`${toolbox.id}:${selected.id}`];
actions.updateModuleData(toolbox.id, selected.id, toggleGuideChecklistRows(data, ids, !isCompleted(key)), "checklist");
}
return {
content, toolbox, selected, hideCompleted, isCompleted, 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),
isVisible: (key) => !hideCompleted || !isCompleted(key),
associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),

View file

@ -40,7 +40,18 @@
&[data-active] .aniimo-minimum-value { color: var(--color-accent-gold); font-weight: 700; }
}
.aniimo-minimum-value { text-align: right; font-variant-numeric: tabular-nums; }
.aniimo-levels-hint { margin: 4px 0; color: var(--color-text-muted); font-size: var(--font-size-xs); }
.aniimo-level-operator-icon {
display: block;
width: 20px;
height: 20px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
shape-rendering: geometricPrecision;
}
.aniimo-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); }
.aniimo-result { min-width: 0; max-width: 280px; width: 100%; }
.aniimo-tabs-slot { height: 36px; min-width: 0; padding: 2px; }

View file

@ -10,6 +10,25 @@
.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-checkbox {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 3;
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: rgba(7, 10, 24, 0.82);
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.28);
}
.guide-checklist-checkbox:hover { border-color: var(--color-border-hover); }
.aniimo-portrait,
.mhwilds-card-art { position: relative; }
.diablo4-affix-card > .guide-checklist-checkbox { right: 12px; bottom: 12px; }
.guide-checklist-mark {
position: absolute;
top: 10px;
@ -21,7 +40,7 @@
.diablo4-affix-card .guide-checklist-mark,
.aniimo-back .guide-checklist-mark { top: auto; left: auto; right: 10px; bottom: 10px; }
.mhwilds-card-back .guide-checklist-mark { left: auto; right: 50px; }
.diablo4-affix-card .diablo4-category-list { padding-right: 24px; }
.diablo4-affix-card .diablo4-category-list { padding-right: 34px; }
[data-checklist-complete="true"] {
.aniimo-back-details.nds-scrollbox[data-variant] { padding-bottom: 36px; }
.aniimo-portrait img, .mhwilds-card-art img { filter: grayscale(1); opacity: 0.55; }