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