Add Aniimo guides and link game cards to toolbox checklists
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s

This commit is contained in:
Shinuwa 2026-09-23 20:05:19 +02:00
parent f0797ccf14
commit 13434f72f6
278 changed files with 10025 additions and 57 deletions

View file

@ -1,4 +1,5 @@
// Rôle : route vers les pages de jeux disponibles et leurs sous-pages.
import { AniimoPage } from "./aniimo/AniimoPage.jsx";
import { GamesPage } from "./GamesPage.jsx";
import { Diablo4Page } from "./diablo4/Diablo4Page.jsx";
import { MhwildsPage } from "./mhwilds/MhwildsPage.jsx";
@ -9,6 +10,7 @@ export function GameRoute(props) {
const heroStyle = game?.images?.heroBg ? { "--game-hero-image": `url("${game.images.heroBg}")` } : undefined;
if (!game) return <GamesPage {...props} />;
if (game.id === "aniimo") return <AniimoPage {...props} game={game} />;
if (game.id === "diablo4") return <Diablo4Page {...props} game={game} />;
if (game.id === "mhwilds") return <MhwildsPage {...props} game={game} />;

View file

@ -0,0 +1,35 @@
// Rôle : affiche le sélecteur de checklist et le masquage commun aux pages guides.
import { Icon } from "../../components/Icon.jsx";
import { useGuideChecklist } from "./useGuideChecklist.js";
export function CurrentGuideChecklistControls({ gameId }) {
const checklist = useGuideChecklist();
return checklist.available && checklist.guideId.split("/")[0] === gameId ? <GuideChecklistControls checklist={checklist} /> : null;
}
export function GuideChecklistControls({ checklist }) {
const { content, toolbox, selected, lists, hideCompleted } = checklist;
return <div className="guide-checklist-controls nds-panel" aria-label={content.label}>
{!toolbox ? <button type="button" className="nds-button" data-size="sm" onClick={checklist.linkToolbox}>{content.linkToolbox}</button> : <>
<label className="field compact">
<span>{content.label}</span>
<select className="nds-select" data-size="sm" value={selected?.id || ""} disabled={!lists.length} onChange={(event) => checklist.associate(event.target.value)}>
<option value="">{content.none}</option>
{lists.map((list) => <option key={list.id} value={list.id}>{list.title} · {list.count} {content.matches}</option>)}
</select>
</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>;
}
export function GuideChecklistMark({ completed, label }) {
return completed ? <span className="guide-checklist-mark nds-badge" data-variant="neutral" role="img" aria-label={label} title={label}>✓</span> : null;
}

View file

@ -0,0 +1,110 @@
// 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 { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { ANIIMO_STATS, aniimoAsset } from "./utils.js";
function Attributes({ values, t }) {
return <span className="aniimo-attributes">{values.map((value) => <span className="nds-badge" data-variant="neutral" key={value} title={t(value)}><img src={aniimoAsset(value)} alt="" width="22" height="22" /><span>{t(value)}</span></span>)}</span>;
}
function shortForm(label) {
const words = label.split(/[\s’-]+/).filter((word) => !/^(forme|de|des|du|la|le|les|d)$/i.test(word));
// Trois lettres distinguent notamment les plateaux, prairies et prismana.
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 }) {
const [flipped, setFlipped] = useState(false);
const frontButton = useRef(null);
const backButton = useRef(null);
const focusAfterFlip = useRef(false);
useLayoutEffect(() => {
if (!focusAfterFlip.current) return;
(flipped ? backButton : frontButton).current?.focus({ preventScroll: true });
focusAfterFlip.current = false;
}, [flipped]);
function flip() {
focusAfterFlip.current = true;
setFlipped((value) => !value);
}
const flipButton = (back, ref) => <button ref={ref} type="button" className="aniimo-flip nds-icon-button" aria-pressed={back} aria-label={`${back ? content.showFront : content.showBack} — ${t(variant.name)}, ${t(variant.form)}`} title={back ? content.showFront : content.showBack} onClick={flip}><Icon name="flip" /></button>;
return (
<div className={`aniimo-card ${flipped ? "is-flipped" : ""}`}>
<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-card-body">
<h2 title={t(variant.name)}>{t(variant.name)}</h2>
<p className="aniimo-form" title={t(variant.form)}>{t(variant.form)}</p>
<span className="aniimo-element-icons">{variant.elements.map((element) => <Tooltip key={element} content={t(element)}><img src={aniimoAsset(element)} alt={t(element)} width="24" height="24" /></Tooltip>)}</span>
</div>
</div>
<div className="aniimo-face aniimo-back nds-card" aria-hidden={!flipped} inert={!flipped}>
{flipButton(true, backButton)}
<GuideChecklistMark completed={completed} label={completedLabel} />
<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>
</div>
<div className="aniimo-card-body aniimo-back-details nds-scrollbox" data-variant="ghost" role="region" aria-label={`${content.showBack} — ${t(variant.name)}`} tabIndex="0">
<div className="aniimo-back-summary">
<div><h3>{content.elements}</h3><Attributes values={variant.elements} t={t} /></div>
<div><h3>{content.role}</h3><Attributes values={[variant.role]} t={t} /></div>
</div>
<h3>{content.abilities}</h3>
<ul className="aniimo-abilities">{variant.homeland_abilities.map((ability) => <li key={ability.type}>
<img src={aniimoAsset(ability.type)} alt="" width="24" height="24" />
<span>{t(ability.type)}</span>
<span className="nds-badge" data-variant="gold">{content.level} {ability.level}</span>
</li>)}</ul>
<h3>{content.statsTitle}</h3>
<div className="aniimo-stats">
{ANIIMO_STATS.map((key) => <div className="aniimo-stat" key={key}>
<span title={content.stats[key]}>{content.stats[key]}</span>
<span className="nds-progress" data-size="sm" role="progressbar" aria-label={content.stats[key]} aria-valuemin={0} aria-valuemax={statsMax} aria-valuenow={variant.attributes.stats[key]}>
<span style={{ width: `${variant.attributes.stats[key] / statsMax * 100}%` }} />
</span>
<span className="aniimo-stat-value">{variant.attributes.stats[key]}</span>
</div>)}
<div className="aniimo-stat-total"><span>{content.statsTotal}</span><strong>{variant.attributes.total}</strong></div>
</div>
</div>
</div>
</div>
</div>
);
}
export function AniimoCard({ variants, t, content, isCompleted, completedLabel, statsMax }) {
const id = useId();
const [selectedKey, setSelectedKey] = useState(variants[0].key);
const variant = variants.find((item) => item.key === selectedKey) || variants[0];
// Mémoriser le repli pour ne pas restaurer un ancien onglet lorsque le filtre est retiré.
if (selectedKey !== variant.key) setSelectedKey(variant.key);
const multiple = variants.length > 1;
function selectTab(index, target) {
setSelectedKey(variants[index].key);
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)}>
<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) => {
const next = event.key === "ArrowRight" ? (index + 1) % variants.length : event.key === "ArrowLeft" ? (index - 1 + variants.length) % variants.length : event.key === "Home" ? 0 : event.key === "End" ? variants.length - 1 : null;
if (next === null) return;
event.preventDefault();
selectTab(next, event.currentTarget);
}}>{shortForm(t(item.form))}</Tooltip>)}
</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} />
</div>
</article>
);
}

View file

@ -0,0 +1,81 @@
// Rôle : expose les catégories repliables et les seuils Aniimo dans un défilement commun.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { aniimoAsset, createAniimoFilters } from "./utils.js";
function LogicChoice({ label, value, onChange, content }) {
return (
<div className="filter-logic">
<span>{content.match}</span>
<div className="filter-logic-group nds-radio-button-group" data-size="xs" role="group" aria-label={label}>
{["and", "or"].map((logic) => <button key={logic} type="button" className={value === logic ? "active" : ""} aria-pressed={value === logic} onClick={() => onChange(logic)}>{content[logic]}</button>)}
</div>
</div>
);
}
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)));
function toggle(key, option, checked) {
onChange({ ...filters, [key]: checked ? [...filters[key], option] : filters[key].filter((value) => value !== option) });
}
return (
<div className="filter-panel nds-filter-panel aniimo-filters">
<div className="filter-panel-head">
<div><p className="eyebrow">{content.filters}</p><h2>{content.title}</h2></div>
<button className="filter-reset-button nds-icon-button" data-size="xs" type="button" title={content.reset} aria-label={content.reset} onClick={() => onChange({ ...createAniimoFilters(), grouped: filters.grouped })}><Icon name="rubber" /></button>
</div>
<label className="field compact">
<span>{content.name}</span>
<input className="nds-input" data-size="sm" type="search" placeholder={content.searchPlaceholder} value={filters.name} onChange={(event) => onChange({ ...filters, name: event.target.value })} />
</label>
<div className="aniimo-filter-sections filter-options legacy-scrollbar" role="region" aria-label={content.filters} tabIndex="0">
{["roles", "elements", "abilities"].map((key) => (
<div key={key} className="aniimo-filter-section-surface">
<fieldset className="aniimo-filter-section">
<legend><button type="button" className="aniimo-filter-toggle nds-button" data-size="sm" data-variant="ghost" aria-expanded={!collapsed[key]} aria-controls={`aniimo-filter-${key}`} onClick={() => setCollapsed((current) => ({ ...current, [key]: !current[key] }))}>
<span>{content[key]}</span>
<span className="aniimo-selected-count" aria-hidden="true">{(key === "abilities" ? Object.keys(filters.abilities).length : filters[key].length) || ""}</span>
<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>}
<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";
const rangeId = `aniimo-minimum-${option}`;
if (isAbility) {
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) => {
const abilities = { ...filters.abilities };
const minimum = Number(event.target.value);
if (minimum > 0) abilities[option] = minimum;
else delete abilities[option];
onChange({ ...filters, abilities });
}} />
<span className="aniimo-minimum-value" aria-hidden="true">{level}</span>
</div>;
}
const selected = filters[key].includes(option);
return (
<label key={option} className={`filter-chip nds-chip ${selected ? "active" : ""}`} data-size="sm">
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={selected} onChange={(event) => toggle(key, option, event.target.checked)} />
<img src={aniimoAsset(option)} alt="" width="16" height="16" />
<span>{t(option)}</span>
</label>
);
})}
</div>
</div>
</fieldset>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
// Rôle : assemble l'accueil Aniimo et la grille filtrable de ses variantes.
import { useMemo } from "react";
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
import { useGuideChecklist } from "../useGuideChecklist.js";
import { Icon } from "../../../components/Icon.jsx";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
import { AniimoCard } from "./AniimoCard.jsx";
import { AniimoFilters } from "./AniimoFilters.jsx";
import { getAniimoStatsMax, aniimoLabel, filterAniimoVariants, groupAniimoVariants } from "./utils.js";
function AniimoListing({ game, aniimo, filters, setFilters, content }) {
const active = filters.aniimo;
const statsMax = useMemo(() => getAniimoStatsMax(aniimo.variants), [aniimo.variants]);
const checklist = useGuideChecklist("aniimo/aniimos");
const filtered = useMemo(() => filterAniimoVariants(aniimo.variants, active), [aniimo.variants, active]);
const visible = filtered.filter((variant) => checklist.isVisible(variant.key));
const groups = useMemo(() => groupAniimoVariants(visible), [visible]);
const totalAniimos = useMemo(() => new Set(aniimo.variants.map((variant) => variant.aniimoId)).size, [aniimo.variants]);
const t = (key) => aniimoLabel(aniimo.translations, key);
const cards = active.grouped ? groups : visible.map((variant) => [variant]);
const update = (next) => setFilters((current) => ({ ...current, aniimo: next }));
return <>
<section className="page-heading game-heading">
<div>
<GameBreadcrumb game={game} />
<div className="game-title-row">
<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>
<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} />
</div>
<p>{content.description}</p>
</div>
<button className="nds-switch" type="button" aria-pressed={active.grouped} onClick={() => update({ ...active, grouped: !active.grouped })}><Icon name="multiple" /><span>{content.groupVariants}</span><i aria-hidden="true" /></button>
</section>
<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>}
</section>
</section>
</>;
}
export function AniimoPage({ category, game, aniimo, filters, setFilters, siteContent }) {
const content = siteContent.aniimo;
if (category !== "aniimos") return <>
<section className="game-hero" style={{ "--game-hero-image": `url("${game.images.heroBg}")` }}><div><p className="eyebrow">{game.eyebrow}</p><h1>{game.title}</h1><p>{game.summary}</p></div></section>
<section className="game-home-grid"><a className="feature game-home-card aniimo-home-card" href="#/games/aniimo/aniimos"><img src={game.images.aniimosCover} alt="" /><strong>{content.title}</strong><span>{content.homeDescription}</span></a></section>
</>;
if (!aniimo.loaded) return <section className="empty nds-empty-state" aria-busy="true"><h1>{content.loading}</h1></section>;
if (aniimo.error) return <section className="empty nds-empty-state" role="alert"><h1>{content.error}</h1><p>{aniimo.error}</p></section>;
return <AniimoListing game={game} aniimo={aniimo} filters={filters} setFilters={setFilters} content={content} />;
}

View file

@ -0,0 +1,67 @@
// Rôle : prépare les variantes Aniimo et applique les critères avant leur regroupement.
export function createAniimoFilters() {
return { name: "", elements: [], roles: [], abilities: {}, elementsLogic: "and", abilitiesLogic: "and", grouped: true };
}
export const ANIIMO_STATS = ["hp", "atk", "p_def", "m_def", "break", "regen"];
// Échelle commune fondée sur le catalogue complet, indépendante des filtres.
export function getAniimoStatsMax(variants) {
return Math.max(1, ...variants.flatMap((variant) => ANIIMO_STATS.map((key) => variant.attributes.stats[key])));
}
export function aniimoLabel(translations, key) {
const value = translations[key] || key.replace(/_/g, " ");
return value.charAt(0).toUpperCase() + value.slice(1);
}
export function aniimoAsset(key) {
return `/static/img/aniimo/${key}.png`;
}
function normalizeName(value) {
return value.trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/œ/g, "oe").replace(/æ/g, "ae");
}
export function prepareAniimoData(aniimos, translations, englishTranslations) {
const variants = aniimos.flatMap((aniimo) => aniimo.variations.map((variation) => ({
...variation,
aniimoId: aniimo.id,
name: aniimo.name,
key: `${aniimo.id}:${variation.form}`,
image: aniimoAsset(`${aniimo.name}__${variation.form}`),
searchNames: [aniimo.name, translations[aniimo.name], englishTranslations[aniimo.name]].filter(Boolean).map(normalizeName)
})));
const sorted = (values) => [...new Set(values)].sort((a, b) => aniimoLabel(translations, a).localeCompare(aniimoLabel(translations, b), "fr"));
return {
variants,
filterOptions: {
elements: sorted(variants.flatMap((variant) => variant.elements)),
roles: sorted(variants.map((variant) => variant.role)),
abilities: sorted(variants.flatMap((variant) => variant.homeland_abilities.map((ability) => ability.type)))
}
};
}
function matchesSelection(selected, logic, predicate) {
return !selected.length || (logic === "or" ? selected.some(predicate) : selected.every(predicate));
}
export function filterAniimoVariants(variants, filters) {
const search = normalizeName(filters.name);
const abilities = Object.entries(filters.abilities);
return variants.filter((variant) => (
(!search || variant.searchNames.some((name) => name.includes(search)))
&& 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))
));
}
export function groupAniimoVariants(variants) {
const groups = new Map();
for (const variant of variants) {
if (!groups.has(variant.aniimoId)) groups.set(variant.aniimoId, []);
groups.get(variant.aniimoId).push(variant);
}
return [...groups.values()];
}

