add list page and multiples optis
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-24 16:14:14 +02:00
parent d9d22683dc
commit 959fa42454
30 changed files with 1015 additions and 45 deletions

View file

@ -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 dun budget de stockage recommandé, avec quota navigateur en information. - Suivi dun 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`.

View file

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

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,9 @@
{
"lists": [
{
"id": "events_items",
"title": "Équipements d'événement",
"file": "events_items.json"
}
]
}

View file

@ -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 à lemploi : é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 dobjets.\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",

View file

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

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

View 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 nest encore disponible pour ce jeu.</p>
</section>
)}
</>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 à lemploi : é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 dobjets.\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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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