structure optimisation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s

This commit is contained in:
Shinuwa 2026-07-22 22:49:36 +02:00
parent 34523e78b1
commit 5c86fd2ceb
19 changed files with 724 additions and 519 deletions

View 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>
);
}

View file

@ -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">

View file

@ -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 }))}
/>
);
}

View file

@ -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} />
)) : (

View file

@ -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>

View 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 }
};
}

View file

@ -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 }))}
/>
);
}

View file

@ -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} />

View file

@ -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>

View file

@ -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);

View file

@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from "react";
function getDefaultPlacement(event, element) {
const rect = element.getBoundingClientRect();
return event.clientY > rect.top + rect.height / 2 ? "after" : "before";
}
export function usePointerReorder({
targetSelector,
getTargetId,
canDropOn = () => true,
getPlacement = getDefaultPlacement,
onMove
}) {
const [draggingId, setDraggingId] = useState("");
const [dropTarget, setDropTarget] = useState({ id: "", placement: "before" });
const optionsRef = useRef({ getTargetId, canDropOn, getPlacement, onMove });
useEffect(() => {
optionsRef.current = { getTargetId, canDropOn, getPlacement, onMove };
}, [canDropOn, getPlacement, getTargetId, onMove]);
useEffect(() => {
if (!draggingId) return undefined;
function getDropTarget(event) {
const { getTargetId, canDropOn, getPlacement } = optionsRef.current;
const element = document.elementFromPoint(event.clientX, event.clientY);
const target = element?.closest?.(targetSelector);
if (!target || !canDropOn(target, draggingId) || getTargetId(target) === draggingId) {
return { id: "", placement: "before" };
}
return {
id: getTargetId(target),
placement: getPlacement(event, target)
};
}
function handlePointerMove(event) {
setDropTarget(getDropTarget(event));
}
function handlePointerUp(event) {
const { onMove } = optionsRef.current;
const target = getDropTarget(event);
if (target.id) onMove(draggingId, target.id, target.placement);
setDraggingId("");
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);
};
}, [draggingId, targetSelector]);
function startDrag(event, id) {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
setDraggingId(id);
setDropTarget({ id: "", placement: "before" });
}
return { draggingId, dropTarget, startDrag };
}

View file

