This commit is contained in:
parent
34523e78b1
commit
5c86fd2ceb
19 changed files with 724 additions and 519 deletions
78
tests/data-validation.test.mjs
Normal file
78
tests/data-validation.test.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
validateDiablo4Affixes,
|
||||||
|
validateEndemicData,
|
||||||
|
validateMonsterData,
|
||||||
|
validateSiteContent,
|
||||||
|
validateTranslationKeys
|
||||||
|
} from "./helpers/data-validation.mjs";
|
||||||
|
|
||||||
|
test("site content json is complete", async () => {
|
||||||
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
|
validateSiteContent(site);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mhwilds data and assets are available", async () => {
|
||||||
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
|
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
||||||
|
const monsters = JSON.parse(await readFile("website/public/data/mhwilds/monsters.json", "utf8"));
|
||||||
|
const endemicLife = JSON.parse(await readFile("website/public/data/mhwilds/endemic_life.json", "utf8"));
|
||||||
|
const translations = JSON.parse(await readFile("website/public/data/mhwilds/i18n/fr.json", "utf8"));
|
||||||
|
const enTranslations = JSON.parse(await readFile("website/public/data/mhwilds/i18n/en.json", "utf8"));
|
||||||
|
const listingSource = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
|
||||||
|
|
||||||
|
assert.equal(site.brand.name, "Sokko G");
|
||||||
|
assert.equal(site.home.hero.title, "Sokko G");
|
||||||
|
assert.equal(site.gamesPage.title, "Jeux disponibles");
|
||||||
|
assert.match(site.toolboxes.summary, /espaces modulaires/);
|
||||||
|
assert.ok(site.toolboxes.storageHelp.items.length >= 3);
|
||||||
|
assert.equal(games.games[0].id, "mhwilds");
|
||||||
|
assert.equal(games.games[0].eyebrow, "Guide de jeu");
|
||||||
|
assert.match(games.games[0].images.cardCover, /games\/mhwilds\/card-cover\.png/);
|
||||||
|
assert.match(games.games[0].images.heroBg, /games\/mhwilds\/hero-bg\.png/);
|
||||||
|
assert.ok(monsters.monsters.length > 0);
|
||||||
|
assert.deepEqual(monsters.elements, ["fire", "water", "thunder", "ice", "dragon"]);
|
||||||
|
validateMonsterData(monsters.monsters);
|
||||||
|
assert.ok(Array.isArray(endemicLife.locations), "endemicLife.locations must be an array");
|
||||||
|
assert.ok(endemicLife.locations.length > 0, "endemicLife.locations must not be empty");
|
||||||
|
assert.ok(endemicLife.endemicLife.length > 0);
|
||||||
|
validateEndemicData(endemicLife);
|
||||||
|
validateTranslationKeys(translations, "fr");
|
||||||
|
validateTranslationKeys(enTranslations, "en");
|
||||||
|
assert.equal(translations.monsters, "monstres");
|
||||||
|
assert.equal(translations.dark_hornet, "frelon clair-obscur");
|
||||||
|
assert.equal(translations["dark hornet"], undefined);
|
||||||
|
assert.match(listingSource, /function MhwildsListing/);
|
||||||
|
const monsterCardSource = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8");
|
||||||
|
assert.match(monsterCardSource, /monster-card/);
|
||||||
|
assert.match(monsterCardSource, /monster\.hitzones/);
|
||||||
|
await readFile("website/public/static/img/mhwilds/chatacabra.png");
|
||||||
|
await readFile("website/public/static/img/games/mhwilds/card-cover.png");
|
||||||
|
await readFile("website/public/static/img/games/mhwilds/hero-bg.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diablo4 data and route are available", async () => {
|
||||||
|
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
||||||
|
const affixes = JSON.parse(await readFile("website/public/data/diablo4/affixes_types.json", "utf8"));
|
||||||
|
const routeSource = await readFile("website/src/features/games/GameRoute.jsx", "utf8");
|
||||||
|
const pageSource = await readFile("website/src/features/games/diablo4/Diablo4Page.jsx", "utf8");
|
||||||
|
const listingSource = await readFile("website/src/features/games/diablo4/Diablo4Listing.jsx", "utf8");
|
||||||
|
const diablo4Game = games.games.find((game) => game.id === "diablo4");
|
||||||
|
|
||||||
|
assert.ok(diablo4Game, "games.json must declare diablo4");
|
||||||
|
assert.match(diablo4Game.images.cardCover, /games\/diablo4\/card-cover\.png/);
|
||||||
|
assert.match(diablo4Game.images.heroBg, /games\/diablo4\/hero-bg\.png/);
|
||||||
|
validateDiablo4Affixes(affixes);
|
||||||
|
assert.match(routeSource, /game\.id === "diablo4"/);
|
||||||
|
assert.match(pageSource, /category === "affixes"/);
|
||||||
|
assert.match(listingSource, /diablo4\.filterOptions\.affixes/);
|
||||||
|
await readFile("website/public/static/img/games/diablo4/card-cover.png");
|
||||||
|
await readFile("website/public/static/img/games/diablo4/hero-bg.png");
|
||||||
|
await readFile("website/public/static/img/diablo4/axe.svg");
|
||||||
|
await readFile("website/public/static/img/diablo4/cube.svg");
|
||||||
|
await readFile("website/public/static/img/diablo4/fire.svg");
|
||||||
|
await readFile("website/public/static/img/diablo4/shield.svg");
|
||||||
|
await readFile("website/public/static/img/diablo4/wing.svg");
|
||||||
|
});
|
||||||
178
tests/helpers/data-validation.mjs
Normal file
178
tests/helpers/data-validation.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const SNAKE_CASE_RE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/;
|
||||||
|
const PHYSICAL_HITZONE_KEYS = ["cut", "blunt", "ammo"];
|
||||||
|
const ELEMENTAL_HITZONE_KEYS = ["fire", "water", "thunder", "ice", "dragon"];
|
||||||
|
|
||||||
|
function valueAt(object, path) {
|
||||||
|
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(object, path) {
|
||||||
|
const value = valueAt(object, path);
|
||||||
|
assert.equal(typeof value, "string", `${path} must be a string`);
|
||||||
|
assert.ok(value.trim(), `${path} must not be empty`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertStringArray(object, path, minLength = 1) {
|
||||||
|
const value = valueAt(object, path);
|
||||||
|
assert.ok(Array.isArray(value), `${path} must be an array`);
|
||||||
|
assert.ok(value.length >= minLength, `${path} must contain at least ${minLength} item(s)`);
|
||||||
|
value.forEach((item, index) => {
|
||||||
|
assert.equal(typeof item, "string", `${path}[${index}] must be a string`);
|
||||||
|
assert.ok(item.trim(), `${path}[${index}] must not be empty`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSiteContent(site) {
|
||||||
|
[
|
||||||
|
"brand.name",
|
||||||
|
"brand.homeAriaLabel",
|
||||||
|
"navigation.home",
|
||||||
|
"navigation.toolboxes",
|
||||||
|
"navigation.games",
|
||||||
|
"navigation.mobileGames",
|
||||||
|
"sidebar.badge",
|
||||||
|
"sidebar.note",
|
||||||
|
"topbar.dashboard",
|
||||||
|
"topbar.toolbox",
|
||||||
|
"topbar.games",
|
||||||
|
"gamesPage.eyebrow",
|
||||||
|
"gamesPage.title",
|
||||||
|
"gamesPage.description",
|
||||||
|
"gamesPage.emptyTitle",
|
||||||
|
"gamesPage.emptyText",
|
||||||
|
"home.hero.eyebrow",
|
||||||
|
"home.hero.title",
|
||||||
|
"home.hero.description",
|
||||||
|
"home.hero.primaryAction",
|
||||||
|
"home.hero.secondaryAction",
|
||||||
|
"home.stats.toolboxSingular",
|
||||||
|
"home.stats.toolboxPlural",
|
||||||
|
"home.stats.toolSingular",
|
||||||
|
"home.stats.toolPlural",
|
||||||
|
"home.origin.eyebrow",
|
||||||
|
"home.origin.title",
|
||||||
|
"home.origin.visualAlt",
|
||||||
|
"home.origin.dialogueAriaLabel",
|
||||||
|
"home.origin.caption",
|
||||||
|
"home.legal.copyright",
|
||||||
|
"home.legal.disclaimer",
|
||||||
|
"toolboxes.eyebrow",
|
||||||
|
"toolboxes.title",
|
||||||
|
"toolboxes.newButton",
|
||||||
|
"toolboxes.importAll",
|
||||||
|
"toolboxes.exportAll",
|
||||||
|
"toolboxes.importOne",
|
||||||
|
"toolboxes.summary",
|
||||||
|
"toolboxes.storageHelp.title",
|
||||||
|
"toolboxes.storageHelp.text",
|
||||||
|
"toolboxes.emptyTitle",
|
||||||
|
"toolboxes.emptyText"
|
||||||
|
].forEach((path) => assertNonEmptyString(site, path));
|
||||||
|
|
||||||
|
assertStringArray(site, "toolboxes.storageHelp.items", 3);
|
||||||
|
|
||||||
|
const lines = valueAt(site, "home.origin.lines");
|
||||||
|
assert.ok(Array.isArray(lines), "home.origin.lines must be an array");
|
||||||
|
assert.ok(lines.length >= 2, "home.origin.lines must contain at least 2 items");
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
assert.ok(["user", "app"].includes(line?.speaker), `home.origin.lines[${index}].speaker must be "user" or "app"`);
|
||||||
|
assert.equal(typeof line?.text, "string", `home.origin.lines[${index}].text must be a string`);
|
||||||
|
assert.ok(line.text.trim(), `home.origin.lines[${index}].text must not be empty`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSnakeCase(value, path) {
|
||||||
|
assert.equal(typeof value, "string", `${path} must be a string`);
|
||||||
|
assert.match(value, SNAKE_CASE_RE, `${path} must be snake_case`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConditionValues(items, path) {
|
||||||
|
assert.ok(Array.isArray(items), `${path} must be an array`);
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
assertSnakeCase(item?.condition, `${path}[${index}].condition`);
|
||||||
|
assert.ok(Array.isArray(item?.values), `${path}[${index}].values must be an array`);
|
||||||
|
item.values.forEach((value, valueIndex) => assertSnakeCase(value, `${path}[${index}].values[${valueIndex}]`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMonsterData(monsters) {
|
||||||
|
assert.ok(Array.isArray(monsters), "monsters must be an array");
|
||||||
|
const ids = new Set();
|
||||||
|
const names = new Set();
|
||||||
|
|
||||||
|
monsters.forEach((monster, monsterIndex) => {
|
||||||
|
const path = `monsters[${monsterIndex}]`;
|
||||||
|
assert.equal(typeof monster.id, "number", `${path}.id must be a number`);
|
||||||
|
assert.ok(Number.isInteger(monster.id), `${path}.id must be an integer`);
|
||||||
|
assert.ok(!ids.has(monster.id), `${path}.id must be unique`);
|
||||||
|
ids.add(monster.id);
|
||||||
|
|
||||||
|
assertSnakeCase(monster.name, `${path}.name`);
|
||||||
|
assert.ok(!names.has(monster.name), `${path}.name must be unique`);
|
||||||
|
names.add(monster.name);
|
||||||
|
assertSnakeCase(monster.type, `${path}.type`);
|
||||||
|
validateConditionValues(monster.ailments, `${path}.ailments`);
|
||||||
|
validateConditionValues(monster.weaknesses, `${path}.weaknesses`);
|
||||||
|
validateConditionValues(monster.weakpoints, `${path}.weakpoints`);
|
||||||
|
|
||||||
|
assert.equal(monster.damage, undefined, `${path}.damage must not be used`);
|
||||||
|
assert.ok(Array.isArray(monster.hitzones), `${path}.hitzones must be an array`);
|
||||||
|
assert.ok(monster.hitzones.length > 0, `${path}.hitzones must not be empty`);
|
||||||
|
monster.hitzones.forEach((hitzone, hitzoneIndex) => {
|
||||||
|
const hitzonePath = `${path}.hitzones[${hitzoneIndex}]`;
|
||||||
|
assertSnakeCase(hitzone.name, `${hitzonePath}.name`);
|
||||||
|
assert.equal(typeof hitzone.physical, "object", `${hitzonePath}.physical must be an object`);
|
||||||
|
assert.equal(typeof hitzone.elemental, "object", `${hitzonePath}.elemental must be an object`);
|
||||||
|
PHYSICAL_HITZONE_KEYS.forEach((key) => assert.equal(typeof hitzone.physical[key], "number", `${hitzonePath}.physical.${key} must be a number`));
|
||||||
|
ELEMENTAL_HITZONE_KEYS.forEach((key) => assert.equal(typeof hitzone.elemental[key], "number", `${hitzonePath}.elemental.${key} must be a number`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEndemicData(endemicPayload) {
|
||||||
|
["endemicLife", "aquaticLife"].forEach((collection) => {
|
||||||
|
assert.ok(Array.isArray(endemicPayload[collection]), `${collection} must be an array`);
|
||||||
|
endemicPayload[collection].forEach((item, itemIndex) => {
|
||||||
|
const path = `${collection}[${itemIndex}]`;
|
||||||
|
assertSnakeCase(item.name, `${path}.name`);
|
||||||
|
if (item.description) assertSnakeCase(item.description, `${path}.description`);
|
||||||
|
validateConditionValues(item.locations, `${path}.locations`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTranslationKeys(translations, path) {
|
||||||
|
Object.keys(translations).forEach((key) => {
|
||||||
|
assert.ok(!/\s/.test(key), `${path}.${key} must not contain spaces`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDiablo4Affixes(payload) {
|
||||||
|
assert.ok(Array.isArray(payload.categories), "diablo4.categories must be an array");
|
||||||
|
assert.ok(payload.categories.length > 0, "diablo4.categories must not be empty");
|
||||||
|
assert.ok(Array.isArray(payload.affixes), "diablo4.affixes must be an array");
|
||||||
|
assert.ok(payload.affixes.length > 0, "diablo4.affixes must not be empty");
|
||||||
|
|
||||||
|
const ids = new Set();
|
||||||
|
const categoryIds = payload.categories.map((category, index) => {
|
||||||
|
assertSnakeCase(category.id, `diablo4.categories[${index}].id`);
|
||||||
|
assertNonEmptyString({ category }, "category.label");
|
||||||
|
assertSnakeCase(category.icon, `diablo4.categories[${index}].icon`);
|
||||||
|
assertSnakeCase(category.tone, `diablo4.categories[${index}].tone`);
|
||||||
|
return category.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
payload.affixes.forEach((affix, index) => {
|
||||||
|
assertSnakeCase(affix.id, `diablo4.affixes[${index}].id`);
|
||||||
|
assert.ok(!ids.has(affix.id), `diablo4.affixes[${index}].id must be unique`);
|
||||||
|
ids.add(affix.id);
|
||||||
|
assertNonEmptyString({ affix }, "affix.label");
|
||||||
|
assert.ok(Array.isArray(affix.categories), `diablo4.affixes[${index}].categories must be an array`);
|
||||||
|
affix.categories.forEach((category, categoryIndex) => {
|
||||||
|
assertSnakeCase(category, `diablo4.affixes[${index}].categories[${categoryIndex}]`);
|
||||||
|
assert.ok(categoryIds.includes(category), `diablo4.affixes[${index}].categories[${categoryIndex}] must exist in diablo4.categories`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,182 +2,6 @@ import { readFile } from "node:fs/promises";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
const SNAKE_CASE_RE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/;
|
|
||||||
const PHYSICAL_HITZONE_KEYS = ["cut", "blunt", "ammo"];
|
|
||||||
const ELEMENTAL_HITZONE_KEYS = ["fire", "water", "thunder", "ice", "dragon"];
|
|
||||||
|
|
||||||
function valueAt(object, path) {
|
|
||||||
return path.split(".").reduce((value, key) => value?.[key], object);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNonEmptyString(object, path) {
|
|
||||||
const value = valueAt(object, path);
|
|
||||||
assert.equal(typeof value, "string", `${path} must be a string`);
|
|
||||||
assert.ok(value.trim(), `${path} must not be empty`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertStringArray(object, path, minLength = 1) {
|
|
||||||
const value = valueAt(object, path);
|
|
||||||
assert.ok(Array.isArray(value), `${path} must be an array`);
|
|
||||||
assert.ok(value.length >= minLength, `${path} must contain at least ${minLength} item(s)`);
|
|
||||||
value.forEach((item, index) => {
|
|
||||||
assert.equal(typeof item, "string", `${path}[${index}] must be a string`);
|
|
||||||
assert.ok(item.trim(), `${path}[${index}] must not be empty`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateSiteContent(site) {
|
|
||||||
[
|
|
||||||
"brand.name",
|
|
||||||
"brand.homeAriaLabel",
|
|
||||||
"navigation.home",
|
|
||||||
"navigation.toolboxes",
|
|
||||||
"navigation.games",
|
|
||||||
"navigation.mobileGames",
|
|
||||||
"sidebar.badge",
|
|
||||||
"sidebar.note",
|
|
||||||
"topbar.dashboard",
|
|
||||||
"topbar.toolbox",
|
|
||||||
"topbar.games",
|
|
||||||
"gamesPage.eyebrow",
|
|
||||||
"gamesPage.title",
|
|
||||||
"gamesPage.description",
|
|
||||||
"gamesPage.emptyTitle",
|
|
||||||
"gamesPage.emptyText",
|
|
||||||
"home.hero.eyebrow",
|
|
||||||
"home.hero.title",
|
|
||||||
"home.hero.description",
|
|
||||||
"home.hero.primaryAction",
|
|
||||||
"home.hero.secondaryAction",
|
|
||||||
"home.stats.toolboxSingular",
|
|
||||||
"home.stats.toolboxPlural",
|
|
||||||
"home.stats.toolSingular",
|
|
||||||
"home.stats.toolPlural",
|
|
||||||
"home.origin.eyebrow",
|
|
||||||
"home.origin.title",
|
|
||||||
"home.origin.visualAlt",
|
|
||||||
"home.origin.dialogueAriaLabel",
|
|
||||||
"home.origin.caption",
|
|
||||||
"home.legal.copyright",
|
|
||||||
"home.legal.disclaimer",
|
|
||||||
"toolboxes.eyebrow",
|
|
||||||
"toolboxes.title",
|
|
||||||
"toolboxes.newButton",
|
|
||||||
"toolboxes.importAll",
|
|
||||||
"toolboxes.exportAll",
|
|
||||||
"toolboxes.importOne",
|
|
||||||
"toolboxes.summary",
|
|
||||||
"toolboxes.storageHelp.title",
|
|
||||||
"toolboxes.storageHelp.text",
|
|
||||||
"toolboxes.emptyTitle",
|
|
||||||
"toolboxes.emptyText"
|
|
||||||
].forEach((path) => assertNonEmptyString(site, path));
|
|
||||||
|
|
||||||
assertStringArray(site, "toolboxes.storageHelp.items", 3);
|
|
||||||
|
|
||||||
const lines = valueAt(site, "home.origin.lines");
|
|
||||||
assert.ok(Array.isArray(lines), "home.origin.lines must be an array");
|
|
||||||
assert.ok(lines.length >= 2, "home.origin.lines must contain at least 2 items");
|
|
||||||
lines.forEach((line, index) => {
|
|
||||||
assert.ok(["user", "app"].includes(line?.speaker), `home.origin.lines[${index}].speaker must be "user" or "app"`);
|
|
||||||
assert.equal(typeof line?.text, "string", `home.origin.lines[${index}].text must be a string`);
|
|
||||||
assert.ok(line.text.trim(), `home.origin.lines[${index}].text must not be empty`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSnakeCase(value, path) {
|
|
||||||
assert.equal(typeof value, "string", `${path} must be a string`);
|
|
||||||
assert.match(value, SNAKE_CASE_RE, `${path} must be snake_case`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateConditionValues(items, path) {
|
|
||||||
assert.ok(Array.isArray(items), `${path} must be an array`);
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
assertSnakeCase(item?.condition, `${path}[${index}].condition`);
|
|
||||||
assert.ok(Array.isArray(item?.values), `${path}[${index}].values must be an array`);
|
|
||||||
item.values.forEach((value, valueIndex) => assertSnakeCase(value, `${path}[${index}].values[${valueIndex}]`));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateMonsterData(monsters) {
|
|
||||||
assert.ok(Array.isArray(monsters), "monsters must be an array");
|
|
||||||
const ids = new Set();
|
|
||||||
const names = new Set();
|
|
||||||
|
|
||||||
monsters.forEach((monster, monsterIndex) => {
|
|
||||||
const path = `monsters[${monsterIndex}]`;
|
|
||||||
assert.equal(typeof monster.id, "number", `${path}.id must be a number`);
|
|
||||||
assert.ok(Number.isInteger(monster.id), `${path}.id must be an integer`);
|
|
||||||
assert.ok(!ids.has(monster.id), `${path}.id must be unique`);
|
|
||||||
ids.add(monster.id);
|
|
||||||
|
|
||||||
assertSnakeCase(monster.name, `${path}.name`);
|
|
||||||
assert.ok(!names.has(monster.name), `${path}.name must be unique`);
|
|
||||||
names.add(monster.name);
|
|
||||||
assertSnakeCase(monster.type, `${path}.type`);
|
|
||||||
validateConditionValues(monster.ailments, `${path}.ailments`);
|
|
||||||
validateConditionValues(monster.weaknesses, `${path}.weaknesses`);
|
|
||||||
validateConditionValues(monster.weakpoints, `${path}.weakpoints`);
|
|
||||||
|
|
||||||
assert.equal(monster.damage, undefined, `${path}.damage must not be used`);
|
|
||||||
assert.ok(Array.isArray(monster.hitzones), `${path}.hitzones must be an array`);
|
|
||||||
assert.ok(monster.hitzones.length > 0, `${path}.hitzones must not be empty`);
|
|
||||||
monster.hitzones.forEach((hitzone, hitzoneIndex) => {
|
|
||||||
const hitzonePath = `${path}.hitzones[${hitzoneIndex}]`;
|
|
||||||
assertSnakeCase(hitzone.name, `${hitzonePath}.name`);
|
|
||||||
assert.equal(typeof hitzone.physical, "object", `${hitzonePath}.physical must be an object`);
|
|
||||||
assert.equal(typeof hitzone.elemental, "object", `${hitzonePath}.elemental must be an object`);
|
|
||||||
PHYSICAL_HITZONE_KEYS.forEach((key) => assert.equal(typeof hitzone.physical[key], "number", `${hitzonePath}.physical.${key} must be a number`));
|
|
||||||
ELEMENTAL_HITZONE_KEYS.forEach((key) => assert.equal(typeof hitzone.elemental[key], "number", `${hitzonePath}.elemental.${key} must be a number`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateEndemicData(endemicPayload) {
|
|
||||||
["endemicLife", "aquaticLife"].forEach((collection) => {
|
|
||||||
assert.ok(Array.isArray(endemicPayload[collection]), `${collection} must be an array`);
|
|
||||||
endemicPayload[collection].forEach((item, itemIndex) => {
|
|
||||||
const path = `${collection}[${itemIndex}]`;
|
|
||||||
assertSnakeCase(item.name, `${path}.name`);
|
|
||||||
if (item.description) assertSnakeCase(item.description, `${path}.description`);
|
|
||||||
validateConditionValues(item.locations, `${path}.locations`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateTranslationKeys(translations, path) {
|
|
||||||
Object.keys(translations).forEach((key) => {
|
|
||||||
assert.ok(!/\s/.test(key), `${path}.${key} must not contain spaces`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateDiablo4Affixes(payload) {
|
|
||||||
assert.ok(Array.isArray(payload.categories), "diablo4.categories must be an array");
|
|
||||||
assert.ok(payload.categories.length > 0, "diablo4.categories must not be empty");
|
|
||||||
assert.ok(Array.isArray(payload.affixes), "diablo4.affixes must be an array");
|
|
||||||
assert.ok(payload.affixes.length > 0, "diablo4.affixes must not be empty");
|
|
||||||
|
|
||||||
const ids = new Set();
|
|
||||||
const categoryIds = payload.categories.map((category, index) => {
|
|
||||||
assertSnakeCase(category.id, `diablo4.categories[${index}].id`);
|
|
||||||
assertNonEmptyString({ category }, "category.label");
|
|
||||||
assertSnakeCase(category.icon, `diablo4.categories[${index}].icon`);
|
|
||||||
assertSnakeCase(category.tone, `diablo4.categories[${index}].tone`);
|
|
||||||
return category.id;
|
|
||||||
});
|
|
||||||
payload.affixes.forEach((affix, index) => {
|
|
||||||
assertSnakeCase(affix.id, `diablo4.affixes[${index}].id`);
|
|
||||||
assert.ok(!ids.has(affix.id), `diablo4.affixes[${index}].id must be unique`);
|
|
||||||
ids.add(affix.id);
|
|
||||||
assertNonEmptyString({ affix }, "affix.label");
|
|
||||||
assert.ok(Array.isArray(affix.categories), `diablo4.affixes[${index}].categories must be an array`);
|
|
||||||
affix.categories.forEach((category, categoryIndex) => {
|
|
||||||
assertSnakeCase(category, `diablo4.affixes[${index}].categories[${categoryIndex}]`);
|
|
||||||
assert.ok(categoryIds.includes(category), `diablo4.affixes[${index}].categories[${categoryIndex}] must exist in diablo4.categories`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("vite entrypoint loads the react application", async () => {
|
test("vite entrypoint loads the react application", async () => {
|
||||||
const html = await readFile("website/index.html", "utf8");
|
const html = await readFile("website/index.html", "utf8");
|
||||||
|
|
||||||
|
|
@ -192,6 +16,7 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
const styleNebula = await readFile("website/src/styles/_nebula.scss", "utf8");
|
const styleNebula = await readFile("website/src/styles/_nebula.scss", "utf8");
|
||||||
const styleTokens = await readFile("website/src/styles/_tokens.scss", "utf8");
|
const styleTokens = await readFile("website/src/styles/_tokens.scss", "utf8");
|
||||||
const styleBase = await readFile("website/src/styles/_base.scss", "utf8");
|
const styleBase = await readFile("website/src/styles/_base.scss", "utf8");
|
||||||
|
const styleCards = await readFile("website/src/styles/_cards.scss", "utf8");
|
||||||
const styleHome = await readFile("website/src/styles/_home.scss", "utf8");
|
const styleHome = await readFile("website/src/styles/_home.scss", "utf8");
|
||||||
const styleToolboxes = await readFile("website/src/styles/_toolboxes.scss", "utf8");
|
const styleToolboxes = await readFile("website/src/styles/_toolboxes.scss", "utf8");
|
||||||
const styleIcons = await readFile("website/src/styles/_icons.scss", "utf8");
|
const styleIcons = await readFile("website/src/styles/_icons.scss", "utf8");
|
||||||
|
|
@ -200,6 +25,8 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
const iconComponent = await readFile("website/src/components/Icon.jsx", "utf8");
|
const iconComponent = await readFile("website/src/components/Icon.jsx", "utf8");
|
||||||
const gamesPage = await readFile("website/src/features/games/GamesPage.jsx", "utf8");
|
const gamesPage = await readFile("website/src/features/games/GamesPage.jsx", "utf8");
|
||||||
const gameRoute = await readFile("website/src/features/games/GameRoute.jsx", "utf8");
|
const gameRoute = await readFile("website/src/features/games/GameRoute.jsx", "utf8");
|
||||||
|
const gameLoaders = await readFile("website/src/features/games/loaders.js", "utf8");
|
||||||
|
const gameFiltersPanel = await readFile("website/src/features/games/GameFiltersPanel.jsx", "utf8");
|
||||||
const diablo4Page = await readFile("website/src/features/games/diablo4/Diablo4Page.jsx", "utf8");
|
const diablo4Page = await readFile("website/src/features/games/diablo4/Diablo4Page.jsx", "utf8");
|
||||||
const diablo4Overview = await readFile("website/src/features/games/diablo4/Diablo4Overview.jsx", "utf8");
|
const diablo4Overview = await readFile("website/src/features/games/diablo4/Diablo4Overview.jsx", "utf8");
|
||||||
const diablo4Listing = await readFile("website/src/features/games/diablo4/Diablo4Listing.jsx", "utf8");
|
const diablo4Listing = await readFile("website/src/features/games/diablo4/Diablo4Listing.jsx", "utf8");
|
||||||
|
|
@ -218,6 +45,7 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
const screenshotsModule = await readFile("website/src/features/toolboxes/modules/ScreenshotsModule.jsx", "utf8");
|
const screenshotsModule = await readFile("website/src/features/toolboxes/modules/ScreenshotsModule.jsx", "utf8");
|
||||||
const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8");
|
const linksModule = await readFile("website/src/features/toolboxes/modules/LinksModule.jsx", "utf8");
|
||||||
const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
|
const countersModule = await readFile("website/src/features/toolboxes/modules/CountersModule.jsx", "utf8");
|
||||||
|
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
|
||||||
|
|
||||||
assert.match(source, /sokkog:toolboxes/);
|
assert.match(source, /sokkog:toolboxes/);
|
||||||
assert.match(source, /sokkog:game-toolbox-links/);
|
assert.match(source, /sokkog:game-toolbox-links/);
|
||||||
|
|
@ -244,6 +72,10 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(styleIcons, /ui-icon-add/);
|
assert.match(styleIcons, /ui-icon-add/);
|
||||||
assert.match(styleMhwilds, /\.mhwilds-card/);
|
assert.match(styleMhwilds, /\.mhwilds-card/);
|
||||||
assert.match(styleMhwilds, /\.mhwilds-flip-indicator/);
|
assert.match(styleMhwilds, /\.mhwilds-flip-indicator/);
|
||||||
|
assert.match(styleMhwilds, /\.game-home-card/);
|
||||||
|
assert.match(styleMhwilds, /\.game-layout/);
|
||||||
|
assert.match(styleMhwilds, /\.game-grid/);
|
||||||
|
assert.match(styleCards, /compact-data-card/);
|
||||||
assert.match(styleResponsive, /@media \(max-width: 760px\)/);
|
assert.match(styleResponsive, /@media \(max-width: 760px\)/);
|
||||||
assert.match(source, /features\/toolboxes\/modules\/index\.jsx/);
|
assert.match(source, /features\/toolboxes\/modules\/index\.jsx/);
|
||||||
assert.match(source, /features\/games\/GamesPage\.jsx/);
|
assert.match(source, /features\/games\/GamesPage\.jsx/);
|
||||||
|
|
@ -256,10 +88,17 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(gameRoute, /export function GameRoute/);
|
assert.match(gameRoute, /export function GameRoute/);
|
||||||
assert.match(gameRoute, /MhwildsPage/);
|
assert.match(gameRoute, /MhwildsPage/);
|
||||||
assert.match(gameRoute, /Diablo4Page/);
|
assert.match(gameRoute, /Diablo4Page/);
|
||||||
|
assert.match(gameLoaders, /export async function loadMhwildsData/);
|
||||||
|
assert.match(gameLoaders, /export async function loadDiablo4Data/);
|
||||||
|
assert.match(gameLoaders, /\/data\/diablo4\/affixes_types\.json/);
|
||||||
|
assert.match(gameLoaders, /filterOptionKeys/);
|
||||||
|
assert.match(gameFiltersPanel, /export function GameFiltersPanel/);
|
||||||
|
assert.match(gameFiltersPanel, /filter-reset-button/);
|
||||||
assert.match(diablo4Page, /export function Diablo4Page/);
|
assert.match(diablo4Page, /export function Diablo4Page/);
|
||||||
assert.match(diablo4Overview, /export function Diablo4Overview/);
|
assert.match(diablo4Overview, /export function Diablo4Overview/);
|
||||||
assert.match(diablo4Listing, /export function Diablo4Listing/);
|
assert.match(diablo4Listing, /export function Diablo4Listing/);
|
||||||
assert.match(diablo4Filters, /export function Diablo4Filters/);
|
assert.match(diablo4Filters, /export function Diablo4Filters/);
|
||||||
|
assert.match(diablo4Filters, /GameFiltersPanel/);
|
||||||
assert.match(diablo4AffixCard, /export function Diablo4AffixCard/);
|
assert.match(diablo4AffixCard, /export function Diablo4AffixCard/);
|
||||||
assert.match(diablo4AffixCard, /categoryMap/);
|
assert.match(diablo4AffixCard, /categoryMap/);
|
||||||
assert.match(diablo4Utils, /getFilteredDiablo4Affixes/);
|
assert.match(diablo4Utils, /getFilteredDiablo4Affixes/);
|
||||||
|
|
@ -268,6 +107,7 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(mhwildsListing, /export function MhwildsListing/);
|
assert.match(mhwildsListing, /export function MhwildsListing/);
|
||||||
assert.match(mhwildsListing, /endemic_life/);
|
assert.match(mhwildsListing, /endemic_life/);
|
||||||
assert.match(mhwildsFilters, /export function MhwildsFilters/);
|
assert.match(mhwildsFilters, /export function MhwildsFilters/);
|
||||||
|
assert.match(mhwildsFilters, /GameFiltersPanel/);
|
||||||
assert.match(monsterCard, /export function MonsterCard/);
|
assert.match(monsterCard, /export function MonsterCard/);
|
||||||
assert.match(monsterCard, /ui-icon-flip/);
|
assert.match(monsterCard, /ui-icon-flip/);
|
||||||
assert.match(endemicCard, /export function EndemicCard/);
|
assert.match(endemicCard, /export function EndemicCard/);
|
||||||
|
|
@ -297,14 +137,15 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(linksModule, /copyText/);
|
assert.match(linksModule, /copyText/);
|
||||||
assert.match(source, /DEFAULT_SITE_CONTENT/);
|
assert.match(source, /DEFAULT_SITE_CONTENT/);
|
||||||
assert.match(source, /\/data\/site\.json/);
|
assert.match(source, /\/data\/site\.json/);
|
||||||
assert.match(source, /\/data\/diablo4\/affixes_types\.json/);
|
|
||||||
assert.match(source, /filterOptionKeys/);
|
|
||||||
assert.match(source, /APP_STORAGE_LIMIT_BYTES/);
|
assert.match(source, /APP_STORAGE_LIMIT_BYTES/);
|
||||||
assert.match(source, /DEFAULT_TOOLBOX_ICON/);
|
assert.match(source, /DEFAULT_TOOLBOX_ICON/);
|
||||||
assert.match(source, /TOOLBOX_ICON_FILES/);
|
assert.match(source, /TOOLBOX_ICON_FILES/);
|
||||||
assert.match(source, /function normalizeToolboxIcon/);
|
assert.match(source, /function normalizeToolboxIcon/);
|
||||||
assert.match(source, /function ToolboxIconPicker/);
|
assert.match(source, /function ToolboxIconPicker/);
|
||||||
assert.match(source, /function ToolboxGameIcon/);
|
assert.match(source, /function ToolboxGameIcon/);
|
||||||
|
assert.match(source, /updateToolboxOrder/);
|
||||||
|
assert.match(source, /draggingToolboxId/);
|
||||||
|
assert.match(source, /toolbox-card-drag-handle/);
|
||||||
assert.match(source, /function StorageQuota/);
|
assert.match(source, /function StorageQuota/);
|
||||||
assert.match(source, /function writeStorageValue/);
|
assert.match(source, /function writeStorageValue/);
|
||||||
assert.match(source, /role="progressbar"/);
|
assert.match(source, /role="progressbar"/);
|
||||||
|
|
@ -323,10 +164,13 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(source, /toolbox-icon-cover/);
|
assert.match(source, /toolbox-icon-cover/);
|
||||||
assert.match(source, /moduleColumns/);
|
assert.match(source, /moduleColumns/);
|
||||||
assert.match(source, /function ToolboxView/);
|
assert.match(source, /function ToolboxView/);
|
||||||
|
assert.match(source, /usePointerReorder/);
|
||||||
assert.match(moduleRegistry, /onDragStart/);
|
assert.match(moduleRegistry, /onDragStart/);
|
||||||
assert.match(moduleRegistry, /onPointerDown/);
|
assert.match(moduleRegistry, /onPointerDown/);
|
||||||
assert.match(moduleRegistry, /setPointerCapture/);
|
assert.match(moduleRegistry, /usePointerReorder/);
|
||||||
assert.match(moduleRegistry, /elementFromPoint/);
|
assert.match(reorderHook, /export function usePointerReorder/);
|
||||||
|
assert.match(reorderHook, /setPointerCapture/);
|
||||||
|
assert.match(reorderHook, /elementFromPoint/);
|
||||||
assert.match(moduleRegistry, /tool-add-card/);
|
assert.match(moduleRegistry, /tool-add-card/);
|
||||||
assert.match(moduleRegistry, /tool-quick-add-button/);
|
assert.match(moduleRegistry, /tool-quick-add-button/);
|
||||||
assert.match(moduleRegistry, /tool-add-quick-toggle/);
|
assert.match(moduleRegistry, /tool-add-quick-toggle/);
|
||||||
|
|
@ -344,74 +188,7 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(source, /function openImageInNewTab/);
|
assert.match(source, /function openImageInNewTab/);
|
||||||
assert.match(source, /<Icon name="zoom"/);
|
assert.match(source, /<Icon name="zoom"/);
|
||||||
assert.match(screenshotsModule, /dataTransfer\.files/);
|
assert.match(screenshotsModule, /dataTransfer\.files/);
|
||||||
});
|
assert.doesNotMatch(`${mhwildsListing}\n${diablo4Listing}\n${diablo4Overview}`, /mhwilds-(home-grid|home-card|heading|title-row|layout|filters|results|grid)/);
|
||||||
|
|
||||||
test("site content json is complete", async () => {
|
|
||||||
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
|
||||||
validateSiteContent(site);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mhwilds data and assets are available", async () => {
|
|
||||||
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
|
||||||
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
|
||||||
const monsters = JSON.parse(await readFile("website/public/data/mhwilds/monsters.json", "utf8"));
|
|
||||||
const endemicLife = JSON.parse(await readFile("website/public/data/mhwilds/endemic_life.json", "utf8"));
|
|
||||||
const translations = JSON.parse(await readFile("website/public/data/mhwilds/i18n/fr.json", "utf8"));
|
|
||||||
const enTranslations = JSON.parse(await readFile("website/public/data/mhwilds/i18n/en.json", "utf8"));
|
|
||||||
const listingSource = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
|
|
||||||
|
|
||||||
assert.equal(site.brand.name, "Sokko G");
|
|
||||||
assert.equal(site.home.hero.title, "Sokko G");
|
|
||||||
assert.equal(site.gamesPage.title, "Jeux disponibles");
|
|
||||||
assert.match(site.toolboxes.summary, /espaces modulaires/);
|
|
||||||
assert.ok(site.toolboxes.storageHelp.items.length >= 3);
|
|
||||||
assert.equal(games.games[0].id, "mhwilds");
|
|
||||||
assert.equal(games.games[0].eyebrow, "Guide de jeu");
|
|
||||||
assert.match(games.games[0].images.cardCover, /games\/mhwilds\/card-cover\.png/);
|
|
||||||
assert.match(games.games[0].images.heroBg, /games\/mhwilds\/hero-bg\.png/);
|
|
||||||
assert.ok(monsters.monsters.length > 0);
|
|
||||||
assert.deepEqual(monsters.elements, ["fire", "water", "thunder", "ice", "dragon"]);
|
|
||||||
validateMonsterData(monsters.monsters);
|
|
||||||
assert.ok(Array.isArray(endemicLife.locations), "endemicLife.locations must be an array");
|
|
||||||
assert.ok(endemicLife.locations.length > 0, "endemicLife.locations must not be empty");
|
|
||||||
assert.ok(endemicLife.endemicLife.length > 0);
|
|
||||||
validateEndemicData(endemicLife);
|
|
||||||
validateTranslationKeys(translations, "fr");
|
|
||||||
validateTranslationKeys(enTranslations, "en");
|
|
||||||
assert.equal(translations.monsters, "monstres");
|
|
||||||
assert.equal(translations.dark_hornet, "frelon clair-obscur");
|
|
||||||
assert.equal(translations["dark hornet"], undefined);
|
|
||||||
assert.match(listingSource, /function MhwildsListing/);
|
|
||||||
const monsterCardSource = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8");
|
|
||||||
assert.match(monsterCardSource, /monster-card/);
|
|
||||||
assert.match(monsterCardSource, /monster\.hitzones/);
|
|
||||||
await readFile("website/public/static/img/mhwilds/chatacabra.png");
|
|
||||||
await readFile("website/public/static/img/games/mhwilds/card-cover.png");
|
|
||||||
await readFile("website/public/static/img/games/mhwilds/hero-bg.png");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("diablo4 data and route are available", async () => {
|
|
||||||
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
|
||||||
const affixes = JSON.parse(await readFile("website/public/data/diablo4/affixes_types.json", "utf8"));
|
|
||||||
const routeSource = await readFile("website/src/features/games/GameRoute.jsx", "utf8");
|
|
||||||
const pageSource = await readFile("website/src/features/games/diablo4/Diablo4Page.jsx", "utf8");
|
|
||||||
const listingSource = await readFile("website/src/features/games/diablo4/Diablo4Listing.jsx", "utf8");
|
|
||||||
const diablo4Game = games.games.find((game) => game.id === "diablo4");
|
|
||||||
|
|
||||||
assert.ok(diablo4Game, "games.json must declare diablo4");
|
|
||||||
assert.match(diablo4Game.images.cardCover, /games\/diablo4\/card-cover\.png/);
|
|
||||||
assert.match(diablo4Game.images.heroBg, /games\/diablo4\/hero-bg\.png/);
|
|
||||||
validateDiablo4Affixes(affixes);
|
|
||||||
assert.match(routeSource, /game\.id === "diablo4"/);
|
|
||||||
assert.match(pageSource, /category === "affixes"/);
|
|
||||||
assert.match(listingSource, /diablo4\.filterOptions\.affixes/);
|
|
||||||
await readFile("website/public/static/img/games/diablo4/card-cover.png");
|
|
||||||
await readFile("website/public/static/img/games/diablo4/hero-bg.png");
|
|
||||||
await readFile("website/public/static/img/diablo4/axe.svg");
|
|
||||||
await readFile("website/public/static/img/diablo4/cube.svg");
|
|
||||||
await readFile("website/public/static/img/diablo4/fire.svg");
|
|
||||||
await readFile("website/public/static/img/diablo4/shield.svg");
|
|
||||||
await readFile("website/public/static/img/diablo4/wing.svg");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("server and vite support local env configuration", async () => {
|
test("server and vite support local env configuration", async () => {
|
||||||
|
|
|
||||||
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 }) {
|
export function Diablo4AffixCard({ affix, categoryMap }) {
|
||||||
return (
|
return (
|
||||||
<article className="mhwilds-card diablo4-affix-card">
|
<article className="compact-data-card diablo4-affix-card">
|
||||||
<div className="diablo4-affix-card-body">
|
<div className="diablo4-affix-card-body">
|
||||||
<h2>{affix.label}</h2>
|
<h2>{affix.label}</h2>
|
||||||
<div className="diablo4-category-list" aria-label="Catégories">
|
<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";
|
import { getCategoryIconStyle, getCategoryId, getCategoryLabel } from "./utils.js";
|
||||||
|
|
||||||
export function Diablo4Filters({ options, filters, setFilters }) {
|
export function Diablo4Filters({ options, filters, setFilters }) {
|
||||||
const selected = new Set(filters.categories || []);
|
|
||||||
const logicLabel = filters.logic === "or" ? "Ou" : "Et";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="filter-panel">
|
<GameFiltersPanel
|
||||||
<div className="filter-panel-head">
|
title="Catégories"
|
||||||
<div>
|
filters={filters}
|
||||||
<p className="eyebrow">Filtres</p>
|
selectedKey="categories"
|
||||||
<h2>Catégories</h2>
|
options={options}
|
||||||
</div>
|
getOptionId={getCategoryId}
|
||||||
<button
|
getOptionLabel={getCategoryLabel}
|
||||||
className="filter-reset-button"
|
getOptionClassName={(option) => `diablo4-filter-chip tone-${option.tone || "default"}`}
|
||||||
onClick={() => setFilters((state) => ({ ...state, diablo4: { name: "", categories: [], logic: "and" } }))}
|
renderOptionMedia={(option) => <span className="diablo4-category-icon" aria-hidden="true" style={getCategoryIconStyle(option)} />}
|
||||||
aria-label="Réinitialiser"
|
onChange={(nextFilters) => setFilters((state) => ({ ...state, diablo4: nextFilters }))}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,22 +13,22 @@ export function Diablo4Listing({ diablo4, filters, setFilters }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="page-heading mhwilds-heading">
|
<section className="page-heading game-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Diablo IV</p>
|
<p className="eyebrow">Diablo IV</p>
|
||||||
<div className="mhwilds-title-row">
|
<div className="game-title-row">
|
||||||
<h1>Affixes</h1>
|
<h1>Affixes</h1>
|
||||||
<span className="results-count">{visible.length} / {diablo4.affixes.length}</span>
|
<span className="results-count">{visible.length} / {diablo4.affixes.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>Filtrez les affixes par nom et catégories pour retrouver rapidement les statistiques utiles à votre build.</p>
|
<p>Filtrez les affixes par nom et catégories pour retrouver rapidement les statistiques utiles à votre build.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="mhwilds-layout diablo4-layout" data-game-category="diablo4-affixes">
|
<section className="game-layout diablo4-layout" data-game-category="diablo4-affixes">
|
||||||
<aside className="mhwilds-filters">
|
<aside className="game-filters">
|
||||||
<Diablo4Filters options={options} filters={activeFilters} setFilters={setFilters} />
|
<Diablo4Filters options={options} filters={activeFilters} setFilters={setFilters} />
|
||||||
</aside>
|
</aside>
|
||||||
<section className="mhwilds-results" aria-live="polite">
|
<section className="game-results" aria-live="polite">
|
||||||
<div className="mhwilds-grid diablo4-affix-grid">
|
<div className="game-grid diablo4-affix-grid">
|
||||||
{visible.length ? visible.map((affix) => (
|
{visible.length ? visible.map((affix) => (
|
||||||
<Diablo4AffixCard key={affix.id} affix={affix} categoryMap={diablo4.categoryMap} />
|
<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>
|
<p>Consultez rapidement les affixes et leurs catégories pour préparer vos builds sans quitter votre session.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="mhwilds-home-grid diablo4-home-grid">
|
<section className="game-home-grid diablo4-home-grid">
|
||||||
<a className="feature mhwilds-home-card diablo4-home-card" href="#/games/diablo4/affixes">
|
<a className="feature game-home-card diablo4-home-card" href="#/games/diablo4/affixes">
|
||||||
<span className="diablo4-home-mark" aria-hidden="true" />
|
<span className="diablo4-home-mark" aria-hidden="true" />
|
||||||
<strong>Affixes</strong>
|
<strong>Affixes</strong>
|
||||||
<span>Recherche et filtres par catégorie pour retrouver les statistiques utiles.</span>
|
<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";
|
import { assetPath } from "./utils.js";
|
||||||
|
|
||||||
export function MhwildsFilters({ category, filterKey, options, filters, setFilters, t }) {
|
export function MhwildsFilters({ category, filterKey, options, filters, setFilters, t }) {
|
||||||
const activeFilters = filters[category];
|
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 (
|
return (
|
||||||
<div className="filter-panel">
|
<GameFiltersPanel
|
||||||
<div className="filter-panel-head">
|
title={category === "monsters" ? t("weaknesses", { capitalize: true }) : t("locations", { capitalize: true })}
|
||||||
<div>
|
nameLabel={t("name", { capitalize: true })}
|
||||||
<p className="eyebrow">Filtres</p>
|
resetLabel={t("reset", { capitalize: true })}
|
||||||
<h2>{category === "monsters" ? t("weaknesses", { capitalize: true }) : t("locations", { capitalize: true })}</h2>
|
logicLabels={{ and: t("and", { capitalize: true }), or: t("or", { capitalize: true }) }}
|
||||||
</div>
|
filters={activeFilters}
|
||||||
<button
|
selectedKey={filterKey}
|
||||||
className="filter-reset-button"
|
options={options}
|
||||||
onClick={() => updateCategory(() => ({ name: "", [filterKey]: [], logic: "and" }))}
|
getOptionLabel={(option) => t(option, { capitalize: true })}
|
||||||
aria-label={t("reset", { capitalize: true })}
|
renderOptionMedia={(option) => <img src={assetPath(option)} alt="" />}
|
||||||
title={t("reset", { capitalize: true })}
|
onChange={(nextFilters) => setFilters((state) => ({ ...state, [category]: nextFilters }))}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ export function MhwildsListing({ category, mhwilds, filters, setFilters, t }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="page-heading mhwilds-heading">
|
<section className="page-heading game-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Monster Hunter Wilds</p>
|
<p className="eyebrow">Monster Hunter Wilds</p>
|
||||||
<div className="mhwilds-title-row">
|
<div className="game-title-row">
|
||||||
<h1>{label}</h1>
|
<h1>{label}</h1>
|
||||||
<span className="results-count">{visible.length} / {items.length}</span>
|
<span className="results-count">{visible.length} / {items.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -30,12 +30,12 @@ export function MhwildsListing({ category, mhwilds, filters, setFilters, t }) {
|
||||||
<a className={`button ${!isMonsters ? "primary" : ""}`} href="#/games/mhwilds/endemic">Faune</a>
|
<a className={`button ${!isMonsters ? "primary" : ""}`} href="#/games/mhwilds/endemic">Faune</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="mhwilds-layout" data-mhwilds-category={category}>
|
<section className="game-layout" data-mhwilds-category={category}>
|
||||||
<aside className="mhwilds-filters">
|
<aside className="game-filters">
|
||||||
<MhwildsFilters category={category} filterKey={filterKey} options={options} filters={filters} setFilters={setFilters} t={t} />
|
<MhwildsFilters category={category} filterKey={filterKey} options={options} filters={filters} setFilters={setFilters} t={t} />
|
||||||
</aside>
|
</aside>
|
||||||
<section className="mhwilds-results" aria-live="polite">
|
<section className="game-results" aria-live="polite">
|
||||||
<div className={`mhwilds-grid ${isMonsters ? "monster-grid" : "endemic-grid"}`}>
|
<div className={`game-grid ${isMonsters ? "monster-grid" : "endemic-grid"}`}>
|
||||||
{visible.length ? visible.map((item) => (
|
{visible.length ? visible.map((item) => (
|
||||||
isMonsters
|
isMonsters
|
||||||
? <MonsterCard key={item.name} monster={item} t={t} />
|
? <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>
|
<p>Consultez rapidement les monstres, faiblesses, afflictions, hitzones et emplacements de faune endémique.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="mhwilds-home-grid">
|
<section className="game-home-grid">
|
||||||
<a className="feature mhwilds-home-card" href="#/games/mhwilds/monsters">
|
<a className="feature game-home-card" href="#/games/mhwilds/monsters">
|
||||||
<img src={assetPath("arkveld")} alt="" />
|
<img src={assetPath("arkveld")} alt="" />
|
||||||
<strong>Monstres</strong>
|
<strong>Monstres</strong>
|
||||||
<span>Recherche, filtres par faiblesse et tableau de dégâts par partie.</span>
|
<span>Recherche, filtres par faiblesse et tableau de dégâts par partie.</span>
|
||||||
</a>
|
</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="" />
|
<img src={assetPath("vigorwasp")} alt="" />
|
||||||
<strong>Faune endémique</strong>
|
<strong>Faune endémique</strong>
|
||||||
<span>Faune endémique et aquatique filtrable par localisation.</span>
|
<span>Faune endémique et aquatique filtrable par localisation.</span>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
|
||||||
import { ChecklistModule } from "./ChecklistModule.jsx";
|
import { ChecklistModule } from "./ChecklistModule.jsx";
|
||||||
import { CountersModule } from "./CountersModule.jsx";
|
import { CountersModule } from "./CountersModule.jsx";
|
||||||
import { LinksModule } from "./LinksModule.jsx";
|
import { LinksModule } from "./LinksModule.jsx";
|
||||||
|
|
@ -174,11 +175,19 @@ export function AddToolControls({ onAdd }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onDelete, onMove }) {
|
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 [measuredSplitIndex, setMeasuredSplitIndex] = useState(() => Math.ceil(toolbox.modules.length / 2));
|
||||||
const moduleElementsRef = useRef(new Map());
|
const moduleElementsRef = useRef(new Map());
|
||||||
const moduleIdSignature = useMemo(() => toolbox.modules.map((module) => module.id).join("|"), [toolbox.modules]);
|
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(() => {
|
useEffect(() => {
|
||||||
setMeasuredSplitIndex(Math.ceil(toolbox.modules.length / 2));
|
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]);
|
}, [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) {
|
function registerModuleElement(moduleId, element) {
|
||||||
if (element) {
|
if (element) {
|
||||||
moduleElementsRef.current.set(moduleId, element);
|
moduleElementsRef.current.set(moduleId, element);
|
||||||
|
|
|
||||||
70
website/src/hooks/usePointerReorder.js
Normal file
70
website/src/hooks/usePointerReorder.js
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,9 @@ import "./styles/main.scss";
|
||||||
import { Icon } from "./components/Icon.jsx";
|
import { Icon } from "./components/Icon.jsx";
|
||||||
import { GameRoute } from "./features/games/GameRoute.jsx";
|
import { GameRoute } from "./features/games/GameRoute.jsx";
|
||||||
import { GamesPage } from "./features/games/GamesPage.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 { AddToolControls, ToolboxModules, TOOLBOX_MODULES } from "./features/toolboxes/modules/index.jsx";
|
||||||
|
import { usePointerReorder } from "./hooks/usePointerReorder.js";
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
registry: "sokkog:toolboxes",
|
registry: "sokkog:toolboxes",
|
||||||
|
|
@ -594,25 +596,8 @@ function App() {
|
||||||
const [siteContent, setSiteContent] = useState(DEFAULT_SITE_CONTENT);
|
const [siteContent, setSiteContent] = useState(DEFAULT_SITE_CONTENT);
|
||||||
const [games, setGames] = useState([]);
|
const [games, setGames] = useState([]);
|
||||||
const [gamesError, setGamesError] = useState("");
|
const [gamesError, setGamesError] = useState("");
|
||||||
const [mhwilds, setMhwilds] = useState({
|
const [mhwilds, setMhwilds] = useState(INITIAL_MHWILDS_STATE);
|
||||||
loaded: false,
|
const [diablo4, setDiablo4] = useState(INITIAL_DIABLO4_STATE);
|
||||||
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 [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
monsters: { name: "", weaknesses: [], logic: "and" },
|
monsters: { name: "", weaknesses: [], logic: "and" },
|
||||||
endemic: { name: "", locations: [], logic: "and" },
|
endemic: { name: "", locations: [], logic: "and" },
|
||||||
|
|
@ -642,77 +627,17 @@ function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!route.startsWith("/games/mhwilds") || mhwilds.loaded || mhwilds.loading) return;
|
if (!route.startsWith("/games/mhwilds") || mhwilds.loaded || mhwilds.loading) return;
|
||||||
setMhwilds((state) => ({ ...state, loading: true, error: "" }));
|
setMhwilds((state) => ({ ...state, loading: true, error: "" }));
|
||||||
Promise.all([
|
loadMhwildsData()
|
||||||
fetch("/data/mhwilds/monsters.json"),
|
.then(setMhwilds)
|
||||||
fetch("/data/mhwilds/endemic_life.json"),
|
.catch((error) => setMhwilds({ ...INITIAL_MHWILDS_STATE, loaded: true, error: error.message }));
|
||||||
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: "" }
|
|
||||||
}));
|
|
||||||
}, [route, mhwilds.loaded, mhwilds.loading]);
|
}, [route, mhwilds.loaded, mhwilds.loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!route.startsWith("/games/diablo4") || diablo4.loaded || diablo4.loading) return;
|
if (!route.startsWith("/games/diablo4") || diablo4.loaded || diablo4.loading) return;
|
||||||
setDiablo4((state) => ({ ...state, loading: true }));
|
setDiablo4((state) => ({ ...state, loading: true, error: "" }));
|
||||||
fetch("/data/diablo4/affixes_types.json")
|
loadDiablo4Data()
|
||||||
.then(async (response) => {
|
.then(setDiablo4)
|
||||||
if (!response.ok) throw new Error("Impossible de charger les données Diablo IV.");
|
.catch((error) => setDiablo4({ ...INITIAL_DIABLO4_STATE, loaded: true, error: error.message }));
|
||||||
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: "" }
|
|
||||||
}));
|
|
||||||
}, [route, diablo4.loaded, diablo4.loading]);
|
}, [route, diablo4.loaded, diablo4.loading]);
|
||||||
|
|
||||||
const t = (key, { capitalize = false } = {}) => {
|
const t = (key, { capitalize = false } = {}) => {
|
||||||
|
|
@ -852,6 +777,10 @@ function App() {
|
||||||
setScreenshot,
|
setScreenshot,
|
||||||
addScreenshotFiles,
|
addScreenshotFiles,
|
||||||
updateModuleData: store.updateModuleData,
|
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 = "") => {
|
importToolbox: async (file, gameId = "") => {
|
||||||
try {
|
try {
|
||||||
return await importToolbox(file, gameId);
|
return await importToolbox(file, gameId);
|
||||||
|
|
@ -1070,6 +999,26 @@ function RichText({ text }) {
|
||||||
|
|
||||||
function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
|
function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
|
||||||
const content = siteContent.toolboxes;
|
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 (
|
return (
|
||||||
<div className="toolbox-page">
|
<div className="toolbox-page">
|
||||||
<section className="page-hero">
|
<section className="page-hero">
|
||||||
|
|
@ -1091,7 +1040,17 @@ function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
|
||||||
</section>
|
</section>
|
||||||
<section className="cards">
|
<section className="cards">
|
||||||
<StorageHelpCard help={content.storageHelp} />
|
<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>
|
<div className="empty"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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 gameCoverImage = getGameCardCover(game);
|
||||||
const coverImage = gameCoverImage || toolbox.icon || DEFAULT_TOOLBOX_ICON;
|
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 (
|
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
|
<a
|
||||||
className={`card-cover toolbox-card-cover-link ${gameCoverImage ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
|
className={`card-cover toolbox-card-cover-link ${gameCoverImage ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
|
||||||
href={`#/toolbox/${toolbox.id}`}
|
href={`#/toolbox/${toolbox.id}`}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,39 @@
|
||||||
0 0 34px rgba(196, 181, 253, 0.12);
|
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 {
|
.feature {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -114,6 +147,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-card {
|
.toolbox-card {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 292px;
|
min-height: 292px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -157,6 +191,57 @@
|
||||||
padding-top: var(--space-5);
|
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 {
|
.toolbox-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: calc(100vh - 68px - var(--space-8) - var(--space-12));
|
min-height: calc(100vh - 68px - var(--space-8) - var(--space-12));
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-home-card .diablo4-home-mark {
|
.game-home-card .diablo4-home-mark {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
@use "mixins";
|
@use "mixins";
|
||||||
|
|
||||||
.mhwilds-home-grid {
|
.game-home-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 380px));
|
grid-template-columns: repeat(auto-fit, minmax(240px, 380px));
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
margin-top: var(--space-6);
|
margin-top: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-home-card {
|
.game-home-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|
@ -21,12 +21,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (corner-shape: scoop) {
|
@supports (corner-shape: scoop) {
|
||||||
.mhwilds-home-card {
|
.game-home-card {
|
||||||
corner-shape: scoop;
|
corner-shape: scoop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-home-card:hover {
|
.game-home-card:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background:
|
background:
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
0 0 34px rgba(196, 181, 253, 0.12);
|
0 0 34px rgba(196, 181, 253, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-home-card img {
|
.game-home-card img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
|
|
@ -48,31 +48,31 @@
|
||||||
background: rgba(5, 7, 17, 0.28);
|
background: rgba(5, 7, 17, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-home-card strong,
|
.game-home-card strong,
|
||||||
.mhwilds-home-card span {
|
.game-home-card span {
|
||||||
margin-inline: var(--space-5);
|
margin-inline: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-home-card strong {
|
.game-home-card strong {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-home-card span {
|
.game-home-card span {
|
||||||
margin-bottom: var(--space-5);
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-heading p {
|
.game-heading p {
|
||||||
max-width: 70ch;
|
max-width: 70ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-title-row {
|
.game-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-title-row h1 {
|
.game-title-row h1 {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-layout {
|
.game-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-filters {
|
.game-filters {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 92px;
|
top: 92px;
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +223,7 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-grid {
|
.game-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,12 +143,12 @@
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-home-grid,
|
.game-home-grid,
|
||||||
.mhwilds-layout {
|
.game-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mhwilds-filters {
|
.game-filters {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue