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 l’action.", 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 d’origine 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 n’est envoyé sur un serveur.", items: [ "Un nettoyage du navigateur ou un changement d’appareil 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 `
Stockage local ${formatBytes(usage.used)} / ${formatBytes(usage.limit)}
`; } 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) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" })[char]); } function escapeAttr(value) { return escapeHtml(value).replace(/`/g, "`"); } 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(/<i>(.+?)<\/i>/g, "$1").replace(/\n/g, "
"); } 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 `
`; } 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 = `
${escapeHtml(topbarLabel)}
${renderTopbarActions(route)}
${content}
`; } 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) => `

${formatContentText(line.text || "")}

`).join(""); shell(`

${escapeHtml(content.hero.eyebrow)}

${escapeHtml(content.hero.title)}

${escapeHtml(content.hero.description)}

${toolboxes.length} ${escapeHtml(toolboxes.length > 1 ? content.stats.toolboxPlural : content.stats.toolboxSingular)}
${toolCount} ${escapeHtml(toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular)}

${escapeHtml(content.origin.eyebrow)}

${escapeHtml(content.origin.title)}

${dialogue} ${escapeHtml(content.origin.caption)}
${escapeAttr(content.origin.visualAlt)}
`); } function renderToolboxes() { const toolboxes = getToolboxes(); const content = siteContent().toolboxes; shell(`

${escapeHtml(content.eyebrow)}

${escapeHtml(content.title)}

${storageHelpCard(content.storageHelp)} ${toolboxes.length ? toolboxes.map(toolboxCard).join("") : `

${escapeHtml(content.emptyTitle)}

${escapeHtml(content.emptyText)}

`}
${renderStorageQuota()}
`); } function storageHelpCard(help) { return `

LocalStorage

${escapeHtml(help.title)}

${escapeHtml(help.text)}

`; } function toolboxCard(toolbox) { const game = getToolboxGame(toolbox); return `
${game?.image ? `${escapeAttr(game.title)}` : ""}

${game ? escapeHtml(game.title) : "Toolbox libre"}

${escapeHtml(toolbox.name)}

Modifiée le ${formatDate(toolbox.updatedAt)}
`; } function renderToolbox(id, { embedded = false } = {}) { const toolbox = getToolboxes().find((item) => item.id === id); if (!toolbox) { if (embedded) return `

Toolbox introuvable.

`; shell(`

Toolbox introuvable

Retour
`); return; } const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2; const content = `

Toolbox

${embedded ? `

${escapeHtml(toolbox.name)}

` : `

${escapeHtml(toolbox.name)}

`}
${embedded ? "" : `
${renderModuleLayoutSwitch(toolbox, moduleColumns)}
`}
${renderToolboxModules(toolbox, moduleColumns)}
${embedded ? "" : renderStorageQuota()}
`; if (embedded) return content; shell(content); } function renderModuleLayoutSwitch(toolbox, moduleColumns) { return `
`; } 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) => `
${modules.map((module) => renderModule(toolbox, module)).join("")}
`).join(""); } function renderModule(toolbox, module) { const label = MODULES[module.type]?.label || module.type; return `

${escapeHtml(module.title || label)}

${module.type === "notepad" ? renderNotepad(toolbox.id, module.id) : ""} ${module.type === "checklist" ? renderChecklist(toolbox.id, module.id) : ""} ${module.type === "screenshots" ? renderScreenshots(toolbox.id, module.id) : ""}
`; } function renderNotepad(toolboxId, moduleId) { const data = getModuleData(toolboxId, moduleId, { text: "" }); return ``; } function renderChecklist(toolboxId, moduleId) { const data = normalizeChecklistData(getModuleData(toolboxId, moduleId, { items: [] })); return `
`; } function renderChecklistItem(toolboxId, moduleId, item) { const done = isChecklistItemDone(item); return `
  • ${item.qtyTarget === 1 ? ` ` : `
    ${clampQty(item.qtyCurrent, item.qtyTarget)}/${item.qtyTarget}
    `} ${escapeHtml(item.label)}
  • `; } function renderScreenshots(toolboxId, moduleId) { const data = getModuleData(toolboxId, moduleId, { shots: [] }); return `
    Coller une image ici
    ${data.shots.map((shot) => `
    `).join("")}
    `; } function renderGames() { const games = getGames(); shell(`

    Pages informatives

    Jeux disponibles

    Choisissez un jeu pour consulter ses données maintenues et associer une toolbox locale.

    ${games.length ? games.map((game) => `
    ${escapeAttr(game.title)}

    ${escapeHtml(game.eyebrow || "Guide de jeu")}

    ${escapeHtml(game.title)}

    ${escapeHtml(game.summary)}

    `).join("") : `

    Aucun jeu disponible

    ${escapeHtml(gamesState.error || "Ajoutez des entrées dans /data/games.json.")}

    `}
    `); } function renderGame(gameId) { const game = getGame(gameId); if (!game) { renderGames(); return; } if (game.id === "mhwilds") { renderMhwilds(currentRoute().split("/")[3] || ""); return; } shell(`

    ${escapeHtml(game.eyebrow || "Guide de jeu")}

    ${escapeHtml(game.title)}

    ${escapeHtml(game.summary)}

    ${game.sections.map((section) => `

    ${escapeHtml(section.title)}

    `).join("")}
    `); } 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(`

    Monster Hunter Wilds

    Monster Hunter: Wilds

    Chargement des données de chasse, faune endémique et filtres associés.

    Chargement

    Préparation des données locales...

    `); loadMhwildsData().then(rerender); return; } if (mhwildsState.error) { shell(`

    Impossible de charger MH Wilds

    ${escapeHtml(mhwildsState.error)}

    `); return; } if (!activeCategory) { renderMhwildsOverview(); return; } renderMhwildsListing(activeCategory); } function renderMhwildsOverview() { shell(`

    Guide de jeu

    Monster Hunter: Wilds

    Consultez rapidement les monstres, faiblesses, afflictions, hitzones et emplacements de faune endémique.

    Monstres Recherche, filtres par faiblesse et tableau de dégâts par partie. Faune endémique Faune endémique et aquatique filtrable par localisation.
    `); } 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(`

    Monster Hunter Wilds

    ${escapeHtml(label)}

    ${visible} / ${total}

    ${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."}

    Monstres Faune
    ${renderMhwildsResults(category)}
    `); } 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 `

    Filtres

    ${category === "monsters" ? t("weaknesses", { capitalize: true }) : t("locations", { capitalize: true })}

    Correspondance
    ${options.map((option) => ` `).join("")}
    `; } function renderMhwildsResults(category) { const items = getFilteredMhwildsItems(category); return `
    ${items.length ? items.map((item) => category === "monsters" ? renderMonsterCard(item) : renderEndemicCard(item)).join("") : `

    Aucun résultat

    Ajustez la recherche ou réinitialisez les filtres actifs.

    `}
    `; } 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 `
    ${escapeAttr(t(monster.name, { capitalize: true }))}

    ${escapeHtml(t(monster.type, { capitalize: true }))}

    ${escapeHtml(t(monster.name, { capitalize: true }))}

    ${renderIconRow(t("weaknesses", { capitalize: true }), weaknesses)} ${renderIconRow(t("ailments", { capitalize: true }), ailments.length ? ailments : ["none"])}

    Détails

    ${escapeHtml(t(monster.name, { capitalize: true }))}

    ${renderDamageTable(monster.damage || [])}
    `; } function renderEndemicCard(item) { const locations = getConditionValues(item.locations); const description = t(item.description); const hasDescription = normalizeText(description) && normalizeText(description) !== normalizeText("à compléter"); return `
    ${escapeAttr(t(item.name, { capitalize: true }))}

    ${escapeHtml(t(item.name, { capitalize: true }))}

    ${renderIconRow(t("locations", { capitalize: true }), locations)} ${hasDescription ? `

    ${escapeHtml(description)}

    ` : ""}
    `; } function renderIconRow(label, values) { return `
    ${escapeHtml(label)}
    ${values.map((value) => value === "none" ? `-` : ` ${escapeAttr(t(value, { capitalize: true }))} `).join("")}
    `; } function renderDamageTable(rows) { if (!rows.length) return ""; const columns = Object.keys(rows[0]); return `
    ${columns.map((column) => ` `).join("")} ${rows.map((row) => ` ${columns.map((column) => column === "name" ? ` ` : ` `).join("")} `).join("")}
    ${column === "name" ? "" : `${escapeAttr(t(column, { capitalize: true }))}`}
    ${escapeHtml(t(row[column], { capitalize: true }))}${row[column]} étoiles
    `; } 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 = `

    Toolbox liée

    ${selectedId ? renderToolbox(selectedId, { embedded: true }) : `

    Aucune toolbox associée à cette page jeu.

    `} ${renderStorageQuota()}
    `; 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 = `
    `; 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 = `
    `; 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 = `
    `; 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 = `
    `; 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(`

    Chargement

    Préparation du contenu...

    `); loadSiteContent().then(rerender); return; } if (!gamesState.loaded) { shell(`

    Chargement

    Préparation des jeux disponibles...

    `); 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();