- Le crawler consomme ces demandes dans sa prochaine itération (~1 min d'attente max).
-
-
▶ Enrich (500 bands)
-
▶ Crawl incrémental (créations + modifs)
-
▶ Crawl complet Europe
+
+
+
+
🕷 Crawler
+
Consommés par le crawler dans sa prochaine itération (~1 min).
+
+ ▶ Enrich (500 bands)
+ ▶ Crawl incrémental (créations + modifs)
+ ▶ Crawl complet Europe
+
+
-
+
+
+
🗺 Géocodeur
+
Le géocodeur tourne en continu — ces actions modifient la queue directement.
+
+ 🔄 Réinitialiser les erreurs de géocodage
+ ♻️ Re-géocoder tout (erreurs + déjà fait)
+
+
+
+
+
+
🧹 Maintenance
+
+ 🧹 Annuler crawl-runs bloqués (>30 min)
+
+
+
+
-
+
+
Historique des jobs déclenchés
+
+
+
+
Journal en direct (20 dernières lignes — auto-refresh 10s)
+
+
+
+
`;
+
+ // Crawler jobs
document.querySelectorAll("[data-job]").forEach((btn) => {
btn.addEventListener("click", async () => {
const job_type = btn.dataset.job;
btn.disabled = true;
const fb = document.getElementById("job-feedback");
- fb.textContent = "Envoi…";
+ fb.textContent = "Envoi…"; fb.style.color = "var(--muted)";
try {
const r = await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type }) });
fb.textContent = `✓ Job #${r.id} créé — le crawler le consommera sous ~1 min.`;
fb.style.color = "var(--ok)";
- await loadJobsList();
+ await Promise.all([loadJobsList(), loadCmdLogTail()]);
} catch (e) {
- fb.textContent = `Erreur: ${e.message}`;
- fb.style.color = "var(--err)";
+ fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
} finally { btn.disabled = false; }
});
});
- await loadJobsList();
+
+ // Geocoder reset errors
+ document.getElementById("cmd-reset-errors").addEventListener("click", async () => {
+ const btn = document.getElementById("cmd-reset-errors");
+ btn.disabled = true;
+ const fb = document.getElementById("geo-cmd-feedback");
+ fb.textContent = "Réinitialisation…"; fb.style.color = "var(--muted)";
+ try {
+ const r = await api("/admin/api/geocoding/reset-errors", { method: "POST", body: JSON.stringify({}) });
+ fb.textContent = `✓ ${r.count} entrée(s) remise(s) en queue.`;
+ fb.style.color = "var(--ok)";
+ await loadCmdLogTail();
+ } catch (e) {
+ fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
+ } finally { btn.disabled = false; }
+ });
+
+ // Geocoder requeue all
+ document.getElementById("cmd-requeue-all").addEventListener("click", async () => {
+ if (!confirm("Réinitialiser TOUTES les entrées (erreurs + déjà géocodées) ? Ceci relance le géocodage depuis zéro.")) return;
+ const btn = document.getElementById("cmd-requeue-all");
+ btn.disabled = true;
+ const fb = document.getElementById("geo-cmd-feedback");
+ fb.textContent = "Réinitialisation…"; fb.style.color = "var(--muted)";
+ try {
+ const r = await api("/admin/api/geocoding/requeue-all", { method: "POST", body: JSON.stringify({ include_done: true }) });
+ fb.textContent = `✓ ${r.count} entrée(s) re-queueée(s).`;
+ fb.style.color = "var(--ok)";
+ await loadCmdLogTail();
+ } catch (e) {
+ fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
+ } finally { btn.disabled = false; }
+ });
+
+ // Cleanup
+ document.getElementById("cmd-cleanup").addEventListener("click", async () => {
+ const btn = document.getElementById("cmd-cleanup");
+ btn.disabled = true;
+ const fb = document.getElementById("maintenance-feedback");
+ fb.textContent = "Nettoyage…"; fb.style.color = "var(--muted)";
+ try {
+ const r = await api("/admin/api/crawl-runs/cleanup", { method: "POST", body: JSON.stringify({ older_than_minutes: 30 }) });
+ fb.textContent = `✓ ${r.cleaned} run(s) annulé(s).`;
+ fb.style.color = "var(--ok)";
+ await loadCmdLogTail();
+ } catch (e) {
+ fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
+ } finally { btn.disabled = false; }
+ });
+
+ await Promise.all([loadJobsList(), loadCmdLogTail()]);
+ if (state.cmdLogTimer) clearInterval(state.cmdLogTimer);
+ state.cmdLogTimer = setInterval(async () => {
+ await loadCmdLogTail();
+ const ts = document.getElementById("cmd-log-ts");
+ if (ts) ts.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`;
+ }, 10000);
}
async function loadJobsList() {
@@ -773,11 +929,11 @@ async function loadJobsList() {
${x.id}
${esc(x.job_type)}
${statusBadge(x.status)}
-
${esc(x.requested_by||"—")}
+
${esc(x.requested_by || "—")}
${fmtDate(x.created_at)}
${fmtDate(x.started_at)}
${fmtDate(x.finished_at)}
-
${esc(x.error||"")}
+
${esc(x.error || "")}
`).join("") || `
Aucun job `}
`;
@@ -786,6 +942,26 @@ async function loadJobsList() {
}
}
+async function loadCmdLogTail() {
+ const el = document.getElementById("cmd-log-tail");
+ if (!el) return;
+ try {
+ const r = await api("/admin/api/logs?pageSize=20&page=1");
+ el.innerHTML = `
+ Date Niveau Message
+
+ ${r.items.map((x) => `
+ ${fmtDate(x.created_at)}
+ ${esc(x.level)}
+ ${esc(x.message)}
+ `).join("") || `Aucun log `}
+
+
`;
+ } catch (e) {
+ el.innerHTML = `
Erreur : ${esc(e.message)}
`;
+ }
+}
+
// ------------------------------------------------------------------
// Checkpoints
// ------------------------------------------------------------------
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index c774a5e..66df9c5 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -1,8 +1,8 @@
FROM node:20-alpine
WORKDIR /app
-COPY package.json package-lock.json* ./
-RUN npm install --omit=dev
+COPY package.json package-lock.json ./
+RUN npm ci --omit=dev
COPY src ./src
COPY migrations ./migrations
diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js
index f3eba55..478e416 100644
--- a/apps/api/src/adminRoutes.js
+++ b/apps/api/src/adminRoutes.js
@@ -483,13 +483,61 @@ export default async function adminRoutes(fastify, opts) {
`INSERT INTO job_triggers (job_type, requested_by) VALUES ($1, $2) RETURNING id`,
[job_type, req.adminUsername]
);
- return { ok: true, id: r.rows[0].id };
+ const triggerId = r.rows[0].id;
+ await pool.query(
+ `INSERT INTO crawl_log (level, message) VALUES ($1, $2)`,
+ ["info", `[admin:${req.adminUsername}] job trigger créé: ${job_type} (#${triggerId})`]
+ ).catch(() => {});
+ return { ok: true, id: triggerId };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur création job" });
}
});
+ // ------------------------------------------------------------------
+ // Géocodage — actions directes sur la queue
+ // ------------------------------------------------------------------
+ fastify.post("/admin/api/geocoding/reset-errors", async (req, reply) => {
+ try {
+ const r = await pool.query(`
+ UPDATE geocode_queue
+ SET status='queued', next_run_at=now(), updated_at=now()
+ WHERE status='error'
+ `);
+ const count = r.rowCount;
+ await pool.query(
+ `INSERT INTO crawl_log (level, message) VALUES ($1, $2)`,
+ ["info", `[admin:${req.adminUsername}] geocoding reset-errors: ${count} entrée(s) remise(s) en queue`]
+ ).catch(() => {});
+ return { ok: true, count };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: "Erreur reset-errors" });
+ }
+ });
+
+ fastify.post("/admin/api/geocoding/requeue-all", async (req, reply) => {
+ try {
+ const { include_done = false } = req.body || {};
+ const statuses = include_done ? ["error", "done"] : ["error"];
+ const r = await pool.query(`
+ UPDATE geocode_queue
+ SET status='queued', next_run_at=now(), tries=0, last_error=NULL, updated_at=now()
+ WHERE status = ANY($1::text[])
+ `, [statuses]);
+ const count = r.rowCount;
+ await pool.query(
+ `INSERT INTO crawl_log (level, message) VALUES ($1, $2)`,
+ ["info", `[admin:${req.adminUsername}] geocoding requeue-all (include_done=${include_done}): ${count} re-queueée(s)`]
+ ).catch(() => {});
+ return { ok: true, count };
+ } catch (err) {
+ fastify.log.error(err);
+ return reply.code(500).send({ ok: false, error: "Erreur requeue-all" });
+ }
+ });
+
// ------------------------------------------------------------------
// Journal d'audit (actions admin)
// ------------------------------------------------------------------
diff --git a/apps/api/src/index.js b/apps/api/src/index.js
deleted file mode 100644
index 23f8bc0..0000000
--- a/apps/api/src/index.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import Fastify from "fastify";
-import pg from "pg";
-import zlib from "node:zlib";
-
-const { Pool } = pg;
-const fastify = Fastify({ logger: true });
-
-// IMPORTANT: permettre le raw body (pour gzip)
-fastify.addContentTypeParser("*", { parseAs: "buffer" }, (req, body, done) => done(null, body));
-
-const PORT = Number(process.env.PORT || 3000);
-const DATABASE_URL = process.env.DATABASE_URL;
-const IMPORT_TOKEN = process.env.BM_IMPORT_TOKEN || "";
-
-let pool = null;
-if (DATABASE_URL) pool = new Pool({ connectionString: DATABASE_URL });
-
-fastify.get("/", async () => ({ ok: true, service: "bm-api" }));
-fastify.get("/health", async () => ({ ok: true }));
-
-fastify.get("/db", async () => {
- if (!pool) return { ok: false, error: "DATABASE_URL not set" };
- const r = await pool.query("select now() as now, current_database() as db");
- return { ok: true, ...r.rows[0] };
-});
-
-fastify.get("/stats", async () => {
- if (!pool) return { ok: false, error: "DATABASE_URL not set" };
- const r = await pool.query(`select (select count(*)::int from bands) as bands`);
- return { ok: true, ...r.rows[0], updated_at: new Date().toISOString() };
-});
-
-fastify.post("/admin/import", async (req, reply) => {
- const auth = String(req.headers.authorization || "");
- const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
-
- if (!IMPORT_TOKEN || token !== IMPORT_TOKEN) {
- return reply.code(401).send({ ok: false, error: "unauthorized" });
- }
- if (!pool) return reply.code(500).send({ ok: false, error: "DATABASE_URL not set" });
-
- const ct = String(req.headers["content-type"] || "");
- let payload;
-
- // req.body est Buffer (grâce au parser "*")
- const buf = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || "");
-
- try {
- if (ct.includes("application/json")) {
- payload = JSON.parse(buf.toString("utf-8"));
- } else if (ct.includes("application/gzip") || ct.includes("application/x-gzip")) {
- payload = JSON.parse(zlib.gunzipSync(buf).toString("utf-8"));
- } else {
- // fallback: tenter JSON direct
- payload = JSON.parse(buf.toString("utf-8"));
- }
- } catch (e) {
- req.log.error(e);
- return reply.code(400).send({ ok: false, error: "invalid body/json" });
- }
-
- if (!payload || !Array.isArray(payload.bands)) {
- return reply.code(400).send({ ok: false, error: "expected { bands: [...] }" });
- }
-
- const client = await pool.connect();
- try {
- await client.query("begin");
- let upserted = 0;
-
- for (const b of payload.bands) {
- if (!b?.ma_id || !b?.name) continue;
-
- await client.query(
- `
- insert into bands (ma_id, name, country, location_text, status, genre, data)
- values ($1,$2,$3,$4,$5,$6,$7::jsonb)
- on conflict (ma_id) do update set
- name=excluded.name,
- country=excluded.country,
- location_text=excluded.location_text,
- status=excluded.status,
- genre=excluded.genre,
- data=excluded.data
- `,
- [
- b.ma_id,
- b.name,
- b.country || null,
- b.location_text || null,
- b.status || null,
- b.genre || null,
- JSON.stringify(b.data || {}),
- ]
- );
-
- upserted++;
- }
-
- await client.query("commit");
- return { ok: true, upserted };
- } catch (e) {
- await client.query("rollback");
- req.log.error(e);
- return reply.code(500).send({ ok: false, error: "import failed" });
- } finally {
- client.release();
- }
-});
-
-fastify.listen({ port: PORT, host: "0.0.0.0" });
diff --git a/apps/web/docker-compose.yml b/apps/web/docker-compose.yml
deleted file mode 100644
index a6ca731..0000000
--- a/apps/web/docker-compose.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-services:
- bm-web:
- image: nginx:alpine
- container_name: bm-web
- networks:
- - stack-vps_admin
- volumes:
- - ./site:/usr/share/nginx/html:ro
- labels:
- - traefik.enable=true
- - traefik.docker.network=stack-vps_admin
-
- # ✅ règle de matching (OBLIGATOIRE)
- - traefik.http.routers.bm-web.rule=Host(`metalfrom.eu`) || Host(`www.metalfrom.eu`)
-
- - traefik.http.routers.bm-web.entrypoints=websecure
- - traefik.http.routers.bm-web.tls=true
- - traefik.http.routers.bm-web.tls.certresolver=le
-
- # ✅ force cert apex + www
- - traefik.http.routers.bm-web.tls.domains[0].main=metalfrom.eu
- - traefik.http.routers.bm-web.tls.domains[0].sans=www.metalfrom.eu
- - traefik.http.routers.bm-web.service=bm-web
- - traefik.http.services.bm-web.loadbalancer.server.port=80
- quizz:
- image: nginx:alpine
- container_name: quizz
- networks:
- - stack-vps_admin
- volumes:
- - ./quizz-site:/usr/share/nginx/html:ro
- labels:
- - traefik.enable=true
- - traefik.docker.network=stack-vps_admin
-
- # ✅ règle de matching (OBLIGATOIRE)
- - traefik.http.routers.quizz.rule=Host(`quizz.nicolasfryder.ovh`)
-
- - traefik.http.routers.quizz.entrypoints=websecure
- - traefik.http.routers.quizz.tls=true
- - traefik.http.routers.quizz.tls.certresolver=le
-
- # ✅ force cert apex + www
- - traefik.http.routers.quizz.tls.domains[0].main=quizz.nicolasfryder.ovh
- - traefik.http.routers.quizz.service=quizz
- - traefik.http.services.quizz.loadbalancer.server.port=80
- restart: unless-stopped
-
-networks:
- stack-vps_admin:
- external: true
diff --git a/apps/web/site/app.js b/apps/web/site/app.js
index 2c0182a..19f82dd 100644
--- a/apps/web/site/app.js
+++ b/apps/web/site/app.js
@@ -2,6 +2,37 @@
/* Performance-optimized with viewport-based loading */
const API_BASE = "https://bm.nicolasfryder.ovh";
+
+// --- i18n ---
+const SUPPORTED_LANGS = ["fr", "en", "de", "es", "it", "pl", "nl", "ro", "pt", "cs", "sv"];
+
+function detectLang() {
+ const nav = (navigator.language || navigator.languages?.[0] || "fr").split("-")[0].toLowerCase();
+ return SUPPORTED_LANGS.includes(nav) ? nav : "fr";
+}
+
+let currentLang = localStorage.getItem("lang") || detectLang();
+
+function t(key) {
+ return window.LOCALES?.[currentLang]?.[key] ?? window.LOCALES?.["fr"]?.[key] ?? key;
+}
+
+function applyI18n() {
+ document.documentElement.lang = currentLang;
+ document.querySelectorAll("[data-i18n]").forEach(el => {
+ el.textContent = t(el.dataset.i18n);
+ });
+ document.querySelectorAll("[data-i18n-placeholder]").forEach(el => {
+ el.placeholder = t(el.dataset.i18nPlaceholder);
+ });
+ // Sort select options (can't use data-i18n on options in all browsers)
+ const sel = document.getElementById("sortSelect");
+ if (sel) {
+ [["sort_az",0],["sort_status",1],["sort_genre",2],["sort_country",3],["sort_year",4]].forEach(([k,i]) => {
+ if (sel.options[i]) sel.options[i].text = t(k);
+ });
+ }
+}
const DARK_TILES = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const DARK_ATTRIB = '© OpenStreetMap contributors © CARTO';
@@ -594,8 +625,8 @@ function createLocationMarker(lat, lon, count, bands, locationName) {
const popupHtml = `
- 📍 ${escapeHtml(locationName || 'Localisation')}
- ${count} groupe${count > 1 ? 's' : ''}
+ 📍 ${escapeHtml(locationName || t('location_fallback'))}
+ ${count} ${count === 1 ? t('group_s') : t('group_p')}
${bandListHtml}
@@ -613,7 +644,8 @@ function createLocationMarker(lat, lon, count, bands, locationName) {
// Click handler - update sidebar list AND open popup
marker.on("click", (e) => {
L.DomEvent.stopPropagation(e);
- const hint = locationName ? `${count} groupe(s) — ${locationName}` : `${count} groupe(s)`;
+ const gLabel = count === 1 ? t('group_s') : t('group_p');
+ const hint = locationName ? `${count} ${gLabel} — ${locationName}` : `${count} ${gLabel}`;
renderList(bands, hint);
// Small delay to ensure popup opens correctly
setTimeout(() => marker.openPopup(), 10);
@@ -1118,7 +1150,7 @@ function renderList(items, hintOverride) {
listElById.clear();
const sorted = sortBands(items);
- $("selectionHint").textContent = hintOverride || `${sorted.length} résultat(s)`;
+ $("selectionHint").textContent = hintOverride || `${sorted.length} ${t("results_label")}`;
const q = currentQuery();
@@ -1302,57 +1334,7 @@ const infoBody = $("infoBody");
const infoClose = $("infoClose");
const infoTitle = $("infoTitle");
-const LEGAL_HTML = `
-
-
-
- Hébergeur
- OVH SAS – 2 rue Kellermann - 59100 Roubaix - France
-
-
-
Propriété intellectuelle
-
Les contenus (textes, visuels, données agrégées) sont proposés à titre informatif. Les logos et noms de groupes restent la propriété de leurs auteurs. L'ensemble des données provient du site https://www.metal-archives.com/ , avec l'autorisation des webmasters
-
-
- Données personnelles
- Ce site ne collecte pas d'informations personnelles ni ne dépose de cookies de suivi. Des logs techniques (adresses IP, user agent, horodatage) peuvent être conservés par l'hébergeur à des fins de sécurité et de dépannage.
-
-
- Responsable de publication
- Nico
-
-
-`;
-
-const FAQ_HTML = `
-
-
- Q : D'où viennent les données ?
- R : Metal Archives + enrichissement géographique automatisés. Quelques changements à la main, mais c'est fastidieux.
-
-
- Q : Puis-je corriger une erreur ?
- R : Oui si elle est en rapport avec le site, contacte-moi via l'adresse indiquée dans les mentions légales. Si elle est en rapport avec les données, alors c'est sur metal archives qu'il faut le changer, et lors de la prochaine synchronisation (manuelle) ce sera corrigé (on espère)
-
-
- Q : Pourquoi certains groupes ne sont pas visibles?
- R : Il y a des erreurs inhérentes au géocodage automatique. La correction manuelle des localisation étant fastidieuse, il est inévitable que certaines données soient faussées.
-
-
- Q : Je viens de créer mon groupe sur Metal Archives mais il n'apparaît pas
- R : Pour le moment aucune synchronisation automatique avec Metal Archives n'est implémentée. Si les Webmasters de Metal Archive souhaitent mettre cela en place, je suis évidemment à l'écoute
-
-
- Q : Est-ce que le site me traque ou collecte mes données?
- R : La seule forme de traçage effectuée par le site est à des fins d'analyse de trafic, effectuée par GoatCounter (goatcounter.com) pour savoir à peu près d'où vous venez, sur quel matos vous regardez le site et autres petites infos. A ma connaissance GoatCounter ne dépose aucun cookie sur vos machines, et moi non plus. Les appels à l'API de goatcounter sont néanmoins bloqués par les adblocker chez moi, donc aucun souci pour vous si vous souhaitez ne pas participer !
-
-
-`;
+// LEGAL_HTML and FAQ_HTML are now generated per-language in locales.js via t('legal_html') / t('faq_html')
function openInfoModal(title, html) {
if (!infoBackdrop || !infoBody || !infoTitle) return;
@@ -1392,7 +1374,7 @@ function setSidebarCollapsed(collapsed) {
// Update button text
const label = toggle.querySelector(".label");
if (label) {
- label.textContent = collapsed ? "Options" : "Fermer";
+ label.textContent = collapsed ? t("options_btn") : t("close_btn");
}
}
// Refresh map size after sidebar animation
@@ -1420,12 +1402,24 @@ if (window.matchMedia("(max-width: 1200px)").matches) {
}, 100);
}
+// Language selector
+const langSelectEl = document.getElementById("langSelect");
+if (langSelectEl) {
+ langSelectEl.value = currentLang;
+ langSelectEl.addEventListener("change", () => {
+ currentLang = langSelectEl.value;
+ localStorage.setItem("lang", currentLang);
+ applyI18n();
+ });
+}
+applyI18n();
+
// Legal/FAQ links
const openLegal = $("openLegal");
-if (openLegal) openLegal.addEventListener("click", () => openInfoModal("Mentions légales", LEGAL_HTML));
+if (openLegal) openLegal.addEventListener("click", () => openInfoModal(t("legal_title"), t("legal_html")));
const openFaq = $("openFaq");
-if (openFaq) openFaq.addEventListener("click", () => openInfoModal("FAQ", FAQ_HTML));
+if (openFaq) openFaq.addEventListener("click", () => openInfoModal(t("faq_title"), t("faq_html")));
// Setup dropdowns
setupDropdown("countryToggle", "countrySelect");
@@ -1434,7 +1428,7 @@ setupDropdown("themeToggle", "themeSelect");
// --- Buttons / controls ---
$("btnNoLocation")?.addEventListener("click", () => {
- modalTitle.textContent = "Groupes sans coordonnées";
+ modalTitle.textContent = t("without_coords_modal");
modalBackdrop.classList.add("on");
modalBackdrop.setAttribute("aria-hidden", "false");
loadNoLocationBands();
@@ -1601,8 +1595,8 @@ function buildTimelineFromRange() {
if (!yearRange.min || !yearRange.max) {
$("yearMin").textContent = "—";
$("yearMax").textContent = "—";
- $("yearHint").textContent = "données manquantes";
- slider.innerHTML = `
Données d'année indisponibles
`;
+ $("yearHint").textContent = t("year_data_missing");
+ slider.innerHTML = `
${t("year_data_unavailable")}
`;
return;
}
@@ -1612,7 +1606,7 @@ function buildTimelineFromRange() {
$("yearMin").textContent = String(min);
$("yearMax").textContent = String(max);
- $("yearHint").textContent = "toutes";
+ $("yearHint").textContent = t("all_years_label");
noUiSlider.create(slider, {
start: [min, max],
@@ -1627,7 +1621,7 @@ function buildTimelineFromRange() {
const a = Math.round(Number(vals[0]));
const b = Math.round(Number(vals[1]));
yearFilter = { min: a, max: b };
- $("yearHint").textContent = (a === min && b === max) ? "toutes" : `${a} → ${b}`;
+ $("yearHint").textContent = (a === min && b === max) ? t("all_years_label") : `${a} → ${b}`;
});
slider.noUiSlider.on("change", () => {
diff --git a/apps/web/site/index.html b/apps/web/site/index.html
index 6fdd136..ade3f11 100644
--- a/apps/web/site/index.html
+++ b/apps/web/site/index.html
@@ -1,4 +1,4 @@
-
+
@@ -26,7 +26,7 @@
-
+
@@ -37,10 +37,23 @@
Metal from Europe
+
+ 🇫🇷 FR
+ 🇬🇧 EN
+ 🇩🇪 DE
+ 🇪🇸 ES
+ 🇮🇹 IT
+ 🇵🇱 PL
+ 🇳🇱 NL
+ 🇷🇴 RO
+ 🇵🇹 PT
+ 🇨🇿 CS
+ 🇸🇪 SV
+
@@ -70,10 +83,11 @@