add list page and multiples optis
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
d9d22683dc
commit
959fa42454
30 changed files with 1015 additions and 45 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
15
website/src/features/games/GameBreadcrumb.jsx
Normal file
15
website/src/features/games/GameBreadcrumb.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Icon } from "../../components/Icon.jsx";
|
||||
|
||||
export function GameBreadcrumb({ game }) {
|
||||
if (!game) return null;
|
||||
|
||||
return (
|
||||
<nav className="game-breadcrumb" aria-label="Fil d'Ariane">
|
||||
<a href="#/" aria-label="Accueil">
|
||||
<Icon name="home" />
|
||||
</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<a href={`#/games/${game.id}`}>{game.title}</a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
95
website/src/features/games/GameListsPage.jsx
Normal file
95
website/src/features/games/GameListsPage.jsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Icon } from "../../components/Icon.jsx";
|
||||
import { CopyChecklistItemsButton } from "./CopyChecklistItemsButton.jsx";
|
||||
import { GameBreadcrumb } from "./GameBreadcrumb.jsx";
|
||||
|
||||
function getChecklistSections(list) {
|
||||
return (list?.categories || []).map((category) => ({
|
||||
title: category.title,
|
||||
items: category.items.map((item) => ({ label: item.name, quantity: item.quantity }))
|
||||
}));
|
||||
}
|
||||
|
||||
export function GameListsPage({ game, lists, selectedListId, onSelectList, linkedToolboxId, onCreateChecklist }) {
|
||||
const selectedList = lists.find((list) => list.id === selectedListId) || lists[0] || null;
|
||||
const selectedSections = getChecklistSections(selectedList);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-heading game-heading">
|
||||
<div>
|
||||
<GameBreadcrumb game={game} />
|
||||
<div className="game-title-row">
|
||||
<h1>Listes</h1>
|
||||
</div>
|
||||
<p>Sélectionnez une liste, puis copiez-la ou créez une checklist dans la toolbox associée.</p>
|
||||
<a className="game-list-help-link button" href="#/about#contribuer">Proposer une nouvelle liste</a>
|
||||
</div>
|
||||
</section>
|
||||
{lists.length ? (
|
||||
<>
|
||||
<section className="game-list-selector" aria-label="Listes disponibles">
|
||||
{lists.map((list) => (
|
||||
<article className={`game-list-selector-item ${selectedList?.id === list.id ? "active" : ""}`} key={list.id}>
|
||||
<button type="button" onClick={() => onSelectList(list.id)}>
|
||||
<strong>{list.title}</strong>
|
||||
<small>{list.itemCount} éléments</small>
|
||||
</button>
|
||||
<button
|
||||
className="game-list-create-button primary"
|
||||
type="button"
|
||||
disabled={!linkedToolboxId}
|
||||
onClick={() => onCreateChecklist?.(list)}
|
||||
title={linkedToolboxId ? "Créer une checklist dans la toolbox associée" : "Associez une toolbox pour créer la checklist"}
|
||||
aria-label={linkedToolboxId ? `Créer une checklist ${list.title}` : "Associez une toolbox pour créer la checklist"}
|
||||
>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className="module-icon-svg module-icon-checklist" />
|
||||
</span>
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className="game-list-panel nebula-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h2>{selectedList.title}</h2>
|
||||
</div>
|
||||
<div className="game-list-actions">
|
||||
<CopyChecklistItemsButton sections={selectedSections} title={`Copier ${selectedList.title.toLowerCase()} pour checklist`} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="game-list-categories">
|
||||
{selectedList.categories.map((category) => (
|
||||
<article className="game-list-category" key={category.id}>
|
||||
<header>
|
||||
<h3>{category.title}</h3>
|
||||
<div className="game-list-actions">
|
||||
<span className="results-count">{category.items.length}</span>
|
||||
<CopyChecklistItemsButton sections={[getChecklistSections({ categories: [category] })[0]]} title={`Copier ${category.title.toLowerCase()} pour checklist`} />
|
||||
</div>
|
||||
</header>
|
||||
<ul>
|
||||
{category.items.map((item) => (
|
||||
<li key={`${selectedList.id}-${category.id}-${item.name}-${item.description}`}>
|
||||
<div className="game-list-item-title">
|
||||
<strong>{item.name}</strong>
|
||||
{item.hasQuantity && item.quantity !== 1 ? <em>x{item.quantity}</em> : null}
|
||||
</div>
|
||||
{item.description ? <span>{item.description}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<section className="empty">
|
||||
<h2>Aucune liste</h2>
|
||||
<p>Aucune liste n’est encore disponible pour ce jeu.</p>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { useMemo } from "react";
|
||||
import { 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
22
website/src/features/games/mhwilds/MhwildsLists.jsx
Normal file
22
website/src/features/games/mhwilds/MhwildsLists.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { GameListsPage } from "../GameListsPage.jsx";
|
||||
|
||||
export function MhwildsLists({ game, mhwilds, linkedToolboxId, actions }) {
|
||||
const lists = mhwilds.lists || [];
|
||||
const [selectedListId, setSelectedListId] = useState("");
|
||||
const selectedList = useMemo(
|
||||
() => lists.find((list) => list.id === selectedListId) || lists[0] || null,
|
||||
[lists, selectedListId]
|
||||
);
|
||||
|
||||
return (
|
||||
<GameListsPage
|
||||
game={game}
|
||||
lists={lists}
|
||||
selectedListId={selectedList?.id || ""}
|
||||
onSelectList={setSelectedListId}
|
||||
linkedToolboxId={linkedToolboxId}
|
||||
onCreateChecklist={(list) => actions.createChecklistFromList(game.id, list)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,13 @@ export function MhwildsOverview({ game }) {
|
|||
<strong>Faune endémique</strong>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
|
|||
save(nextSections);
|
||||
setTextImport("");
|
||||
setImportOpen(false);
|
||||
context.notify?.("Import checklist terminé.");
|
||||
}
|
||||
|
||||
function setHideCompletedSections(hideCompletedSections) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue