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