This commit is contained in:
parent
34523e78b1
commit
5c86fd2ceb
19 changed files with 724 additions and 519 deletions
72
website/src/features/games/GameFiltersPanel.jsx
Normal file
72
website/src/features/games/GameFiltersPanel.jsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Icon } from "../../components/Icon.jsx";
|
||||
|
||||
export function GameFiltersPanel({
|
||||
title,
|
||||
nameLabel = "Nom",
|
||||
resetLabel = "Réinitialiser",
|
||||
matchLabel = "Correspondance",
|
||||
logicLabels = { and: "Et", or: "Ou" },
|
||||
filters,
|
||||
selectedKey,
|
||||
options,
|
||||
getOptionId = (option) => option,
|
||||
getOptionLabel = (option) => option,
|
||||
getOptionClassName = () => "",
|
||||
renderOptionMedia,
|
||||
onChange
|
||||
}) {
|
||||
const selected = new Set(filters[selectedKey] || []);
|
||||
const logicLabel = filters.logic === "or" ? logicLabels.or : logicLabels.and;
|
||||
|
||||
function update(updater) {
|
||||
onChange(updater(filters));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Filtres</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="filter-reset-button"
|
||||
onClick={() => onChange({ name: "", [selectedKey]: [], logic: "and" })}
|
||||
aria-label={resetLabel}
|
||||
title={resetLabel}
|
||||
>
|
||||
<Icon name="rubber" />
|
||||
</button>
|
||||
</div>
|
||||
<label className="field compact">
|
||||
<span>{nameLabel}</span>
|
||||
<input value={filters.name} placeholder="Rechercher..." onChange={(event) => update((state) => ({ ...state, name: event.target.value }))} />
|
||||
</label>
|
||||
<div className="filter-logic">
|
||||
<span>{matchLabel}</span>
|
||||
<button onClick={() => update((state) => ({ ...state, logic: state.logic === "and" ? "or" : "and" }))}>{logicLabel}</button>
|
||||
</div>
|
||||
<div className="filter-options legacy-scrollbar">
|
||||
{options.map((option) => {
|
||||
const optionId = getOptionId(option);
|
||||
return (
|
||||
<label className={`filter-chip ${getOptionClassName(option)} ${selected.has(optionId) ? "active" : ""}`} key={optionId}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(optionId)}
|
||||
onChange={(event) => update((state) => {
|
||||
const values = new Set(state[selectedKey] || []);
|
||||
if (event.target.checked) values.add(optionId);
|
||||
else values.delete(optionId);
|
||||
return { ...state, [selectedKey]: [...values] };
|
||||
})}
|
||||
/>
|
||||
{renderOptionMedia?.(option)}
|
||||
<span>{getOptionLabel(option)}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { getCategoryIconStyle, getCategoryLabel } from "./utils.js";
|
|||
|
||||
export function Diablo4AffixCard({ affix, categoryMap }) {
|
||||
return (
|
||||
<article className="mhwilds-card diablo4-affix-card">
|
||||
<article className="compact-data-card diablo4-affix-card">
|
||||
<div className="diablo4-affix-card-body">
|
||||
<h2>{affix.label}</h2>
|
||||
<div className="diablo4-category-list" aria-label="Catégories">
|
||||
|
|
|
|||
|
|
@ -1,58 +1,18 @@
|
|||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { GameFiltersPanel } from "../GameFiltersPanel.jsx";
|
||||
import { getCategoryIconStyle, getCategoryId, getCategoryLabel } from "./utils.js";
|
||||
|
||||
export function Diablo4Filters({ options, filters, setFilters }) {
|
||||
const selected = new Set(filters.categories || []);
|
||||
const logicLabel = filters.logic === "or" ? "Ou" : "Et";
|
||||
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Filtres</p>
|
||||
<h2>Catégories</h2>
|
||||
</div>
|
||||
<button
|
||||
className="filter-reset-button"
|
||||
onClick={() => setFilters((state) => ({ ...state, diablo4: { name: "", categories: [], logic: "and" } }))}
|
||||
aria-label="Réinitialiser"
|
||||
title="Réinitialiser"
|
||||
>
|
||||
<Icon name="rubber" />
|
||||
</button>
|
||||
</div>
|
||||
<label className="field compact">
|
||||
<span>Nom</span>
|
||||
<input
|
||||
value={filters.name}
|
||||
placeholder="Rechercher..."
|
||||
onChange={(event) => setFilters((state) => ({ ...state, diablo4: { ...state.diablo4, name: event.target.value } }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="filter-logic">
|
||||
<span>Correspondance</span>
|
||||
<button onClick={() => setFilters((state) => ({ ...state, diablo4: { ...state.diablo4, logic: state.diablo4.logic === "and" ? "or" : "and" } }))}>
|
||||
{logicLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div className="filter-options legacy-scrollbar">
|
||||
{options.map((option) => (
|
||||
<label className={`filter-chip diablo4-filter-chip tone-${option.tone || "default"} ${selected.has(getCategoryId(option)) ? "active" : ""}`} key={getCategoryId(option)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(getCategoryId(option))}
|
||||
onChange={(event) => setFilters((state) => {
|
||||
const values = new Set(state.diablo4.categories || []);
|
||||
if (event.target.checked) values.add(getCategoryId(option));
|
||||
else values.delete(getCategoryId(option));
|
||||
return { ...state, diablo4: { ...state.diablo4, categories: [...values] } };
|
||||
})}
|
||||
/>
|
||||
<span className="diablo4-category-icon" aria-hidden="true" style={getCategoryIconStyle(option)} />
|
||||
<span>{getCategoryLabel(option)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<GameFiltersPanel
|
||||
title="Catégories"
|
||||
filters={filters}
|
||||
selectedKey="categories"
|
||||
options={options}
|
||||
getOptionId={getCategoryId}
|
||||
getOptionLabel={getCategoryLabel}
|
||||
getOptionClassName={(option) => `diablo4-filter-chip tone-${option.tone || "default"}`}
|
||||
renderOptionMedia={(option) => <span className="diablo4-category-icon" aria-hidden="true" style={getCategoryIconStyle(option)} />}
|
||||
onChange={(nextFilters) => setFilters((state) => ({ ...state, diablo4: nextFilters }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,22 +13,22 @@ export function Diablo4Listing({ diablo4, filters, setFilters }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<section className="page-heading mhwilds-heading">
|
||||
<section className="page-heading game-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Diablo IV</p>
|
||||
<div className="mhwilds-title-row">
|
||||
<div className="game-title-row">
|
||||
<h1>Affixes</h1>
|
||||
<span className="results-count">{visible.length} / {diablo4.affixes.length}</span>
|
||||
</div>
|
||||
<p>Filtrez les affixes par nom et catégories pour retrouver rapidement les statistiques utiles à votre build.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mhwilds-layout diablo4-layout" data-game-category="diablo4-affixes">
|
||||
<aside className="mhwilds-filters">
|
||||
<section className="game-layout diablo4-layout" data-game-category="diablo4-affixes">
|
||||
<aside className="game-filters">
|
||||
<Diablo4Filters options={options} filters={activeFilters} setFilters={setFilters} />
|
||||
</aside>
|
||||
<section className="mhwilds-results" aria-live="polite">
|
||||
<div className="mhwilds-grid diablo4-affix-grid">
|
||||
<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} />
|
||||
)) : (
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ export function Diablo4Overview({ game }) {
|
|||
<p>Consultez rapidement les affixes et leurs catégories pour préparer vos builds sans quitter votre session.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mhwilds-home-grid diablo4-home-grid">
|
||||
<a className="feature mhwilds-home-card diablo4-home-card" href="#/games/diablo4/affixes">
|
||||
<section className="game-home-grid diablo4-home-grid">
|
||||
<a className="feature game-home-card diablo4-home-card" href="#/games/diablo4/affixes">
|
||||
<span className="diablo4-home-mark" aria-hidden="true" />
|
||||
<strong>Affixes</strong>
|
||||
<span>Recherche et filtres par catégorie pour retrouver les statistiques utiles.</span>
|
||||
|
|
|
|||
76
website/src/features/games/loaders.js
Normal file
76
website/src/features/games/loaders.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
export const INITIAL_MHWILDS_STATE = {
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: "",
|
||||
translations: {},
|
||||
monsters: [],
|
||||
endemic: [],
|
||||
filterOptions: { monsters: [], endemic: [] },
|
||||
filterOptionKeys: { monsters: "", endemic: "" }
|
||||
};
|
||||
|
||||
export const INITIAL_DIABLO4_STATE = {
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: "",
|
||||
affixes: [],
|
||||
filterOptions: { affixes: [] },
|
||||
categoryMap: {},
|
||||
filterOptionKeys: { affixes: "" }
|
||||
};
|
||||
|
||||
export async function loadMhwildsData() {
|
||||
const [monstersResponse, endemicResponse, translationsResponse] = await Promise.all([
|
||||
fetch("/data/mhwilds/monsters.json"),
|
||||
fetch("/data/mhwilds/endemic_life.json"),
|
||||
fetch("/data/mhwilds/i18n/fr.json")
|
||||
]);
|
||||
|
||||
if (!monstersResponse.ok || !endemicResponse.ok || !translationsResponse.ok) {
|
||||
throw new Error("Impossible de charger les données Monster Hunter Wilds.");
|
||||
}
|
||||
|
||||
const [monstersJson, endemicJson, translations] = await Promise.all([
|
||||
monstersResponse.json(),
|
||||
endemicResponse.json(),
|
||||
translationsResponse.json()
|
||||
]);
|
||||
const monsterFilterKey = Object.keys(monstersJson).find((key) => key !== "monsters") || "";
|
||||
const endemicFilterKey = Object.keys(endemicJson).find((key) => key !== "endemicLife" && key !== "aquaticLife") || "";
|
||||
|
||||
return {
|
||||
loaded: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
translations,
|
||||
monsters: monstersJson.monsters || [],
|
||||
endemic: [...(endemicJson.endemicLife || []), ...(endemicJson.aquaticLife || [])],
|
||||
filterOptions: {
|
||||
monsters: monstersJson[monsterFilterKey] || [],
|
||||
endemic: endemicJson[endemicFilterKey] || []
|
||||
},
|
||||
filterOptionKeys: {
|
||||
monsters: monsterFilterKey,
|
||||
endemic: endemicFilterKey
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDiablo4Data() {
|
||||
const response = await fetch("/data/diablo4/affixes_types.json");
|
||||
if (!response.ok) throw new Error("Impossible de charger les données Diablo IV.");
|
||||
|
||||
const payload = await response.json();
|
||||
const filterKey = Object.keys(payload).find((key) => key !== "affixes") || "";
|
||||
const categories = payload[filterKey] || [];
|
||||
|
||||
return {
|
||||
loaded: true,
|
||||
loading: false,
|
||||
error: "",
|
||||
affixes: payload.affixes || [],
|
||||
filterOptions: { affixes: categories },
|
||||
categoryMap: Object.fromEntries(categories.map((category) => [category.id, category])),
|
||||
filterOptionKeys: { affixes: filterKey }
|
||||
};
|
||||
}
|
||||
|
|
@ -1,54 +1,21 @@
|
|||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { GameFiltersPanel } from "../GameFiltersPanel.jsx";
|
||||
import { assetPath } from "./utils.js";
|
||||
|
||||
export function MhwildsFilters({ category, filterKey, options, filters, setFilters, t }) {
|
||||
const activeFilters = filters[category];
|
||||
const selected = new Set(activeFilters[filterKey] || []);
|
||||
const logicLabel = activeFilters.logic === "or" ? t("or", { capitalize: true }) : t("and", { capitalize: true });
|
||||
const updateCategory = (updater) => setFilters((state) => ({ ...state, [category]: updater(state[category]) }));
|
||||
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Filtres</p>
|
||||
<h2>{category === "monsters" ? t("weaknesses", { capitalize: true }) : t("locations", { capitalize: true })}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="filter-reset-button"
|
||||
onClick={() => updateCategory(() => ({ name: "", [filterKey]: [], logic: "and" }))}
|
||||
aria-label={t("reset", { capitalize: true })}
|
||||
title={t("reset", { capitalize: true })}
|
||||
>
|
||||
<Icon name="rubber" />
|
||||
</button>
|
||||
</div>
|
||||
<label className="field compact">
|
||||
<span>{t("name", { capitalize: true })}</span>
|
||||
<input value={activeFilters.name} placeholder="Rechercher..." onChange={(event) => updateCategory((state) => ({ ...state, name: event.target.value }))} />
|
||||
</label>
|
||||
<div className="filter-logic">
|
||||
<span>Correspondance</span>
|
||||
<button onClick={() => updateCategory((state) => ({ ...state, logic: state.logic === "and" ? "or" : "and" }))}>{logicLabel}</button>
|
||||
</div>
|
||||
<div className="filter-options legacy-scrollbar">
|
||||
{options.map((option) => (
|
||||
<label className={`filter-chip ${selected.has(option) ? "active" : ""}`} key={option}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(option)}
|
||||
onChange={(event) => updateCategory((state) => {
|
||||
const values = new Set(state[filterKey]);
|
||||
if (event.target.checked) values.add(option);
|
||||
else values.delete(option);
|
||||
return { ...state, [filterKey]: [...values] };
|
||||
})}
|
||||
/>
|
||||
<img src={assetPath(option)} alt="" />
|
||||
<span>{t(option, { capitalize: true })}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<GameFiltersPanel
|
||||
title={category === "monsters" ? t("weaknesses", { capitalize: true }) : t("locations", { capitalize: true })}
|
||||
nameLabel={t("name", { capitalize: true })}
|
||||
resetLabel={t("reset", { capitalize: true })}
|
||||
logicLabels={{ and: t("and", { capitalize: true }), or: t("or", { capitalize: true }) }}
|
||||
filters={activeFilters}
|
||||
selectedKey={filterKey}
|
||||
options={options}
|
||||
getOptionLabel={(option) => t(option, { capitalize: true })}
|
||||
renderOptionMedia={(option) => <img src={assetPath(option)} alt="" />}
|
||||
onChange={(nextFilters) => setFilters((state) => ({ ...state, [category]: nextFilters }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ export function MhwildsListing({ category, mhwilds, filters, setFilters, t }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<section className="page-heading mhwilds-heading">
|
||||
<section className="page-heading game-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Monster Hunter Wilds</p>
|
||||
<div className="mhwilds-title-row">
|
||||
<div className="game-title-row">
|
||||
<h1>{label}</h1>
|
||||
<span className="results-count">{visible.length} / {items.length}</span>
|
||||
</div>
|
||||
|
|
@ -30,12 +30,12 @@ export function MhwildsListing({ category, mhwilds, filters, setFilters, t }) {
|
|||
<a className={`button ${!isMonsters ? "primary" : ""}`} href="#/games/mhwilds/endemic">Faune</a>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mhwilds-layout" data-mhwilds-category={category}>
|
||||
<aside className="mhwilds-filters">
|
||||
<section className="game-layout" data-mhwilds-category={category}>
|
||||
<aside className="game-filters">
|
||||
<MhwildsFilters category={category} filterKey={filterKey} options={options} filters={filters} setFilters={setFilters} t={t} />
|
||||
</aside>
|
||||
<section className="mhwilds-results" aria-live="polite">
|
||||
<div className={`mhwilds-grid ${isMonsters ? "monster-grid" : "endemic-grid"}`}>
|
||||
<section className="game-results" aria-live="polite">
|
||||
<div className={`game-grid ${isMonsters ? "monster-grid" : "endemic-grid"}`}>
|
||||
{visible.length ? visible.map((item) => (
|
||||
isMonsters
|
||||
? <MonsterCard key={item.name} monster={item} t={t} />
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ export function MhwildsOverview({ game }) {
|
|||
<p>Consultez rapidement les monstres, faiblesses, afflictions, hitzones et emplacements de faune endémique.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mhwilds-home-grid">
|
||||
<a className="feature mhwilds-home-card" href="#/games/mhwilds/monsters">
|
||||
<section className="game-home-grid">
|
||||
<a className="feature game-home-card" href="#/games/mhwilds/monsters">
|
||||
<img src={assetPath("arkveld")} alt="" />
|
||||
<strong>Monstres</strong>
|
||||
<span>Recherche, filtres par faiblesse et tableau de dégâts par partie.</span>
|
||||
</a>
|
||||
<a className="feature mhwilds-home-card" href="#/games/mhwilds/endemic">
|
||||
<a className="feature game-home-card" href="#/games/mhwilds/endemic">
|
||||
<img src={assetPath("vigorwasp")} alt="" />
|
||||
<strong>Faune endémique</strong>
|
||||
<span>Faune endémique et aquatique filtrable par localisation.</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
|
||||
import { ChecklistModule } from "./ChecklistModule.jsx";
|
||||
import { CountersModule } from "./CountersModule.jsx";
|
||||
import { LinksModule } from "./LinksModule.jsx";
|
||||
|
|
@ -174,11 +175,19 @@ export function AddToolControls({ onAdd }) {
|
|||
}
|
||||
|
||||
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDelete, onMove }) {
|
||||
const [draggingModuleId, setDraggingModuleId] = useState("");
|
||||
const [dropTarget, setDropTarget] = useState({ id: "", placement: "before" });
|
||||
const [measuredSplitIndex, setMeasuredSplitIndex] = useState(() => Math.ceil(toolbox.modules.length / 2));
|
||||
const moduleElementsRef = useRef(new Map());
|
||||
const moduleIdSignature = useMemo(() => toolbox.modules.map((module) => module.id).join("|"), [toolbox.modules]);
|
||||
const {
|
||||
draggingId: draggingModuleId,
|
||||
dropTarget,
|
||||
startDrag: startModuleDrag
|
||||
} = usePointerReorder({
|
||||
targetSelector: ".module",
|
||||
getTargetId: (target) => target.dataset.moduleId,
|
||||
canDropOn: (target) => target.dataset.toolboxId === toolbox.id,
|
||||
onMove
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMeasuredSplitIndex(Math.ceil(toolbox.modules.length / 2));
|
||||
|
|
@ -219,51 +228,6 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDe
|
|||
};
|
||||
}, [moduleColumns, toolbox.id, moduleIdSignature, toolbox.modules.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingModuleId) return undefined;
|
||||
|
||||
function getDropTarget(event) {
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const target = element?.closest?.(".module");
|
||||
if (!target || target.dataset.toolboxId !== toolbox.id || target.dataset.moduleId === draggingModuleId) {
|
||||
return { id: "", placement: "before" };
|
||||
}
|
||||
const rect = target.getBoundingClientRect();
|
||||
return {
|
||||
id: target.dataset.moduleId,
|
||||
placement: event.clientY > rect.top + rect.height / 2 ? "after" : "before"
|
||||
};
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
setDropTarget(getDropTarget(event));
|
||||
}
|
||||
|
||||
function handlePointerUp(event) {
|
||||
const target = getDropTarget(event);
|
||||
if (target.id) onMove(draggingModuleId, target.id, target.placement);
|
||||
setDraggingModuleId("");
|
||||
setDropTarget({ id: "", placement: "before" });
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
window.addEventListener("pointercancel", handlePointerUp, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
};
|
||||
}, [draggingModuleId, onMove, toolbox.id]);
|
||||
|
||||
function startModuleDrag(event, moduleId) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setDraggingModuleId(moduleId);
|
||||
setDropTarget({ id: "", placement: "before" });
|
||||
}
|
||||
|
||||
function registerModuleElement(moduleId, element) {
|
||||
if (element) {
|
||||
moduleElementsRef.current.set(moduleId, element);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue