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
|
|
@ -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} />;
|
||||
|
||||
|
|
|
|||
35
website/src/features/games/GuideChecklistControls.jsx
Normal file
35
website/src/features/games/GuideChecklistControls.jsx
Normal 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;
|
||||
}
|
||||
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()];
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
58
website/src/features/games/guideChecklist.js
Normal file
58
website/src/features/games/guideChecklist.js
Normal 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, " ")]
|
||||
}));
|
||||
}
|
||||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
30
website/src/features/games/useGuideChecklist.js
Normal file
30
website/src/features/games/useGuideChecklist.js
Normal 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)
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Rôle : charge le contenu éditorial et les données publiques nécessaires à l'application.
|
||||
import { useEffect, useState } from "react";
|
||||
import { INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwildsData } from "../features/games/loaders.js";
|
||||
import { INITIAL_ANIIMO_STATE, loadAniimoData, INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwildsData } from "../features/games/loaders.js";
|
||||
|
||||
function useGameData(active, initialState, load) {
|
||||
const [requested, setRequested] = useState(active);
|
||||
|
|
@ -30,6 +30,7 @@ export function useAppData(route) {
|
|||
const [games, setGames] = useState([]);
|
||||
const [gamesError, setGamesError] = useState("");
|
||||
const mhwilds = useGameData(route.startsWith("/games/mhwilds"), INITIAL_MHWILDS_STATE, loadMhwildsData);
|
||||
const aniimo = useGameData(route.startsWith("/games/aniimo"), INITIAL_ANIIMO_STATE, loadAniimoData);
|
||||
const diablo4 = useGameData(route.startsWith("/games/diablo4"), INITIAL_DIABLO4_STATE, loadDiablo4Data);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -64,6 +65,7 @@ export function useAppData(route) {
|
|||
games,
|
||||
gamesError,
|
||||
mhwilds,
|
||||
diablo4
|
||||
diablo4,
|
||||
aniimo
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
// Rôle : gère l'état des filtres actifs pour les pages de jeu.
|
||||
import { useState } from "react";
|
||||
import { createAniimoFilters } from "../features/games/aniimo/utils.js";
|
||||
|
||||
export function useGameFilters() {
|
||||
return useState({
|
||||
aniimo: createAniimoFilters(),
|
||||
monsters: { name: "", weaknesses: [], logic: "and" },
|
||||
endemic: { name: "", locations: [], logic: "and" },
|
||||
diablo4: { name: "", categories: [], logic: "and" }
|
||||
|
|
|
|||
34
website/src/hooks/useGuideChecklistSession.js
Normal file
34
website/src/hooks/useGuideChecklistSession.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Rôle : conserve les liaisons guide/checklist pendant la session et retire les références supprimées.
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { GUIDE_CHECKLIST_SESSION_KEY, validGuideAssociations } from "../features/games/guideChecklist.js";
|
||||
|
||||
function readSession() {
|
||||
try {
|
||||
const value = JSON.parse(sessionStorage.getItem(GUIDE_CHECKLIST_SESSION_KEY) || "{}");
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export function useGuideChecklistSession({ toolboxes, links, ready }) {
|
||||
const [associations, setAssociations] = useState(readSession);
|
||||
const valid = useMemo(() => ready ? validGuideAssociations(associations, toolboxes, links) : associations, [associations, toolboxes, links, ready]);
|
||||
if (ready && Object.keys(valid).length !== Object.keys(associations).length) setAssociations(valid);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
try {
|
||||
if (Object.keys(valid).length) sessionStorage.setItem(GUIDE_CHECKLIST_SESSION_KEY, JSON.stringify(valid));
|
||||
else sessionStorage.removeItem(GUIDE_CHECKLIST_SESSION_KEY);
|
||||
} catch { /* La session reste disponible en mémoire si le navigateur refuse le stockage. */ }
|
||||
}, [ready, valid]);
|
||||
|
||||
function setAssociation(guideId, association) {
|
||||
setAssociations((current) => {
|
||||
const next = { ...current };
|
||||
if (association) next[guideId] = association;
|
||||
else delete next[guideId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return { associations: valid, setAssociation };
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import { Icon } from "./components/Icon.jsx";
|
|||
import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedToolboxes.js";
|
||||
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
|
||||
import { useAppData } from "./hooks/useAppData.js";
|
||||
import { GuideChecklistContext } from "./features/games/useGuideChecklist.js";
|
||||
import { useGuideChecklistSession } from "./hooks/useGuideChecklistSession.js";
|
||||
import { useGameFilters } from "./hooks/useGameFilters.js";
|
||||
import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
|
||||
import { RouteContent } from "./router/RouteContent.jsx";
|
||||
|
|
@ -28,7 +30,7 @@ function getNotificationDuration(message, options = {}) {
|
|||
|
||||
function App() {
|
||||
const route = useHashRoute();
|
||||
const { siteContent, siteContentError, games, gamesError, mhwilds, diablo4 } = useAppData(route);
|
||||
const { siteContent, siteContentError, games, gamesError, mhwilds, diablo4, aniimo } = useAppData(route);
|
||||
const [filters, setFilters] = useGameFilters();
|
||||
const [confirmModal, setConfirmModal] = useState(null);
|
||||
const [createModal, setCreateModal] = useState(null);
|
||||
|
|
@ -40,6 +42,7 @@ function App() {
|
|||
const [storageError, setStorageError] = useState("");
|
||||
const handleStorageError = useCallback((message) => setStorageError(message), []);
|
||||
const store = useIndexedToolboxes(handleStorageError);
|
||||
const guideChecklistSession = useGuideChecklistSession(store);
|
||||
|
||||
const notify = useCallback((message, options = {}) => {
|
||||
setNotifications((current) => [
|
||||
|
|
@ -122,6 +125,7 @@ function App() {
|
|||
}
|
||||
|
||||
return (
|
||||
<GuideChecklistContext.Provider value={{ route, aniimo, mhwilds, diablo4, siteContent, toolboxes: store.toolboxes, links: store.links, moduleData: store.moduleData, guideChecklistSession, actions }}>
|
||||
<Shell route={route} content={siteContent} games={games} toolboxes={store.toolboxes} links={store.links} drawerGameId={drawerGameId} actions={actions}>
|
||||
<AppErrorBoundary route={route}>
|
||||
<RouteContent
|
||||
|
|
@ -131,6 +135,7 @@ function App() {
|
|||
gamesError={gamesError}
|
||||
mhwilds={mhwilds}
|
||||
diablo4={diablo4}
|
||||
aniimo={aniimo}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
t={t}
|
||||
|
|
@ -192,6 +197,7 @@ function App() {
|
|||
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
|
||||
/>
|
||||
</Shell>
|
||||
</GuideChecklistContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ const RADIO_OPTIONS = {
|
|||
};
|
||||
|
||||
const COMPONENTS = [
|
||||
{
|
||||
id: "nds-range",
|
||||
name: "Range",
|
||||
className: "nds-range",
|
||||
sizes: ["sm", "md"],
|
||||
variants: VARIANT_VALUES.none,
|
||||
attributes: { "data-size": ["sm", "md"], "data-state": FIELD_STATE_VALUES },
|
||||
render: ({ attributes, state }) => <InteractiveRange attributes={attributes} disabled={state === "disabled"} />
|
||||
},
|
||||
{
|
||||
id: "nds-input",
|
||||
name: "Input",
|
||||
|
|
@ -400,10 +409,10 @@ const PATTERN_COMPONENTS = [
|
|||
id: "nds-progress",
|
||||
name: "Progress",
|
||||
className: "nds-progress",
|
||||
sizes: SIZE_VALUES.none,
|
||||
sizes: ["md", "sm"],
|
||||
variants: ["primary", "success", "warning", "danger"],
|
||||
attributes: { "data-variant": ["primary", "success", "warning", "danger"] },
|
||||
render: ({ attributes, variant }) => <span className="nds-progress" {...attributes} aria-label={`Progression ${variant}`}><span style={{ width: variant === "danger" ? "86%" : "58%" }} /></span>
|
||||
attributes: { "data-size": ["md", "sm"], "data-variant": ["primary", "success", "warning", "danger"] },
|
||||
render: ({ attributes, variant }) => <span className="nds-progress" {...attributes} role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={variant === "danger" ? 86 : 58} aria-label={`Progression ${variant}`}><span style={{ width: variant === "danger" ? "86%" : "58%" }} /></span>
|
||||
},
|
||||
{
|
||||
id: "nds-filter-panel",
|
||||
|
|
@ -661,6 +670,14 @@ function InteractiveRadioButtonGroup({ attributes, options, iconOnly = false })
|
|||
);
|
||||
}
|
||||
|
||||
function InteractiveRange({ attributes, disabled }) {
|
||||
const [value, setValue] = useState(1);
|
||||
return <label className="field">
|
||||
<span>Niveau minimum ≥ {value}</span>
|
||||
<input className="nds-range" {...dataProps(attributes)} type="range" min="1" max="4" step="1" value={value} disabled={disabled} onChange={(event) => setValue(Number(event.target.value))} />
|
||||
</label>;
|
||||
}
|
||||
|
||||
function InteractiveStepper({ attributes, input = false }) {
|
||||
const [value, setValue] = useState(2);
|
||||
const increment = (step) => setValue((current) => Math.min(20, Math.max(0, current + step)));
|
||||
|
|
|
|||
198
website/src/styles/_aniimo.scss
Normal file
198
website/src/styles/_aniimo.scss
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// Rôle : adapte les filtres et cartes Aniimo aux portraits verticaux et aux variantes.
|
||||
@use "design-system/surfaces";
|
||||
|
||||
.aniimo-home-card img { padding: 0 15%; object-fit: cover; object-position: 50% 38%; }
|
||||
.aniimo-layout .game-results { min-width: 0; }
|
||||
.aniimo-filters { min-width: 0; }
|
||||
.aniimo-filter-sections { gap: 16px; }
|
||||
.aniimo-filter-section-surface + .aniimo-filter-section-surface { padding-top: 12px; border-top: 1px solid var(--color-border); }
|
||||
.aniimo-filter-section {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
legend { width: 100%; padding: 0; margin-bottom: 6px; }
|
||||
}
|
||||
.aniimo-filter-toggle.nds-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding-inline: 4px;
|
||||
.ui-icon { width: 14px; height: 14px; }
|
||||
}
|
||||
.aniimo-selected-count { margin-left: auto; color: var(--color-accent-gold); }
|
||||
.aniimo-filter-options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.aniimo-chip-options { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||
.aniimo-chip-options .filter-chip { min-width: 0; }
|
||||
.aniimo-ability-options { gap: 2px; }
|
||||
.aniimo-ability-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(64px, 1fr) 2ch;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
label { display: flex; align-items: center; gap: 4px; min-width: 0; white-space: nowrap; }
|
||||
img { flex-shrink: 0; object-fit: contain; }
|
||||
&[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-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; }
|
||||
.aniimo-tabs.nds-radio-button-group[data-size] {
|
||||
display: grid;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
width: 100%;
|
||||
padding: 2px;
|
||||
button { min-width: 0; padding-inline: 0; white-space: nowrap; font-size: 11px; }
|
||||
button[data-form="prismana_form"] {
|
||||
position: relative;
|
||||
background-image: linear-gradient(110deg,
|
||||
color-mix(in srgb, var(--color-accent-pink) 16%, transparent),
|
||||
color-mix(in srgb, var(--color-accent-gold) 16%, transparent),
|
||||
color-mix(in srgb, var(--color-accent-cyan) 16%, transparent),
|
||||
color-mix(in srgb, var(--color-primary) 16%, transparent));
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 1px;
|
||||
left: 4px;
|
||||
height: 2px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(90deg, var(--color-accent-pink), var(--color-accent-gold), var(--color-accent-cyan), var(--color-primary));
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.aniimo-card { position: relative; perspective: 1200px; }
|
||||
.aniimo-card-inner {
|
||||
@include surfaces.flip-card-inner;
|
||||
position: relative;
|
||||
}
|
||||
.aniimo-card:hover .aniimo-card-inner,
|
||||
.aniimo-card:focus-within .aniimo-card-inner,
|
||||
.aniimo-card.is-flipped .aniimo-card-inner { will-change: transform; }
|
||||
.aniimo-card.is-flipped .aniimo-card-inner { transform: rotateY(180deg); }
|
||||
.aniimo-face.nds-card {
|
||||
@include surfaces.card-surface(rgba(16, 20, 38, 0.92));
|
||||
min-width: 0;
|
||||
contain: paint;
|
||||
overflow: hidden;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.aniimo-card:hover .aniimo-face { @include surfaces.flip-card-face-hover; }
|
||||
.aniimo-front { position: relative; display: flex; flex-direction: column; transform: rotateY(0deg); }
|
||||
.aniimo-front .aniimo-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 96px;
|
||||
height: 96px;
|
||||
padding: 8px 12px;
|
||||
h2 { line-height: 20px; }
|
||||
.aniimo-form {
|
||||
flex: 1;
|
||||
margin: 2px 0 4px;
|
||||
line-height: 14px;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
}
|
||||
.aniimo-back {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.aniimo-back-heading { flex: 0 0 auto; }
|
||||
.aniimo-back-details.nds-scrollbox[data-variant="ghost"] {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding: 0 12px 12px;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
h3:first-child { margin-top: 0; }
|
||||
> h3 { margin-top: var(--space-6); }
|
||||
}
|
||||
.aniimo-portrait {
|
||||
@include surfaces.card-cover-divider;
|
||||
aspect-ratio: 3 / 4;
|
||||
max-height: 250px;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; }
|
||||
}
|
||||
// Les deux portraits de Petit Esprit de Feu ont davantage de vide sous le personnage.
|
||||
.aniimo-result[data-aniimo="11001"] .aniimo-portrait img { object-position: center 20%; }
|
||||
.aniimo-card-body {
|
||||
padding: 10px 12px;
|
||||
h2 { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-base); }
|
||||
h3 { margin: 12px 0 6px; font-size: var(--font-size-sm); }
|
||||
}
|
||||
.aniimo-back h2 { padding-right: 30px; }
|
||||
.aniimo-form { margin: 3px 0 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-muted); font-size: var(--font-size-xs); }
|
||||
.aniimo-element-icons { display: flex; align-items: center; justify-content: center; gap: 8px; height: 24px; }
|
||||
.aniimo-element-icons img { display: block; object-fit: contain; }
|
||||
.aniimo-attributes { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.aniimo-attributes .nds-badge { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.aniimo-attributes img, .aniimo-abilities img { object-fit: contain; flex-shrink: 0; }
|
||||
.aniimo-abilities {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 6px; font-size: var(--font-size-sm); }
|
||||
}
|
||||
.aniimo-face .aniimo-flip {
|
||||
@include surfaces.flip-card-indicator;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
.ui-icon { width: 18px; height: 18px; }
|
||||
}
|
||||
.aniimo-card:hover .aniimo-flip { @include surfaces.flip-card-indicator-hover; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aniimo-card-inner { transition: none; }
|
||||
}
|
||||
|
||||
.aniimo-results-count [data-copy-target="true"] { color: var(--color-accent-gold); font-weight: 700; }
|
||||
.aniimo-stats { display: grid; gap: 8px; }
|
||||
.aniimo-stat { display: grid; grid-template-columns: 66px minmax(0, 1fr) 3ch; align-items: center; gap: 6px; font-size: var(--font-size-xs); }
|
||||
.aniimo-stat-value { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.aniimo-stat-total { display: flex; justify-content: space-between; gap: 8px; font-size: var(--font-size-sm); }
|
||||
|
||||
.aniimo-back-summary {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-5);
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--color-border) 50%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
> div { min-width: 0; }
|
||||
.nds-badge {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 4px 6px;
|
||||
flex-wrap: nowrap;
|
||||
font-size: var(--font-size-xs);
|
||||
> span { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
}
|
||||
img { width: 18px; height: 18px; }
|
||||
}
|
||||
|
|
@ -52,8 +52,7 @@
|
|||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 420ms var(--ease-standard);
|
||||
@include surfaces.flip-card-inner;
|
||||
}
|
||||
|
||||
.monster-card:hover .mhwilds-card-inner,
|
||||
|
|
@ -86,27 +85,8 @@
|
|||
}
|
||||
|
||||
.mhwilds-flip-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(165, 180, 252, 0.12);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(7, 10, 24, 0.36);
|
||||
color: var(--color-text-muted);
|
||||
@include surfaces.flip-card-indicator;
|
||||
pointer-events: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.78;
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease-standard),
|
||||
background-color var(--duration-fast) var(--ease-standard),
|
||||
color var(--duration-fast) var(--ease-standard),
|
||||
opacity var(--duration-fast) var(--ease-standard),
|
||||
box-shadow var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.mhwilds-flip-indicator .ui-icon {
|
||||
|
|
@ -123,21 +103,11 @@
|
|||
}
|
||||
|
||||
.monster-card:hover .mhwilds-flip-indicator {
|
||||
border-color: rgba(196, 181, 253, 0.22);
|
||||
background: rgba(16, 20, 38, 0.52);
|
||||
color: #c4b5fd;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 12px rgba(139, 92, 246, 0.1);
|
||||
@include surfaces.flip-card-indicator-hover;
|
||||
}
|
||||
|
||||
.monster-card:hover .mhwilds-card-face {
|
||||
@include surfaces.card-surface-hover(
|
||||
rgba(16, 20, 38, 0.95),
|
||||
rgba(139, 92, 246, 0.76),
|
||||
rgba(196, 181, 253, 0.58),
|
||||
0 0 14px rgba(139, 92, 246, 0.14),
|
||||
0 0 24px rgba(196, 181, 253, 0.08)
|
||||
);
|
||||
@include surfaces.flip-card-face-hover;
|
||||
}
|
||||
|
||||
.mhwilds-card-art {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Rôle : assemble l'API publique NDS par classes de famille et attributs data-*.
|
||||
@use "attributes/fields";
|
||||
@use "attributes/ranges";
|
||||
@use "attributes/buttons";
|
||||
@use "attributes/steppers";
|
||||
@use "attributes/switches";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Rôle : expose les mixins publics des contrôles interactifs.
|
||||
@forward "controls/fields";
|
||||
@forward "controls/ranges";
|
||||
@forward "controls/buttons";
|
||||
@forward "controls/steppers";
|
||||
@forward "controls/switches";
|
||||
|
|
|
|||
|
|
@ -29,3 +29,49 @@
|
|||
@mixin card-cover-divider {
|
||||
border-bottom: 1px solid rgba(246, 196, 83, 0.42);
|
||||
}
|
||||
|
||||
@mixin flip-card-inner {
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 420ms var(--ease-standard);
|
||||
}
|
||||
|
||||
@mixin flip-card-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(165, 180, 252, 0.12);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(7, 10, 24, 0.36);
|
||||
color: var(--color-text-muted);
|
||||
box-shadow: none;
|
||||
opacity: 0.78;
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease-standard),
|
||||
background-color var(--duration-fast) var(--ease-standard),
|
||||
color var(--duration-fast) var(--ease-standard),
|
||||
opacity var(--duration-fast) var(--ease-standard),
|
||||
box-shadow var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
@mixin flip-card-indicator-hover {
|
||||
border-color: rgba(196, 181, 253, 0.22);
|
||||
background: rgba(16, 20, 38, 0.52);
|
||||
color: #c4b5fd;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 12px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
@mixin flip-card-face-hover {
|
||||
@include card-surface-hover(
|
||||
rgba(16, 20, 38, 0.95),
|
||||
rgba(139, 92, 246, 0.76),
|
||||
rgba(196, 181, 253, 0.58),
|
||||
0 0 14px rgba(139, 92, 246, 0.14),
|
||||
0 0 24px rgba(196, 181, 253, 0.08)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,9 +99,11 @@
|
|||
}
|
||||
|
||||
.nds-chip[data-size="sm"] {
|
||||
min-height: 34px;
|
||||
padding: 5px 10px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
gap: 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
> img, > .ui-icon { width: 16px; height: 16px; }
|
||||
}
|
||||
|
||||
.nds-empty-state {
|
||||
|
|
@ -128,6 +130,10 @@
|
|||
@include controls.progress-pattern;
|
||||
}
|
||||
|
||||
.nds-progress[data-size="sm"] {
|
||||
@include controls.progress-pattern(sm);
|
||||
}
|
||||
|
||||
.nds-progress > span,
|
||||
.nds-progress-bar {
|
||||
@include controls.progress-bar-pattern;
|
||||
|
|
|
|||
5
website/src/styles/design-system/attributes/_ranges.scss
Normal file
5
website/src/styles/design-system/attributes/_ranges.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Rôle : expose la primitive publique nds-range en tailles md et sm.
|
||||
@use "../controls";
|
||||
|
||||
.nds-range { @include controls.range-control; }
|
||||
.nds-range[data-size="sm"] { @include controls.range-control(sm); }
|
||||
|
|
@ -31,6 +31,7 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nds-range:focus-visible,
|
||||
.nds-input:focus-visible,
|
||||
.nds-time:focus-visible,
|
||||
.nds-select:focus-visible,
|
||||
|
|
@ -49,6 +50,7 @@
|
|||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.nds-range:disabled,
|
||||
.nds-button:disabled,
|
||||
.nds-icon-button:disabled,
|
||||
.nds-quantity-stepper button:disabled,
|
||||
|
|
|
|||
|
|
@ -205,10 +205,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
@mixin progress-pattern {
|
||||
@mixin progress-pattern($size: md) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
height: var(--space-2);
|
||||
@if $size == sm { height: calc(var(--space-2) / 2); }
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(165, 180, 252, 0.1);
|
||||
border-radius: var(--radius-pill);
|
||||
|
|
|
|||
48
website/src/styles/design-system/controls/_ranges.scss
Normal file
48
website/src/styles/design-system/controls/_ranges.scss
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Rôle : définit les curseurs natifs NDS et leurs tailles sans modifier leur comportement clavier.
|
||||
@mixin range-track {
|
||||
height: 6px;
|
||||
border: 1px solid var(--color-border-hover);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
@mixin range-thumb($size) {
|
||||
box-sizing: border-box;
|
||||
width: $size;
|
||||
height: $size;
|
||||
border: 2px solid var(--color-accent-gold);
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
@mixin range-control($size: md) {
|
||||
$thumb: 20px;
|
||||
$height: 44px;
|
||||
@if $size == sm {
|
||||
$thumb: 18px;
|
||||
$height: 36px;
|
||||
}
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: $height;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
|
||||
&::-webkit-slider-runnable-track { @include range-track; }
|
||||
&::-moz-range-track { @include range-track; }
|
||||
&::-webkit-slider-thumb {
|
||||
@include range-thumb($thumb);
|
||||
appearance: none;
|
||||
margin-top: calc((4px - #{$thumb}) / 2);
|
||||
}
|
||||
&::-moz-range-thumb { @include range-thumb($thumb); }
|
||||
&:hover:not(:disabled)::-webkit-slider-thumb { background: var(--color-primary-hover); }
|
||||
&:hover:not(:disabled)::-moz-range-thumb { background: var(--color-primary-hover); }
|
||||
}
|
||||
32
website/src/styles/games/_checklist.scss
Normal file
32
website/src/styles/games/_checklist.scss
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Rôle : présente la liaison checklist et l'état terminé communs aux cartes de guides.
|
||||
.guide-checklist-controls {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
min-width: 0;
|
||||
.field { min-width: 0; margin: 0; }
|
||||
select { width: 100%; min-width: 0; }
|
||||
p { margin: 0; color: var(--color-text-muted); font-size: var(--font-size-sm); }
|
||||
.nds-switch { max-width: 100%; }
|
||||
.guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-3); }
|
||||
}
|
||||
.guide-checklist-mark {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.endemic-card, .diablo4-affix-card { position: relative; }
|
||||
.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; }
|
||||
[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; }
|
||||
.diablo4-category-icon { filter: grayscale(1); opacity: 0.55; }
|
||||
}
|
||||
|
|
@ -83,3 +83,8 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-chip[data-size="sm"] {
|
||||
grid-template-columns: 16px 16px minmax(0, 1fr);
|
||||
span { font-size: inherit; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,3 +238,8 @@
|
|||
mask-image: url("/static/icons/back.svg");
|
||||
-webkit-mask-image: url("/static/icons/back.svg");
|
||||
}
|
||||
|
||||
.ui-icon-multiple {
|
||||
mask-image: url("/static/icons/multiples.svg");
|
||||
-webkit-mask-image: url("/static/icons/multiples.svg");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,5 +13,7 @@
|
|||
@use "overlays";
|
||||
@use "mhwilds";
|
||||
@use "diablo4";
|
||||
@use "aniimo";
|
||||
@use "games/checklist";
|
||||
@use "design-system/attributes";
|
||||
@use "responsive";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue