From 959fa42454ed4c080937f03ac0b9264fb9f62054 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 24 Jul 2026 16:14:14 +0200 Subject: [PATCH] add list page and multiples optis --- README.md | 11 + package.json | 7 +- scripts/generate-list-indexes.mjs | 51 ++++ tests/data-validation.test.mjs | 22 ++ tests/helpers/data-validation.mjs | 7 + tests/static-smoke.test.mjs | 21 +- .../data/mhwilds/lists/events_items.json | 174 ++++++++++++ website/public/data/mhwilds/lists/index.json | 9 + website/public/data/site.json | 9 + .../games/CopyChecklistItemsButton.jsx | 33 ++- website/src/features/games/GameBreadcrumb.jsx | 15 + website/src/features/games/GameListsPage.jsx | 95 +++++++ .../features/games/diablo4/Diablo4Listing.jsx | 5 +- .../games/diablo4/Diablo4Overview.jsx | 2 +- .../features/games/diablo4/Diablo4Page.jsx | 2 +- website/src/features/games/loaders.js | 56 +++- .../features/games/mhwilds/MhwildsListing.jsx | 5 +- .../features/games/mhwilds/MhwildsLists.jsx | 22 ++ .../games/mhwilds/MhwildsOverview.jsx | 7 + .../features/games/mhwilds/MhwildsPage.jsx | 7 +- .../toolboxes/modules/CalculatorModule.jsx | 1 + .../toolboxes/modules/ChecklistModule.jsx | 1 + .../toolboxes/modules/LinksModule.jsx | 2 + website/src/main.jsx | 119 +++++++- website/src/styles/_diablo4.scss | 15 +- website/src/styles/_games.scss | 266 +++++++++++++++++- website/src/styles/_home.scss | 66 ++++- website/src/styles/_icons.scss | 5 + website/src/styles/_overlays.scss | 21 ++ website/src/styles/_responsive.scss | 4 +- 30 files changed, 1015 insertions(+), 45 deletions(-) create mode 100644 scripts/generate-list-indexes.mjs create mode 100644 website/public/data/mhwilds/lists/events_items.json create mode 100644 website/public/data/mhwilds/lists/index.json create mode 100644 website/src/features/games/GameBreadcrumb.jsx create mode 100644 website/src/features/games/GameListsPage.jsx create mode 100644 website/src/features/games/mhwilds/MhwildsLists.jsx diff --git a/README.md b/README.md index 4ee094f..1f60c09 100644 --- a/README.md +++ b/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. - Suivi d’un budget de stockage recommandé, avec quota navigateur en information. - Pages de guides de jeu éditables via JSON. +- Listes de jeu exportables vers les checklists. ## 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/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/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/en.json` | Traductions anglaises des données MHWilds. | | `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 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 Les pages jeux sont dans `website/src/features/games`. diff --git a/package.json b/package.json index 4c15a5f..36899d4 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "type": "module", "scripts": { "start": "node server.mjs", - "dev": "vite", - "build": "vite build", + "dev": "npm run lists:index && vite", + "build": "npm run lists:index && vite build", "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" }, "dependencies": { diff --git a/scripts/generate-list-indexes.mjs b/scripts/generate-list-indexes.mjs new file mode 100644 index 0000000..4e96a47 --- /dev/null +++ b/scripts/generate-list-indexes.mjs @@ -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)); diff --git a/tests/data-validation.test.mjs b/tests/data-validation.test.mjs index 68e37cd..4e12498 100644 --- a/tests/data-validation.test.mjs +++ b/tests/data-validation.test.mjs @@ -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 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 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 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"); @@ -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.endemicLife.length > 0); 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(enTranslations, "en"); assert.equal(translations.monsters, "monstres"); diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index ca746d8..3cba2db 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -65,6 +65,13 @@ export function validateSiteContent(site) { "about.description", "about.toolsTitle", "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.title", "toolboxes.newButton", diff --git a/tests/static-smoke.test.mjs b/tests/static-smoke.test.mjs index 3bf1734..6e0c024 100644 --- a/tests/static-smoke.test.mjs +++ b/tests/static-smoke.test.mjs @@ -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 gameRoute = await readFile("website/src/features/games/GameRoute.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 gameFiltersPanel = await readFile("website/src/features/games/GameFiltersPanel.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 diablo4Utils = await readFile("website/src/features/games/diablo4/utils.js", "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 mhwildsLists = await readFile("website/src/features/games/mhwilds/MhwildsLists.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 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, /Diablo4Page/); 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, /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, /\/data\/mhwilds\/lists\/index\.json/); + assert.match(gameLoaders, /loadGameLists/); + assert.match(gameLoaders, /normalizeGameListFile/); assert.match(gameLoaders, /export async function loadDiablo4Data/); assert.match(gameLoaders, /\/data\/diablo4\/affixes_types\.json/); 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, /getCategoryIconStyle/); 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, /GameBreadcrumb/); + assert.match(mhwildsLists, /export function MhwildsLists/); + assert.match(mhwildsLists, /GameListsPage/); + assert.match(mhwildsLists, /selectedListId/); assert.match(mhwildsListing, /CopyChecklistItemsButton/); assert.match(mhwildsListing, /endemic_life/); assert.match(mhwildsFilters, /export function MhwildsFilters/); diff --git a/website/public/data/mhwilds/lists/events_items.json b/website/public/data/mhwilds/lists/events_items.json new file mode 100644 index 0000000..5f1240d --- /dev/null +++ b/website/public/data/mhwilds/lists/events_items.json @@ -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 + } + ] +} diff --git a/website/public/data/mhwilds/lists/index.json b/website/public/data/mhwilds/lists/index.json new file mode 100644 index 0000000..1df66a1 --- /dev/null +++ b/website/public/data/mhwilds/lists/index.json @@ -0,0 +1,9 @@ +{ + "lists": [ + { + "id": "events_items", + "title": "Équipements d'événement", + "file": "events_items.json" + } + ] +} diff --git a/website/public/data/site.json b/website/public/data/site.json index 076a5d5..ab4da9b 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -106,6 +106,15 @@ } ], "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": [ { "icon": "notepad", diff --git a/website/src/features/games/CopyChecklistItemsButton.jsx b/website/src/features/games/CopyChecklistItemsButton.jsx index 2ed20f7..953a4d9 100644 --- a/website/src/features/games/CopyChecklistItemsButton.jsx +++ b/website/src/features/games/CopyChecklistItemsButton.jsx @@ -10,21 +10,46 @@ async function copyText(value) { } } +function notify(message) { + window.dispatchEvent(new CustomEvent("sokkog:notify", { detail: { message } })); +} + export function formatChecklistImportItems(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) - .map((label) => `${label}:1`) + .map((entry) => `${entry.label}:${entry.quantity}`) .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 text = formatChecklistImportItems(labels); + const text = sections ? formatChecklistImportSections(sections) : formatChecklistImportItems(labels); async function copyItems() { if (!text || !await copyText(text)) return; setCopied(true); + notify("Copié dans le presse-papiers."); window.setTimeout(() => setCopied(false), 1400); } diff --git a/website/src/features/games/GameBreadcrumb.jsx b/website/src/features/games/GameBreadcrumb.jsx new file mode 100644 index 0000000..c5bce1b --- /dev/null +++ b/website/src/features/games/GameBreadcrumb.jsx @@ -0,0 +1,15 @@ +import { Icon } from "../../components/Icon.jsx"; + +export function GameBreadcrumb({ game }) { + if (!game) return null; + + return ( + + ); +} diff --git a/website/src/features/games/GameListsPage.jsx b/website/src/features/games/GameListsPage.jsx new file mode 100644 index 0000000..aa3bd80 --- /dev/null +++ b/website/src/features/games/GameListsPage.jsx @@ -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 ( + <> +
+
+ +
+