View file

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

View file

@ -1,4 +1,5 @@
// Rôle : affiche et filtre la liste des affixes Diablo IV.
import { useGuideChecklist } from "../useGuideChecklist.js";
import { useMemo } from "react";
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
@ -9,11 +10,14 @@ import { getCategoryLabel, getFilteredDiablo4Affixes } from "./utils.js";
export function Diablo4Listing({ game, diablo4, filters, setFilters }) {
const activeFilters = filters.diablo4;
const options = [...(diablo4.filterOptions.affixes || [])].sort((a, b) => getCategoryLabel(a).localeCompare(getCategoryLabel(b), "fr"));
const visible = useMemo(
const filtered = useMemo(
() => getFilteredDiablo4Affixes({ affixes: diablo4.affixes, filters: activeFilters }),
[diablo4.affixes, activeFilters]
);
const checklist = useGuideChecklist("diablo4/affixes");
const visible = filtered.filter((affix) => checklist.isVisible(affix.id));
return (
<>
<section className="page-heading game-heading">
@ -34,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} />
<Diablo4AffixCard key={affix.id} affix={affix} categoryMap={diablo4.categoryMap} completed={checklist.isCompleted(affix.id)} completedLabel={checklist.content.completed} />
)) : (
<div className="empty nds-empty-state">
<h2>Aucun résultat</h2>

View file

@ -0,0 +1,58 @@
// Rôle : reconnaît les lignes de checklist et valide les associations temporaires des guides.
export const GUIDE_CHECKLIST_SESSION_KEY = "sokkog:guide-checklists";
const GUIDES = new Set(["aniimo/aniimos", "mhwilds/monsters", "mhwilds/endemic", "diablo4/affixes"]);
export function normalizeGuideLabel(value) {
return String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.toLocaleLowerCase("fr").replace(/œ/g, "oe").replace(/æ/g, "ae").trim().replace(/\s+/g, " ");
}
export function matchGuideChecklist(entries, data) {
const rows = Array.isArray(data?.sections) ? data.sections.flatMap((section) => section.items || []) : data?.items || [];
const byLabel = new Map();
for (const row of rows) {
const label = normalizeGuideLabel(row.label);
if (!label) continue;
if (!byLabel.has(label)) byLabel.set(label, []);
byLabel.get(label).push(row);
}
const findRows = (labels = []) => [...new Set(labels.map(normalizeGuideLabel))].flatMap((label) => byLabel.get(label) || []);
const matched = new Set();
const completed = new Set();
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);
if (matches.every((row) => Number(row.qtyCurrent || 0) >= Math.max(1, Number(row.qtyTarget || 1)))) completed.add(entry.key);
}
return { count: matched.size, completed };
}
export function validGuideAssociations(value, toolboxes, links) {
return Object.fromEntries(Object.entries(value || {}).filter(([guideId, association]) => {
if (!GUIDES.has(guideId) || !association || typeof association !== "object") return false;
const toolbox = toolboxes.find((item) => item.id === association.toolboxId && item.id === links[guideId.split("/")[0]]);
return toolbox?.modules.some((module) => module.id === association.moduleId && module.type === "checklist");
}).map(([key, association]) => [key, {
toolboxId: association.toolboxId, moduleId: association.moduleId, hideCompleted: association.hideCompleted === true
}]));
}
export function aniimoChecklistEntries(variants, translations, englishTranslations) {
return variants.map((variant) => {
const names = [translations[variant.name], englishTranslations[variant.name]].filter(Boolean);
const forms = [translations[variant.form], englishTranslations[variant.form]].filter(Boolean);
return { key: variant.key, labels: names.flatMap((name) => forms.map((form) => `${name} — ${form}`)), fallbackLabels: names };
});
}
export function getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }) {
if (!GUIDES.has(guideId)) return null;
if (guideId === "aniimo/aniimos") return aniimo.loaded ? aniimoChecklistEntries(aniimo.variants, aniimo.translations, aniimo.englishTranslations) : null;
if (guideId === "diablo4/affixes") return diablo4.loaded ? diablo4.affixes.map((affix) => ({ key: affix.id, labels: [affix.label] })) : null;
if (!mhwilds.loaded) return null;
return (guideId === "mhwilds/monsters" ? mhwilds.monsters : mhwilds.endemic).map((item) => ({
key: item.name, labels: [mhwilds.translations[item.name] || item.name.replace(/_/g, " ")]
}));
}

