diff --git a/.gitignore b/.gitignore index f150e39..9057a64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,10 @@ infra/.venv/ infra/temp/ -infra/ma_state.json infra/geocode-enqueue.log -infra/ma_debug.* apps/api/node_modules/ __pycache__/ *.pyc *.pyo infra/.env -infra/temp -infra/docker-compose.yml.backup .claude/ +CLAUDE.md diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js index e1683ea..d23ba5c 100644 --- a/apps/admin/site/app.js +++ b/apps/admin/site/app.js @@ -9,6 +9,8 @@ const state = { audit: { page: 1, pageSize: 50 }, logsTimer: null, queueTimer: null, + geoTimer: null, + cmdLogTimer: null, }; // ------------------------------------------------------------------ @@ -39,6 +41,8 @@ function showLogin() { document.getElementById("app").classList.add("hidden"); if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; } if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; } + if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; } + if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; } } function showApp() { @@ -105,6 +109,8 @@ function router() { }); if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; } if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = null; } + if (state.geoTimer) { clearInterval(state.geoTimer); state.geoTimer = null; } + if (state.cmdLogTimer) { clearInterval(state.cmdLogTimer); state.cmdLogTimer = null; } const renderers = { dashboard: renderDashboard, bands: renderBands, @@ -672,93 +678,243 @@ function conflictCard(b) { // Géocodage // ------------------------------------------------------------------ async function renderGeocoding() { - content().innerHTML = `
Chargement…
`; + if (!document.getElementById("geo-stats")) { + content().innerHTML = ` +
+
+
+

Progression

+
+ + + Auto-refresh 30s +
+
+
+
+
+
+
+
+
+

20 derniers géocodages

+
Chargement…
+
+ `; + + document.getElementById("geo-reset-errors").addEventListener("click", async () => { + const btn = document.getElementById("geo-reset-errors"); + btn.disabled = true; + const fb = document.getElementById("geo-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 — le géocodeur les traite automatiquement.`; + fb.style.color = "var(--ok)"; + await loadGeocoding(); + } catch (e) { + fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; + } finally { btn.disabled = false; } + }); + + document.getElementById("geo-requeue-done").addEventListener("click", async () => { + if (!confirm("Réinitialiser TOUTES les entrées (erreurs + déjà géocodées) ? Ceci relance le géocodage complet.")) return; + const btn = document.getElementById("geo-requeue-done"); + btn.disabled = true; + const fb = document.getElementById("geo-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 loadGeocoding(); + } catch (e) { + fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)"; + } finally { btn.disabled = false; } + }); + } + + await loadGeocoding(); + if (state.geoTimer) clearInterval(state.geoTimer); + state.geoTimer = setInterval(async () => { + await loadGeocoding(); + const el = document.getElementById("geo-refresh-status"); + if (el) el.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`; + }, 30000); +} + +async function loadGeocoding() { try { const r = await api("/admin/api/geocoding"); const total = r.queue.reduce((s, x) => s + x.n, 0); const done = r.queue.find((x) => x.status === "done")?.n || 0; const queued = r.queue.find((x) => x.status === "queued")?.n || 0; const errored = r.queue.find((x) => x.status === "error")?.n || 0; + const processing = r.queue.find((x) => x.status === "processing")?.n || 0; const pct = total ? Math.round((done / total) * 100) : 0; - content().innerHTML = ` -
- ${statCard("Total queue", total)} - ${statCard("Géocodés ✓", done, "ok")} - ${statCard("En attente", queued, "warn")} - ${statCard("Erreurs", errored, "err")} - ${statCard("Cache Geoapify", r.cache_size)} -
-
-

Progression

-
-
-
-
${pct}% — ${done.toLocaleString("fr-FR")} / ${total.toLocaleString("fr-FR")}
-
-
-

20 derniers géocodages

-
- - - ${r.recent.map((x) => ` - - - - - - - - `).join("") || ``} - -
MA IDNomPaysProviderQueryGéocodé leErreur
${x.ma_id}${esc(x.name)}${esc(x.country||"")}${esc(x.geocode_provider||"")}${esc(x.geocode_query||"")}${fmtDate(x.geocoded_at)}${esc(x.geocode_error||"")}
Aucun
-
+ const statsEl = document.getElementById("geo-stats"); + if (statsEl) statsEl.innerHTML = ` + ${statCard("Total queue", total)} + ${statCard("Géocodés ✓", done, "ok")} + ${statCard("En attente", queued, "warn")} + ${statCard("En cours", processing || 0, processing ? "ok" : "")} + ${statCard("Erreurs", errored, errored ? "err" : "")} + ${statCard("Cache Geoapify", r.cache_size)} `; + + const bar = document.getElementById("geo-bar"); + if (bar) bar.style.width = pct + "%"; + const pctEl = document.getElementById("geo-pct"); + if (pctEl) pctEl.textContent = `${pct}% — ${done.toLocaleString("fr-FR")} / ${total.toLocaleString("fr-FR")}${processing ? ` (${processing} en cours)` : ""}`; + + const recentWrap = document.getElementById("geo-recent-wrap"); + if (recentWrap) recentWrap.innerHTML = ` + + + ${r.recent.map((x) => ` + + + + + + + + `).join("") || ``} + +
MA IDNomPaysProviderQueryGéocodé leErreur
${x.ma_id}${esc(x.name)}${esc(x.country || "")}${esc(x.geocode_provider || "")}${esc(x.geocode_query || "")}${fmtDate(x.geocoded_at)}${esc(x.geocode_error || "")}
Aucun
`; } catch (e) { - content().innerHTML = `
Erreur : ${esc(e.message)}
`; + const pctEl = document.getElementById("geo-pct"); + if (pctEl) pctEl.textContent = `Erreur : ${esc(e.message)}`; } } // ------------------------------------------------------------------ -// Jobs (déclenchement manuel des workers) +// Centre de commandes (Jobs + Géocodeur + Maintenance + Live log) // ------------------------------------------------------------------ async function renderJobs() { content().innerHTML = ` -
-

Déclencher un job manuellement

-

- Le crawler consomme ces demandes dans sa prochaine itération (~1 min d'attente max). -

-
- - - +
+ +
+

🕷 Crawler

+

Consommés par le crawler dans sa prochaine itération (~1 min).

+
+ + + +
+
-
+ +
+

🗺 Géocodeur

+

Le géocodeur tourne en continu — ces actions modifient la queue directement.

+
+ + +
+
+
+ +
+

🧹 Maintenance

+
+ +
+
+
+
-
+ +

Historique des jobs déclenchés

Chargement…
+ +
+
+

Journal en direct (20 dernières lignes — auto-refresh 10s)

+ +
+
Chargement…
+
`; + + // 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 = ` + + + ${r.items.map((x) => ` + + + + `).join("") || ``} + +
DateNiveauMessage
${fmtDate(x.created_at)}${esc(x.level)}${esc(x.message)}
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 = ` -
-
- Éditeur du site
- Auteur: Nico
- Statut : particulier
- Contact : contact@metalfrom.eu -
-
- 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
+