Listes

+
+

Sélectionnez une liste, puis copiez-la ou créez une checklist dans la toolbox associée.

+ Proposer une nouvelle liste +
+
+ {lists.length ? ( + <> +
+ {lists.map((list) => ( +
+ + +
+ ))} +
+
+
+
+

{selectedList.title}

+
+
+ +
+
+
+ {selectedList.categories.map((category) => ( +
+
+

{category.title}

+
+ {category.items.length} + +
+
+
    + {category.items.map((item) => ( +
  • +
    + {item.name} + {item.hasQuantity && item.quantity !== 1 ? x{item.quantity} : null} +
    + {item.description ? {item.description} : null} +
  • + ))} +
+
+ ))} +
+
+ + ) : ( +
+

Aucune liste

+

Aucune liste n’est encore disponible pour ce jeu.

+
+ )} + + ); +} diff --git a/website/src/features/games/diablo4/Diablo4Listing.jsx b/website/src/features/games/diablo4/Diablo4Listing.jsx index 5bc27a6..1de7848 100644 --- a/website/src/features/games/diablo4/Diablo4Listing.jsx +++ b/website/src/features/games/diablo4/Diablo4Listing.jsx @@ -1,10 +1,11 @@ import { useMemo } from "react"; import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx"; +import { GameBreadcrumb } from "../GameBreadcrumb.jsx"; import { Diablo4AffixCard } from "./Diablo4AffixCard.jsx"; import { Diablo4Filters } from "./Diablo4Filters.jsx"; import { getCategoryLabel, getFilteredDiablo4Affixes } from "./utils.js"; -export function Diablo4Listing({ diablo4, filters, setFilters }) { +export function Diablo4Listing({ game, diablo4, filters, setFilters }) { const activeFilters = filters.diablo4; const options = [...(diablo4.filterOptions.affixes || [])].sort((a, b) => getCategoryLabel(a).localeCompare(getCategoryLabel(b), "fr")); const visible = useMemo( @@ -16,7 +17,7 @@ export function Diablo4Listing({ diablo4, filters, setFilters }) { <>
-