@ -4,7 +4,9 @@ import "./styles/main.scss";
import { Icon } from "./components/Icon.jsx";
import { GameRoute } from "./features/games/GameRoute.jsx";
import { GamesPage } from "./features/games/GamesPage.jsx";
import { INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwildsData } from "./features/games/loaders.js";
import { AddToolControls, ToolboxModules, TOOLBOX_MODULES } from "./features/toolboxes/modules/index.jsx";
import { usePointerReorder } from "./hooks/usePointerReorder.js";
const STORAGE_KEYS = {
registry: "sokkog:toolboxes",
@ -594,25 +596,8 @@ function App() {
const [siteContent, setSiteContent] = useState(DEFAULT_SITE_CONTENT);
const [games, setGames] = useState([]);
const [gamesError, setGamesError] = useState("");
const [mhwilds, setMhwilds] = useState({
loaded: false,
loading: false,
error: "",
translations: {},
monsters: [],
endemic: [],
filterOptions: { monsters: [], endemic: [] },
filterOptionKeys: { monsters: "", endemic: "" }
});
const [diablo4, setDiablo4] = useState({
loaded: false,
loading: false,
error: "",
affixes: [],
filterOptions: { affixes: [] },
categoryMap: {},
filterOptionKeys: { affixes: "" }
});
const [mhwilds, setMhwilds] = useState(INITIAL_MHWILDS_STATE);
const [diablo4, setDiablo4] = useState(INITIAL_DIABLO4_STATE);
const [filters, setFilters] = useState({
monsters: { name: "", weaknesses: [], logic: "and" },
endemic: { name: "", locations: [], logic: "and" },
@ -642,77 +627,17 @@ function App() {
useEffect(() => {
if (!route.startsWith("/games/mhwilds") || mhwilds.loaded || mhwilds.loading) return;
setMhwilds((state) => ({ ...state, loading: true, error: "" }));
Promise.all([
fetch("/data/mhwilds/monsters.json"),
fetch("/data/mhwilds/endemic_life.json"),
fetch("/data/mhwilds/i18n/fr.json")
]).then(async ([monstersResponse, endemicResponse, translationsResponse]) => {
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") || "";
setMhwilds({
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
}
});
}).catch((error) => setMhwilds({
loaded: true,
loading: false,
error: error.message,
translations: {},
monsters: [],
endemic: [],
filterOptions: { monsters: [], endemic: [] },
filterOptionKeys: { monsters: "", endemic: "" }
}));
loadMhwildsData()
.then(setMhwilds)
.catch((error) => setMhwilds({ ...INITIAL_MHWILDS_STATE, loaded: true, error: error.message }));
}, [route, mhwilds.loaded, mhwilds.loading]);
useEffect(() => {
if (!route.startsWith("/games/diablo4") || diablo4.loaded || diablo4.loading) return;
setDiablo4((state) => ({ ...state, loading: true }));
fetch("/data/diablo4/affixes_types.json")
.then(async (response) => {
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] || [];
setDiablo4({
loaded: true,
loading: false,
error: "",
affixes: payload.affixes || [],
filterOptions: { affixes: categories },
categoryMap: Object.fromEntries(categories.map((category) => [category.id, category])),
filterOptionKeys: { affixes: filterKey }
});
})
.catch((error) => setDiablo4({
loaded: true,
loading: false,
error: error.message,
affixes: [],
filterOptions: { affixes: [] },
categoryMap: {},
filterOptionKeys: { affixes: "" }
}));
setDiablo4((state) => ({ ...state, loading: true, error: "" }));
loadDiablo4Data()
.then(setDiablo4)
.catch((error) => setDiablo4({ ...INITIAL_DIABLO4_STATE, loaded: true, error: error.message }));
}, [route, diablo4.loaded, diablo4.loading]);
const t = (key, { capitalize = false } = {}) => {
@ -852,6 +777,10 @@ function App() {
setScreenshot,
addScreenshotFiles,
updateModuleData: store.updateModuleData,
updateToolboxOrder: (orderedIds) => {
const order = new Map(orderedIds.map((id, index) => [id, index]));
store.setToolboxes([...store.toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999)));
},
importToolbox: async (file, gameId = "") => {
try {
return await importToolbox(file, gameId);
@ -1070,6 +999,26 @@ function RichText({ text }) {
function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
const content = siteContent.toolboxes;
const updateToolboxOrder = actions.updateToolboxOrder;
const {
draggingId: draggingToolboxId,
dropTarget,
startDrag: startToolboxDrag
} = usePointerReorder({
targetSelector: ".toolbox-card",
getTargetId: (target) => target.dataset.toolboxId,
getPlacement: (event, target) => {
const rect = target.getBoundingClientRect();
return event.clientY > rect.top + rect.height / 2 || event.clientX > rect.left + rect.width / 2 ? "after" : "before";
},
onMove: (draggingId, targetId, placement) => {
const nextIds = toolboxes.map((toolbox) => toolbox.id).filter((id) => id !== draggingId);
const targetIndex = nextIds.indexOf(targetId);
nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, draggingId);
updateToolboxOrder(nextIds);
}
});
return (
<div className="toolbox-page">
<section className="page-hero">
@ -1091,7 +1040,17 @@ function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
</section>
<section className="cards">
<StorageHelpCard help={content.storageHelp} />
{toolboxes.length ? toolboxes.map((toolbox) => <ToolboxCard key={toolbox.id} toolbox={toolbox} game={getToolboxGame(toolbox)} actions={actions} />) : (
{toolboxes.length ? toolboxes.map((toolbox) => (
<ToolboxCard
key={toolbox.id}
toolbox={toolbox}
game={getToolboxGame(toolbox)}
actions={actions}
draggingToolboxId={draggingToolboxId}
dropTarget={dropTarget}
onDragStart={startToolboxDrag}
/>
)) : (
<div className="empty"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>
)}
</section>
@ -1117,11 +1076,30 @@ function StorageHelpCard({ help }) {
);
}
function ToolboxCard({ toolbox, game, actions }) {
function ToolboxCard({ toolbox, game, actions, draggingToolboxId, dropTarget, onDragStart }) {
const gameCoverImage = getGameCardCover(game);
const coverImage = gameCoverImage || toolbox.icon || DEFAULT_TOOLBOX_ICON;
const isDragging = draggingToolboxId === toolbox.id;
const isDropTarget = dropTarget.id === toolbox.id;
const className = [
"card",
"toolbox-card",
isDragging ? "is-dragging" : "",
isDropTarget ? "is-drop-target" : "",
isDropTarget && dropTarget.placement === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<article className="card toolbox-card">
<article className={className} data-toolbox-id={toolbox.id}>
<button
className="toolbox-card-drag-handle"
type="button"
onPointerDown={(event) => onDragStart(event, toolbox.id)}
aria-label={`Déplacer ${toolbox.name}`}
title="Déplacer"
>
<Icon name="drag" />
</button>
<a
className={`card-cover toolbox-card-cover-link ${gameCoverImage ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
href={`#/toolbox/${toolbox.id}`}

View file

@ -79,6 +79,39 @@
0 0 34px rgba(196, 181, 253, 0.12);
}
.compact-data-card {
overflow: hidden;
border: 1px solid transparent;
border-radius: var(--radius-md);
background:
linear-gradient(rgba(17, 20, 34, 0.93), rgba(17, 20, 34, 0.93)) padding-box,
radial-gradient(circle at 100% 0%, rgba(34, 211, 238, 0.24), transparent 24%) border-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.34) 0%, rgba(246, 196, 83, 0.2) 54%, rgba(34, 211, 238, 0.22) 100%) border-box;
box-shadow: var(--shadow-sm);
transition:
transform var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard);
}
@supports (corner-shape: scoop) {
.compact-data-card {
corner-shape: scoop;
}
}
.compact-data-card:hover {
transform: none;
border-color: transparent;
background:
linear-gradient(rgba(20, 23, 39, 0.96), rgba(20, 23, 39, 0.96)) padding-box,
linear-gradient(135deg, rgba(139, 92, 246, 0.62), rgba(196, 181, 253, 0.42)) border-box;
box-shadow:
var(--shadow-sm),
0 0 14px rgba(139, 92, 246, 0.12),
0 0 22px rgba(196, 181, 253, 0.07);
}
.feature {
display: grid;
gap: 8px;
@ -114,6 +147,7 @@
}
.toolbox-card {
position: relative;
display: flex;
min-height: 292px;
flex-direction: column;
@ -157,6 +191,57 @@
padding-top: var(--space-5);
}
.toolbox-card-drag-handle {
position: absolute;
top: 10px;
left: 10px;
z-index: 3;
display: inline-grid;
width: 34px;
height: 34px;
place-items: center;
padding: 0;
border-color: rgba(165, 180, 252, 0.12);
background: rgba(7, 10, 24, 0.42);
color: var(--color-text-muted);
cursor: grab;
opacity: 0.72;
touch-action: none;
user-select: none;
}
.toolbox-card-drag-handle .ui-icon {
width: 18px;
height: 18px;
}
.toolbox-card:hover .toolbox-card-drag-handle,
.toolbox-card:focus-within .toolbox-card-drag-handle {
opacity: 1;
}
.toolbox-card-drag-handle:active {
cursor: grabbing;
}
.toolbox-card.is-dragging {
cursor: grabbing;
opacity: 0.58;
transform: scale(0.99);
}
.toolbox-card.is-drop-target {
box-shadow:
var(--shadow-sm),
inset 3px 0 0 rgba(246, 196, 83, 0.82);
}
.toolbox-card.is-drop-target.drop-after {
box-shadow:
var(--shadow-sm),
inset -3px 0 0 rgba(246, 196, 83, 0.82);
}
.toolbox-page {
display: flex;
min-height: calc(100vh - 68px - var(--space-8) - var(--space-12));

View file

@ -25,7 +25,7 @@
color: var(--color-text-primary);
}
.mhwilds-home-card .diablo4-home-mark {
.game-home-card .diablo4-home-mark {
margin: 0;
}

View file

@ -1,6 +1,6 @@
@use "mixins";
.mhwilds-home-grid {
.game-home-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 380px));
justify-content: center;
@ -8,7 +8,7 @@
margin-top: var(--space-6);
}
.mhwilds-home-card {
.game-home-card {
overflow: hidden;
border-color: transparent;
border-radius: var(--radius-md);
@ -21,12 +21,12 @@
}
@supports (corner-shape: scoop) {
.mhwilds-home-card {
.game-home-card {
corner-shape: scoop;
}
}
.mhwilds-home-card:hover {
.game-home-card:hover {
transform: none;
border-color: transparent;
background:
@ -38,7 +38,7 @@
0 0 34px rgba(196, 181, 253, 0.12);
}
.mhwilds-home-card img {
.game-home-card img {
display: block;
width: 100%;
height: 120px;
@ -48,31 +48,31 @@
background: rgba(5, 7, 17, 0.28);
}
.mhwilds-home-card strong,
.mhwilds-home-card span {
.game-home-card strong,
.game-home-card span {
margin-inline: var(--space-5);
}
.mhwilds-home-card strong {
.game-home-card strong {
margin-top: var(--space-4);
}
.mhwilds-home-card span {
.game-home-card span {
margin-bottom: var(--space-5);
}
.mhwilds-heading p {
.game-heading p {
max-width: 70ch;
}
.mhwilds-title-row {
.game-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.mhwilds-title-row h1 {
.game-title-row h1 {
margin-bottom: 0;
}
@ -89,7 +89,7 @@
font-weight: 800;
}
.mhwilds-layout {
.game-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: var(--space-5);
@ -97,7 +97,7 @@
align-items: start;
}
.mhwilds-filters {
.game-filters {
position: sticky;
top: 92px;
}
@ -223,7 +223,7 @@
white-space: nowrap;
}
.mhwilds-grid {
.game-grid {
display: grid;
gap: var(--space-4);
}

View file

@ -143,12 +143,12 @@
display: contents;
}
.mhwilds-home-grid,
.mhwilds-layout {
.game-home-grid,
.game-layout {
grid-template-columns: 1fr;
}
.mhwilds-filters {
.game-filters {
position: static;
}