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

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

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 { 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 }) {
<>
<section className="page-heading game-heading">
<div>
<p className="eyebrow">Diablo IV</p>
<GameBreadcrumb game={game} />
<div className="game-title-row">
<h1>Affixes</h1>
<span className="results-count">{visible.length} / {diablo4.affixes.length}</span>

View file

@ -12,7 +12,7 @@ export function Diablo4Overview({ game }) {
</section>
<section className="game-home-grid diablo4-home-grid">
<a className="feature game-home-card diablo4-home-card" href="#/games/diablo4/affixes">
<span className="diablo4-home-mark" aria-hidden="true" />
<span className="game-home-card-icon diablo4-home-mark" aria-hidden="true" />
<strong>Affixes</strong>
<span>Recherche et filtres par catégorie pour retrouver les statistiques utiles.</span>
</a>

View file

@ -34,5 +34,5 @@ export function Diablo4Page({ category, game, diablo4, filters, setFilters }) {
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: {},
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.");

View file

@ -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 }) {
<>
<section className="page-heading game-heading">
<div>
<p className="eyebrow">Monster Hunter Wilds</p>
<GameBreadcrumb game={game} />
<div className="game-title-row">
<h1>{label}</h1>
<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>
<span>Faune endémique et aquatique filtrable par localisation.</span>
</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>
</>
);

View file

@ -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 <MhwildsOverview game={game} />;
if (activeCategory === "lists") return <MhwildsLists game={game} mhwilds={mhwilds} linkedToolboxId={links?.[game.id] || ""} actions={actions} />;
return (
<MhwildsListing
category={activeCategory}
game={game}
mhwilds={mhwilds}
filters={filters}
setFilters={setFilters}

View file

@ -111,6 +111,7 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
.join("\n");
if (!text || !await context.copyText(text)) return;
setCopied(true);
context.notify?.(textContent.copiedTitle || "Copié");
window.setTimeout(() => setCopied(false), 1400);
}

View file

@ -46,6 +46,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
save(nextSections);
setTextImport("");
setImportOpen(false);
context.notify?.("Import checklist terminé.");
}
function setHideCompletedSections(hideCompletedSections) {

View file

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

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.",
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 à 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: [
{ 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 && <ConfirmModal title="Quota local atteint" message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
{screenshot && <ScreenshotViewer shot={screenshot} onClose={() => setScreenshot(null)} />}
{notification && <NotificationToast key={notification.id} message={notification.message} />}
</Shell>
);
}
@ -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 (
<div className="about-page">
<section className="page-hero">
<div>
<p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1>
<p>{content.description}</p>
<p><RichText text={content.description} /></p>
</div>
</section>
<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>
<div>
<h3>{section.title}</h3>
<p>{section.text}</p>
<p><RichText text={section.text} /></p>
{index === storageSectionIndex && limits.length > 0 && (
<div className="about-storage-reminders">
<p className="eyebrow">{content.limitsTitle}</p>
@ -1263,7 +1342,7 @@ function AboutPage({ siteContent }) {
</span>
<div>
<h4>{reminder.title}</h4>
<p>{reminder.text}</p>
<p><RichText text={reminder.text} /></p>
</div>
</li>
);
@ -1291,12 +1370,29 @@ function AboutPage({ siteContent }) {
</span>
<div>
<h3>{tool.name}</h3>
<p>{tool.description}</p>
<p><RichText text={tool.description} /></p>
</div>
</article>
))}
</div>
</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>
);
}
@ -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 (
<div className="notification-toast" role="status" aria-live="polite">
{message}
</div>
);
}
function CreateToolboxModal({ gameId, initialName = "", onClose }) {
useModalScrollLock();
const [name, setName] = useState(initialName);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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