sokko-g/website/src/app.js
Shinuwa 1e1ede6cf0
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s
Initial Commit
2026-07-20 23:10:37 +02:00

2300 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const STORAGE_KEYS = {
registry: "sokkog:toolboxes",
links: "sokkog:game-toolbox-links",
drawerWidth: "sokkog:drawer-width"
};
const APP_STORAGE_PREFIX = "sokkog:";
const APP_STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
const APP_STORAGE_WARNING_RATIO = 0.85;
const MODULES = {
notepad: { label: "Bloc notes", icon: "notepad" },
checklist: { label: "Checklist", icon: "checklist" },
screenshots: { label: "Screenshots", icon: "picture" }
};
const DEFAULT_SITE_CONTENT = {
brand: { name: "Sokko G", homeAriaLabel: "Accueil Sokko G" },
navigation: { home: "Accueil", toolboxes: "Toolboxes", games: "Pages jeux", mobileGames: "Infos" },
sidebar: { badge: "Local only", note: "Données stockées dans ce navigateur." },
topbar: { dashboard: "Dashboard", toolbox: "Toolbox active", games: "Informations jeu" },
home: {
hero: {
eyebrow: "Session gaming efficace",
title: "Sokko G",
description: "Centralisez vos outils et repères de jeu dans une interface locale, rapide à consulter, pensée pour accompagner vos sessions sans interrompre laction.",
primaryAction: "Voir les toolboxes",
secondaryAction: "Voir les jeux"
},
stats: {
toolboxSingular: "toolbox créée",
toolboxPlural: "toolboxs créées",
toolSingular: "outil disponible",
toolPlural: "outils disponibles"
},
origin: {
eyebrow: "Origine du nom",
title: "Pourquoi Sokko G ?",
visualAlt: "Dragon Sokko G",
dialogueAriaLabel: "Dialogue dorigine du nom Sokko G",
caption: "\"G\" c'est pour Gaming",
lines: []
}
},
toolboxes: {
eyebrow: "Données locales",
title: "Toolboxes",
newButton: "Nouvelle toolbox",
importAll: "Importer tout",
exportAll: "Exporter tout",
importOne: "Importer une toolbox",
storageHelp: {
title: "Sauvegardes locales",
text: "Vos toolboxes restent sur cet appareil, dans ce navigateur. Rien nest envoyé sur un serveur.",
items: [
"Un nettoyage du navigateur ou un changement dappareil peut supprimer les données.",
"Surveillez le quota, surtout si vous ajoutez des screenshots.",
"Faites un export global régulier pour garder une sauvegarde."
]
},
emptyTitle: "Aucune toolbox",
emptyText: "Créez une première toolbox pour stocker vos outils dans ce navigateur."
}
};
const app = document.querySelector("#app");
const MHWILDS_IMG_PATH = "/static/img/mhwilds";
const siteState = {
loaded: false,
loading: false,
content: DEFAULT_SITE_CONTENT
};
const gamesState = {
loaded: false,
loading: false,
error: "",
games: []
};
const mhwildsState = {
loaded: false,
loading: false,
error: "",
translations: {},
monsters: [],
endemic: [],
filters: {
monsters: { name: "", weaknesses: [], logic: "and" },
endemic: { name: "", locations: [], logic: "and" }
}
};
let draggedModule = null;
const ID_PREFIXES = {
tbx: "t",
mod: "m",
item: "i",
shot: "s"
};
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
function randomToken(length = 6) {
const bytes = new Uint8Array(length);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
return [...bytes].map((byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join("");
}
return Math.random().toString(36).slice(2, 2 + length).padEnd(length, "0");
}
function uid(prefix) {
return `${ID_PREFIXES[prefix] || "x"}${randomToken()}`;
}
function readJson(key, fallback) {
try {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
}
function writeJson(key, value) {
return writeStorageValue(key, JSON.stringify(value));
}
function stringStorageBytes(value) {
return String(value || "").length * 2;
}
function getAppStorageUsage(projected = {}) {
let used = 0;
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (!key?.startsWith(APP_STORAGE_PREFIX) || Object.prototype.hasOwnProperty.call(projected, key)) continue;
used += stringStorageBytes(key) + stringStorageBytes(localStorage.getItem(key));
}
Object.entries(projected).forEach(([key, value]) => {
if (!key.startsWith(APP_STORAGE_PREFIX) || value == null) return;
used += stringStorageBytes(key) + stringStorageBytes(value);
});
return {
used,
limit: APP_STORAGE_LIMIT_BYTES,
ratio: Math.min(1, used / APP_STORAGE_LIMIT_BYTES)
};
}
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace(".", ",")} Mio`;
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} Kio`;
return `${bytes} o`;
}
function renderStorageQuota() {
const usage = getAppStorageUsage();
const percent = Math.round(usage.ratio * 100);
const state = usage.ratio >= 1 ? "danger" : usage.ratio >= APP_STORAGE_WARNING_RATIO ? "warning" : "ok";
return `
<section class="storage-quota storage-quota-${state}" aria-label="Quota de stockage local">
<div>
<span>Stockage local</span>
<strong>${formatBytes(usage.used)} / ${formatBytes(usage.limit)}</strong>
</div>
<div class="storage-quota-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${percent}" aria-label="Stockage local utilisé">
<span style="width:${percent}%"></span>
</div>
</section>
`;
}
function refreshStorageQuotaIndicators() {
document.querySelectorAll(".storage-quota").forEach((quota) => {
quota.outerHTML = renderStorageQuota();
});
}
function showStorageQuotaModal(usage) {
if (document.querySelector(".storage-quota-modal")) return;
queueMicrotask(() => {
showConfirmModal({
title: "Quota local atteint",
message: `Sokko G utilise ${formatBytes(usage.used)} sur ${formatBytes(usage.limit)}. Supprimez des outils, des screenshots ou des toolboxes avant d'ajouter de nouvelles données.`,
confirmLabel: "Compris",
cancelLabel: "Fermer",
danger: true,
className: "storage-quota-modal"
});
});
}
function writeStorageValue(key, value) {
const projectedUsage = getAppStorageUsage({ [key]: value });
if (projectedUsage.used > APP_STORAGE_LIMIT_BYTES) {
showStorageQuotaModal(projectedUsage);
return false;
}
try {
localStorage.setItem(key, value);
if (key !== STORAGE_KEYS.drawerWidth) refreshStorageQuotaIndicators();
return true;
} catch (error) {
if (error?.name === "QuotaExceededError" || error?.code === 22) {
showStorageQuotaModal(projectedUsage);
return false;
}
throw error;
}
}
function getDefaultModuleTitle(type) {
return MODULES[type]?.label || "Outil";
}
function normalizeToolboxModule(module) {
if (!module || typeof module !== "object" || !module.type) return null;
const title = String(module.title || "").trim();
const normalized = {
id: module.id || uid("mod"),
type: module.type
};
if (title && title !== getDefaultModuleTitle(module.type)) {
normalized.title = title;
}
return normalized;
}
function normalizeToolbox(toolbox) {
if (!toolbox || typeof toolbox !== "object") return null;
return {
id: toolbox.id || uid("tbx"),
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
modules: (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean),
updatedAt: toolbox.updatedAt || new Date().toISOString()
};
}
function normalizeToolboxes(toolboxes) {
return (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean);
}
function compactToolboxForStorage(toolbox) {
const normalized = normalizeToolbox(toolbox);
if (!normalized) return null;
const compact = {
id: normalized.id,
name: normalized.name,
modules: normalized.modules,
updatedAt: normalized.updatedAt
};
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
return compact;
}
function compactToolboxesForStorage(toolboxes) {
return (Array.isArray(toolboxes) ? toolboxes : []).map(compactToolboxForStorage).filter(Boolean);
}
function getToolboxes() {
return normalizeToolboxes(readJson(STORAGE_KEYS.registry, []));
}
function saveToolboxes(toolboxes) {
return writeJson(STORAGE_KEYS.registry, compactToolboxesForStorage(toolboxes));
}
function getLinks() {
return readJson(STORAGE_KEYS.links, {});
}
function saveLinks(links) {
return writeJson(STORAGE_KEYS.links, links);
}
function moduleStorageKey(toolboxId, moduleId) {
return `sokkog:toolbox:${toolboxId}:module:${moduleId}`;
}
function globalModuleKey(toolboxId, moduleId) {
return `${toolboxId}:${moduleId}`;
}
function downloadJson(payload, filename) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
function getModuleData(toolboxId, moduleId, fallback) {
const value = readJson(moduleStorageKey(toolboxId, moduleId), undefined);
return value == null ? fallback : value;
}
function getToolboxModule(toolboxId, moduleId) {
return getToolboxes()
.find((toolbox) => toolbox.id === toolboxId)
?.modules.find((module) => module.id === moduleId);
}
function compactChecklistItemForStorage(item) {
const normalized = normalizeChecklistItem(item);
const compact = {
id: normalized.id,
label: normalized.label
};
if (normalized.qtyTarget !== 1) compact.qtyTarget = normalized.qtyTarget;
if (normalized.qtyCurrent !== 0) compact.qtyCurrent = normalized.qtyCurrent;
return compact;
}
function compactModuleDataForStorage(type, value) {
if (type === "notepad") {
const text = String(value?.text || "");
return text ? { text } : null;
}
if (type === "checklist") {
const items = normalizeChecklistData(value).items.map(compactChecklistItemForStorage);
return items.length ? { items } : null;
}
if (type === "screenshots") {
const shots = (Array.isArray(value?.shots) ? value.shots : [])
.filter((shot) => shot?.dataUrl)
.map((shot) => ({
id: shot.id || uid("shot"),
dataUrl: shot.dataUrl
}));
return shots.length ? { shots } : null;
}
return value;
}
function createExportIdFactory() {
const counts = {};
return (prefix) => {
counts[prefix] = (counts[prefix] || 0) + 1;
return `${ID_PREFIXES[prefix] || "x"}${counts[prefix].toString(36)}`;
};
}
function remapModuleDataForExport(type, data, nextId) {
const compact = compactModuleDataForStorage(type, data);
if (!compact) return null;
if (type === "checklist") {
return {
items: compact.items.map((item) => ({
...item,
id: nextId("item")
}))
};
}
if (type === "screenshots") {
return {
shots: compact.shots.map((shot) => ({
...shot,
id: nextId("shot")
}))
};
}
return compact;
}
function createToolboxExportPayload(toolbox) {
const source = normalizeToolbox(toolbox);
const nextId = createExportIdFactory();
const moduleIdMap = new Map();
const exportedToolbox = compactToolboxForStorage({
...source,
id: nextId("tbx"),
modules: source.modules.map((module) => {
const id = nextId("mod");
moduleIdMap.set(module.id, id);
return { ...module, id };
})
});
const modules = Object.fromEntries(source.modules
.map((module) => [
moduleIdMap.get(module.id),
remapModuleDataForExport(module.type, getModuleData(source.id, module.id, null), nextId)
])
.filter(([, data]) => data != null));
return { toolbox: exportedToolbox, modules };
}
function createGlobalExportPayload() {
const toolboxes = getToolboxes();
const modules = {};
toolboxes.forEach((toolbox) => {
toolbox.modules.forEach((module) => {
const data = compactModuleDataForStorage(module.type, getModuleData(toolbox.id, module.id, null));
if (data) modules[globalModuleKey(toolbox.id, module.id)] = data;
});
});
return {
version: 1,
exportedAt: new Date().toISOString(),
toolboxes: compactToolboxesForStorage(toolboxes),
modules,
links: getLinks()
};
}
function setModuleData(toolboxId, moduleId, value) {
const module = getToolboxModule(toolboxId, moduleId);
const compact = compactModuleDataForStorage(module?.type, value);
const key = moduleStorageKey(toolboxId, moduleId);
if (compact == null) {
localStorage.removeItem(key);
refreshStorageQuotaIndicators();
return true;
}
return writeJson(key, compact);
}
function parsePositiveInt(value, fallback = 1) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function clampQty(value, target) {
const parsed = Number.parseInt(value, 10);
const safeValue = Number.isFinite(parsed) ? parsed : 0;
return Math.min(Math.max(safeValue, 0), Math.max(1, target));
}
function normalizeChecklistItem(item) {
const qtyTarget = Math.max(1, parsePositiveInt(item?.qtyTarget, 1));
const qtyCurrent = clampQty(item?.qtyCurrent, qtyTarget);
return {
id: item?.id || uid("item"),
label: String(item?.label || "").trim(),
qtyTarget,
qtyCurrent
};
}
function normalizeChecklistData(data) {
return {
items: (data?.items || [])
.map(normalizeChecklistItem)
.filter((item) => item.label)
};
}
function isChecklistItemDone(item) {
return clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
}
function getGames() {
return gamesState.games;
}
function getGame(gameId) {
return getGames().find((item) => item.id === gameId);
}
function getToolboxGameId(toolbox) {
const links = getLinks();
return Object.entries(links).find(([, toolboxId]) => toolboxId === toolbox?.id)?.[0] || "";
}
function getToolboxGame(toolbox) {
return getGame(getToolboxGameId(toolbox));
}
async function loadGames() {
if (gamesState.loaded || gamesState.loading) return;
gamesState.loading = true;
gamesState.error = "";
try {
const response = await fetch("/data/games.json");
if (!response.ok) throw new Error("Impossible de charger la liste des jeux.");
const payload = await response.json();
gamesState.games = Array.isArray(payload.games) ? payload.games : [];
gamesState.loaded = true;
} catch (error) {
gamesState.error = error.message;
gamesState.games = [];
gamesState.loaded = true;
} finally {
gamesState.loading = false;
}
}
async function loadSiteContent() {
if (siteState.loaded || siteState.loading) return;
siteState.loading = true;
try {
const response = await fetch("/data/site.json");
if (!response.ok) throw new Error("Impossible de charger le contenu du site.");
const payload = await response.json();
siteState.content = mergeContent(DEFAULT_SITE_CONTENT, payload);
} catch {
siteState.content = DEFAULT_SITE_CONTENT;
} finally {
siteState.loaded = true;
siteState.loading = false;
}
}
function getDrawerWidth() {
const value = Number(localStorage.getItem(STORAGE_KEYS.drawerWidth));
if (!Number.isFinite(value) || value <= 0) return "";
const maxWidth = Math.floor(window.innerWidth * 0.94);
return Math.min(Math.max(value, 360), maxWidth);
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#039;"
})[char]);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#096;");
}
function assetPath(name) {
return encodeURI(`${MHWILDS_IMG_PATH}/${name}.png`);
}
function t(key, { capitalize = false } = {}) {
const value = mhwildsState.translations[key] || key;
if (!capitalize) return value;
return value.charAt(0).toUpperCase() + value.slice(1);
}
function mergeContent(defaults, overrides) {
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) return defaults;
return Object.fromEntries(Object.entries(defaults).map(([key, value]) => {
const override = overrides[key];
if (Array.isArray(value)) return [key, Array.isArray(override) ? override : value];
if (value && typeof value === "object") return [key, mergeContent(value, override)];
return [key, override ?? value];
}));
}
function siteContent() {
return siteState.content;
}
function formatContentText(value) {
return escapeHtml(value).replace(/&lt;i&gt;(.+?)&lt;\/i&gt;/g, "<i>$1</i>").replace(/\n/g, "<br>");
}
function formatDate(value) {
return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function navigate(path) {
location.hash = path;
}
function currentRoute() {
const hashRoute = location.hash.replace(/^#/, "");
if (hashRoute) return hashRoute;
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 === "/toolboxes") return "/toolboxes";
if (path === "/games") return "/games";
if (path === "/games/mhwilds") return "/games/mhwilds";
if (path === "/games/mhwilds/monsters") return "/games/mhwilds/monsters";
if (path === "/games/mhwilds/endemic") return "/games/mhwilds/endemic";
return "/";
}
function getRouteGameId(route) {
const [, section, gameId] = route.split("/");
return section === "games" ? gameId || "" : "";
}
function renderTopbarActions(route) {
const gameId = getRouteGameId(route);
const game = getGame(gameId);
if (!game) return "";
const links = getLinks();
const linkedToolbox = getToolboxes().find((item) => item.id === links[game.id]);
const label = linkedToolbox
? `Ouvrir la toolbox ${linkedToolbox.name}`
: "Associer une toolbox";
return `
<div class="topbar-actions">
<button class="drawer-button toolbox-icon-button primary" data-action="open-drawer" data-game-id="${game.id}" aria-label="${escapeAttr(label)}" title="${escapeAttr(label)}">
<img src="/static/icons/toolbox.svg" alt="" aria-hidden="true" />
</button>
</div>
`;
}
function shell(content) {
const route = currentRoute();
const contentConfig = siteContent();
const topbarLabel = route.startsWith("/toolbox")
? contentConfig.topbar.toolbox
: route.startsWith("/games")
? contentConfig.topbar.games
: contentConfig.topbar.dashboard;
app.innerHTML = `
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href="#/" aria-label="${escapeAttr(contentConfig.brand.homeAriaLabel)}">
<span class="brand-mark" aria-hidden="true"></span>
<span>${escapeHtml(contentConfig.brand.name)}</span>
</a>
<nav class="nav" aria-label="Navigation principale">
<a class="nav-item ${route === "/" ? "active" : ""}" href="#/"><span class="nav-icon nav-icon-home" aria-hidden="true"></span><strong>${escapeHtml(contentConfig.navigation.home)}</strong></a>
<a class="nav-item ${route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""}" href="#/toolboxes"><span class="nav-icon nav-icon-toolbox" aria-hidden="true"></span><strong>${escapeHtml(contentConfig.navigation.toolboxes)}</strong></a>
<a class="nav-item ${route.startsWith("/games") ? "active" : ""}" href="#/games"><span class="nav-icon nav-icon-controller" aria-hidden="true"></span><strong>${escapeHtml(contentConfig.navigation.games)}</strong></a>
</nav>
<div class="sidebar-note">
<span class="badge">${escapeHtml(contentConfig.sidebar.badge)}</span>
<p>${escapeHtml(contentConfig.sidebar.note)}</p>
<div class="sidebar-storage-actions" aria-label="Actions globales de stockage">
<label class="sidebar-action-button" aria-label="Importer toutes les toolboxes" title="Importer tout">
<span class="ui-icon ui-icon-import" aria-hidden="true"></span>
<input type="file" accept="application/json" data-action="import-all-toolboxes" hidden />
</label>
<button class="sidebar-action-button" data-action="export-all-toolboxes" aria-label="Exporter toutes les toolboxes" title="Exporter tout">
<span class="ui-icon ui-icon-export" aria-hidden="true"></span>
</button>
</div>
</div>
</aside>
<div class="content-shell">
<header class="topbar">
<div>
<strong>${escapeHtml(topbarLabel)}</strong>
</div>
${renderTopbarActions(route)}
</header>
<main>${content}</main>
</div>
<nav class="mobile-nav" aria-label="Navigation mobile">
<a class="${route === "/" ? "active" : ""}" href="#/">${escapeHtml(contentConfig.navigation.home)}</a>
<a class="${route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""}" href="#/toolboxes">${escapeHtml(contentConfig.navigation.toolboxes)}</a>
<button class="primary" data-action="new-toolbox">+</button>
<a class="${route.startsWith("/games") ? "active" : ""}" href="#/games">${escapeHtml(contentConfig.navigation.mobileGames)}</a>
</nav>
</div>
`;
}
function createToolbox({ name }) {
const now = new Date().toISOString();
const toolbox = {
id: uid("tbx"),
name: name.trim() || "Nouvelle toolbox",
updatedAt: now,
modules: [
{ id: uid("mod"), type: "notepad", title: "Notes rapides" },
{ id: uid("mod"), type: "checklist" }
]
};
return saveToolboxes([toolbox, ...getToolboxes()]) ? toolbox : null;
}
function updateToolbox(toolbox) {
toolbox.updatedAt = new Date().toISOString();
return saveToolboxes(getToolboxes().map((item) => item.id === toolbox.id ? toolbox : item));
}
function moveToolboxModule(toolboxId, fromModuleId, toModuleId, placement = "before") {
if (!fromModuleId || !toModuleId || fromModuleId === toModuleId) return false;
const toolbox = getToolboxes().find((item) => item.id === toolboxId);
if (!toolbox) return false;
const modules = [...toolbox.modules];
const fromIndex = modules.findIndex((module) => module.id === fromModuleId);
const toIndex = modules.findIndex((module) => module.id === toModuleId);
if (fromIndex < 0 || toIndex < 0) return false;
const [moved] = modules.splice(fromIndex, 1);
const targetIndex = modules.findIndex((module) => module.id === toModuleId);
const insertIndex = placement === "after" ? targetIndex + 1 : targetIndex;
modules.splice(insertIndex, 0, moved);
toolbox.modules = modules;
updateToolbox(toolbox);
return true;
}
function deleteToolbox(id) {
const toolbox = getToolboxes().find((item) => item.id === id);
if (toolbox) {
toolbox.modules.forEach((module) => localStorage.removeItem(moduleStorageKey(id, module.id)));
}
saveToolboxes(getToolboxes().filter((item) => item.id !== id));
const links = getLinks();
Object.entries(links).forEach(([gameId, toolboxId]) => {
if (toolboxId === id) delete links[gameId];
});
saveLinks(links);
}
function linkToolboxToGame(gameId, toolboxId) {
const previousLinks = getLinks();
const previousToolboxId = previousLinks[gameId] || "";
const links = getLinks();
if (toolboxId) links[gameId] = toolboxId;
else delete links[gameId];
saveLinks(links);
const touchedToolboxIds = new Set([previousToolboxId, toolboxId].filter(Boolean));
const now = new Date().toISOString();
const nextToolboxes = getToolboxes().map((toolbox) => {
return touchedToolboxIds.has(toolbox.id) ? { ...toolbox, updatedAt: now } : toolbox;
});
saveToolboxes(nextToolboxes);
}
function renderHome() {
const toolboxes = getToolboxes();
const toolCount = Object.keys(MODULES).length;
const content = siteContent().home;
const dialogue = content.origin.lines.map((line) => `
<p class="dialogue-line dialogue-line-${line.speaker === "app" ? "app" : "user"}">${formatContentText(line.text || "")}</p>
`).join("");
shell(`
<section class="hero">
<div>
<p class="eyebrow">${escapeHtml(content.hero.eyebrow)}</p>
<h1>${escapeHtml(content.hero.title)}</h1>
<p>${escapeHtml(content.hero.description)}</p>
<div class="actions">
<a class="button primary" href="#/toolboxes">${escapeHtml(content.hero.primaryAction)}</a>
<a class="button" href="#/games">${escapeHtml(content.hero.secondaryAction)}</a>
</div>
</div>
<div class="home-stats hero-stats" aria-label="Statistiques locales du site">
<article>
<strong>${toolboxes.length}</strong>
<span>${escapeHtml(toolboxes.length > 1 ? content.stats.toolboxPlural : content.stats.toolboxSingular)}</span>
</article>
<article>
<strong>${toolCount}</strong>
<span>${escapeHtml(toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular)}</span>
</article>
</div>
</section>
<section class="origin-section" aria-labelledby="origin-title">
<div class="origin-copy">
<p class="eyebrow">${escapeHtml(content.origin.eyebrow)}</p>
<h2 id="origin-title">${escapeHtml(content.origin.title)}</h2>
<div class="home-dialogue" aria-label="${escapeAttr(content.origin.dialogueAriaLabel)}">
${dialogue}
<small class="dialogue-caption">${escapeHtml(content.origin.caption)}</small>
</div>
</div>
<div class="home-visual" aria-label="Illustration Sokko G">
<img src="/static/img/dragon.png" alt="${escapeAttr(content.origin.visualAlt)}" />
</div>
</section>
`);
}
function renderToolboxes() {
const toolboxes = getToolboxes();
const content = siteContent().toolboxes;
shell(`
<div class="toolbox-page">
<section class="page-heading">
<div>
<p class="eyebrow">${escapeHtml(content.eyebrow)}</p>
<h1>${escapeHtml(content.title)}</h1>
</div>
<div class="actions">
<button class="primary" data-action="new-toolbox">${escapeHtml(content.newButton)}</button>
<label class="import-button">
${escapeHtml(content.importOne)}
<input type="file" accept="application/json" data-action="import-toolbox" hidden />
</label>
</div>
</section>
<section class="toolbar toolbar-stack">
<div>
<label class="import-button">
${escapeHtml(content.importAll)}
<input type="file" accept="application/json" data-action="import-all-toolboxes" hidden />
</label>
<button data-action="export-all-toolboxes">${escapeHtml(content.exportAll)}</button>
</div>
</section>
<section class="cards">
${storageHelpCard(content.storageHelp)}
${toolboxes.length ? toolboxes.map(toolboxCard).join("") : `
<div class="empty">
<h2>${escapeHtml(content.emptyTitle)}</h2>
<p>${escapeHtml(content.emptyText)}</p>
</div>
`}
</section>
${renderStorageQuota()}
</div>
`);
}
function storageHelpCard(help) {
return `
<article class="card storage-help-card" aria-labelledby="storage-help-title">
<div class="card-body">
<div class="storage-help-heading">
<span class="storage-help-mark" aria-hidden="true"></span>
<div>
<p class="eyebrow">LocalStorage</p>
<h2 id="storage-help-title">${escapeHtml(help.title)}</h2>
</div>
</div>
<p class="storage-help-intro">${escapeHtml(help.text)}</p>
<ul class="storage-help-list">
${help.items.map((item, index) => `
<li>
<span>${index + 1}</span>
<p>${escapeHtml(item)}</p>
</li>
`).join("")}
</ul>
</div>
</article>
`;
}
function toolboxCard(toolbox) {
const game = getToolboxGame(toolbox);
return `
<article class="card toolbox-card">
<div class="card-cover ${game?.image ? "toolbox-card-cover" : ""}" style="--game-cover:${escapeAttr(game?.cover || "var(--gradient-nebula)")}; background:${game?.image ? "" : "var(--gradient-nebula)"}">
${game?.image ? `<img src="${escapeAttr(game.image)}" alt="${escapeAttr(game.title)}" loading="lazy" />` : ""}
</div>
<div class="card-body">
<p class="eyebrow">${game ? escapeHtml(game.title) : "Toolbox libre"}</p>
<h2>${escapeHtml(toolbox.name)}</h2>
<small>Modifiée le ${formatDate(toolbox.updatedAt)}</small>
<div class="card-actions">
<a class="button card-icon-button" href="#/toolbox/${toolbox.id}" aria-label="Ouvrir ${escapeAttr(toolbox.name)}" title="Ouvrir">
<span class="ui-icon ui-icon-open" aria-hidden="true"></span>
</a>
<button class="card-icon-button" data-action="export-toolbox" data-id="${toolbox.id}" aria-label="Exporter ${escapeAttr(toolbox.name)}" title="Exporter">
<span class="ui-icon ui-icon-export" aria-hidden="true"></span>
</button>
<button class="card-icon-button danger" data-action="delete-toolbox" data-id="${toolbox.id}" aria-label="Supprimer ${escapeAttr(toolbox.name)}" title="Supprimer">
<span class="ui-icon ui-icon-trash" aria-hidden="true"></span>
</button>
</div>
</div>
</article>
`;
}
function renderToolbox(id, { embedded = false } = {}) {
const toolbox = getToolboxes().find((item) => item.id === id);
if (!toolbox) {
if (embedded) return `<div class="empty"><p>Toolbox introuvable.</p></div>`;
shell(`<div class="empty"><h1>Toolbox introuvable</h1><a class="button" href="#/toolboxes">Retour</a></div>`);
return;
}
const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const content = `
<div class="${embedded ? "toolbox-embedded" : "toolbox-page"}">
<section class="toolbox-head">
<div>
<p class="eyebrow">Toolbox</p>
${embedded ? `<h1>${escapeHtml(toolbox.name)}</h1>` : `
<h1
class="toolbox-title"
contenteditable="true"
spellcheck="false"
data-action="edit-toolbox-title"
data-id="${toolbox.id}"
title="Cliquer pour renommer"
>${escapeHtml(toolbox.name)}</h1>
`}
</div>
<div class="actions">
<select data-action="add-module" data-id="${toolbox.id}" aria-label="Ajouter un outil">
<option value="">Ajouter un outil</option>
${Object.entries(MODULES).map(([type, module]) => `<option value="${type}">${module.label}</option>`).join("")}
</select>
</div>
</section>
${embedded ? "" : `
<section class="modules-toolbar" aria-label="Options d'affichage des outils">
${renderModuleLayoutSwitch(toolbox, moduleColumns)}
</section>
`}
<section class="modules" data-toolbox-id="${toolbox.id}" data-layout-cols="${moduleColumns}">
${renderToolboxModules(toolbox, moduleColumns)}
</section>
${embedded ? "" : renderStorageQuota()}
</div>
`;
if (embedded) return content;
shell(content);
}
function renderModuleLayoutSwitch(toolbox, moduleColumns) {
return `
<div class="layout-switch" role="group" aria-label="Mode d'affichage des outils">
<button class="${moduleColumns === 1 ? "active" : ""}" data-action="set-module-layout" data-id="${toolbox.id}" data-columns="1" aria-pressed="${moduleColumns === 1 ? "true" : "false"}" aria-label="Afficher en lignes" title="Afficher en lignes">
<span class="ui-icon ui-icon-rows" aria-hidden="true"></span>
</button>
<button class="${moduleColumns === 2 ? "active" : ""}" data-action="set-module-layout" data-id="${toolbox.id}" data-columns="2" aria-pressed="${moduleColumns === 2 ? "true" : "false"}" aria-label="Afficher en colonnes" title="Afficher en colonnes">
<span class="ui-icon ui-icon-columns" aria-hidden="true"></span>
</button>
</div>
`;
}
function renderToolboxModules(toolbox, moduleColumns) {
if (moduleColumns === 1) {
return toolbox.modules.map((module) => renderModule(toolbox, module)).join("");
}
const columns = [[], []];
toolbox.modules.forEach((module, index) => {
columns[index % 2].push(module);
});
return columns.map((modules) => `
<div class="module-column">
${modules.map((module) => renderModule(toolbox, module)).join("")}
</div>
`).join("");
}
function renderModule(toolbox, module) {
const label = MODULES[module.type]?.label || module.type;
return `
<article class="module" data-toolbox-id="${toolbox.id}" data-module-id="${module.id}" draggable="true">
<header>
<div>
<span class="module-drag-handle" aria-hidden="true" title="Déplacer l'outil"></span>
<span class="module-icon" aria-hidden="true">
<span class="module-icon-svg module-icon-${escapeAttr(MODULES[module.type]?.icon || "notepad")}"></span>
</span>
<h2
class="module-title"
contenteditable="true"
spellcheck="false"
data-action="edit-module-title"
data-toolbox-id="${toolbox.id}"
data-module-id="${module.id}"
title="Cliquer pour renommer"
>${escapeHtml(module.title || label)}</h2>
</div>
<div>
<button
class="module-delete-button danger"
data-action="delete-module"
data-toolbox-id="${toolbox.id}"
data-module-id="${module.id}"
aria-label="Retirer ${escapeAttr(module.title || label)}"
title="Retirer"
>
<span class="ui-icon ui-icon-trash" aria-hidden="true"></span>
</button>
</div>
</header>
${module.type === "notepad" ? renderNotepad(toolbox.id, module.id) : ""}
${module.type === "checklist" ? renderChecklist(toolbox.id, module.id) : ""}
${module.type === "screenshots" ? renderScreenshots(toolbox.id, module.id) : ""}
</article>
`;
}
function renderNotepad(toolboxId, moduleId) {
const data = getModuleData(toolboxId, moduleId, { text: "" });
return `<textarea class="notepad" data-action="save-note" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}" placeholder="Notes rapides...">${escapeHtml(data.text || "")}</textarea>`;
}
function renderChecklist(toolboxId, moduleId) {
const data = normalizeChecklistData(getModuleData(toolboxId, moduleId, { items: [] }));
return `
<form class="inline-form checklist-add-form" data-action="add-check-item" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}">
<input name="label" placeholder="Nouvel item" />
<input class="checklist-qty-input" name="qty" type="number" min="1" value="1" aria-label="Quantité cible" />
<button class="primary">Ajouter</button>
</form>
<ul class="checklist">
${data.items.map((item) => renderChecklistItem(toolboxId, moduleId, item)).join("")}
</ul>
`;
}
function renderChecklistItem(toolboxId, moduleId, item) {
const done = isChecklistItemDone(item);
return `
<li class="checklist-item ${done ? "is-complete" : ""}">
<div class="checklist-item-main">
${item.qtyTarget === 1 ? `
<input type="checkbox" ${done ? "checked" : ""} data-action="toggle-check-item" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}" data-item-id="${item.id}" aria-label="Terminer ${escapeAttr(item.label)}" />
` : `
<div class="checklist-qty-controls" aria-label="Quantité ${escapeAttr(item.label)}">
<button type="button" data-action="adjust-check-qty" data-delta="-1" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}" data-item-id="${item.id}" aria-label="Retirer une quantité">-</button>
<small>${clampQty(item.qtyCurrent, item.qtyTarget)}/${item.qtyTarget}</small>
<button type="button" data-action="adjust-check-qty" data-delta="1" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}" data-item-id="${item.id}" aria-label="Ajouter une quantité">+</button>
</div>
`}
<span>${escapeHtml(item.label)}</span>
</div>
<button data-action="delete-check-item" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}" data-item-id="${item.id}" aria-label="Supprimer ${escapeAttr(item.label)}">×</button>
</li>
`;
}
function renderScreenshots(toolboxId, moduleId) {
const data = getModuleData(toolboxId, moduleId, { shots: [] });
return `
<label class="dropzone" data-action="drop-screenshot" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}">
<input type="file" accept="image/*" multiple data-action="add-screenshot" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}" hidden />
Ajouter des screenshots
</label>
<div
class="paste-target"
contenteditable="true"
role="textbox"
data-action="paste-screenshot"
data-toolbox-id="${toolboxId}"
data-module-id="${moduleId}"
aria-label="Coller une image depuis le presse-papiers"
>Coller une image ici</div>
<div class="shots">
${data.shots.map((shot) => `
<figure>
<button class="shot-preview" data-action="view-screenshot" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}" data-shot-id="${shot.id}" aria-label="Agrandir le screenshot">
<img src="${shot.dataUrl}" alt="Screenshot" />
</button>
<button class="shot-delete-button danger" data-action="delete-screenshot" data-toolbox-id="${toolboxId}" data-module-id="${moduleId}" data-shot-id="${shot.id}" aria-label="Supprimer le screenshot" title="Supprimer">
<span class="ui-icon ui-icon-trash" aria-hidden="true"></span>
</button>
</figure>
`).join("")}
</div>
`;
}
function renderGames() {
const games = getGames();
shell(`
<section class="page-heading">
<div>
<p class="eyebrow">Pages informatives</p>
<h1>Jeux disponibles</h1>
<p>Choisissez un jeu pour consulter ses données maintenues et associer une toolbox locale.</p>
</div>
</section>
<section class="game-list">
${games.length ? games.map((game) => `
<article class="card game-card">
<div class="card-cover game-card-cover" style="--game-cover:${escapeAttr(game.cover || "var(--gradient-nebula)")}">
<img src="${escapeAttr(game.image || "")}" alt="${escapeAttr(game.title)}" loading="lazy" />
</div>
<div class="card-body">
<p class="eyebrow">${escapeHtml(game.eyebrow || "Guide de jeu")}</p>
<h2>${escapeHtml(game.title)}</h2>
<p>${escapeHtml(game.summary)}</p>
<div class="card-actions">
<a class="button primary" href="#/games/${game.id}">Ouvrir</a>
</div>
</div>
</article>
`).join("") : `
<div class="empty">
<h2>Aucun jeu disponible</h2>
<p>${escapeHtml(gamesState.error || "Ajoutez des entrées dans /data/games.json.")}</p>
</div>
`}
</section>
`);
}
function renderGame(gameId) {
const game = getGame(gameId);
if (!game) {
renderGames();
return;
}
if (game.id === "mhwilds") {
renderMhwilds(currentRoute().split("/")[3] || "");
return;
}
shell(`
<section class="game-hero" style="background:${game.cover}">
<div>
<p class="eyebrow">${escapeHtml(game.eyebrow || "Guide de jeu")}</p>
<h1>${escapeHtml(game.title)}</h1>
<p>${escapeHtml(game.summary)}</p>
</div>
</section>
<section class="info-grid">
${game.sections.map((section) => `
<article class="info-panel">
<h2>${escapeHtml(section.title)}</h2>
<ul>${section.items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>
</article>
`).join("")}
</section>
<aside class="drawer" id="toolbox-drawer" aria-hidden="true"></aside>
`);
}
async function loadMhwildsData() {
if (mhwildsState.loaded || mhwildsState.loading) return;
mhwildsState.loading = true;
mhwildsState.error = "";
try {
const [monstersResponse, endemicResponse, translationsResponse] = await Promise.all([
fetch("/data/mhwilds/monsters.json"),
fetch("/data/mhwilds/endemic_life.json"),
fetch("/data/mhwilds/i18n/fr.json")
]);
if (!monstersResponse.ok || !endemicResponse.ok || !translationsResponse.ok) {
throw new Error("Impossible de charger les données Monster Hunter Wilds.");
}
const [monstersJson, endemicJson, translations] = await Promise.all([
monstersResponse.json(),
endemicResponse.json(),
translationsResponse.json()
]);
mhwildsState.monsters = monstersJson.monsters || [];
mhwildsState.endemic = [
...(endemicJson.endemicLife || []),
...(endemicJson.aquaticLife || [])
];
mhwildsState.translations = translations;
mhwildsState.loaded = true;
} catch (error) {
mhwildsState.error = error.message;
} finally {
mhwildsState.loading = false;
}
}
function renderMhwilds(category) {
const activeCategory = category === "monsters" || category === "endemic" ? category : "";
if (!mhwildsState.loaded) {
shell(`
<section class="game-hero mhwilds-hero">
<div>
<p class="eyebrow">Monster Hunter Wilds</p>
<h1>Monster Hunter: Wilds</h1>
<p>Chargement des données de chasse, faune endémique et filtres associés.</p>
</div>
</section>
<section class="empty"><h2>Chargement</h2><p>Préparation des données locales...</p></section>
`);
loadMhwildsData().then(rerender);
return;
}
if (mhwildsState.error) {
shell(`
<section class="empty">
<h1>Impossible de charger MH Wilds</h1>
<p>${escapeHtml(mhwildsState.error)}</p>
<button class="primary" data-action="retry-mhwilds">Réessayer</button>
</section>
`);
return;
}
if (!activeCategory) {
renderMhwildsOverview();
return;
}
renderMhwildsListing(activeCategory);
}
function renderMhwildsOverview() {
shell(`
<section class="game-hero mhwilds-hero">
<div>
<p class="eyebrow">Guide de jeu</p>
<h1>Monster Hunter: Wilds</h1>
<p>Consultez rapidement les monstres, faiblesses, afflictions, hitzones et emplacements de faune endémique.</p>
</div>
</section>
<section class="mhwilds-home-grid">
<a class="feature mhwilds-home-card" href="#/games/mhwilds/monsters">
<img src="${assetPath("arkveld")}" alt="" />
<strong>Monstres</strong>
<span>Recherche, filtres par faiblesse et tableau de dégâts par partie.</span>
</a>
<a class="feature mhwilds-home-card" href="#/games/mhwilds/endemic">
<img src="${assetPath("vigorwasp")}" alt="" />
<strong>Faune endémique</strong>
<span>Faune endémique et aquatique filtrable par localisation.</span>
</a>
</section>
<aside class="drawer" id="toolbox-drawer" aria-hidden="true"></aside>
`);
}
function renderMhwildsListing(category) {
const isMonsters = category === "monsters";
const label = isMonsters ? t("monsters", { capitalize: true }) : t("endemic life", { capitalize: true });
const filterKey = isMonsters ? "weaknesses" : "locations";
const options = getUniqueConditionValues(isMonsters ? mhwildsState.monsters : mhwildsState.endemic, filterKey);
const total = isMonsters ? mhwildsState.monsters.length : mhwildsState.endemic.length;
const visible = getFilteredMhwildsItems(category).length;
shell(`
<section class="page-heading mhwilds-heading">
<div>
<p class="eyebrow">Monster Hunter Wilds</p>
<div class="mhwilds-title-row">
<h1>${escapeHtml(label)}</h1>
<span class="results-count" data-mhwilds-count>${visible} / ${total}</span>
</div>
<p>${isMonsters ? "Filtrez les monstres par nom et faiblesses, puis consultez leurs dégâts détaillés." : "Filtrez la faune par nom et zones d'apparition."}</p>
</div>
<div class="actions">
<a class="button ${isMonsters ? "primary" : ""}" href="#/games/mhwilds/monsters">Monstres</a>
<a class="button ${!isMonsters ? "primary" : ""}" href="#/games/mhwilds/endemic">Faune</a>
</div>
</section>
<section class="mhwilds-layout" data-mhwilds-category="${category}">
<aside class="mhwilds-filters">
${renderMhwildsFilters(category, filterKey, options)}
</aside>
<section class="mhwilds-results" aria-live="polite">
${renderMhwildsResults(category)}
</section>
</section>
<aside class="drawer" id="toolbox-drawer" aria-hidden="true"></aside>
`);
}
function renderMhwildsFilters(category, filterKey, options) {
const filters = mhwildsState.filters[category];
const selected = new Set(filters[filterKey]);
const logicLabel = filters.logic === "or" ? t("or", { capitalize: true }) : t("and", { capitalize: true });
return `
<div class="filter-panel">
<div class="filter-panel-head">
<div>
<p class="eyebrow">Filtres</p>
<h2>${category === "monsters" ? t("weaknesses", { capitalize: true }) : t("locations", { capitalize: true })}</h2>
</div>
<button class="filter-reset-button" data-action="reset-mhwilds-filters" data-category="${category}" aria-label="${escapeAttr(t("reset", { capitalize: true }))}" title="${escapeAttr(t("reset", { capitalize: true }))}">
<span class="ui-icon ui-icon-rubber" aria-hidden="true"></span>
</button>
</div>
<label class="field compact">
<span>${t("name", { capitalize: true })}</span>
<input value="${escapeAttr(filters.name)}" placeholder="Rechercher..." data-action="set-mhwilds-name" data-category="${category}" />
</label>
<div class="filter-logic">
<span>Correspondance</span>
<button data-action="toggle-mhwilds-logic" data-category="${category}" aria-label="Changer la logique de filtre">${logicLabel}</button>
</div>
<div class="filter-options legacy-scrollbar">
${options.map((option) => `
<label class="filter-chip ${selected.has(option) ? "active" : ""}">
<input type="checkbox" ${selected.has(option) ? "checked" : ""} data-action="toggle-mhwilds-option" data-category="${category}" data-filter-key="${filterKey}" data-value="${escapeAttr(option)}" />
<img src="${assetPath(option)}" alt="" />
<span>${escapeHtml(t(option, { capitalize: true }))}</span>
</label>
`).join("")}
</div>
</div>
`;
}
function renderMhwildsResults(category) {
const items = getFilteredMhwildsItems(category);
return `
<div class="mhwilds-grid ${category === "monsters" ? "monster-grid" : "endemic-grid"}">
${items.length ? items.map((item) => category === "monsters" ? renderMonsterCard(item) : renderEndemicCard(item)).join("") : `
<div class="empty">
<h2>Aucun résultat</h2>
<p>Ajustez la recherche ou réinitialisez les filtres actifs.</p>
</div>
`}
</div>
`;
}
function refreshMhwildsResults(category) {
const results = document.querySelector(".mhwilds-results");
if (results) results.innerHTML = renderMhwildsResults(category);
const count = document.querySelector("[data-mhwilds-count]");
if (count) {
const total = category === "monsters" ? mhwildsState.monsters.length : mhwildsState.endemic.length;
count.textContent = `${getFilteredMhwildsItems(category).length} / ${total}`;
}
}
function renderMonsterCard(monster) {
const weaknesses = getConditionValues(monster.weaknesses);
const ailments = getConditionValues(monster.ailments).filter((value) => value !== "none");
return `
<article class="mhwilds-card monster-card" data-action="flip-monster-card" tabindex="0" role="button" aria-pressed="false" aria-label="Afficher les dégâts de ${escapeAttr(t(monster.name, { capitalize: true }))}">
<div class="mhwilds-card-inner">
<div class="mhwilds-card-face mhwilds-card-front">
<div class="mhwilds-card-art">
<img src="${assetPath(monster.name)}" alt="${escapeAttr(t(monster.name, { capitalize: true }))}" loading="lazy" />
</div>
<div class="mhwilds-card-body">
<p class="eyebrow">${escapeHtml(t(monster.type, { capitalize: true }))}</p>
<h2>${escapeHtml(t(monster.name, { capitalize: true }))}</h2>
${renderIconRow(t("weaknesses", { capitalize: true }), weaknesses)}
${renderIconRow(t("ailments", { capitalize: true }), ailments.length ? ailments : ["none"])}
</div>
</div>
<div class="mhwilds-card-face mhwilds-card-back">
<div class="mhwilds-card-body">
<p class="eyebrow">Détails</p>
<h2>${escapeHtml(t(monster.name, { capitalize: true }))}</h2>
</div>
${renderDamageTable(monster.damage || [])}
</div>
</div>
</article>
`;
}
function renderEndemicCard(item) {
const locations = getConditionValues(item.locations);
const description = t(item.description);
const hasDescription = normalizeText(description) && normalizeText(description) !== normalizeText("à compléter");
return `
<article class="mhwilds-card endemic-card">
<div class="mhwilds-card-art">
<img src="${assetPath(item.name)}" alt="${escapeAttr(t(item.name, { capitalize: true }))}" loading="lazy" />
</div>
<div class="mhwilds-card-body">
<h2>${escapeHtml(t(item.name, { capitalize: true }))}</h2>
${renderIconRow(t("locations", { capitalize: true }), locations)}
${hasDescription ? `<p>${escapeHtml(description)}</p>` : ""}
</div>
</article>
`;
}
function renderIconRow(label, values) {
return `
<div class="mhwilds-icon-row">
<span>${escapeHtml(label)}</span>
<div>
${values.map((value) => value === "none" ? `<em>-</em>` : `
<img src="${assetPath(value)}" alt="${escapeAttr(t(value, { capitalize: true }))}" title="${escapeAttr(t(value, { capitalize: true }))}" loading="lazy" />
`).join("")}
</div>
</div>
`;
}
function renderDamageTable(rows) {
if (!rows.length) return "";
const columns = Object.keys(rows[0]);
return `
<div class="damage-table-wrap legacy-scrollbar">
<table class="damage-table">
<thead>
<tr>
${columns.map((column) => `
<th>${column === "name" ? "" : `<img src="${assetPath(column)}" alt="${escapeAttr(t(column, { capitalize: true }))}" title="${escapeAttr(t(column, { capitalize: true }))}" />`}</th>
`).join("")}
</tr>
</thead>
<tbody>
${rows.map((row) => `
<tr>
${columns.map((column) => column === "name" ? `
<td title="${escapeAttr(t(row[column], { capitalize: true }))}">${escapeHtml(t(row[column], { capitalize: true }))}</td>
` : `
<td><img src="${assetPath(`${row[column]}-stars`)}" alt="${row[column]} étoiles" /></td>
`).join("")}
</tr>
`).join("")}
</tbody>
</table>
</div>
`;
}
function getFilteredMhwildsItems(category) {
const items = category === "monsters" ? mhwildsState.monsters : mhwildsState.endemic;
const filters = mhwildsState.filters[category];
const filterKey = category === "monsters" ? "weaknesses" : "locations";
const selected = filters[filterKey];
const search = normalizeText(filters.name);
return items.filter((item) => {
const nameMatches = !search || normalizeText(t(item.name)).includes(search) || normalizeText(item.name).includes(search);
if (!nameMatches) return false;
if (!selected.length) return true;
const values = getConditionValues(item[filterKey]);
if (filters.logic === "or" && selected.length >= 2) {
return selected.some((value) => values.includes(value));
}
return selected.every((value) => values.includes(value));
});
}
function getConditionValues(conditions = []) {
return [...new Set(conditions.flatMap((condition) => condition.values || []))];
}
function getUniqueConditionValues(items, property) {
return [...new Set(items.flatMap((item) => getConditionValues(item[property])))]
.filter((value) => value !== "none")
.sort((a, b) => t(a).localeCompare(t(b), "fr"));
}
function normalizeText(value) {
return String(value || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\s+/g, " ");
}
function openDrawer(gameId) {
const drawer = document.querySelector("#toolbox-drawer");
const toolboxes = getToolboxes();
const links = getLinks();
const selectedId = links[gameId] || "";
const drawerWidth = getDrawerWidth();
drawer.innerHTML = `
<div class="drawer-backdrop" data-action="close-drawer"></div>
<section class="drawer-panel legacy-scrollbar" ${drawerWidth ? `style="width:${drawerWidth}px"` : ""}>
<div class="drawer-content">
<span class="drawer-resize-handle" aria-hidden="true"></span>
<header>
<div>
<p class="eyebrow">Toolbox liée</p>
<div class="drawer-toolbox-actions" aria-label="Actions toolbox liée">
<label class="drawer-action-button" aria-label="Importer une toolbox" title="Importer">
<span class="ui-icon ui-icon-import" aria-hidden="true"></span>
<input type="file" accept="application/json" data-action="import-toolbox" data-game-id="${gameId}" hidden />
</label>
<button class="drawer-action-button" data-action="open-link-toolbox-modal" data-game-id="${gameId}" aria-label="Lier une toolbox" title="Lier">
<span class="ui-icon ui-icon-link" aria-hidden="true"></span>
</button>
<button class="drawer-action-button" data-action="export-toolbox" data-id="${selectedId}" ${selectedId ? "" : "disabled"} aria-label="Exporter la toolbox liée" title="Exporter">
<span class="ui-icon ui-icon-export" aria-hidden="true"></span>
</button>
</div>
</div>
<button class="drawer-close-button" data-action="close-drawer" aria-label="Fermer" title="Fermer">
<span class="ui-icon ui-icon-close" aria-hidden="true"></span>
</button>
</header>
${selectedId ? renderToolbox(selectedId, { embedded: true }) : `
<div class="empty">
<p>Aucune toolbox associée à cette page jeu.</p>
<button class="primary" data-action="new-toolbox-for-game" data-game-id="${gameId}">Créer et associer</button>
</div>
`}
${renderStorageQuota()}
</div>
</section>
`;
drawer.dataset.gameId = gameId;
drawer.setAttribute("aria-hidden", "false");
}
function closeDrawer() {
const drawer = document.querySelector("#toolbox-drawer");
if (drawer) {
drawer.setAttribute("aria-hidden", "true");
drawer.innerHTML = "";
delete drawer.dataset.gameId;
}
}
function refreshToolboxView(sourceElement = null) {
const drawer = sourceElement?.closest?.("#toolbox-drawer");
if (drawer?.dataset.gameId) {
openDrawer(drawer.dataset.gameId);
return;
}
rerender();
}
function showToolboxCreateModal({ gameId = "" } = {}) {
return new Promise((resolve) => {
const host = document.createElement("div");
host.className = "confirm-modal-root";
host.innerHTML = `
<div class="confirm-backdrop" data-toolbox-create-result="cancel"></div>
<section class="confirm-modal toolbox-create-modal" role="dialog" aria-modal="true" aria-labelledby="toolbox-create-title">
<header>
<h2 id="toolbox-create-title">${gameId ? "Créer et associer une toolbox" : "Nouvelle toolbox"}</h2>
</header>
<form data-action="toolbox-create-form">
<label class="field">
<span>Nom</span>
<input name="name" autocomplete="off" placeholder="Nom de la toolbox" required />
</label>
<footer>
<button type="button" data-toolbox-create-result="cancel">Annuler</button>
<button class="primary" type="submit">Créer</button>
</footer>
</form>
</section>
`;
const previousFocus = document.activeElement;
document.body.appendChild(host);
const input = host.querySelector("input[name='name']");
function close(result) {
host.remove();
document.removeEventListener("keydown", onKeydown);
previousFocus?.focus?.();
resolve(result);
}
function onKeydown(event) {
if (event.key === "Escape") close(null);
}
host.addEventListener("click", (event) => {
if (event.target.closest("[data-toolbox-create-result='cancel']")) close(null);
});
host.querySelector("form").addEventListener("submit", (event) => {
event.preventDefault();
const name = new FormData(event.currentTarget).get("name")?.trim();
if (!name) return;
const toolbox = createToolbox({ name });
if (!toolbox) return;
if (gameId) {
const links = getLinks();
links[gameId] = toolbox.id;
saveLinks(links);
}
close(toolbox);
});
document.addEventListener("keydown", onKeydown);
input.focus();
});
}
function showToolboxLinkModal(gameId) {
return new Promise((resolve) => {
const toolboxes = getToolboxes();
const selectedId = getLinks()[gameId] || "";
const host = document.createElement("div");
host.className = "confirm-modal-root";
host.innerHTML = `
<div class="confirm-backdrop" data-toolbox-link-result="cancel"></div>
<section class="confirm-modal toolbox-link-modal" role="dialog" aria-modal="true" aria-labelledby="toolbox-link-title">
<header>
<h2 id="toolbox-link-title">Lier une toolbox</h2>
</header>
<form data-action="toolbox-link-form" data-game-id="${gameId}">
<label class="field">
<span>Toolbox associée</span>
<select name="toolboxId">
<option value="">Aucune</option>
${toolboxes.map((toolbox) => `<option value="${toolbox.id}" ${toolbox.id === selectedId ? "selected" : ""}>${escapeHtml(toolbox.name)}</option>`).join("")}
</select>
</label>
<footer>
<button type="button" data-toolbox-link-result="cancel">Annuler</button>
<button class="primary" type="submit">Valider</button>
</footer>
</form>
</section>
`;
const previousFocus = document.activeElement;
document.body.appendChild(host);
const select = host.querySelector("select");
function close(result) {
host.remove();
document.removeEventListener("keydown", onKeydown);
previousFocus?.focus?.();
resolve(result);
}
function onKeydown(event) {
if (event.key === "Escape") close(null);
}
host.addEventListener("click", (event) => {
if (event.target.closest("[data-toolbox-link-result='cancel']")) close(null);
});
host.querySelector("form").addEventListener("submit", (event) => {
event.preventDefault();
close(new FormData(event.currentTarget).get("toolboxId") || "");
});
document.addEventListener("keydown", onKeydown);
select.focus();
});
}
async function compressImage(file) {
const bitmap = await createImageBitmap(file);
const maxSide = 1400;
const ratio = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.round(bitmap.width * ratio);
canvas.height = Math.round(bitmap.height * ratio);
canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/jpeg", 0.78);
}
async function addScreenshotFiles(toolboxId, moduleId, files) {
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
if (!imageFiles.length) return false;
const data = getModuleData(toolboxId, moduleId, { shots: [] });
for (const file of imageFiles) {
data.shots.unshift({
id: uid("shot"),
dataUrl: await compressImage(file)
});
}
return setModuleData(toolboxId, moduleId, data);
}
function exportToolbox(id) {
const toolbox = normalizeToolbox(getToolboxes().find((item) => item.id === id));
if (!toolbox) return;
const payload = createToolboxExportPayload(toolbox);
downloadJson(payload, `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
}
function exportAllToolboxes() {
downloadJson(createGlobalExportPayload(), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`);
}
async function importToolbox(file) {
const payload = JSON.parse(await file.text());
if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide");
const imported = normalizeToolbox({ ...payload.toolbox, id: uid("tbx"), updatedAt: new Date().toISOString() });
const moduleIdMap = new Map();
imported.modules = imported.modules.map((module) => {
const nextId = uid("mod");
moduleIdMap.set(module.id, nextId);
return { ...module, id: nextId };
});
saveToolboxes([imported, ...getToolboxes()]);
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
const nextId = moduleIdMap.get(oldId);
if (nextId) setModuleData(imported.id, nextId, data);
});
return imported;
}
async function importAllToolboxes(file) {
const payload = JSON.parse(await file.text());
if (!Array.isArray(payload.toolboxes) || !payload.modules || typeof payload.modules !== "object") {
throw new Error("Format d'import global invalide");
}
const toolboxIdMap = new Map();
const moduleIdMap = new Map();
const projected = {};
const importedToolboxes = payload.toolboxes.map((toolbox) => {
const nextToolboxId = uid("tbx");
toolboxIdMap.set(toolbox.id, nextToolboxId);
const modules = (toolbox.modules || []).map((module) => {
const nextModuleId = uid("mod");
moduleIdMap.set(globalModuleKey(toolbox.id, module.id), nextModuleId);
return { ...module, id: nextModuleId };
});
return normalizeToolbox({
...toolbox,
id: nextToolboxId,
name: `${toolbox.name || "Toolbox"} (import)`,
modules,
updatedAt: new Date().toISOString()
});
});
const nextToolboxes = [...importedToolboxes, ...getToolboxes()];
const importedToolboxMap = new Map(importedToolboxes.map((toolbox) => [toolbox.id, toolbox]));
projected[STORAGE_KEYS.registry] = JSON.stringify(compactToolboxesForStorage(nextToolboxes));
Object.entries(payload.modules).forEach(([key, data]) => {
const [oldToolboxId] = key.split(":");
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
const nextModuleId = moduleIdMap.get(key);
const toolbox = importedToolboxMap.get(nextToolboxId);
const module = toolbox?.modules.find((item) => item.id === nextModuleId);
const compact = compactModuleDataForStorage(module?.type, data);
if (nextToolboxId && nextModuleId && compact) {
projected[moduleStorageKey(nextToolboxId, nextModuleId)] = JSON.stringify(compact);
}
});
const links = getLinks();
Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => {
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
if (nextToolboxId) links[gameId] = nextToolboxId;
});
projected[STORAGE_KEYS.links] = JSON.stringify(links);
const projectedUsage = getAppStorageUsage(projected);
if (projectedUsage.used > APP_STORAGE_LIMIT_BYTES) {
showStorageQuotaModal(projectedUsage);
return false;
}
Object.entries(projected).forEach(([key, value]) => writeStorageValue(key, value));
return true;
}
function showConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel = "Annuler", danger = false, className = "" }) {
return new Promise((resolve) => {
const host = document.createElement("div");
host.className = ["confirm-modal-root", className].filter(Boolean).join(" ");
host.innerHTML = `
<div class="confirm-backdrop" data-confirm-result="cancel"></div>
<section class="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message">
<header>
<h2 id="confirm-title">${escapeHtml(title)}</h2>
</header>
<p id="confirm-message">${escapeHtml(message)}</p>
<footer>
<button data-confirm-result="cancel">${escapeHtml(cancelLabel)}</button>
<button class="${danger ? "danger confirm-danger" : "primary"}" data-confirm-result="confirm">${escapeHtml(confirmLabel)}</button>
</footer>
</section>
`;
const previousFocus = document.activeElement;
document.body.appendChild(host);
const cancelButton = host.querySelector('[data-confirm-result="cancel"]');
const confirmButton = host.querySelector('[data-confirm-result="confirm"]');
function close(result) {
host.remove();
document.removeEventListener("keydown", onKeydown);
previousFocus?.focus?.();
resolve(result);
}
function onKeydown(event) {
if (event.key === "Escape") close(false);
if (event.key !== "Tab") return;
const focusable = [cancelButton, confirmButton];
const currentIndex = focusable.indexOf(document.activeElement);
const nextIndex = event.shiftKey
? (currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1)
: (currentIndex === focusable.length - 1 ? 0 : currentIndex + 1);
event.preventDefault();
focusable[nextIndex].focus();
}
host.addEventListener("click", (event) => {
const result = event.target.closest("[data-confirm-result]")?.dataset.confirmResult;
if (!result) return;
close(result === "confirm");
});
document.addEventListener("keydown", onKeydown);
confirmButton.focus();
});
}
function showScreenshotViewer(shot) {
const host = document.createElement("div");
host.className = "screenshot-viewer-root";
host.innerHTML = `
<div class="screenshot-viewer-backdrop" data-action="close-screenshot-viewer"></div>
<section class="screenshot-viewer" role="dialog" aria-modal="true" aria-label="Screenshot">
<header>
<button data-action="close-screenshot-viewer" aria-label="Fermer">Fermer</button>
</header>
<img src="${shot.dataUrl}" alt="Screenshot" />
</section>
`;
document.body.appendChild(host);
function close() {
host.remove();
document.removeEventListener("keydown", onKeydown);
}
function onKeydown(event) {
if (event.key === "Escape") close();
}
host.addEventListener("click", (event) => {
if (event.target.closest("[data-action='close-screenshot-viewer']")) close();
});
document.addEventListener("keydown", onKeydown);
}
function rerender() {
if (!siteState.loaded) {
shell(`
<section class="empty">
<h1>Chargement</h1>
<p>Préparation du contenu...</p>
</section>
`);
loadSiteContent().then(rerender);
return;
}
if (!gamesState.loaded) {
shell(`
<section class="empty">
<h1>Chargement</h1>
<p>Préparation des jeux disponibles...</p>
</section>
`);
loadGames().then(rerender);
return;
}
const route = currentRoute();
if (route === "/") renderHome();
else if (route === "/toolboxes") renderToolboxes();
else if (route.startsWith("/toolbox/")) renderToolbox(route.split("/")[2]);
else if (route === "/games") renderGames();
else if (route.startsWith("/games/")) renderGame(route.split("/")[2]);
else navigate("/");
}
document.addEventListener("click", async (event) => {
const target = event.target.closest("[data-action]");
if (!target) return;
const action = target.dataset.action;
if (action === "flip-monster-card") {
target.classList.toggle("is-flipped");
target.setAttribute("aria-pressed", target.classList.contains("is-flipped") ? "true" : "false");
return;
}
if (action === "new-toolbox") {
const toolbox = await showToolboxCreateModal();
if (toolbox) navigate(`/toolbox/${toolbox.id}`);
}
if (action === "delete-toolbox") {
const toolbox = getToolboxes().find((item) => item.id === target.dataset.id);
const confirmed = await showConfirmModal({
title: "Supprimer la toolbox",
message: `Supprimer "${toolbox?.name || "cette toolbox"}" et ses données locales ?`,
confirmLabel: "Supprimer",
danger: true
});
if (confirmed) {
deleteToolbox(target.dataset.id);
rerender();
}
}
if (action === "export-toolbox") exportToolbox(target.dataset.id);
if (action === "export-all-toolboxes") exportAllToolboxes();
if (action === "open-link-toolbox-modal") {
const toolboxId = await showToolboxLinkModal(target.dataset.gameId);
if (toolboxId == null) return;
linkToolboxToGame(target.dataset.gameId, toolboxId);
openDrawer(target.dataset.gameId);
}
if (action === "set-module-layout") {
const toolbox = getToolboxes().find((item) => item.id === target.dataset.id);
if (toolbox) {
toolbox.moduleColumns = Number(target.dataset.columns) === 1 ? 1 : 2;
updateToolbox(toolbox);
refreshToolboxView(target);
}
}
if (action === "view-screenshot") {
const data = getModuleData(target.dataset.toolboxId, target.dataset.moduleId, { shots: [] });
const shot = data.shots.find((item) => item.id === target.dataset.shotId);
if (shot) showScreenshotViewer(shot);
}
if (action === "close-screenshot-viewer") {
target.closest(".screenshot-viewer-root")?.remove();
}
if (action === "delete-module") {
const toolbox = getToolboxes().find((item) => item.id === target.dataset.toolboxId);
const module = toolbox?.modules.find((item) => item.id === target.dataset.moduleId);
const confirmed = await showConfirmModal({
title: "Retirer l'outil",
message: `Retirer "${module?.title || "cet outil"}" de la toolbox ?`,
confirmLabel: "Retirer",
danger: true
});
if (confirmed && toolbox) {
toolbox.modules = toolbox.modules.filter((item) => item.id !== target.dataset.moduleId);
localStorage.removeItem(moduleStorageKey(toolbox.id, target.dataset.moduleId));
updateToolbox(toolbox);
refreshToolboxView(target);
}
}
if (action === "open-drawer") openDrawer(target.dataset.gameId);
if (action === "close-drawer") closeDrawer();
if (action === "new-toolbox-for-game") {
const toolbox = await showToolboxCreateModal({ gameId: target.dataset.gameId });
if (toolbox) openDrawer(target.dataset.gameId);
}
if (action === "retry-mhwilds") {
mhwildsState.loaded = false;
mhwildsState.loading = false;
mhwildsState.error = "";
rerender();
}
if (action === "reset-mhwilds-filters") {
const category = target.dataset.category;
const filterKey = category === "monsters" ? "weaknesses" : "locations";
mhwildsState.filters[category] = { name: "", [filterKey]: [], logic: "and" };
rerender();
}
if (action === "toggle-mhwilds-logic") {
const category = target.dataset.category;
const filters = mhwildsState.filters[category];
filters.logic = filters.logic === "and" ? "or" : "and";
rerender();
}
if (action === "delete-check-item") {
const data = normalizeChecklistData(getModuleData(target.dataset.toolboxId, target.dataset.moduleId, { items: [] }));
data.items = data.items.filter((item) => item.id !== target.dataset.itemId);
setModuleData(target.dataset.toolboxId, target.dataset.moduleId, data);
refreshToolboxView(target);
}
if (action === "adjust-check-qty") {
const data = normalizeChecklistData(getModuleData(target.dataset.toolboxId, target.dataset.moduleId, { items: [] }));
const item = data.items.find((entry) => entry.id === target.dataset.itemId);
if (item) {
const delta = Number.parseInt(target.dataset.delta, 10) || 0;
item.qtyCurrent = clampQty(item.qtyCurrent + delta, item.qtyTarget);
setModuleData(target.dataset.toolboxId, target.dataset.moduleId, data);
refreshToolboxView(target);
}
}
if (action === "delete-screenshot") {
const data = getModuleData(target.dataset.toolboxId, target.dataset.moduleId, { shots: [] });
data.shots = data.shots.filter((shot) => shot.id !== target.dataset.shotId);
setModuleData(target.dataset.toolboxId, target.dataset.moduleId, data);
refreshToolboxView(target);
}
});
document.addEventListener("pointerdown", (event) => {
const handle = event.target.closest(".drawer-resize-handle");
if (!handle) return;
const panel = handle.closest(".drawer-panel");
if (!panel) return;
event.preventDefault();
const startX = event.clientX;
const startWidth = panel.getBoundingClientRect().width;
const minWidth = 360;
const maxWidth = Math.floor(window.innerWidth * 0.94);
document.body.classList.add("is-resizing-drawer");
function resizeDrawer(moveEvent) {
const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, minWidth), maxWidth);
panel.style.width = `${nextWidth}px`;
writeStorageValue(STORAGE_KEYS.drawerWidth, String(Math.round(nextWidth)));
}
function stopResize() {
document.body.classList.remove("is-resizing-drawer");
window.removeEventListener("pointermove", resizeDrawer);
window.removeEventListener("pointerup", stopResize);
window.removeEventListener("pointercancel", stopResize);
}
window.addEventListener("pointermove", resizeDrawer);
window.addEventListener("pointerup", stopResize);
window.addEventListener("pointercancel", stopResize);
});
document.addEventListener("focusin", (event) => {
const pasteTarget = event.target.closest("[data-action='paste-screenshot']");
if (pasteTarget && pasteTarget.textContent.trim() === "Coller une image ici") {
pasteTarget.textContent = "";
return;
}
const title = event.target.closest("[data-action='edit-module-title'], [data-action='edit-toolbox-title']");
if (!title) return;
title.dataset.previousTitle = title.textContent.trim();
});
document.addEventListener("focusout", (event) => {
const pasteTarget = event.target.closest("[data-action='paste-screenshot']");
if (pasteTarget && !pasteTarget.textContent.trim()) {
pasteTarget.textContent = "Coller une image ici";
return;
}
const title = event.target.closest("[data-action='edit-module-title'], [data-action='edit-toolbox-title']");
if (!title) return;
if (title.dataset.action === "edit-toolbox-title") {
const toolbox = getToolboxes().find((item) => item.id === title.dataset.id);
if (!toolbox) return;
const nextTitle = title.textContent.trim() || "Nouvelle toolbox";
title.textContent = nextTitle;
if (nextTitle !== toolbox.name) {
toolbox.name = nextTitle;
updateToolbox(toolbox);
}
return;
}
const toolbox = getToolboxes().find((item) => item.id === title.dataset.toolboxId);
const module = toolbox?.modules.find((item) => item.id === title.dataset.moduleId);
if (!toolbox || !module) return;
const fallback = MODULES[module.type]?.label || "Outil";
const nextTitle = title.textContent.trim() || fallback;
title.textContent = nextTitle;
if (nextTitle !== module.title) {
module.title = nextTitle;
updateToolbox(toolbox);
}
});
document.addEventListener("dragstart", (event) => {
const moduleElement = event.target.closest(".module[draggable='true']");
if (!moduleElement) return;
if (event.target.closest("button, input, select, textarea, [contenteditable='true']")) {
event.preventDefault();
return;
}
draggedModule = {
toolboxId: moduleElement.dataset.toolboxId,
moduleId: moduleElement.dataset.moduleId
};
moduleElement.classList.add("is-dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", draggedModule.moduleId);
});
document.addEventListener("dragover", (event) => {
const dropzone = event.target.closest("[data-action='drop-screenshot']");
if (dropzone) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
dropzone.classList.add("is-drag-over");
return;
}
const moduleElement = event.target.closest(".module[draggable='true']");
if (!moduleElement || !draggedModule || moduleElement.dataset.toolboxId !== draggedModule.toolboxId) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
document.querySelectorAll(".module.is-drop-target").forEach((item) => item.classList.remove("is-drop-target", "drop-after"));
const rect = moduleElement.getBoundingClientRect();
moduleElement.classList.add("is-drop-target");
moduleElement.classList.toggle("drop-after", event.clientY > rect.top + rect.height / 2);
});
document.addEventListener("dragleave", (event) => {
const dropzone = event.target.closest("[data-action='drop-screenshot']");
if (!dropzone || dropzone.contains(event.relatedTarget)) return;
dropzone.classList.remove("is-drag-over");
});
document.addEventListener("drop", async (event) => {
const dropzone = event.target.closest("[data-action='drop-screenshot']");
if (dropzone) {
event.preventDefault();
dropzone.classList.remove("is-drag-over");
if (await addScreenshotFiles(dropzone.dataset.toolboxId, dropzone.dataset.moduleId, event.dataTransfer.files)) {
refreshToolboxView(dropzone);
}
return;
}
const moduleElement = event.target.closest(".module[draggable='true']");
if (!moduleElement || !draggedModule || moduleElement.dataset.toolboxId !== draggedModule.toolboxId) return;
event.preventDefault();
const rect = moduleElement.getBoundingClientRect();
const placement = event.clientY > rect.top + rect.height / 2 ? "after" : "before";
if (moveToolboxModule(draggedModule.toolboxId, draggedModule.moduleId, moduleElement.dataset.moduleId, placement)) {
refreshToolboxView(moduleElement);
}
});
document.addEventListener("dragend", () => {
draggedModule = null;
document.querySelectorAll(".module.is-dragging, .module.is-drop-target").forEach((item) => {
item.classList.remove("is-dragging", "is-drop-target", "drop-after");
});
});
document.addEventListener("keydown", (event) => {
const editableTitle = event.target.closest("[data-action='edit-module-title'], [data-action='edit-toolbox-title']");
if (editableTitle && event.key === "Enter") {
event.preventDefault();
editableTitle.blur();
return;
}
if (editableTitle && event.key === "Escape") {
event.preventDefault();
editableTitle.textContent = editableTitle.dataset.previousTitle || editableTitle.textContent;
editableTitle.blur();
return;
}
const target = event.target.closest("[data-action='flip-monster-card']");
if (!target || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
target.classList.toggle("is-flipped");
target.setAttribute("aria-pressed", target.classList.contains("is-flipped") ? "true" : "false");
});
document.addEventListener("change", async (event) => {
const target = event.target.closest("[data-action]");
if (!target) return;
if (target.dataset.action === "add-module" && target.value) {
const toolbox = getToolboxes().find((item) => item.id === target.dataset.id);
toolbox.modules.push({ id: uid("mod"), type: target.value });
updateToolbox(toolbox);
refreshToolboxView(target);
}
if (target.dataset.action === "toggle-check-item") {
const data = normalizeChecklistData(getModuleData(target.dataset.toolboxId, target.dataset.moduleId, { items: [] }));
const item = data.items.find((entry) => entry.id === target.dataset.itemId);
if (item) {
item.qtyTarget = 1;
item.qtyCurrent = target.checked ? 1 : 0;
}
setModuleData(target.dataset.toolboxId, target.dataset.moduleId, data);
target.closest(".checklist-item")?.classList.toggle("is-complete", target.checked);
}
if (target.dataset.action === "add-screenshot") {
if (await addScreenshotFiles(target.dataset.toolboxId, target.dataset.moduleId, target.files)) {
refreshToolboxView(target);
}
}
if (target.dataset.action === "link-toolbox") {
linkToolboxToGame(target.dataset.gameId, target.value);
openDrawer(target.dataset.gameId);
}
if (target.dataset.action === "toggle-mhwilds-option") {
const { category, filterKey, value } = target.dataset;
const values = new Set(mhwildsState.filters[category][filterKey]);
if (target.checked) values.add(value);
else values.delete(value);
mhwildsState.filters[category][filterKey] = [...values];
target.closest(".filter-chip")?.classList.toggle("active", target.checked);
refreshMhwildsResults(category);
}
if (target.dataset.action === "import-toolbox" && target.files[0]) {
try {
const imported = await importToolbox(target.files[0]);
if (target.dataset.gameId && imported) {
linkToolboxToGame(target.dataset.gameId, imported.id);
openDrawer(target.dataset.gameId);
} else {
rerender();
}
} catch (error) {
showConfirmModal({
title: "Import impossible",
message: error.message,
confirmLabel: "Compris",
cancelLabel: "Fermer",
danger: true
});
}
}
if (target.dataset.action === "import-all-toolboxes" && target.files[0]) {
try {
if (await importAllToolboxes(target.files[0])) rerender();
} catch (error) {
showConfirmModal({
title: "Import global impossible",
message: error.message,
confirmLabel: "Compris",
cancelLabel: "Fermer",
danger: true
});
}
}
});
document.addEventListener("paste", async (event) => {
const target = event.target.closest("[data-action='paste-screenshot']");
if (!target) return;
const files = [...(event.clipboardData?.items || [])]
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter(Boolean);
if (!files.length) return;
event.preventDefault();
target.textContent = "Coller une image ici";
if (await addScreenshotFiles(target.dataset.toolboxId, target.dataset.moduleId, files)) {
refreshToolboxView(target);
}
});
document.addEventListener("submit", (event) => {
const form = event.target.closest("[data-action='add-check-item']");
if (!form) return;
event.preventDefault();
const label = new FormData(form).get("label")?.trim();
if (!label) return;
const qtyTarget = parsePositiveInt(new FormData(form).get("qty"), 1);
const data = normalizeChecklistData(getModuleData(form.dataset.toolboxId, form.dataset.moduleId, { items: [] }));
data.items.push({ id: uid("item"), label, qtyTarget, qtyCurrent: 0 });
setModuleData(form.dataset.toolboxId, form.dataset.moduleId, data);
form.reset();
refreshToolboxView(form);
});
document.addEventListener("input", (event) => {
const mhwildsNameInput = event.target.closest("[data-action='set-mhwilds-name']");
if (mhwildsNameInput) {
const category = mhwildsNameInput.dataset.category;
mhwildsState.filters[category].name = mhwildsNameInput.value;
refreshMhwildsResults(category);
return;
}
const target = event.target.closest("[data-action='save-note']");
if (!target) return;
setModuleData(target.dataset.toolboxId, target.dataset.moduleId, { text: target.value });
});
window.addEventListener("hashchange", rerender);
rerender();