metalfrom.eu/apps/web/site/index.html
Nicolas FRYDER 1fe5b4a618 fix(web): SRI sur les dépendances CDN et timeout des appels API
Les cinq bibliothèques chargées depuis unpkg et jsDelivr (Leaflet,
MarkerCluster, leaflet.heat, noUiSlider, flag-icons) n'avaient aucun contrôle
d'intégrité : un CDN compromis exécutait du code arbitraire sur le site public.
Les neuf hashes ont été recalculés depuis les URL versionnées.

GoatCounter reste sans SRI : son script n'est pas versionné.

apiGet() reçoit un timeout : sans lui, une API qui ne répond jamais laissait
l'interface et le rechargement du viewport bloqués indéfiniment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:44:26 +02:00

273 lines
16 KiB
HTML

<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Metal from Europe</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=UnifrakturCook:wght@700&family=Inter:wght@300;400;600;700;900&display=swap" rel="stylesheet">
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha384-sHL9NAb7lN7rfvG5lfHpm643Xkcjzp4jFvuavGOndn6pjVqS6ny56CAt3nsEVT4H" crossorigin="anonymous" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha384-cxOPjt7s7Iz04uaHJceBmS+qpjv2JkIHNVcuOrM+YHwZOmJGBXI00mdUXEq65HTH" crossorigin="anonymous"></script>
<!-- MarkerCluster -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"
integrity="sha384-pmjIAcz2bAn0xukfxADbZIb3t8oRT9Sv0rvO+BR5Csr6Dhqq+nZs59P0pPKQJkEV" crossorigin="anonymous"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"
integrity="sha384-wgw+aLYNQ7dlhK47ZPK7FRACiq7ROZwgFNg0m04avm4CaXS+Z9Y7nMu8yNjBKYC+" crossorigin="anonymous"/>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"
integrity="sha384-eXVCORTRlv4FUUgS/xmOyr66XBVraen8ATNLMESp92FKXLAMiKkerixTiBvXriZr" crossorigin="anonymous"></script>
<!-- Heatmap -->
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"
integrity="sha384-mFKkGiGvT5vo1fEyGCD3hshDdKmW3wzXW/x+fWriYJArD0R3gawT6lMvLboM22c0" crossorigin="anonymous"></script>
<!-- noUiSlider (double slider timeline) -->
<link rel="stylesheet" href="https://unpkg.com/nouislider@15.7.1/dist/nouislider.min.css"
integrity="sha384-PSZaVsyG9jDu8hFaSJev5s/9poIJlX7cuxSGdqCgXRHpo2DzIaZAyCd2rG/DJJmV" crossorigin="anonymous">
<script src="https://unpkg.com/nouislider@15.7.1/dist/nouislider.min.js"
integrity="sha384-/gBUOLHADjY2rp6bHB0IyW9AC28q4OsnirJScje4l1crgYW7Qarx3dH8zcqcUgmy" crossorigin="anonymous"></script>
<!-- GoatCounter (script d'analytics non versionné → pas de SRI possible) -->
<script data-goatcounter="https://metalfromeurope.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"
integrity="sha384-aQuvIWWIbpu/mSqULLDiUveyYiPoJzPKAWjUmGJ+Elm+N/LJhzfZqsutsfw870JS" crossorigin="anonymous">
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<header id="topbar">
<button class="sidebar-toggle" id="sidebarToggle" type="button" aria-expanded="true" aria-controls="sidebar" title="Afficher / masquer les options">
<span class="icon"></span>
<span class="label" data-i18n="options_btn">Options</span>
</button>
<div class="brand">Metal from Europe</div>
<div class="topbar-right">
<div class="lang-dropdown">
<button class="lang-trigger" id="langTrigger" type="button" aria-haspopup="listbox" aria-expanded="false">
<span class="fi lang-flag"></span>
<span class="lang-code">EN</span>
<span class="lang-arrow"></span>
</button>
<div class="lang-options hidden" id="langOptions" role="listbox" aria-label="Choisir la langue">
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="en"><span class="fi fi-gb"></span>English</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="fr"><span class="fi fi-fr"></span>Français</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="de"><span class="fi fi-de"></span>Deutsch</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="es"><span class="fi fi-es"></span>Español</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="it"><span class="fi fi-it"></span>Italiano</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="pl"><span class="fi fi-pl"></span>Polski</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="nl"><span class="fi fi-nl"></span>Nederlands</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="ro"><span class="fi fi-ro"></span>Română</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="pt"><span class="fi fi-pt"></span>Português</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="cs"><span class="fi fi-cz"></span>Čeština</button>
<button class="lang-opt" type="button" role="option" aria-selected="false" data-lang="sv"><span class="fi fi-se"></span>Svenska</button>
</div>
</div>
<a href="https://discord.gg/P2DE57Ab" target="_blank" rel="noopener noreferrer" class="social-link" title="Rejoindre notre Discord">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</a>
<a href="https://x.com/MetalFromEurope" target="_blank" rel="noopener noreferrer" class="social-link" title="Suivre sur X/Twitter">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
<a href="https://www.instagram.com/metalfromeurope/" target="_blank" rel="noopener noreferrer" class="social-link" title="Suivre sur Instagram">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8A1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5a5 5 0 0 1-5 5a5 5 0 0 1-5-5a5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3z"/>
</svg>
</a>
<a href="https://www.metal-archives.com/" target="_blank" rel="noopener noreferrer" class="social-link ma-link" title="Visiter Metal Archives">
<span class="ma-text">Metal Archives</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" class="external-icon">
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"/>
</svg>
</a>
</div>
</header>
<div id="app">
<aside id="sidebar">
<div class="sidebar-inner">
<div class="topbar">
<div class="search">
<input
id="q"
type="search"
placeholder="Rechercher (nom, genre, ville, pays)…"
data-i18n-placeholder="search_placeholder"
aria-label="Rechercher des groupes"
autocomplete="off"
>
<div class="search-suggestions" id="searchSuggestions"></div>
</div>
</div>
<div class="stats">
<span class="chip"><span class="dot"></span><b id="count">0</b> <span data-i18n="displayed">affichés</span></span>
<span class="chip"><b id="total">0</b> <span data-i18n="total_bands">total</span></span>
<span class="chip"><b id="unique">0</b> <span data-i18n="localities">localités</span></span>
<span class="chip"><b id="geocoded">0</b> <span data-i18n="located">localisés</span></span>
</div>
<div class="controls">
<div class="controls-head">
<div class="controls-title" data-i18n="view_section">Vue</div>
<div class="controls-actions">
<button class="btn btn-compact" id="btnReset" type="button" data-i18n="reset_view">Reset view</button>
<label class="switch" title="Activer la heatmap">
<input type="checkbox" id="toggleHeat" />
<span class="slider"></span>
<span class="switch-label" data-i18n="heatmap_label">Heatmap</span>
</label>
</div>
</div>
</div>
<div class="controls">
<div class="controls-head">
<div class="controls-title" data-i18n="filters_section">Filtres</div>
<button class="btn btn-compact" id="btnNoLocation" type="button">
<span data-i18n="without_coords_btn">Sans coords</span> <span class="badge" id="noLocCount">0</span>
</button>
</div>
<!-- Country -->
<div class="filter-box">
<div class="filter-title" data-i18n="country_label">Pays</div>
<button class="dropdown-trigger" id="countryToggle" type="button" aria-expanded="false" aria-controls="countrySelect">
<span class="label" data-i18n="selection_label">Sélection</span>
<span class="summary" id="countrySummary" data-i18n="all_label">Tous</span>
<span class="caret"></span>
</button>
<div class="multiselect is-collapsed" id="countrySelect"></div>
</div>
<!-- Status -->
<div class="filter-box">
<div class="filter-title" data-i18n="status_label">Statut</div>
<div class="chips" id="statusGrid"></div>
</div>
<!-- Macro genres -->
<div class="filter-box">
<div class="filter-title" data-i18n="genres_label">Genres</div>
<div class="macro-grid" id="macroGenres"></div>
</div>
<!-- Genre multiselect -->
<div class="filter-box">
<div class="filter-title" data-i18n="subgenres_label">Sous-genres</div>
<button class="dropdown-trigger" id="genreToggle" type="button" aria-expanded="false" aria-controls="genreSelect">
<span class="label" data-i18n="selection_label">Sélection</span>
<span class="summary" id="genreSummary" data-i18n="all_label">Tous</span>
<span class="caret"></span>
</button>
<div class="multiselect is-collapsed" id="genreSelect"></div>
</div>
<!-- Themes -->
<div class="filter-box" id="themeBox" style="display:none;">
<div class="filter-title" data-i18n="themes_label">Thèmes</div>
<button class="dropdown-trigger" id="themeToggle" type="button" aria-expanded="false" aria-controls="themeSelect">
<span class="label" data-i18n="selection_label">Sélection</span>
<span class="summary" id="themeSummary" data-i18n="all_label">Tous</span>
<span class="caret"></span>
</button>
<div class="multiselect is-collapsed" id="themeSelect"></div>
</div>
<!-- Timeline -->
<div class="filter-box">
<div class="filter-title" data-i18n="timeline_label">Timeline (année de formation)</div>
<div class="timeline">
<div id="yearSlider"></div>
<div class="timeline-meta">
<span class="chip"><b id="yearMin"></b></span>
<span class="chip"><b id="yearMax"></b></span>
<span class="chip"><b id="yearHint"></b></span>
</div>
</div>
</div>
<!-- Sort -->
<div class="filter-box">
<div class="filter-title" data-i18n="sort_label">Tri de la liste</div>
<select id="sortSelect" class="select" aria-label="Trier les groupes">
<option value="az" data-i18n="sort_az">A → Z</option>
<option value="status" data-i18n="sort_status">Par statut</option>
<option value="genre" data-i18n="sort_genre">Par genre</option>
<option value="country" data-i18n="sort_country">Par pays</option>
<option value="year" data-i18n="sort_year">Par année (récent → ancien)</option>
</select>
</div>
</div>
<div class="panel">
<div class="panel-header">
<div class="h" data-i18n="list_section">Liste</div>
<div class="sub" id="selectionHint"></div>
</div>
<div class="panel-body" id="list"></div>
</div>
</div>
</aside>
<main id="mapWrap">
<div id="map" role="application" aria-label="Carte des groupes de Metal"></div>
</main>
</div>
<footer id="legalBar">
<div class="legal-inner">
<button class="legal-link" id="openLegal" type="button" data-i18n="legal_link">Mentions légales</button>
<span class="legal-dot"></span>
<button class="legal-link" id="openFaq" type="button" data-i18n="faq_link">FAQ</button>
<span class="legal-dot"></span>
<span class="legal-stats" aria-label="Statistiques de visites">
<span class="legal-stat"><span data-i18n="visits_label">Visites</span> <b id="gcTotal"></b></span>
<span class="legal-dot">/</span>
<span class="legal-stat"><span data-i18n="this_month_label">Ce mois</span> <b id="gcMonth"></b></span>
</span>
</div>
</footer>
<!-- Modal: no location -->
<div class="modal-backdrop" id="modalBackdrop" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-head">
<div class="h" id="modalTitle">Sans coords</div>
<div class="x" id="modalClose" title="Fermer" aria-label="Fermer la fenêtre"></div>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<!-- Modal: legal / faq -->
<div class="modal-backdrop" id="infoBackdrop" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="infoTitle">
<div class="modal-head">
<div class="h" id="infoTitle">Infos</div>
<div class="x" id="infoClose" title="Fermer" aria-label="Fermer la fenêtre"></div>
</div>
<div class="modal-body" id="infoBody"></div>
</div>
</div>
<script src="./locales.js"></script>
<!-- pure.js définit la globale BMPure : doit être chargé avant app.js -->
<script src="./pure.js"></script>
<script src="./app.js"></script>
</body>
</html>