View file

@ -1,4 +1,6 @@
// Rôle : charge et normalise les données publiques des jeux.
import { prepareAniimoData } from "./aniimo/utils.js";
export const INITIAL_MHWILDS_STATE = {
loaded: false,
loading: false,
@ -125,3 +127,23 @@ export async function loadDiablo4Data(signal) {
filterOptionKeys: { affixes: filterKey }
};
}
export const INITIAL_ANIIMO_STATE = {
loaded: false,
loading: false,
error: "",
translations: {},
variants: [],
filterOptions: { elements: [], roles: [], abilities: [] }
};
export async function loadAniimoData(signal) {
const responses = await Promise.all([
fetch("/data/aniimo/aniimos.json", { signal }),
fetch("/data/aniimo/i18n/fr.json", { signal }),
fetch("/data/aniimo/i18n/en.json", { signal })
]);
if (responses.some((response) => !response.ok)) throw new Error("Les données Aniimo sont indisponibles.");
const [aniimos, translations, englishTranslations] = await Promise.all(responses.map((response) => response.json()));
return { loaded: true, loading: false, error: "", translations, englishTranslations, ...prepareAniimoData(aniimos, translations, englishTranslations) };
}

View file

@ -1,4 +1,5 @@
// Rôle : affiche les listes filtrables de monstres et de faune Monster Hunter Wilds.
import { useGuideChecklist } from "../useGuideChecklist.js";
import { useMemo } from "react";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
import { EndemicCard } from "./cards/EndemicCard.jsx";
@ -14,7 +15,9 @@ export function MhwildsListing({ category, game, mhwilds, filters, setFilters, t
const options = (mhwilds.filterOptions?.[category]?.length ? mhwilds.filterOptions[category] : getUniqueConditionValues(items, filterKey, t))
.filter((value) => value !== "none")
.sort((a, b) => t(a).localeCompare(t(b), "fr"));
const visible = useMemo(() => getFilteredMhwildsItems({ category, filterKey, mhwilds, filters, t }), [category, filterKey, mhwilds, filters, t]);
const filtered = useMemo(() => getFilteredMhwildsItems({ category, filterKey, mhwilds, filters, t }), [category, filterKey, mhwilds, filters, t]);
const checklist = useGuideChecklist(`mhwilds/${category}`);
const visible = filtered.filter((item) => checklist.isVisible(item.name));
const label = isMonsters ? t("monsters", { capitalize: true }) : t("endemic_life", { capitalize: true });
return (
@ -42,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} />
: <EndemicCard key={item.name} item={item} t={t} />
? <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} />
)) : (
<div className="empty nds-empty-state">
<h2>Aucun résultat</h2>

View file

@ -1,8 +1,9 @@
// Rôle : affiche une carte de faune endémique Monster Hunter Wilds.
import { GuideChecklistMark } from "../../GuideChecklistControls.jsx";
import { IconRow } from "./IconRow.jsx";
import { assetPath, getConditionValues, normalizeText } from "../utils.js";
export function EndemicCard({ item, t }) {
export function EndemicCard({ item, t, completed, completedLabel }) {
const locations = getConditionValues(item.locations);
const description = t(item.description);
const normalizedDescription = normalizeText(description);
@ -11,7 +12,8 @@ export function EndemicCard({ item, t }) {
&& normalizedDescription !== normalizeText("to be completed");
return (
<article className="mhwilds-card endemic-card">
<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" />
</div>

View file

@ -1,10 +1,11 @@
// Rôle : affiche une carte monstre retournable avec faiblesses et hitzones.
import { 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 }) {
export function MonsterCard({ monster, t, completed, completedLabel }) {
const [flipped, setFlipped] = useState(false);
const weaknesses = getConditionValues(monster.weaknesses);
const ailments = getConditionValues(monster.ailments).filter((value) => value !== "none");
@ -12,6 +13,7 @@ export function MonsterCard({ monster, t }) {
return (
<article
className={`mhwilds-card monster-card ${flipped ? "is-flipped" : ""}`}
data-checklist-complete={completed || undefined}
onClick={() => setFlipped(!flipped)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
@ -21,10 +23,11 @@ export function MonsterCard({ monster, t }) {
tabIndex="0"
role="button"
aria-pressed={flipped}
aria-label={`Afficher les dégâts de ${t(monster.name, { capitalize: true })}`}
aria-label={`Afficher les dégâts de ${t(monster.name, { capitalize: true })}${completed ? ` — ${completedLabel}` : ""}`}
>
<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>
@ -39,6 +42,7 @@ export function MonsterCard({ monster, t }) {
</div>
</div>
<div className="mhwilds-card-face mhwilds-card-back">
<GuideChecklistMark completed={completed} label={completedLabel} />
<span className="mhwilds-flip-indicator" aria-hidden="true">
<span className="ui-icon ui-icon-flip" />
</span>

View file

@ -0,0 +1,30 @@
// Rôle : partage la progression réactive et les commandes de liaison entre les guides.
import { createContext, useContext, useMemo } from "react";
import { getGuideChecklistEntries, matchGuideChecklist } from "./guideChecklist.js";
export const GuideChecklistContext = createContext(null);
export function useGuideChecklist(requestedGuideId) {
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];
const content = siteContent.guideChecklist;
const toolbox = toolboxes.find((item) => item.id === links[gameId]);
const association = guideChecklistSession.associations[guideId];
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module, index) => ({
id: module.id, title: module.title || `${content.untitled} ${index + 1}`,
...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`])
})), [entries, toolbox, moduleData, content.untitled]);
const selected = lists.find((list) => list.id === association?.moduleId);
const hideCompleted = Boolean(selected && association.hideCompleted);
const isCompleted = (key) => Boolean(selected?.completed.has(key));
return {
content, toolbox, selected, hideCompleted, isCompleted, guideId, available: entries !== null,
lists: lists.filter((list) => list.count || list.id === selected?.id),
isVisible: (key) => !hideCompleted || !isCompleted(key),
associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),
toggleHidden: () => guideChecklistSession.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted }),
linkToolbox: () => actions.setLinkModalGameId(gameId)
};
}

View file

@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx";
import { Icon } from "../../components/Icon.jsx";
import { ImportButton } from "../../components/ImportButton.jsx";
import { CurrentGuideChecklistControls } from "../games/GuideChecklistControls.jsx";
import { StorageQuota } from "../../components/StorageQuota.jsx";
import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
@ -399,6 +400,7 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
</div>
<button className="drawer-close-button nds-icon-button" data-size="sm" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</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} /> : (
<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>
)}