add list page and multiples optis
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
d9d22683dc
commit
959fa42454
30 changed files with 1015 additions and 45 deletions
11
README.md
11
README.md
|
|
@ -14,6 +14,7 @@ Sokko G propose des toolboxes modulaires stockées dans le navigateur, ainsi que
|
||||||
- Panneau latéral redimensionnable sur les pages jeux.
|
- Panneau latéral redimensionnable sur les pages jeux.
|
||||||
- Suivi d’un budget de stockage recommandé, avec quota navigateur en information.
|
- Suivi d’un budget de stockage recommandé, avec quota navigateur en information.
|
||||||
- Pages de guides de jeu éditables via JSON.
|
- Pages de guides de jeu éditables via JSON.
|
||||||
|
- Listes de jeu exportables vers les checklists.
|
||||||
|
|
||||||
## Démarrage
|
## Démarrage
|
||||||
|
|
||||||
|
|
@ -68,6 +69,8 @@ Les contenus maintenus à la main sont regroupés dans `website/public/data`.
|
||||||
| `website/public/data/games.json` | Liste des jeux affichés sur `/games` et contenus des cards jeux. |
|
| `website/public/data/games.json` | Liste des jeux affichés sur `/games` et contenus des cards jeux. |
|
||||||
| `website/public/data/mhwilds/monsters.json` | Données des monstres Monster Hunter Wilds. |
|
| `website/public/data/mhwilds/monsters.json` | Données des monstres Monster Hunter Wilds. |
|
||||||
| `website/public/data/mhwilds/endemic_life.json` | Données de la faune Monster Hunter Wilds. |
|
| `website/public/data/mhwilds/endemic_life.json` | Données de la faune Monster Hunter Wilds. |
|
||||||
|
| `website/public/data/mhwilds/lists/*.json` | Listes de jeu MHWilds copiables ou convertibles en checklist. |
|
||||||
|
| `website/public/data/mhwilds/lists/index.json` | Index généré automatiquement des listes MHWilds. |
|
||||||
| `website/public/data/mhwilds/i18n/fr.json` | Traductions françaises des données MHWilds. |
|
| `website/public/data/mhwilds/i18n/fr.json` | Traductions françaises des données MHWilds. |
|
||||||
| `website/public/data/mhwilds/i18n/en.json` | Traductions anglaises des données MHWilds. |
|
| `website/public/data/mhwilds/i18n/en.json` | Traductions anglaises des données MHWilds. |
|
||||||
| `website/public/data/diablo4/affixes_types.json` | Données et filtres des affixes Diablo IV. |
|
| `website/public/data/diablo4/affixes_types.json` | Données et filtres des affixes Diablo IV. |
|
||||||
|
|
@ -75,6 +78,14 @@ Les contenus maintenus à la main sont regroupés dans `website/public/data`.
|
||||||
|
|
||||||
Les images publiques sont dans `website/public/static`.
|
Les images publiques sont dans `website/public/static`.
|
||||||
|
|
||||||
|
Les index de listes sont générés automatiquement par :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lists:index
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce script est lancé avant `npm run dev`, `npm run build` et `npm run check`.
|
||||||
|
|
||||||
## Pages jeux
|
## Pages jeux
|
||||||
|
|
||||||
Les pages jeux sont dans `website/src/features/games`.
|
Les pages jeux sont dans `website/src/features/games`.
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"dev": "vite",
|
"dev": "npm run lists:index && vite",
|
||||||
"build": "vite build",
|
"build": "npm run lists:index && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "node --check server.mjs && node --check vite.config.js && npm test && npm run build",
|
"lists:index": "node scripts/generate-list-indexes.mjs",
|
||||||
|
"check": "node --check server.mjs && node --check vite.config.js && npm run lists:index && npm test && npm run build",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
51
scripts/generate-list-indexes.mjs
Normal file
51
scripts/generate-list-indexes.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { access, readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const DATA_DIRECTORY = "website/public/data";
|
||||||
|
|
||||||
|
function formatTitle(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/\.json$/i, "")
|
||||||
|
.replace(/[_-]+/g, " ")
|
||||||
|
.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateListIndex(directory) {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
const files = entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json")
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b, "fr"));
|
||||||
|
|
||||||
|
const lists = await Promise.all(files.map(async (file) => {
|
||||||
|
const payload = JSON.parse(await readFile(path.join(directory, file), "utf8"));
|
||||||
|
const id = file.replace(/\.json$/i, "");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: payload.titre || formatTitle(id),
|
||||||
|
file
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
await writeFile(path.join(directory, "index.json"), `${JSON.stringify({ lists }, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findListDirectories() {
|
||||||
|
const entries = await readdir(DATA_DIRECTORY, { withFileTypes: true });
|
||||||
|
const directories = await Promise.all(entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map(async (entry) => {
|
||||||
|
const listDirectory = path.join(DATA_DIRECTORY, entry.name, "lists");
|
||||||
|
try {
|
||||||
|
await access(listDirectory);
|
||||||
|
return listDirectory;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return directories.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listDirectories = await findListDirectories();
|
||||||
|
await Promise.all(listDirectories.map(generateListIndex));
|
||||||
|
|
@ -19,6 +19,7 @@ test("mhwilds data and assets are available", async () => {
|
||||||
const games = JSON.parse(await readFile("website/public/data/games.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 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 endemicLife = JSON.parse(await readFile("website/public/data/mhwilds/endemic_life.json", "utf8"));
|
||||||
|
const listIndex = JSON.parse(await readFile("website/public/data/mhwilds/lists/index.json", "utf8"));
|
||||||
const translations = JSON.parse(await readFile("website/public/data/mhwilds/i18n/fr.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 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");
|
const listingSource = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
|
||||||
|
|
@ -39,6 +40,27 @@ test("mhwilds data and assets are available", async () => {
|
||||||
assert.ok(endemicLife.locations.length > 0, "endemicLife.locations must not be empty");
|
assert.ok(endemicLife.locations.length > 0, "endemicLife.locations must not be empty");
|
||||||
assert.ok(endemicLife.endemicLife.length > 0);
|
assert.ok(endemicLife.endemicLife.length > 0);
|
||||||
validateEndemicData(endemicLife);
|
validateEndemicData(endemicLife);
|
||||||
|
assert.ok(Array.isArray(listIndex.lists), "mhwilds list index must expose lists");
|
||||||
|
assert.ok(listIndex.lists.length >= 1, "mhwilds list index must contain at least one list");
|
||||||
|
for (const list of listIndex.lists) {
|
||||||
|
assert.equal(typeof list.id, "string", "mhwilds list id must be a string");
|
||||||
|
assert.equal(typeof list.title, "string", "mhwilds list title must be a string");
|
||||||
|
assert.match(list.file, /\.json$/, "mhwilds list file must target a json file");
|
||||||
|
const listData = JSON.parse(await readFile(`website/public/data/mhwilds/lists/${list.file}`, "utf8"));
|
||||||
|
assert.equal(typeof listData.titre, "string", `${list.file}.titre must be a string`);
|
||||||
|
assert.ok(listData.titre.trim(), `${list.file}.titre must not be empty`);
|
||||||
|
for (const [category, items] of Object.entries(listData).filter(([key]) => key !== "titre")) {
|
||||||
|
assert.ok(Array.isArray(items), `mhwilds list category ${category} must be an array`);
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
assert.equal(typeof item.nom, "string", `${list.file}.${category}[${index}].nom must be a string`);
|
||||||
|
assert.ok(item.nom.trim(), `${list.file}.${category}[${index}].nom must not be empty`);
|
||||||
|
assert.equal(typeof item.description, "string", `${list.file}.${category}[${index}].description must be a string`);
|
||||||
|
if (item.quantite !== undefined) {
|
||||||
|
assert.ok(Number.isFinite(Number(item.quantite)), `${list.file}.${category}[${index}].quantite must be numeric`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
validateTranslationKeys(translations, "fr");
|
validateTranslationKeys(translations, "fr");
|
||||||
validateTranslationKeys(enTranslations, "en");
|
validateTranslationKeys(enTranslations, "en");
|
||||||
assert.equal(translations.monsters, "monstres");
|
assert.equal(translations.monsters, "monstres");
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,13 @@ export function validateSiteContent(site) {
|
||||||
"about.description",
|
"about.description",
|
||||||
"about.toolsTitle",
|
"about.toolsTitle",
|
||||||
"about.limitsTitle",
|
"about.limitsTitle",
|
||||||
|
"about.contribute.eyebrow",
|
||||||
|
"about.contribute.title",
|
||||||
|
"about.contribute.description",
|
||||||
|
"about.contribute.example",
|
||||||
|
"about.contribute.promptTitle",
|
||||||
|
"about.contribute.promptIntro",
|
||||||
|
"about.contribute.promptText",
|
||||||
"toolboxes.eyebrow",
|
"toolboxes.eyebrow",
|
||||||
"toolboxes.title",
|
"toolboxes.title",
|
||||||
"toolboxes.newButton",
|
"toolboxes.newButton",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
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 checklistCopyButton = await readFile("website/src/features/games/CopyChecklistItemsButton.jsx", "utf8");
|
const checklistCopyButton = await readFile("website/src/features/games/CopyChecklistItemsButton.jsx", "utf8");
|
||||||
|
const gameListsPage = await readFile("website/src/features/games/GameListsPage.jsx", "utf8");
|
||||||
const gameLoaders = await readFile("website/src/features/games/loaders.js", "utf8");
|
const gameLoaders = await readFile("website/src/features/games/loaders.js", "utf8");
|
||||||
const gameFiltersPanel = await readFile("website/src/features/games/GameFiltersPanel.jsx", "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");
|
||||||
|
|
@ -39,7 +40,9 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
const diablo4AffixCard = await readFile("website/src/features/games/diablo4/Diablo4AffixCard.jsx", "utf8");
|
const diablo4AffixCard = await readFile("website/src/features/games/diablo4/Diablo4AffixCard.jsx", "utf8");
|
||||||
const diablo4Utils = await readFile("website/src/features/games/diablo4/utils.js", "utf8");
|
const diablo4Utils = await readFile("website/src/features/games/diablo4/utils.js", "utf8");
|
||||||
const mhwildsPage = await readFile("website/src/features/games/mhwilds/MhwildsPage.jsx", "utf8");
|
const mhwildsPage = await readFile("website/src/features/games/mhwilds/MhwildsPage.jsx", "utf8");
|
||||||
|
const mhwildsOverview = await readFile("website/src/features/games/mhwilds/MhwildsOverview.jsx", "utf8");
|
||||||
const mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
|
const mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
|
||||||
|
const mhwildsLists = await readFile("website/src/features/games/mhwilds/MhwildsLists.jsx", "utf8");
|
||||||
const mhwildsFilters = await readFile("website/src/features/games/mhwilds/MhwildsFilters.jsx", "utf8");
|
const mhwildsFilters = await readFile("website/src/features/games/mhwilds/MhwildsFilters.jsx", "utf8");
|
||||||
const monsterCard = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8");
|
const monsterCard = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8");
|
||||||
const endemicCard = await readFile("website/src/features/games/mhwilds/cards/EndemicCard.jsx", "utf8");
|
const endemicCard = await readFile("website/src/features/games/mhwilds/cards/EndemicCard.jsx", "utf8");
|
||||||
|
|
@ -123,10 +126,19 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(gameRoute, /MhwildsPage/);
|
assert.match(gameRoute, /MhwildsPage/);
|
||||||
assert.match(gameRoute, /Diablo4Page/);
|
assert.match(gameRoute, /Diablo4Page/);
|
||||||
assert.match(checklistCopyButton, /formatChecklistImportItems/);
|
assert.match(checklistCopyButton, /formatChecklistImportItems/);
|
||||||
assert.match(checklistCopyButton, /\$\{label\}:1/);
|
assert.match(checklistCopyButton, /formatChecklistImportSections/);
|
||||||
|
assert.match(checklistCopyButton, /\$\{entry\.label\}:\$\{entry\.quantity\}/);
|
||||||
|
assert.match(checklistCopyButton, /# \$\{title\}/);
|
||||||
assert.match(checklistCopyButton, /navigator\.clipboard\.writeText/);
|
assert.match(checklistCopyButton, /navigator\.clipboard\.writeText/);
|
||||||
assert.match(checklistCopyButton, /results-copy-button/);
|
assert.match(checklistCopyButton, /results-copy-button/);
|
||||||
|
assert.match(gameListsPage, /export function GameListsPage/);
|
||||||
|
assert.match(gameListsPage, /game-list-selector/);
|
||||||
|
assert.match(gameListsPage, /game-list-create-button/);
|
||||||
|
assert.match(gameListsPage, /module-icon-checklist/);
|
||||||
assert.match(gameLoaders, /export async function loadMhwildsData/);
|
assert.match(gameLoaders, /export async function loadMhwildsData/);
|
||||||
|
assert.match(gameLoaders, /\/data\/mhwilds\/lists\/index\.json/);
|
||||||
|
assert.match(gameLoaders, /loadGameLists/);
|
||||||
|
assert.match(gameLoaders, /normalizeGameListFile/);
|
||||||
assert.match(gameLoaders, /export async function loadDiablo4Data/);
|
assert.match(gameLoaders, /export async function loadDiablo4Data/);
|
||||||
assert.match(gameLoaders, /\/data\/diablo4\/affixes_types\.json/);
|
assert.match(gameLoaders, /\/data\/diablo4\/affixes_types\.json/);
|
||||||
assert.match(gameLoaders, /filterOptionKeys/);
|
assert.match(gameLoaders, /filterOptionKeys/);
|
||||||
|
|
@ -143,7 +155,14 @@ test("react application defines the expected local toolbox primitives", async ()
|
||||||
assert.match(diablo4Utils, /getFilteredDiablo4Affixes/);
|
assert.match(diablo4Utils, /getFilteredDiablo4Affixes/);
|
||||||
assert.match(diablo4Utils, /getCategoryIconStyle/);
|
assert.match(diablo4Utils, /getCategoryIconStyle/);
|
||||||
assert.match(mhwildsPage, /export function MhwildsPage/);
|
assert.match(mhwildsPage, /export function MhwildsPage/);
|
||||||
|
assert.match(mhwildsPage, /MhwildsLists/);
|
||||||
|
assert.match(mhwildsPage, /linkedToolboxId/);
|
||||||
|
assert.match(mhwildsOverview, /#\/games\/mhwilds\/lists/);
|
||||||
assert.match(mhwildsListing, /export function MhwildsListing/);
|
assert.match(mhwildsListing, /export function MhwildsListing/);
|
||||||
|
assert.match(mhwildsListing, /GameBreadcrumb/);
|
||||||
|
assert.match(mhwildsLists, /export function MhwildsLists/);
|
||||||
|
assert.match(mhwildsLists, /GameListsPage/);
|
||||||
|
assert.match(mhwildsLists, /selectedListId/);
|
||||||
assert.match(mhwildsListing, /CopyChecklistItemsButton/);
|
assert.match(mhwildsListing, /CopyChecklistItemsButton/);
|
||||||
assert.match(mhwildsListing, /endemic_life/);
|
assert.match(mhwildsListing, /endemic_life/);
|
||||||
assert.match(mhwildsFilters, /export function MhwildsFilters/);
|
assert.match(mhwildsFilters, /export function MhwildsFilters/);
|
||||||
|
|
|
||||||
174
website/public/data/mhwilds/lists/events_items.json
Normal file
174
website/public/data/mhwilds/lists/events_items.json
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
{
|
||||||
|
"titre": "Équipements d'événement",
|
||||||
|
"armures": [
|
||||||
|
{
|
||||||
|
"nom": "Floraison α",
|
||||||
|
"description": "Quete: Les filles de Carabosse",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Amstrigien α",
|
||||||
|
"description": "Quete: Quand chantent les Quematrice ?",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Cache-œil interdit α",
|
||||||
|
"description": "Quete: Ça ne m'atteindra pas !",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Étoffe de dragon scellée α",
|
||||||
|
"description": "Quete: En liberté",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Faux Felyne α",
|
||||||
|
"description": "Quete: Jamais deux sans trois",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Collier de plumes α",
|
||||||
|
"description": "Quete: Un brasier mis à nu",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Plongée α",
|
||||||
|
"description": "Quete: Choisissez votre poison",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Orion α",
|
||||||
|
"description": "Quete: La neige a des crocs",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "O.E.I.L. analytique α",
|
||||||
|
"description": "Quete: Il fait plus sombre qu'on ne le croit",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Wudwud volontaire α",
|
||||||
|
"description": "Quete: Un travail de longue haleine",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Récolte α",
|
||||||
|
"description": "Quete: Récolte de Hirabami",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Gelidron α",
|
||||||
|
"description": "Quete: Même pas peur",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Uth Duna γ",
|
||||||
|
"description": "Quete: Des racines profondes",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Nu Udra γ",
|
||||||
|
"description": "Quete: Maudite soit la flamme obscure",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Jin Dahaad γ",
|
||||||
|
"description": "Quete: Le cœur de Judecca",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Rey Dau γ",
|
||||||
|
"description": "Quete: Un éclair silencieux",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Rompomasque α",
|
||||||
|
"description": "Quete: Le remède du docteur",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Âge d'azur α",
|
||||||
|
"description": "Quete: Éclair de génie",
|
||||||
|
"quantite": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"armes": [
|
||||||
|
{
|
||||||
|
"nom": "Cornemuse-maïs",
|
||||||
|
"description": "Quete: Ça sent mauvais",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Lames savoureuses",
|
||||||
|
"description": "Quete: La viande au cœur de Wyveria",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Patte de chat",
|
||||||
|
"description": "Quete: Lala, crapauds et ours, quelle histoire !",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Brise-sceau",
|
||||||
|
"description": "Quete: Le rêve de tout chasseur",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Lame Astre bleu",
|
||||||
|
"description": "Quete: Éclair de génie",
|
||||||
|
"quantite": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"palico": [
|
||||||
|
{
|
||||||
|
"nom": "Papillon Felyne α",
|
||||||
|
"description": "Quete: Les filles de Carabosse",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Déguisement Wudwud α",
|
||||||
|
"description": "Quete: Kut-Ku, conquérant de la forêt",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Collier à clochette Felyne α",
|
||||||
|
"description": "Quete: Un brasier mis à nu",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Aloha Felyne α",
|
||||||
|
"description": "Quete: Choisissez votre poison",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Fantôme Felyne α",
|
||||||
|
"description": "Quete: Récolte de Hirabami",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Chat des neiges Felyne α",
|
||||||
|
"description": "Quete: La neige a des crocs",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Ère d'azur Felyne α",
|
||||||
|
"description": "Quete: Éclair de génie",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Voleur α",
|
||||||
|
"description": "Quete: Lala, crapauds et ours, quelle histoire !",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Étoile Felyne α",
|
||||||
|
"description": "Quete: Éclair de génie",
|
||||||
|
"quantite": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Hymstrigien Felyne α",
|
||||||
|
"description": "Quete: Quand chantent les Quematrice ?",
|
||||||
|
"quantite": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
website/public/data/mhwilds/lists/index.json
Normal file
9
website/public/data/mhwilds/lists/index.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"lists": [
|
||||||
|
{
|
||||||
|
"id": "events_items",
|
||||||
|
"title": "Équipements d'événement",
|
||||||
|
"file": "events_items.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -106,6 +106,15 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"toolsTitle": "Outils disponibles",
|
"toolsTitle": "Outils disponibles",
|
||||||
|
"contribute": {
|
||||||
|
"eyebrow": "Contribuer",
|
||||||
|
"title": "Proposer une liste de jeu",
|
||||||
|
"description": "Les listes servent à transformer des données de jeu en checklists prêtes à l’emploi : équipements à fabriquer, créatures à capturer, monstres à battre, objets à suivre, etc. Une bonne liste est structurée par catégories, avec des noms clairs, une courte description utile et une quantité uniquement quand elle a du sens.\n\nExemple :",
|
||||||
|
"example": "{\n \"titre\": \"Équipements\",\n \"armures\": [\n {\n \"nom\": \"Set d'armure sombre\",\n \"description\": \"Obtenir en quête\",\n \"quantite\": 1\n }\n ]\n}",
|
||||||
|
"promptTitle": "Prompt utile",
|
||||||
|
"promptIntro": "Ajoutez cette précision à votre demande pour obtenir un JSON directement exploitable :",
|
||||||
|
"promptText": "Génère uniquement un JSON valide.\nLe JSON doit contenir une propriété racine \"titre\", puis une ou plusieurs catégories.\nChaque catégorie doit être un tableau d’objets.\nChaque objet doit contenir \"nom\", \"description\" et \"quantite\".\nLa propriété \"quantite\" est facultative.\nRegroupe les éléments de manière logique.\nNe renvoie aucun texte, aucune explication et aucun Markdown."
|
||||||
|
},
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
{
|
||||||
"icon": "notepad",
|
"icon": "notepad",
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,46 @@ async function copyText(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notify(message) {
|
||||||
|
window.dispatchEvent(new CustomEvent("sokkog:notify", { detail: { message } }));
|
||||||
|
}
|
||||||
|
|
||||||
export function formatChecklistImportItems(labels) {
|
export function formatChecklistImportItems(labels) {
|
||||||
return labels
|
return labels
|
||||||
.map((label) => String(label || "").trim())
|
.map((entry) => {
|
||||||
|
if (typeof entry === "object" && entry !== null) {
|
||||||
|
const label = String(entry.label || entry.name || "").trim();
|
||||||
|
const quantity = Number.parseInt(entry.quantity ?? entry.quantite ?? 1, 10);
|
||||||
|
return label ? { label, quantity: Number.isFinite(quantity) ? quantity : 1 } : null;
|
||||||
|
}
|
||||||
|
const label = String(entry || "").trim();
|
||||||
|
return label ? { label, quantity: 1 } : null;
|
||||||
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((label) => `${label}:1`)
|
.map((entry) => `${entry.label}:${entry.quantity}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyChecklistItemsButton({ labels, title = "Copier pour checklist" }) {
|
export function formatChecklistImportSections(sections = []) {
|
||||||
|
return sections
|
||||||
|
.map((section) => {
|
||||||
|
const title = String(section?.title || "").trim();
|
||||||
|
const items = formatChecklistImportItems(section?.items || []);
|
||||||
|
if (!items) return "";
|
||||||
|
return title ? `# ${title}\n${items}` : items;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyChecklistItemsButton({ labels, sections, title = "Copier pour checklist" }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const text = formatChecklistImportItems(labels);
|
const text = sections ? formatChecklistImportSections(sections) : formatChecklistImportItems(labels);
|
||||||
|
|
||||||
async function copyItems() {
|
async function copyItems() {
|
||||||
if (!text || !await copyText(text)) return;
|
if (!text || !await copyText(text)) return;
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
|
notify("Copié dans le presse-papiers.");
|
||||||
window.setTimeout(() => setCopied(false), 1400);
|
window.setTimeout(() => setCopied(false), 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
15
website/src/features/games/GameBreadcrumb.jsx
Normal file
15
website/src/features/games/GameBreadcrumb.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Icon } from "../../components/Icon.jsx";
|
||||||
|
|
||||||
|
export function GameBreadcrumb({ game }) {
|
||||||
|
if (!game) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="game-breadcrumb" aria-label="Fil d'Ariane">
|
||||||
|
<a href="#/" aria-label="Accueil">
|
||||||
|
<Icon name="home" />
|
||||||
|
</a>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<a href={`#/games/${game.id}`}>{game.title}</a>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
website/src/features/games/GameListsPage.jsx
Normal file
95
website/src/features/games/GameListsPage.jsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { Icon } from "../../components/Icon.jsx";
|
||||||
|
import { CopyChecklistItemsButton } from "./CopyChecklistItemsButton.jsx";
|
||||||
|
import { GameBreadcrumb } from "./GameBreadcrumb.jsx";
|
||||||
|
|
||||||
|
function getChecklistSections(list) {
|
||||||
|
return (list?.categories || []).map((category) => ({
|
||||||
|
title: category.title,
|
||||||
|
items: category.items.map((item) => ({ label: item.name, quantity: item.quantity }))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameListsPage({ game, lists, selectedListId, onSelectList, linkedToolboxId, onCreateChecklist }) {
|
||||||
|
const selectedList = lists.find((list) => list.id === selectedListId) || lists[0] || null;
|
||||||
|
const selectedSections = getChecklistSections(selectedList);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-heading game-heading">
|
||||||
|
<div>
|
||||||
|
<GameBreadcrumb game={game} />
|
||||||
|
<div className="game-title-row">
|
||||||
|
<h1>Listes</h1>
|
||||||
|
</div>
|
||||||
|
<p>Sélectionnez une liste, puis copiez-la ou créez une checklist dans la toolbox associée.</p>
|
||||||
|
<a className="game-list-help-link button" href="#/about#contribuer">Proposer une nouvelle liste</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{lists.length ? (
|
||||||
|
<>
|
||||||
|
<section className="game-list-selector" aria-label="Listes disponibles">
|
||||||
|
{lists.map((list) => (
|
||||||
|
<article className={`game-list-selector-item ${selectedList?.id === list.id ? "active" : ""}`} key={list.id}>
|
||||||
|
<button type="button" onClick={() => onSelectList(list.id)}>
|
||||||
|
<strong>{list.title}</strong>
|
||||||
|
<small>{list.itemCount} éléments</small>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="game-list-create-button primary"
|
||||||
|
type="button"
|
||||||
|
disabled={!linkedToolboxId}
|
||||||
|
onClick={() => onCreateChecklist?.(list)}
|
||||||
|
title={linkedToolboxId ? "Créer une checklist dans la toolbox associée" : "Associez une toolbox pour créer la checklist"}
|
||||||
|
aria-label={linkedToolboxId ? `Créer une checklist ${list.title}` : "Associez une toolbox pour créer la checklist"}
|
||||||
|
>
|
||||||
|
<span className="module-icon" aria-hidden="true">
|
||||||
|
<span className="module-icon-svg module-icon-checklist" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
<section className="game-list-panel nebula-panel">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h2>{selectedList.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="game-list-actions">
|
||||||
|
<CopyChecklistItemsButton sections={selectedSections} title={`Copier ${selectedList.title.toLowerCase()} pour checklist`} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="game-list-categories">
|
||||||
|
{selectedList.categories.map((category) => (
|
||||||
|
<article className="game-list-category" key={category.id}>
|
||||||
|
<header>
|
||||||
|
<h3>{category.title}</h3>
|
||||||
|
<div className="game-list-actions">
|
||||||
|
<span className="results-count">{category.items.length}</span>
|
||||||
|
<CopyChecklistItemsButton sections={[getChecklistSections({ categories: [category] })[0]]} title={`Copier ${category.title.toLowerCase()} pour checklist`} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ul>
|
||||||
|
{category.items.map((item) => (
|
||||||
|
<li key={`${selectedList.id}-${category.id}-${item.name}-${item.description}`}>
|
||||||
|
<div className="game-list-item-title">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
{item.hasQuantity && item.quantity !== 1 ? <em>x{item.quantity}</em> : null}
|
||||||
|
</div>
|
||||||
|
{item.description ? <span>{item.description}</span> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<section className="empty">
|
||||||
|
<h2>Aucune liste</h2>
|
||||||
|
<p>Aucune liste n’est encore disponible pour ce jeu.</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
|
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
|
||||||
|
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
||||||
import { Diablo4AffixCard } from "./Diablo4AffixCard.jsx";
|
import { Diablo4AffixCard } from "./Diablo4AffixCard.jsx";
|
||||||
import { Diablo4Filters } from "./Diablo4Filters.jsx";
|
import { Diablo4Filters } from "./Diablo4Filters.jsx";
|
||||||
import { getCategoryLabel, getFilteredDiablo4Affixes } from "./utils.js";
|
import { getCategoryLabel, getFilteredDiablo4Affixes } from "./utils.js";
|
||||||
|
|
||||||
export function Diablo4Listing({ diablo4, filters, setFilters }) {
|
export function Diablo4Listing({ game, diablo4, filters, setFilters }) {
|
||||||
const activeFilters = filters.diablo4;
|
const activeFilters = filters.diablo4;
|
||||||
const options = [...(diablo4.filterOptions.affixes || [])].sort((a, b) => getCategoryLabel(a).localeCompare(getCategoryLabel(b), "fr"));
|
const options = [...(diablo4.filterOptions.affixes || [])].sort((a, b) => getCategoryLabel(a).localeCompare(getCategoryLabel(b), "fr"));
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
|
|
@ -16,7 +17,7 @@ export function Diablo4Listing({ diablo4, filters, setFilters }) {
|
||||||
<>
|
<>
|
||||||
<section className="page-heading game-heading">
|
<section className="page-heading game-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Diablo IV</p>
|
<GameBreadcrumb game={game} />
|
||||||
<div className="game-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>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export function Diablo4Overview({ game }) {
|
||||||
</section>
|
</section>
|
||||||
<section className="game-home-grid diablo4-home-grid">
|
<section className="game-home-grid diablo4-home-grid">
|
||||||
<a className="feature game-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="game-home-card-icon 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>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -34,5 +34,5 @@ export function Diablo4Page({ category, game, diablo4, filters, setFilters }) {
|
||||||
|
|
||||||
if (!activeCategory) return <Diablo4Overview game={game} />;
|
if (!activeCategory) return <Diablo4Overview game={game} />;
|
||||||
|
|
||||||
return <Diablo4Listing diablo4={diablo4} filters={filters} setFilters={setFilters} />;
|
return <Diablo4Listing game={game} diablo4={diablo4} filters={filters} setFilters={setFilters} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export const INITIAL_MHWILDS_STATE = {
|
||||||
translations: {},
|
translations: {},
|
||||||
monsters: [],
|
monsters: [],
|
||||||
endemic: [],
|
endemic: [],
|
||||||
|
lists: [],
|
||||||
filterOptions: { monsters: [], endemic: [] },
|
filterOptions: { monsters: [], endemic: [] },
|
||||||
filterOptionKeys: { monsters: "", endemic: "" }
|
filterOptionKeys: { monsters: "", endemic: "" }
|
||||||
};
|
};
|
||||||
|
|
@ -20,21 +21,24 @@ export const INITIAL_DIABLO4_STATE = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadMhwildsData() {
|
export async function loadMhwildsData() {
|
||||||
const [monstersResponse, endemicResponse, translationsResponse] = await Promise.all([
|
const [monstersResponse, endemicResponse, listIndexResponse, translationsResponse] = await Promise.all([
|
||||||
fetch("/data/mhwilds/monsters.json"),
|
fetch("/data/mhwilds/monsters.json"),
|
||||||
fetch("/data/mhwilds/endemic_life.json"),
|
fetch("/data/mhwilds/endemic_life.json"),
|
||||||
|
fetch("/data/mhwilds/lists/index.json"),
|
||||||
fetch("/data/mhwilds/i18n/fr.json")
|
fetch("/data/mhwilds/i18n/fr.json")
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!monstersResponse.ok || !endemicResponse.ok || !translationsResponse.ok) {
|
if (!monstersResponse.ok || !endemicResponse.ok || !listIndexResponse.ok || !translationsResponse.ok) {
|
||||||
throw new Error("Impossible de charger les données Monster Hunter Wilds.");
|
throw new Error("Impossible de charger les données Monster Hunter Wilds.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [monstersJson, endemicJson, translations] = await Promise.all([
|
const [monstersJson, endemicJson, listIndexJson, translations] = await Promise.all([
|
||||||
monstersResponse.json(),
|
monstersResponse.json(),
|
||||||
endemicResponse.json(),
|
endemicResponse.json(),
|
||||||
|
listIndexResponse.json(),
|
||||||
translationsResponse.json()
|
translationsResponse.json()
|
||||||
]);
|
]);
|
||||||
|
const lists = await loadGameLists("mhwilds", listIndexJson);
|
||||||
const monsterFilterKey = Object.keys(monstersJson).find((key) => key !== "monsters") || "";
|
const monsterFilterKey = Object.keys(monstersJson).find((key) => key !== "monsters") || "";
|
||||||
const endemicFilterKey = Object.keys(endemicJson).find((key) => key !== "endemicLife" && key !== "aquaticLife") || "";
|
const endemicFilterKey = Object.keys(endemicJson).find((key) => key !== "endemicLife" && key !== "aquaticLife") || "";
|
||||||
|
|
||||||
|
|
@ -45,6 +49,7 @@ export async function loadMhwildsData() {
|
||||||
translations,
|
translations,
|
||||||
monsters: monstersJson.monsters || [],
|
monsters: monstersJson.monsters || [],
|
||||||
endemic: [...(endemicJson.endemicLife || []), ...(endemicJson.aquaticLife || [])],
|
endemic: [...(endemicJson.endemicLife || []), ...(endemicJson.aquaticLife || [])],
|
||||||
|
lists,
|
||||||
filterOptions: {
|
filterOptions: {
|
||||||
monsters: monstersJson[monsterFilterKey] || [],
|
monsters: monstersJson[monsterFilterKey] || [],
|
||||||
endemic: endemicJson[endemicFilterKey] || []
|
endemic: endemicJson[endemicFilterKey] || []
|
||||||
|
|
@ -56,6 +61,51 @@ export async function loadMhwildsData() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadGameLists(gameId, indexPayload) {
|
||||||
|
const entries = Array.isArray(indexPayload?.lists) ? indexPayload.lists : [];
|
||||||
|
const results = await Promise.all(entries.map(async (entry) => {
|
||||||
|
const response = await fetch(`/data/${gameId}/lists/${entry.file}`);
|
||||||
|
if (!response.ok) throw new Error(`Impossible de charger la liste ${entry.file}.`);
|
||||||
|
const payload = await response.json();
|
||||||
|
return normalizeGameListFile(entry, payload);
|
||||||
|
}));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGameListFile(entry, payload) {
|
||||||
|
const id = String(entry?.id || entry?.file || "").replace(/\.json$/i, "");
|
||||||
|
const categories = Object.entries(payload || {}).filter(([, items]) => Array.isArray(items)).map(([categoryId, items]) => ({
|
||||||
|
id: categoryId,
|
||||||
|
title: formatListTitle(categoryId),
|
||||||
|
items: Array.isArray(items) ? items.map(normalizeGameListItem).filter((item) => item.name) : []
|
||||||
|
})).filter((category) => category.items.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: payload?.titre || entry?.title || formatListTitle(id),
|
||||||
|
file: entry?.file || `${id}.json`,
|
||||||
|
categories,
|
||||||
|
itemCount: categories.reduce((count, category) => count + category.items.length, 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGameListItem(item) {
|
||||||
|
const hasQuantity = item?.quantite !== undefined || item?.quantity !== undefined;
|
||||||
|
const quantity = Number.parseInt(item?.quantite ?? item?.quantity ?? 1, 10);
|
||||||
|
return {
|
||||||
|
name: String(item?.nom || item?.name || item?.label || "").trim(),
|
||||||
|
description: String(item?.description || item?.quete || item?.quest || item?.source || "").trim(),
|
||||||
|
quantity: Number.isFinite(quantity) ? quantity : 1,
|
||||||
|
hasQuantity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatListTitle(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadDiablo4Data() {
|
export async function loadDiablo4Data() {
|
||||||
const response = await fetch("/data/diablo4/affixes_types.json");
|
const response = await fetch("/data/diablo4/affixes_types.json");
|
||||||
if (!response.ok) throw new Error("Impossible de charger les données Diablo IV.");
|
if (!response.ok) throw new Error("Impossible de charger les données Diablo IV.");
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
|
||||||
import { EndemicCard } from "./cards/EndemicCard.jsx";
|
import { EndemicCard } from "./cards/EndemicCard.jsx";
|
||||||
import { MonsterCard } from "./cards/MonsterCard.jsx";
|
import { MonsterCard } from "./cards/MonsterCard.jsx";
|
||||||
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
|
import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx";
|
||||||
import { MhwildsFilters } from "./MhwildsFilters.jsx";
|
import { MhwildsFilters } from "./MhwildsFilters.jsx";
|
||||||
import { getFilteredMhwildsItems, getMhwildsFilterKey, getUniqueConditionValues } from "./utils.js";
|
import { getFilteredMhwildsItems, getMhwildsFilterKey, getUniqueConditionValues } from "./utils.js";
|
||||||
|
|
||||||
export function MhwildsListing({ category, mhwilds, filters, setFilters, t }) {
|
export function MhwildsListing({ category, game, mhwilds, filters, setFilters, t }) {
|
||||||
const isMonsters = category === "monsters";
|
const isMonsters = category === "monsters";
|
||||||
const filterKey = getMhwildsFilterKey(category, mhwilds);
|
const filterKey = getMhwildsFilterKey(category, mhwilds);
|
||||||
const items = isMonsters ? mhwilds.monsters : mhwilds.endemic;
|
const items = isMonsters ? mhwilds.monsters : mhwilds.endemic;
|
||||||
|
|
@ -19,7 +20,7 @@ export function MhwildsListing({ category, mhwilds, filters, setFilters, t }) {
|
||||||
<>
|
<>
|
||||||
<section className="page-heading game-heading">
|
<section className="page-heading game-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Monster Hunter Wilds</p>
|
<GameBreadcrumb game={game} />
|
||||||
<div className="game-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>
|
||||||
|
|
|
||||||
22
website/src/features/games/mhwilds/MhwildsLists.jsx
Normal file
22
website/src/features/games/mhwilds/MhwildsLists.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { GameListsPage } from "../GameListsPage.jsx";
|
||||||
|
|
||||||
|
export function MhwildsLists({ game, mhwilds, linkedToolboxId, actions }) {
|
||||||
|
const lists = mhwilds.lists || [];
|
||||||
|
const [selectedListId, setSelectedListId] = useState("");
|
||||||
|
const selectedList = useMemo(
|
||||||
|
() => lists.find((list) => list.id === selectedListId) || lists[0] || null,
|
||||||
|
[lists, selectedListId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameListsPage
|
||||||
|
game={game}
|
||||||
|
lists={lists}
|
||||||
|
selectedListId={selectedList?.id || ""}
|
||||||
|
onSelectList={setSelectedListId}
|
||||||
|
linkedToolboxId={linkedToolboxId}
|
||||||
|
onCreateChecklist={(list) => actions.createChecklistFromList(game.id, list)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,13 @@ export function MhwildsOverview({ game }) {
|
||||||
<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>
|
||||||
</a>
|
</a>
|
||||||
|
<a className="feature game-home-card" href="#/games/mhwilds/lists">
|
||||||
|
<span className="game-home-card-icon" aria-hidden="true">
|
||||||
|
<span className="module-icon-svg module-icon-checklist" />
|
||||||
|
</span>
|
||||||
|
<strong>Listes</strong>
|
||||||
|
<span>Données copiables au format checklist pour préparer vos suivis.</span>
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { MhwildsListing } from "./MhwildsListing.jsx";
|
import { MhwildsListing } from "./MhwildsListing.jsx";
|
||||||
|
import { MhwildsLists } from "./MhwildsLists.jsx";
|
||||||
import { MhwildsOverview } from "./MhwildsOverview.jsx";
|
import { MhwildsOverview } from "./MhwildsOverview.jsx";
|
||||||
|
|
||||||
export function MhwildsPage({ category, game, mhwilds, filters, setFilters, t }) {
|
export function MhwildsPage({ category, game, mhwilds, filters, setFilters, t, links, actions }) {
|
||||||
const activeCategory = category === "monsters" || category === "endemic" ? category : "";
|
const activeCategory = category === "monsters" || category === "endemic" || category === "lists" ? category : "";
|
||||||
const heroStyle = game?.images?.heroBg ? { "--game-hero-image": `url("${game.images.heroBg}")` } : undefined;
|
const heroStyle = game?.images?.heroBg ? { "--game-hero-image": `url("${game.images.heroBg}")` } : undefined;
|
||||||
|
|
||||||
if (!mhwilds.loaded) {
|
if (!mhwilds.loaded) {
|
||||||
|
|
@ -33,10 +34,12 @@ export function MhwildsPage({ category, game, mhwilds, filters, setFilters, t })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeCategory) return <MhwildsOverview game={game} />;
|
if (!activeCategory) return <MhwildsOverview game={game} />;
|
||||||
|
if (activeCategory === "lists") return <MhwildsLists game={game} mhwilds={mhwilds} linkedToolboxId={links?.[game.id] || ""} actions={actions} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MhwildsListing
|
<MhwildsListing
|
||||||
category={activeCategory}
|
category={activeCategory}
|
||||||
|
game={game}
|
||||||
mhwilds={mhwilds}
|
mhwilds={mhwilds}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if (!text || !await context.copyText(text)) return;
|
if (!text || !await context.copyText(text)) return;
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
|
context.notify?.(textContent.copiedTitle || "Copié");
|
||||||
window.setTimeout(() => setCopied(false), 1400);
|
window.setTimeout(() => setCopied(false), 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
||||||
save(nextSections);
|
save(nextSections);
|
||||||
setTextImport("");
|
setTextImport("");
|
||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
|
context.notify?.("Import checklist terminé.");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHideCompletedSections(hideCompletedSections) {
|
function setHideCompletedSections(hideCompletedSections) {
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,14 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) {
|
||||||
save([...data.links, ...imported]);
|
save([...data.links, ...imported]);
|
||||||
setTextImport("");
|
setTextImport("");
|
||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
|
context.notify?.("Import de liens terminé.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyUrl(link) {
|
async function copyUrl(link) {
|
||||||
const copied = await context.copyText(link.url);
|
const copied = await context.copyText(link.url);
|
||||||
if (!copied) return;
|
if (!copied) return;
|
||||||
setCopiedId(link.id);
|
setCopiedId(link.id);
|
||||||
|
context.notify?.(textContent.copiedTitle || "Copié");
|
||||||
window.setTimeout(() => setCopiedId(""), 1400);
|
window.setTimeout(() => setCopiedId(""), 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,15 @@ const DEFAULT_SITE_CONTENT = {
|
||||||
description: "Sokko G est une webapp locale pensée pour garder les bons outils et les bonnes informations à portée de main pendant une session gaming.",
|
description: "Sokko G est une webapp locale pensée pour garder les bons outils et les bonnes informations à portée de main pendant une session gaming.",
|
||||||
sections: [],
|
sections: [],
|
||||||
toolsTitle: "Outils disponibles",
|
toolsTitle: "Outils disponibles",
|
||||||
|
contribute: {
|
||||||
|
eyebrow: "Contribuer",
|
||||||
|
title: "Proposer une liste de jeu",
|
||||||
|
description: "Les listes servent à transformer des données de jeu en checklists prêtes à l’emploi : équipements à fabriquer, créatures à capturer, monstres à battre, objets à suivre, etc. Une bonne liste est structurée par catégories, avec des noms clairs, une courte description utile et une quantité uniquement quand elle a du sens.\n\nExemple :",
|
||||||
|
example: "{\n \"titre\": \"Équipements\",\n \"equipements\": [\n {\n \"nom\": \"Set d'armure sombre\",\n \"description\": \"Obtenir en quête\",\n \"quantite\": 1\n }\n ]\n}",
|
||||||
|
promptTitle: "Prompt utile",
|
||||||
|
promptIntro: "Ajoutez cette précision à votre demande pour obtenir un format directement exploitable :",
|
||||||
|
promptText: "Génère uniquement un JSON valide.\nLe JSON doit contenir une propriété racine \"titre\", puis une ou plusieurs catégories.\nChaque catégorie doit être un tableau d’objets.\nChaque objet doit contenir \"nom\" et \"description\".\nLa propriété \"quantite\" est facultative ; si elle est absente, elle sera interprétée comme 1.\nRegroupe les éléments de manière logique.\nNe renvoie aucun texte, aucune explication et aucun Markdown."
|
||||||
|
},
|
||||||
tools: [
|
tools: [
|
||||||
{ icon: "notepad", name: "Bloc notes", description: "Notes rapides pendant une session." },
|
{ icon: "notepad", name: "Bloc notes", description: "Notes rapides pendant une session." },
|
||||||
{ icon: "checklist", name: "Checklist", description: "Objectifs et quantités à suivre." },
|
{ icon: "checklist", name: "Checklist", description: "Objectifs et quantités à suivre." },
|
||||||
|
|
@ -652,11 +661,12 @@ function mergeContent(defaults, overrides) {
|
||||||
|
|
||||||
function currentRoute() {
|
function currentRoute() {
|
||||||
const hashRoute = location.hash.replace(/^#/, "");
|
const hashRoute = location.hash.replace(/^#/, "");
|
||||||
if (hashRoute) return hashRoute;
|
if (hashRoute) return hashRoute.replace(/#.+$/, "");
|
||||||
const path = location.pathname.replace(/\/+$/, "") || "/";
|
const path = location.pathname.replace(/\/+$/, "") || "/";
|
||||||
if (path === "/mhwilds") return "/games/mhwilds";
|
if (path === "/mhwilds") return "/games/mhwilds";
|
||||||
if (path === "/mhwilds/monsters") return "/games/mhwilds/monsters";
|
if (path === "/mhwilds/monsters") return "/games/mhwilds/monsters";
|
||||||
if (path === "/mhwilds/endemic") return "/games/mhwilds/endemic";
|
if (path === "/mhwilds/endemic") return "/games/mhwilds/endemic";
|
||||||
|
if (path === "/mhwilds/lists") return "/games/mhwilds/lists";
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -850,9 +860,29 @@ function App() {
|
||||||
const [linkModalGameId, setLinkModalGameId] = useState("");
|
const [linkModalGameId, setLinkModalGameId] = useState("");
|
||||||
const [drawerGameId, setDrawerGameId] = useState("");
|
const [drawerGameId, setDrawerGameId] = useState("");
|
||||||
const [screenshot, setScreenshot] = useState(null);
|
const [screenshot, setScreenshot] = useState(null);
|
||||||
|
const [notification, setNotification] = useState(null);
|
||||||
const [storageError, setStorageError] = useState("");
|
const [storageError, setStorageError] = useState("");
|
||||||
const store = useIndexedToolboxes((message) => setStorageError(message));
|
const store = useIndexedToolboxes((message) => setStorageError(message));
|
||||||
|
|
||||||
|
function notify(message) {
|
||||||
|
setNotification({ id: Date.now(), message });
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleNotification(event) {
|
||||||
|
if (event.detail?.message) notify(event.detail.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("sokkog:notify", handleNotification);
|
||||||
|
return () => window.removeEventListener("sokkog:notify", handleNotification);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notification) return undefined;
|
||||||
|
const timeoutId = window.setTimeout(() => setNotification(null), 1800);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [notification]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch("/data/site.json").then((response) => response.ok ? response.json() : DEFAULT_SITE_CONTENT).catch(() => DEFAULT_SITE_CONTENT),
|
fetch("/data/site.json").then((response) => response.ok ? response.json() : DEFAULT_SITE_CONTENT).catch(() => DEFAULT_SITE_CONTENT),
|
||||||
|
|
@ -945,6 +975,41 @@ function App() {
|
||||||
return store.updateModuleData(toolboxId, moduleId, data);
|
return store.updateModuleData(toolboxId, moduleId, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createChecklistFromList(gameId, list) {
|
||||||
|
const toolboxId = store.links[gameId] || "";
|
||||||
|
const toolbox = store.toolboxes.find((item) => item.id === toolboxId);
|
||||||
|
if (!toolbox || !list) return false;
|
||||||
|
|
||||||
|
const moduleId = uid("mod");
|
||||||
|
const sections = (list.categories || []).map((category) => ({
|
||||||
|
id: uid("section"),
|
||||||
|
title: category.title || "",
|
||||||
|
items: (category.items || []).map((item) => ({
|
||||||
|
id: uid("item"),
|
||||||
|
label: item.name,
|
||||||
|
qtyTarget: Math.max(1, Number.parseInt(item.quantity, 10) || 1),
|
||||||
|
qtyCurrent: 0
|
||||||
|
})).filter((item) => item.label)
|
||||||
|
})).filter((section) => section.items.length);
|
||||||
|
|
||||||
|
if (!sections.length) return false;
|
||||||
|
|
||||||
|
setConfirmModal({
|
||||||
|
title: "Créer une checklist",
|
||||||
|
message: `Créer la checklist "${list.title || "Checklist"}" dans la toolbox "${toolbox.name}" ?`,
|
||||||
|
confirmLabel: "Créer",
|
||||||
|
onResolve: (confirmed) => {
|
||||||
|
if (!confirmed) return;
|
||||||
|
store.setToolboxes(store.toolboxes.map((item) => item.id === toolboxId
|
||||||
|
? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt: new Date().toISOString() }
|
||||||
|
: item));
|
||||||
|
store.updateModuleData(toolboxId, moduleId, { sections }, "checklist");
|
||||||
|
notify("Checklist créée dans la toolbox associée.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function importToolbox(file, gameId = "") {
|
async function importToolbox(file, gameId = "") {
|
||||||
const payload = JSON.parse(await file.text());
|
const payload = JSON.parse(await file.text());
|
||||||
if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide");
|
if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide");
|
||||||
|
|
@ -1011,7 +1076,9 @@ function App() {
|
||||||
setLinkModalGameId,
|
setLinkModalGameId,
|
||||||
setDrawerGameId,
|
setDrawerGameId,
|
||||||
setScreenshot,
|
setScreenshot,
|
||||||
|
createChecklistFromList,
|
||||||
addScreenshotFiles,
|
addScreenshotFiles,
|
||||||
|
notify,
|
||||||
removeModuleData: store.removeModuleData,
|
removeModuleData: store.removeModuleData,
|
||||||
updateModuleData: store.updateModuleData,
|
updateModuleData: store.updateModuleData,
|
||||||
updateToolboxOrder: (orderedIds) => {
|
updateToolboxOrder: (orderedIds) => {
|
||||||
|
|
@ -1020,7 +1087,9 @@ function App() {
|
||||||
},
|
},
|
||||||
importToolbox: async (file, gameId = "") => {
|
importToolbox: async (file, gameId = "") => {
|
||||||
try {
|
try {
|
||||||
return await importToolbox(file, gameId);
|
const toolbox = await importToolbox(file, gameId);
|
||||||
|
if (toolbox) notify("Toolbox importée.");
|
||||||
|
return toolbox;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setConfirmModal({
|
setConfirmModal({
|
||||||
title: "Import impossible",
|
title: "Import impossible",
|
||||||
|
|
@ -1035,6 +1104,7 @@ function App() {
|
||||||
importAllToolboxes: async (file) => {
|
importAllToolboxes: async (file) => {
|
||||||
try {
|
try {
|
||||||
await importAllToolboxes(file);
|
await importAllToolboxes(file);
|
||||||
|
notify("Import global terminé.");
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setConfirmModal({
|
setConfirmModal({
|
||||||
|
|
@ -1051,8 +1121,12 @@ function App() {
|
||||||
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
|
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
|
||||||
if (!toolbox) return;
|
if (!toolbox) return;
|
||||||
downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
|
downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
|
||||||
|
notify("Export de la toolbox lancé.");
|
||||||
},
|
},
|
||||||
exportAllToolboxes: () => downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`)
|
exportAllToolboxes: () => {
|
||||||
|
downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`);
|
||||||
|
notify("Export global lancé.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1115,6 +1189,7 @@ function App() {
|
||||||
}} />}
|
}} />}
|
||||||
{storageError && <ConfirmModal title="Quota local atteint" message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
|
{storageError && <ConfirmModal title="Quota local atteint" message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
|
||||||
{screenshot && <ScreenshotViewer shot={screenshot} onClose={() => setScreenshot(null)} />}
|
{screenshot && <ScreenshotViewer shot={screenshot} onClose={() => setScreenshot(null)} />}
|
||||||
|
{notification && <NotificationToast key={notification.id} message={notification.message} />}
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1229,13 +1304,17 @@ function AboutPage({ siteContent }) {
|
||||||
const limits = Array.isArray(content.limits) ? content.limits : [];
|
const limits = Array.isArray(content.limits) ? content.limits : [];
|
||||||
const storageSectionIndex = content.sections.findIndex((section) => section.title.toLowerCase().includes("stockage"));
|
const storageSectionIndex = content.sections.findIndex((section) => section.title.toLowerCase().includes("stockage"));
|
||||||
const normalizeReminder = (item, index) => typeof item === "string" ? { title: `Point ${index + 1}`, text: item, icon: "toolbox" } : item;
|
const normalizeReminder = (item, index) => typeof item === "string" ? { title: `Point ${index + 1}`, text: item, icon: "toolbox" } : item;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!location.hash.includes("#contribuer")) return;
|
||||||
|
requestAnimationFrame(() => document.querySelector("#contribuer")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className="about-page">
|
<div className="about-page">
|
||||||
<section className="page-hero">
|
<section className="page-hero">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{content.eyebrow}</p>
|
<p className="eyebrow">{content.eyebrow}</p>
|
||||||
<h1>{content.title}</h1>
|
<h1>{content.title}</h1>
|
||||||
<p>{content.description}</p>
|
<p><RichText text={content.description} /></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="about-story-section nebula-panel" aria-labelledby="about-story-title">
|
<section className="about-story-section nebula-panel" aria-labelledby="about-story-title">
|
||||||
|
|
@ -1249,7 +1328,7 @@ function AboutPage({ siteContent }) {
|
||||||
<span>{String(index + 1).padStart(2, "0")}</span>
|
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>{section.title}</h3>
|
<h3>{section.title}</h3>
|
||||||
<p>{section.text}</p>
|
<p><RichText text={section.text} /></p>
|
||||||
{index === storageSectionIndex && limits.length > 0 && (
|
{index === storageSectionIndex && limits.length > 0 && (
|
||||||
<div className="about-storage-reminders">
|
<div className="about-storage-reminders">
|
||||||
<p className="eyebrow">{content.limitsTitle}</p>
|
<p className="eyebrow">{content.limitsTitle}</p>
|
||||||
|
|
@ -1263,7 +1342,7 @@ function AboutPage({ siteContent }) {
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h4>{reminder.title}</h4>
|
<h4>{reminder.title}</h4>
|
||||||
<p>{reminder.text}</p>
|
<p><RichText text={reminder.text} /></p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
@ -1291,12 +1370,29 @@ function AboutPage({ siteContent }) {
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>{tool.name}</h3>
|
<h3>{tool.name}</h3>
|
||||||
<p>{tool.description}</p>
|
<p><RichText text={tool.description} /></p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{content.contribute && (
|
||||||
|
<section className="about-contribute-section nebula-panel" id="contribuer" aria-labelledby="about-contribute-title">
|
||||||
|
<div className="about-section-heading">
|
||||||
|
<p className="eyebrow">{content.contribute.eyebrow}</p>
|
||||||
|
<h2 id="about-contribute-title">{content.contribute.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="about-contribute-content">
|
||||||
|
<p><RichText text={content.contribute.description} /></p>
|
||||||
|
<pre><code>{content.contribute.example}</code></pre>
|
||||||
|
<div className="about-prompt-box">
|
||||||
|
<h3>{content.contribute.promptTitle}</h3>
|
||||||
|
<p><RichText text={content.contribute.promptIntro} /></p>
|
||||||
|
<pre><code>{content.contribute.promptText}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1544,6 +1640,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
hostnameFromUrl,
|
hostnameFromUrl,
|
||||||
copyText,
|
copyText,
|
||||||
|
notify: actions.notify,
|
||||||
compressImageFile: compressImage,
|
compressImageFile: compressImage,
|
||||||
clampQty,
|
clampQty,
|
||||||
uid,
|
uid,
|
||||||
|
|
@ -1754,6 +1851,14 @@ function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationToast({ message }) {
|
||||||
|
return (
|
||||||
|
<div className="notification-toast" role="status" aria-live="polite">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CreateToolboxModal({ gameId, initialName = "", onClose }) {
|
function CreateToolboxModal({ gameId, initialName = "", onClose }) {
|
||||||
useModalScrollLock();
|
useModalScrollLock();
|
||||||
const [name, setName] = useState(initialName);
|
const [name, setName] = useState(initialName);
|
||||||
|
|
|
||||||
|
|
@ -15,25 +15,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.diablo4-home-mark {
|
.diablo4-home-mark {
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
height: 120px;
|
|
||||||
margin: 0;
|
|
||||||
place-items: center;
|
|
||||||
border-bottom: 1px solid rgba(246, 196, 83, 0.42);
|
|
||||||
background: rgba(5, 7, 17, 0.28);
|
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-home-card .diablo4-home-mark {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diablo4-home-mark::before {
|
.diablo4-home-mark::before {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
width: 72px;
|
width: 82px;
|
||||||
height: 72px;
|
height: 82px;
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
filter: drop-shadow(0 16px 24px rgba(0, 0, 0, 0.44));
|
filter: drop-shadow(0 16px 24px rgba(0, 0, 0, 0.44));
|
||||||
mask: url("/static/img/diablo4/cube.svg") center / contain no-repeat;
|
mask: url("/static/img/diablo4/cube.svg") center / contain no-repeat;
|
||||||
|
|
|
||||||
|
|
@ -48,16 +48,32 @@
|
||||||
background: rgba(5, 7, 17, 0.28);
|
background: rgba(5, 7, 17, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-home-card strong,
|
.game-home-card-icon {
|
||||||
.game-home-card span {
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
place-items: center;
|
||||||
|
border-bottom: 1px solid rgba(246, 196, 83, 0.42);
|
||||||
|
background: rgba(5, 7, 17, 0.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-home-card-icon .module-icon-svg {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
filter: drop-shadow(0 12px 22px rgba(0, 0, 0, 0.36));
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-home-card > strong,
|
||||||
|
.game-home-card > span:not(.game-home-card-icon) {
|
||||||
margin-inline: var(--space-5);
|
margin-inline: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-home-card strong {
|
.game-home-card > strong {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-home-card span {
|
.game-home-card > span:not(.game-home-card-icon) {
|
||||||
margin-bottom: var(--space-5);
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +92,39 @@
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-breadcrumb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-breadcrumb a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-breadcrumb .ui-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 0 currentColor)
|
||||||
|
drop-shadow(0 0 4px rgba(246, 196, 83, 0.22));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-breadcrumb a:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.results-count {
|
.results-count {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
|
|
@ -95,6 +144,215 @@
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-list-help-link {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
border-color: rgba(165, 180, 252, 0.18);
|
||||||
|
background: rgba(5, 7, 17, 0.24);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-help-link:hover {
|
||||||
|
border-color: rgba(246, 196, 83, 0.44);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(246, 196, 83, 0.1), rgba(139, 92, 246, 0.08)) padding-box,
|
||||||
|
linear-gradient(135deg, rgba(246, 196, 83, 0.42), rgba(196, 181, 253, 0.22)) border-box;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-selector {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 300px));
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-selector-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
min-height: 68px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(165, 180, 252, 0.14);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(16, 20, 38, 0.58);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-selector-item:hover,
|
||||||
|
.game-list-selector-item.active {
|
||||||
|
border-color: rgba(246, 196, 83, 0.42);
|
||||||
|
background: rgba(31, 37, 68, 0.72);
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-sm),
|
||||||
|
0 0 14px rgba(246, 196, 83, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-selector-item > button:not(.game-list-create-button) {
|
||||||
|
display: grid;
|
||||||
|
min-height: 48px;
|
||||||
|
align-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-selector-item strong {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-selector-item small {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-selector-item .game-list-create-button {
|
||||||
|
width: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-panel {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-panel > header,
|
||||||
|
.game-list-category header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px solid rgba(246, 196, 83, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-category header {
|
||||||
|
padding: 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-categories {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-category {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-category:not(:last-child) {
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-category h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-category ul {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-category li {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-height: 58px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(165, 180, 252, 0.07);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(5, 7, 17, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-item-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-category li strong {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-category li span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-category li em {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid rgba(246, 196, 83, 0.28);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: rgba(246, 196, 83, 0.08);
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-create-button {
|
||||||
|
width: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-create-button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-create-button .module-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list-create-button .module-icon-svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.game-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);
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ span {
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-story-item > div > p {
|
.about-story-item > div > p {
|
||||||
max-width: 78ch;
|
max-width: 108ch;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
line-height: 1.62;
|
line-height: 1.62;
|
||||||
|
|
@ -331,6 +331,70 @@ span {
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-contribute-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
|
||||||
|
gap: var(--space-6);
|
||||||
|
scroll-margin-top: var(--space-6);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contribute-content {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contribute-content > p {
|
||||||
|
max-width: 112ch;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contribute-content pre,
|
||||||
|
.about-prompt-box {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid rgba(165, 180, 252, 0.12);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(5, 7, 17, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contribute-content pre {
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contribute-content code {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-prompt-box {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-prompt-box h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-prompt-box p {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-prompt-box pre {
|
||||||
|
border-color: rgba(246, 196, 83, 0.16);
|
||||||
|
background: rgba(5, 7, 17, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
.about-tools-list {
|
.about-tools-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@
|
||||||
-webkit-mask-image: url("/static/icons/open.svg");
|
-webkit-mask-image: url("/static/icons/open.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-icon-home {
|
||||||
|
mask-image: url("/static/icons/home.svg");
|
||||||
|
-webkit-mask-image: url("/static/icons/home.svg");
|
||||||
|
}
|
||||||
|
|
||||||
.ui-icon-enter {
|
.ui-icon-enter {
|
||||||
mask-image: url("/static/icons/enter.svg");
|
mask-image: url("/static/icons/enter.svg");
|
||||||
-webkit-mask-image: url("/static/icons/enter.svg");
|
-webkit-mask-image: url("/static/icons/enter.svg");
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,27 @@ body.is-resizing-drawer * {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-toast {
|
||||||
|
position: fixed;
|
||||||
|
right: var(--space-5);
|
||||||
|
bottom: var(--space-5);
|
||||||
|
z-index: 360;
|
||||||
|
max-width: min(360px, calc(100vw - 32px));
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 1px solid rgba(246, 196, 83, 0.34);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(16, 20, 38, 0.94), rgba(24, 20, 42, 0.94)) padding-box,
|
||||||
|
linear-gradient(135deg, rgba(246, 196, 83, 0.48), rgba(139, 92, 246, 0.28)) border-box;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-sm),
|
||||||
|
0 0 18px rgba(139, 92, 246, 0.16);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 800;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-add-modal {
|
.tool-add-modal {
|
||||||
width: min(560px, calc(100vw - 32px));
|
width: min(560px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-story-section {
|
.about-story-section,
|
||||||
|
.about-contribute-section {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +75,7 @@
|
||||||
.page-hero,
|
.page-hero,
|
||||||
.origin-section,
|
.origin-section,
|
||||||
.about-story-section,
|
.about-story-section,
|
||||||
|
.about-contribute-section,
|
||||||
.about-tools-list,
|
.about-tools-list,
|
||||||
.section-grid,
|
.section-grid,
|
||||||
.info-grid {
|
.info-grid {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue