Add Aniimo guides and link game cards to toolbox checklists
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s
This commit is contained in:
parent
f0797ccf14
commit
13434f72f6
278 changed files with 10025 additions and 57 deletions
110
website/src/features/games/aniimo/AniimoCard.jsx
Normal file
110
website/src/features/games/aniimo/AniimoCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
website/src/features/games/aniimo/AniimoFilters.jsx
Normal file
81
website/src/features/games/aniimo/AniimoFilters.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
website/src/features/games/aniimo/AniimoPage.jsx
Normal file
53
website/src/features/games/aniimo/AniimoPage.jsx
Normal 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} />;
|
||||
}
|
||||
67
website/src/features/games/aniimo/utils.js
Normal file
67
website/src/features/games/aniimo/utils.js
Normal 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()];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue