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 = `
`;
}
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)}
`);
}
function renderToolboxes() {
const toolboxes = getToolboxes();
const content = siteContent().toolboxes;
shell(`
${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)}
${help.items.map((item, index) => `
${index + 1}
${escapeHtml(item)}
`).join("")}
`;
}
function toolboxCard(toolbox) {
const game = getToolboxGame(toolbox);
return `
${game?.image ? `
` : ""}
${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 ``;
shell(``);
return;
}
const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const content = `
${embedded ? "" : `
`}
${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 `
${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 `
${data.items.map((item) => renderChecklistItem(toolboxId, moduleId, item)).join("")}
`;
}
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 `
Ajouter des screenshots
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) => `
${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)}
${section.items.map((item) => `${escapeHtml(item)} `).join("")}
`).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)}
Réessayer
`);
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.
`);
}
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."}
${renderMhwildsFilters(category, filterKey, options)}
${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 `
`;
}
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 `
${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 `
${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" ? `
- ` : `
`).join("")}
`;
}
function renderDamageTable(rows) {
if (!rows.length) return "";
const columns = Object.keys(rows[0]);
return `
`;
}
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 = `
`;
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 = `
${escapeHtml(message)}
${escapeHtml(cancelLabel)}
${escapeHtml(confirmLabel)}
`;
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();