Diablo IV

+

Affixes

{visible.length} / {diablo4.affixes.length} diff --git a/website/src/features/games/diablo4/Diablo4Overview.jsx b/website/src/features/games/diablo4/Diablo4Overview.jsx index b58eb96..5f053d1 100644 --- a/website/src/features/games/diablo4/Diablo4Overview.jsx +++ b/website/src/features/games/diablo4/Diablo4Overview.jsx @@ -12,7 +12,7 @@ export function Diablo4Overview({ game }) {
- diff --git a/website/src/features/games/diablo4/Diablo4Page.jsx b/website/src/features/games/diablo4/Diablo4Page.jsx index a720bbd..15b2005 100644 --- a/website/src/features/games/diablo4/Diablo4Page.jsx +++ b/website/src/features/games/diablo4/Diablo4Page.jsx @@ -34,5 +34,5 @@ export function Diablo4Page({ category, game, diablo4, filters, setFilters }) { if (!activeCategory) return ; - return ; + return ; } diff --git a/website/src/features/games/loaders.js b/website/src/features/games/loaders.js index 1ad1332..a3c7d45 100644 --- a/website/src/features/games/loaders.js +++ b/website/src/features/games/loaders.js @@ -5,6 +5,7 @@ export const INITIAL_MHWILDS_STATE = { translations: {}, monsters: [], endemic: [], + lists: [], filterOptions: { monsters: [], endemic: [] }, filterOptionKeys: { monsters: "", endemic: "" } }; @@ -20,21 +21,24 @@ export const INITIAL_DIABLO4_STATE = { }; 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/endemic_life.json"), + fetch("/data/mhwilds/lists/index.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."); } - const [monstersJson, endemicJson, translations] = await Promise.all([ + const [monstersJson, endemicJson, listIndexJson, translations] = await Promise.all([ monstersResponse.json(), endemicResponse.json(), + listIndexResponse.json(), translationsResponse.json() ]); + const lists = await loadGameLists("mhwilds", listIndexJson); const monsterFilterKey = Object.keys(monstersJson).find((key) => key !== "monsters") || ""; const endemicFilterKey = Object.keys(endemicJson).find((key) => key !== "endemicLife" && key !== "aquaticLife") || ""; @@ -45,6 +49,7 @@ export async function loadMhwildsData() { translations, monsters: monstersJson.monsters || [], endemic: [...(endemicJson.endemicLife || []), ...(endemicJson.aquaticLife || [])], + lists, filterOptions: { monsters: monstersJson[monsterFilterKey] || [], 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() { const response = await fetch("/data/diablo4/affixes_types.json"); if (!response.ok) throw new Error("Impossible de charger les données Diablo IV."); diff --git a/website/src/features/games/mhwilds/MhwildsListing.jsx b/website/src/features/games/mhwilds/MhwildsListing.jsx index 97e511a..433296f 100644 --- a/website/src/features/games/mhwilds/MhwildsListing.jsx +++ b/website/src/features/games/mhwilds/MhwildsListing.jsx @@ -1,11 +1,12 @@ import { useMemo } from "react"; +import { GameBreadcrumb } from "../GameBreadcrumb.jsx"; import { EndemicCard } from "./cards/EndemicCard.jsx"; import { MonsterCard } from "./cards/MonsterCard.jsx"; import { CopyChecklistItemsButton } from "../CopyChecklistItemsButton.jsx"; import { MhwildsFilters } from "./MhwildsFilters.jsx"; 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 filterKey = getMhwildsFilterKey(category, mhwilds); const items = isMonsters ? mhwilds.monsters : mhwilds.endemic; @@ -19,7 +20,7 @@ export function MhwildsListing({ category, mhwilds, filters, setFilters, t }) { <>
-

Monster Hunter Wilds

+

{label}

{visible.length} / {items.length} diff --git a/website/src/features/games/mhwilds/MhwildsLists.jsx b/website/src/features/games/mhwilds/MhwildsLists.jsx new file mode 100644 index 0000000..3d51f8e --- /dev/null +++ b/website/src/features/games/mhwilds/MhwildsLists.jsx @@ -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 ( + actions.createChecklistFromList(game.id, list)} + /> + ); +} diff --git a/website/src/features/games/mhwilds/MhwildsOverview.jsx b/website/src/features/games/mhwilds/MhwildsOverview.jsx index 05905ed..f3cad98 100644 --- a/website/src/features/games/mhwilds/MhwildsOverview.jsx +++ b/website/src/features/games/mhwilds/MhwildsOverview.jsx @@ -23,6 +23,13 @@ export function MhwildsOverview({ game }) { Faune endémique Faune endémique et aquatique filtrable par localisation. + +
); diff --git a/website/src/features/games/mhwilds/MhwildsPage.jsx b/website/src/features/games/mhwilds/MhwildsPage.jsx index 247d8dd..d4a1dc3 100644 --- a/website/src/features/games/mhwilds/MhwildsPage.jsx +++ b/website/src/features/games/mhwilds/MhwildsPage.jsx @@ -1,8 +1,9 @@ import { MhwildsListing } from "./MhwildsListing.jsx"; +import { MhwildsLists } from "./MhwildsLists.jsx"; import { MhwildsOverview } from "./MhwildsOverview.jsx"; -export function MhwildsPage({ category, game, mhwilds, filters, setFilters, t }) { - const activeCategory = category === "monsters" || category === "endemic" ? category : ""; +export function MhwildsPage({ category, game, mhwilds, filters, setFilters, t, links, actions }) { + const activeCategory = category === "monsters" || category === "endemic" || category === "lists" ? category : ""; const heroStyle = game?.images?.heroBg ? { "--game-hero-image": `url("${game.images.heroBg}")` } : undefined; if (!mhwilds.loaded) { @@ -33,10 +34,12 @@ export function MhwildsPage({ category, game, mhwilds, filters, setFilters, t }) } if (!activeCategory) return ; + if (activeCategory === "lists") return ; return ( setCopied(false), 1400); } diff --git a/website/src/features/toolboxes/modules/ChecklistModule.jsx b/website/src/features/toolboxes/modules/ChecklistModule.jsx index 00b44e7..6c57ee6 100644 --- a/website/src/features/toolboxes/modules/ChecklistModule.jsx +++ b/website/src/features/toolboxes/modules/ChecklistModule.jsx @@ -46,6 +46,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) { save(nextSections); setTextImport(""); setImportOpen(false); + context.notify?.("Import checklist terminé."); } function setHideCompletedSections(hideCompletedSections) { diff --git a/website/src/features/toolboxes/modules/LinksModule.jsx b/website/src/features/toolboxes/modules/LinksModule.jsx index abfadec..a6a8289 100644 --- a/website/src/features/toolboxes/modules/LinksModule.jsx +++ b/website/src/features/toolboxes/modules/LinksModule.jsx @@ -47,12 +47,14 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) { save([...data.links, ...imported]); setTextImport(""); setImportOpen(false); + context.notify?.("Import de liens terminé."); } async function copyUrl(link) { const copied = await context.copyText(link.url); if (!copied) return; setCopiedId(link.id); + context.notify?.(textContent.copiedTitle || "Copié"); window.setTimeout(() => setCopiedId(""), 1400); } diff --git a/website/src/main.jsx b/website/src/main.jsx index 27b07ba..cb3c541 100644 --- a/website/src/main.jsx +++ b/website/src/main.jsx @@ -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.", sections: [], 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: [ { icon: "notepad", name: "Bloc notes", description: "Notes rapides pendant une session." }, { icon: "checklist", name: "Checklist", description: "Objectifs et quantités à suivre." }, @@ -652,11 +661,12 @@ function mergeContent(defaults, overrides) { function currentRoute() { const hashRoute = location.hash.replace(/^#/, ""); - if (hashRoute) return hashRoute; + if (hashRoute) return hashRoute.replace(/#.+$/, ""); const path = location.pathname.replace(/\/+$/, "") || "/"; if (path === "/mhwilds") return "/games/mhwilds"; if (path === "/mhwilds/monsters") return "/games/mhwilds/monsters"; if (path === "/mhwilds/endemic") return "/games/mhwilds/endemic"; + if (path === "/mhwilds/lists") return "/games/mhwilds/lists"; return path; } @@ -850,9 +860,29 @@ function App() { const [linkModalGameId, setLinkModalGameId] = useState(""); const [drawerGameId, setDrawerGameId] = useState(""); const [screenshot, setScreenshot] = useState(null); + const [notification, setNotification] = useState(null); const [storageError, setStorageError] = useState(""); 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(() => { Promise.all([ 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); } + 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 = "") { const payload = JSON.parse(await file.text()); if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide"); @@ -1011,7 +1076,9 @@ function App() { setLinkModalGameId, setDrawerGameId, setScreenshot, + createChecklistFromList, addScreenshotFiles, + notify, removeModuleData: store.removeModuleData, updateModuleData: store.updateModuleData, updateToolboxOrder: (orderedIds) => { @@ -1020,7 +1087,9 @@ function App() { }, importToolbox: async (file, gameId = "") => { try { - return await importToolbox(file, gameId); + const toolbox = await importToolbox(file, gameId); + if (toolbox) notify("Toolbox importée."); + return toolbox; } catch (error) { setConfirmModal({ title: "Import impossible", @@ -1035,6 +1104,7 @@ function App() { importAllToolboxes: async (file) => { try { await importAllToolboxes(file); + notify("Import global terminé."); return true; } catch (error) { setConfirmModal({ @@ -1051,8 +1121,12 @@ function App() { const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id)); if (!toolbox) return; 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 ( @@ -1115,6 +1189,7 @@ function App() { }} />} {storageError && setStorageError("")} />} {screenshot && setScreenshot(null)} />} + {notification && } ); } @@ -1229,13 +1304,17 @@ function AboutPage({ siteContent }) { const limits = Array.isArray(content.limits) ? content.limits : []; 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; + useEffect(() => { + if (!location.hash.includes("#contribuer")) return; + requestAnimationFrame(() => document.querySelector("#contribuer")?.scrollIntoView({ behavior: "smooth", block: "start" })); + }, []); return (

{content.eyebrow}

{content.title}

-

{content.description}

+

@@ -1249,7 +1328,7 @@ function AboutPage({ siteContent }) { {String(index + 1).padStart(2, "0")}

{section.title}

-

{section.text}

+

{index === storageSectionIndex && limits.length > 0 && (

{content.limitsTitle}

@@ -1263,7 +1342,7 @@ function AboutPage({ siteContent }) {

{reminder.title}

-

{reminder.text}

+

); @@ -1291,12 +1370,29 @@ function AboutPage({ siteContent }) {

{tool.name}

-

{tool.description}

+

))}
+ {content.contribute && ( +
+
+

{content.contribute.eyebrow}

+

{content.contribute.title}

+
+
+

+
{content.contribute.example}
+
+

{content.contribute.promptTitle}

+

+
{content.contribute.promptText}
+
+
+
+ )}
); } @@ -1544,6 +1640,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto normalizeUrl, hostnameFromUrl, copyText, + notify: actions.notify, compressImageFile: compressImage, clampQty, uid, @@ -1754,6 +1851,14 @@ function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel ); } +function NotificationToast({ message }) { + return ( +
+ {message} +
+ ); +} + function CreateToolboxModal({ gameId, initialName = "", onClose }) { useModalScrollLock(); const [name, setName] = useState(initialName); diff --git a/website/src/styles/_diablo4.scss b/website/src/styles/_diablo4.scss index cd1e918..27190e9 100644 --- a/website/src/styles/_diablo4.scss +++ b/website/src/styles/_diablo4.scss @@ -15,25 +15,14 @@ } .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); } -.game-home-card .diablo4-home-mark { - margin: 0; -} - .diablo4-home-mark::before { content: ""; display: block; - width: 72px; - height: 72px; + width: 82px; + height: 82px; background: currentColor; filter: drop-shadow(0 16px 24px rgba(0, 0, 0, 0.44)); mask: url("/static/img/diablo4/cube.svg") center / contain no-repeat; diff --git a/website/src/styles/_games.scss b/website/src/styles/_games.scss index ba7054c..1c9cdb6 100644 --- a/website/src/styles/_games.scss +++ b/website/src/styles/_games.scss @@ -48,16 +48,32 @@ background: rgba(5, 7, 17, 0.28); } -.game-home-card strong, -.game-home-card span { +.game-home-card-icon { + 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); } -.game-home-card strong { +.game-home-card > strong { margin-top: var(--space-4); } -.game-home-card span { +.game-home-card > span:not(.game-home-card-icon) { margin-bottom: var(--space-5); } @@ -76,6 +92,39 @@ 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 { display: inline-flex; min-height: 28px; @@ -95,6 +144,215 @@ 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 { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); diff --git a/website/src/styles/_home.scss b/website/src/styles/_home.scss index 5ef098f..c5b1aeb 100644 --- a/website/src/styles/_home.scss +++ b/website/src/styles/_home.scss @@ -319,7 +319,7 @@ span { } .about-story-item > div > p { - max-width: 78ch; + max-width: 108ch; margin: 0; color: var(--color-text-secondary); line-height: 1.62; @@ -331,6 +331,70 @@ span { 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 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/website/src/styles/_icons.scss b/website/src/styles/_icons.scss index 7aaf071..9b3517b 100644 --- a/website/src/styles/_icons.scss +++ b/website/src/styles/_icons.scss @@ -16,6 +16,11 @@ -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 { mask-image: url("/static/icons/enter.svg"); -webkit-mask-image: url("/static/icons/enter.svg"); diff --git a/website/src/styles/_overlays.scss b/website/src/styles/_overlays.scss index c052450..92f5f7d 100644 --- a/website/src/styles/_overlays.scss +++ b/website/src/styles/_overlays.scss @@ -306,6 +306,27 @@ body.is-resizing-drawer * { 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 { width: min(560px, calc(100vw - 32px)); } diff --git a/website/src/styles/_responsive.scss b/website/src/styles/_responsive.scss index 3851c98..1795351 100644 --- a/website/src/styles/_responsive.scss +++ b/website/src/styles/_responsive.scss @@ -30,7 +30,8 @@ justify-content: center; } - .about-story-section { + .about-story-section, + .about-contribute-section { grid-template-columns: 1fr; } @@ -74,6 +75,7 @@ .page-hero, .origin-section, .about-story-section, + .about-contribute-section, .about-tools-list, .section-grid, .info-grid {