feat: i18n front-end (11 langues), centre de commandes admin, remerciements légaux
- i18n front-end (fr/en/de/es/it/pl/nl/ro/pt/cs/sv) via locales.js + data-i18n attrs - Sélecteur de langue avec drapeaux dans la topbar, persisté en localStorage - Mentions légales et FAQ générées dynamiquement par locale (buildLegal/buildFaq) - Section "Remerciements" : Geoapify, OSM, Leaflet, Metal Archives, HellBlazer - Admin : centre de commandes (crawler jobs, géocodeur, maintenance, live log tail 10s) - Admin : boutons reset-errors / requeue-all géocodeur avec confirmation - Admin : toutes les actions admin loguées dans crawl_log pour audit - Suppression anciens artefacts (worker, infra/, apps/api/src/index.js) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c90b5063f1
commit
6a1b2c3e44
16 changed files with 887 additions and 601 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,13 +1,10 @@
|
||||||
infra/.venv/
|
infra/.venv/
|
||||||
infra/temp/
|
infra/temp/
|
||||||
infra/ma_state.json
|
|
||||||
infra/geocode-enqueue.log
|
infra/geocode-enqueue.log
|
||||||
infra/ma_debug.*
|
|
||||||
apps/api/node_modules/
|
apps/api/node_modules/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
infra/.env
|
infra/.env
|
||||||
infra/temp
|
|
||||||
infra/docker-compose.yml.backup
|
|
||||||
.claude/
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ const state = {
|
||||||
audit: { page: 1, pageSize: 50 },
|
audit: { page: 1, pageSize: 50 },
|
||||||
logsTimer: null,
|
logsTimer: null,
|
||||||
queueTimer: null,
|
queueTimer: null,
|
||||||
|
geoTimer: null,
|
||||||
|
cmdLogTimer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -39,6 +41,8 @@ function showLogin() {
|
||||||
document.getElementById("app").classList.add("hidden");
|
document.getElementById("app").classList.add("hidden");
|
||||||
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
|
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
|
||||||
if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = 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() {
|
function showApp() {
|
||||||
|
|
@ -105,6 +109,8 @@ function router() {
|
||||||
});
|
});
|
||||||
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
|
if (state.logsTimer) { clearInterval(state.logsTimer); state.logsTimer = null; }
|
||||||
if (state.queueTimer) { clearInterval(state.queueTimer); state.queueTimer = 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 = {
|
const renderers = {
|
||||||
dashboard: renderDashboard,
|
dashboard: renderDashboard,
|
||||||
bands: renderBands,
|
bands: renderBands,
|
||||||
|
|
@ -672,93 +678,243 @@ function conflictCard(b) {
|
||||||
// Géocodage
|
// Géocodage
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
async function renderGeocoding() {
|
async function renderGeocoding() {
|
||||||
content().innerHTML = `<div class="loading">Chargement…</div>`;
|
if (!document.getElementById("geo-stats")) {
|
||||||
|
content().innerHTML = `
|
||||||
|
<div class="grid grid-stats" id="geo-stats"></div>
|
||||||
|
<div class="card" style="margin-bottom:16px">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:12px">
|
||||||
|
<h2 style="margin:0">Progression</h2>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||||
|
<button class="btn btn-mini" id="geo-reset-errors">🔄 Réinitialiser erreurs</button>
|
||||||
|
<button class="btn btn-mini" id="geo-requeue-done">♻️ Re-géocoder tout (erreurs + fait)</button>
|
||||||
|
<span id="geo-refresh-status" style="font-size:11px;color:var(--muted)">Auto-refresh 30s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:rgba(255,255,255,0.06);border-radius:8px;height:18px;overflow:hidden;margin-bottom:8px">
|
||||||
|
<div id="geo-bar" style="width:0%;height:100%;background:linear-gradient(90deg,var(--blood),rgba(198,26,26,0.6));transition:width 0.4s ease"></div>
|
||||||
|
</div>
|
||||||
|
<div id="geo-pct" style="font-size:12px;color:var(--muted)">—</div>
|
||||||
|
<div id="geo-feedback" style="margin-top:8px;font-size:12px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>20 derniers géocodages</h2>
|
||||||
|
<div class="table-wrap" id="geo-recent-wrap"><div class="loading">Chargement…</div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const r = await api("/admin/api/geocoding");
|
const r = await api("/admin/api/geocoding");
|
||||||
const total = r.queue.reduce((s, x) => s + x.n, 0);
|
const total = r.queue.reduce((s, x) => s + x.n, 0);
|
||||||
const done = r.queue.find((x) => x.status === "done")?.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 queued = r.queue.find((x) => x.status === "queued")?.n || 0;
|
||||||
const errored = r.queue.find((x) => x.status === "error")?.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;
|
const pct = total ? Math.round((done / total) * 100) : 0;
|
||||||
|
|
||||||
content().innerHTML = `
|
const statsEl = document.getElementById("geo-stats");
|
||||||
<div class="grid grid-stats">
|
if (statsEl) statsEl.innerHTML = `
|
||||||
${statCard("Total queue", total)}
|
${statCard("Total queue", total)}
|
||||||
${statCard("Géocodés ✓", done, "ok")}
|
${statCard("Géocodés ✓", done, "ok")}
|
||||||
${statCard("En attente", queued, "warn")}
|
${statCard("En attente", queued, "warn")}
|
||||||
${statCard("Erreurs", errored, "err")}
|
${statCard("En cours", processing || 0, processing ? "ok" : "")}
|
||||||
|
${statCard("Erreurs", errored, errored ? "err" : "")}
|
||||||
${statCard("Cache Geoapify", r.cache_size)}
|
${statCard("Cache Geoapify", r.cache_size)}
|
||||||
</div>
|
`;
|
||||||
<div class="card" style="margin-bottom:16px">
|
|
||||||
<h2>Progression</h2>
|
const bar = document.getElementById("geo-bar");
|
||||||
<div style="background:rgba(255,255,255,0.06);border-radius:8px;height:18px;overflow:hidden;margin-bottom:8px">
|
if (bar) bar.style.width = pct + "%";
|
||||||
<div style="width:${pct}%;height:100%;background:linear-gradient(90deg,var(--blood),rgba(198,26,26,0.6))"></div>
|
const pctEl = document.getElementById("geo-pct");
|
||||||
</div>
|
if (pctEl) pctEl.textContent = `${pct}% — ${done.toLocaleString("fr-FR")} / ${total.toLocaleString("fr-FR")}${processing ? ` (${processing} en cours)` : ""}`;
|
||||||
<div style="font-size:12px;color:var(--muted)">${pct}% — ${done.toLocaleString("fr-FR")} / ${total.toLocaleString("fr-FR")}</div>
|
|
||||||
</div>
|
const recentWrap = document.getElementById("geo-recent-wrap");
|
||||||
<div class="card">
|
if (recentWrap) recentWrap.innerHTML = `<table>
|
||||||
<h2>20 derniers géocodages</h2>
|
|
||||||
<div class="table-wrap"><table>
|
|
||||||
<thead><tr><th>MA ID</th><th>Nom</th><th>Pays</th><th>Provider</th><th>Query</th><th>Géocodé le</th><th>Erreur</th></tr></thead>
|
<thead><tr><th>MA ID</th><th>Nom</th><th>Pays</th><th>Provider</th><th>Query</th><th>Géocodé le</th><th>Erreur</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${r.recent.map((x) => `<tr>
|
${r.recent.map((x) => `<tr>
|
||||||
<td>${x.ma_id}</td>
|
<td>${x.ma_id}</td>
|
||||||
<td>${esc(x.name)}</td>
|
<td>${esc(x.name)}</td>
|
||||||
<td>${esc(x.country||"")}</td>
|
<td>${esc(x.country || "")}</td>
|
||||||
<td>${esc(x.geocode_provider||"")}</td>
|
<td>${esc(x.geocode_provider || "")}</td>
|
||||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${esc(x.geocode_query||"")}</td>
|
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${esc(x.geocode_query || "")}</td>
|
||||||
<td>${fmtDate(x.geocoded_at)}</td>
|
<td>${fmtDate(x.geocoded_at)}</td>
|
||||||
<td style="color:var(--err)">${esc(x.geocode_error||"")}</td>
|
<td style="color:var(--err)">${esc(x.geocode_error || "")}</td>
|
||||||
</tr>`).join("") || `<tr><td colspan="7" class="empty">Aucun</td></tr>`}
|
</tr>`).join("") || `<tr><td colspan="7" class="empty">Aucun</td></tr>`}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table>`;
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
content().innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
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() {
|
async function renderJobs() {
|
||||||
content().innerHTML = `
|
content().innerHTML = `
|
||||||
<div class="card" style="margin-bottom:16px">
|
<div class="grid grid-2" style="margin-bottom:16px">
|
||||||
<h2>Déclencher un job manuellement</h2>
|
|
||||||
<p style="font-size:12px;color:var(--muted);margin:0 0 14px">
|
<div class="card">
|
||||||
Le crawler consomme ces demandes dans sa prochaine itération (~1 min d'attente max).
|
<h2>🕷 Crawler</h2>
|
||||||
</p>
|
<p style="font-size:12px;color:var(--muted);margin:0 0 14px">Consommés par le crawler dans sa prochaine itération (~1 min).</p>
|
||||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
<button class="btn" data-job="enrich">▶ Enrich (500 bands)</button>
|
<button class="btn" data-job="enrich">▶ Enrich (500 bands)</button>
|
||||||
<button class="btn" data-job="incremental">▶ Crawl incrémental (créations + modifs)</button>
|
<button class="btn" data-job="incremental">▶ Crawl incrémental (créations + modifs)</button>
|
||||||
<button class="btn" data-job="full_crawl">▶ Crawl complet Europe</button>
|
<button class="btn" data-job="full_crawl" style="border-color:rgba(198,26,26,0.5)">▶ Crawl complet Europe</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="job-feedback" style="margin-top:12px;font-size:12px;color:var(--muted)"></div>
|
<div id="job-feedback" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<h2>🗺 Géocodeur</h2>
|
||||||
|
<p style="font-size:12px;color:var(--muted);margin:0 0 14px">Le géocodeur tourne en continu — ces actions modifient la queue directement.</p>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
|
<button class="btn" id="cmd-reset-errors">🔄 Réinitialiser les erreurs de géocodage</button>
|
||||||
|
<button class="btn" id="cmd-requeue-all" style="border-color:rgba(198,26,26,0.5)">♻️ Re-géocoder tout (erreurs + déjà fait)</button>
|
||||||
|
</div>
|
||||||
|
<div id="geo-cmd-feedback" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>🧹 Maintenance</h2>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
|
<button class="btn" id="cmd-cleanup">🧹 Annuler crawl-runs bloqués (>30 min)</button>
|
||||||
|
</div>
|
||||||
|
<div id="maintenance-feedback" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:16px">
|
||||||
<h2>Historique des jobs déclenchés</h2>
|
<h2>Historique des jobs déclenchés</h2>
|
||||||
<div id="jobs-list"><div class="loading">Chargement…</div></div>
|
<div id="jobs-list"><div class="loading">Chargement…</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||||
|
<h2 style="margin:0">Journal en direct <span style="font-weight:400;font-size:12px;color:var(--muted)">(20 dernières lignes — auto-refresh 10s)</span></h2>
|
||||||
|
<span id="cmd-log-ts" style="font-size:11px;color:var(--muted)"></span>
|
||||||
|
</div>
|
||||||
|
<div id="cmd-log-tail" class="table-wrap"><div class="loading">Chargement…</div></div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Crawler jobs
|
||||||
document.querySelectorAll("[data-job]").forEach((btn) => {
|
document.querySelectorAll("[data-job]").forEach((btn) => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
const job_type = btn.dataset.job;
|
const job_type = btn.dataset.job;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const fb = document.getElementById("job-feedback");
|
const fb = document.getElementById("job-feedback");
|
||||||
fb.textContent = "Envoi…";
|
fb.textContent = "Envoi…"; fb.style.color = "var(--muted)";
|
||||||
try {
|
try {
|
||||||
const r = await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type }) });
|
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.textContent = `✓ Job #${r.id} créé — le crawler le consommera sous ~1 min.`;
|
||||||
fb.style.color = "var(--ok)";
|
fb.style.color = "var(--ok)";
|
||||||
await loadJobsList();
|
await Promise.all([loadJobsList(), loadCmdLogTail()]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
fb.textContent = `Erreur: ${e.message}`;
|
fb.textContent = `Erreur: ${e.message}`; fb.style.color = "var(--err)";
|
||||||
fb.style.color = "var(--err)";
|
|
||||||
} finally { btn.disabled = false; }
|
} 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() {
|
async function loadJobsList() {
|
||||||
|
|
@ -773,11 +929,11 @@ async function loadJobsList() {
|
||||||
<td>${x.id}</td>
|
<td>${x.id}</td>
|
||||||
<td>${esc(x.job_type)}</td>
|
<td>${esc(x.job_type)}</td>
|
||||||
<td>${statusBadge(x.status)}</td>
|
<td>${statusBadge(x.status)}</td>
|
||||||
<td>${esc(x.requested_by||"—")}</td>
|
<td>${esc(x.requested_by || "—")}</td>
|
||||||
<td>${fmtDate(x.created_at)}</td>
|
<td>${fmtDate(x.created_at)}</td>
|
||||||
<td>${fmtDate(x.started_at)}</td>
|
<td>${fmtDate(x.started_at)}</td>
|
||||||
<td>${fmtDate(x.finished_at)}</td>
|
<td>${fmtDate(x.finished_at)}</td>
|
||||||
<td style="color:var(--err)">${esc(x.error||"")}</td>
|
<td style="color:var(--err)">${esc(x.error || "")}</td>
|
||||||
</tr>`).join("") || `<tr><td colspan="8" class="empty">Aucun job</td></tr>`}
|
</tr>`).join("") || `<tr><td colspan="8" class="empty">Aucun job</td></tr>`}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>`;
|
</table></div>`;
|
||||||
|
|
@ -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 = `<table>
|
||||||
|
<thead><tr><th>Date</th><th>Niveau</th><th>Message</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${r.items.map((x) => `<tr>
|
||||||
|
<td style="white-space:nowrap;color:var(--muted)">${fmtDate(x.created_at)}</td>
|
||||||
|
<td><span class="badge ${x.level === "error" ? "err" : x.level === "warning" ? "warn" : "ok"}">${esc(x.level)}</span></td>
|
||||||
|
<td style="font-family:monospace;font-size:11px;word-break:break-all">${esc(x.message)}</td>
|
||||||
|
</tr>`).join("") || `<tr><td colspan="3" class="empty">Aucun log</td></tr>`}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<div class="empty">Erreur : ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Checkpoints
|
// Checkpoints
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
|
|
|
||||||
|
|
@ -483,13 +483,61 @@ export default async function adminRoutes(fastify, opts) {
|
||||||
`INSERT INTO job_triggers (job_type, requested_by) VALUES ($1, $2) RETURNING id`,
|
`INSERT INTO job_triggers (job_type, requested_by) VALUES ($1, $2) RETURNING id`,
|
||||||
[job_type, req.adminUsername]
|
[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) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return reply.code(500).send({ ok: false, error: "Erreur création job" });
|
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)
|
// Journal d'audit (actions admin)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -2,6 +2,37 @@
|
||||||
/* Performance-optimized with viewport-based loading */
|
/* Performance-optimized with viewport-based loading */
|
||||||
|
|
||||||
const API_BASE = "https://bm.nicolasfryder.ovh";
|
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_TILES = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||||||
const DARK_ATTRIB = '© OpenStreetMap contributors © CARTO';
|
const DARK_ATTRIB = '© OpenStreetMap contributors © CARTO';
|
||||||
|
|
||||||
|
|
@ -594,8 +625,8 @@ function createLocationMarker(lat, lon, count, bands, locationName) {
|
||||||
const popupHtml = `
|
const popupHtml = `
|
||||||
<div style="min-width:280px; max-width:350px;">
|
<div style="min-width:280px; max-width:350px;">
|
||||||
<div style="font-weight:900; margin-bottom:8px; padding-bottom:8px; font-size:14px; color:#c61a1a; border-bottom:1px solid rgba(255,255,255,0.1);">
|
<div style="font-weight:900; margin-bottom:8px; padding-bottom:8px; font-size:14px; color:#c61a1a; border-bottom:1px solid rgba(255,255,255,0.1);">
|
||||||
📍 ${escapeHtml(locationName || 'Localisation')}
|
📍 ${escapeHtml(locationName || t('location_fallback'))}
|
||||||
<span style="font-weight:normal;color:#a2b0c2;font-size:12px;margin-left:8px;">${count} groupe${count > 1 ? 's' : ''}</span>
|
<span style="font-weight:normal;color:#a2b0c2;font-size:12px;margin-left:8px;">${count} ${count === 1 ? t('group_s') : t('group_p')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="max-height:300px; overflow-y:auto; margin:-4px;">
|
<div style="max-height:300px; overflow-y:auto; margin:-4px;">
|
||||||
${bandListHtml}
|
${bandListHtml}
|
||||||
|
|
@ -613,7 +644,8 @@ function createLocationMarker(lat, lon, count, bands, locationName) {
|
||||||
// Click handler - update sidebar list AND open popup
|
// Click handler - update sidebar list AND open popup
|
||||||
marker.on("click", (e) => {
|
marker.on("click", (e) => {
|
||||||
L.DomEvent.stopPropagation(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);
|
renderList(bands, hint);
|
||||||
// Small delay to ensure popup opens correctly
|
// Small delay to ensure popup opens correctly
|
||||||
setTimeout(() => marker.openPopup(), 10);
|
setTimeout(() => marker.openPopup(), 10);
|
||||||
|
|
@ -1118,7 +1150,7 @@ function renderList(items, hintOverride) {
|
||||||
listElById.clear();
|
listElById.clear();
|
||||||
|
|
||||||
const sorted = sortBands(items);
|
const sorted = sortBands(items);
|
||||||
$("selectionHint").textContent = hintOverride || `${sorted.length} résultat(s)`;
|
$("selectionHint").textContent = hintOverride || `${sorted.length} ${t("results_label")}`;
|
||||||
|
|
||||||
const q = currentQuery();
|
const q = currentQuery();
|
||||||
|
|
||||||
|
|
@ -1302,57 +1334,7 @@ const infoBody = $("infoBody");
|
||||||
const infoClose = $("infoClose");
|
const infoClose = $("infoClose");
|
||||||
const infoTitle = $("infoTitle");
|
const infoTitle = $("infoTitle");
|
||||||
|
|
||||||
const LEGAL_HTML = `
|
// LEGAL_HTML and FAQ_HTML are now generated per-language in locales.js via t('legal_html') / t('faq_html')
|
||||||
<div style="display:flex;flex-direction:column;gap:16px;">
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Éditeur du site</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">Auteur: Nico</span><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">Statut : particulier</span><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">Contact : <a href="mailto:contact@metalfrom.eu" style="color: rgba(198,26,26,0.85); text-decoration: none;">contact@metalfrom.eu</a></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Hébergeur</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">OVH SAS – 2 rue Kellermann - 59100 Roubaix - France</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Propriété intellectuelle</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">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 <a href="https://www.metal-archives.com/" target="_blank" rel="noopener noreferrer" style="color: rgba(198,26,26,0.85); text-decoration: none;">https://www.metal-archives.com/</a>, avec l'autorisation des webmasters</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Données personnelles</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">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.</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Responsable de publication</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">Nico</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FAQ_HTML = `
|
|
||||||
<div style="display:flex;flex-direction:column;gap:14px;">
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : D'où viennent les données ?</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">R : Metal Archives + enrichissement géographique automatisés. Quelques changements à la main, mais c'est fastidieux.</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : Puis-je corriger une erreur ?</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">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)</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : Pourquoi certains groupes ne sont pas visibles?</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">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.</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : Je viens de créer mon groupe sur Metal Archives mais il n'apparaît pas</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">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</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : Est-ce que le site me traque ou collecte mes données?</b><br/>
|
|
||||||
<span style="color: rgba(162,176,194,0.90);">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 !</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
function openInfoModal(title, html) {
|
function openInfoModal(title, html) {
|
||||||
if (!infoBackdrop || !infoBody || !infoTitle) return;
|
if (!infoBackdrop || !infoBody || !infoTitle) return;
|
||||||
|
|
@ -1392,7 +1374,7 @@ function setSidebarCollapsed(collapsed) {
|
||||||
// Update button text
|
// Update button text
|
||||||
const label = toggle.querySelector(".label");
|
const label = toggle.querySelector(".label");
|
||||||
if (label) {
|
if (label) {
|
||||||
label.textContent = collapsed ? "Options" : "Fermer";
|
label.textContent = collapsed ? t("options_btn") : t("close_btn");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refresh map size after sidebar animation
|
// Refresh map size after sidebar animation
|
||||||
|
|
@ -1420,12 +1402,24 @@ if (window.matchMedia("(max-width: 1200px)").matches) {
|
||||||
}, 100);
|
}, 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
|
// Legal/FAQ links
|
||||||
const openLegal = $("openLegal");
|
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");
|
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
|
// Setup dropdowns
|
||||||
setupDropdown("countryToggle", "countrySelect");
|
setupDropdown("countryToggle", "countrySelect");
|
||||||
|
|
@ -1434,7 +1428,7 @@ setupDropdown("themeToggle", "themeSelect");
|
||||||
|
|
||||||
// --- Buttons / controls ---
|
// --- Buttons / controls ---
|
||||||
$("btnNoLocation")?.addEventListener("click", () => {
|
$("btnNoLocation")?.addEventListener("click", () => {
|
||||||
modalTitle.textContent = "Groupes sans coordonnées";
|
modalTitle.textContent = t("without_coords_modal");
|
||||||
modalBackdrop.classList.add("on");
|
modalBackdrop.classList.add("on");
|
||||||
modalBackdrop.setAttribute("aria-hidden", "false");
|
modalBackdrop.setAttribute("aria-hidden", "false");
|
||||||
loadNoLocationBands();
|
loadNoLocationBands();
|
||||||
|
|
@ -1601,8 +1595,8 @@ function buildTimelineFromRange() {
|
||||||
if (!yearRange.min || !yearRange.max) {
|
if (!yearRange.min || !yearRange.max) {
|
||||||
$("yearMin").textContent = "—";
|
$("yearMin").textContent = "—";
|
||||||
$("yearMax").textContent = "—";
|
$("yearMax").textContent = "—";
|
||||||
$("yearHint").textContent = "données manquantes";
|
$("yearHint").textContent = t("year_data_missing");
|
||||||
slider.innerHTML = `<div class="timeline-empty">Données d'année indisponibles</div>`;
|
slider.innerHTML = `<div class="timeline-empty">${t("year_data_unavailable")}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1612,7 +1606,7 @@ function buildTimelineFromRange() {
|
||||||
|
|
||||||
$("yearMin").textContent = String(min);
|
$("yearMin").textContent = String(min);
|
||||||
$("yearMax").textContent = String(max);
|
$("yearMax").textContent = String(max);
|
||||||
$("yearHint").textContent = "toutes";
|
$("yearHint").textContent = t("all_years_label");
|
||||||
|
|
||||||
noUiSlider.create(slider, {
|
noUiSlider.create(slider, {
|
||||||
start: [min, max],
|
start: [min, max],
|
||||||
|
|
@ -1627,7 +1621,7 @@ function buildTimelineFromRange() {
|
||||||
const a = Math.round(Number(vals[0]));
|
const a = Math.round(Number(vals[0]));
|
||||||
const b = Math.round(Number(vals[1]));
|
const b = Math.round(Number(vals[1]));
|
||||||
yearFilter = { min: a, max: b };
|
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", () => {
|
slider.noUiSlider.on("change", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
<link rel="stylesheet" href="https://unpkg.com/nouislider@15.7.1/dist/nouislider.min.css">
|
<link rel="stylesheet" href="https://unpkg.com/nouislider@15.7.1/dist/nouislider.min.css">
|
||||||
<script src="https://unpkg.com/nouislider@15.7.1/dist/nouislider.min.js"></script>
|
<script src="https://unpkg.com/nouislider@15.7.1/dist/nouislider.min.js"></script>
|
||||||
|
|
||||||
<!-- GoatCounter pour les visites-->
|
<!-- GoatCounter -->
|
||||||
<script data-goatcounter="https://metalfromeurope.goatcounter.com/count"
|
<script data-goatcounter="https://metalfromeurope.goatcounter.com/count"
|
||||||
async src="//gc.zgo.at/count.js"></script>
|
async src="//gc.zgo.at/count.js"></script>
|
||||||
|
|
||||||
|
|
@ -37,10 +37,23 @@
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<button class="sidebar-toggle" id="sidebarToggle" type="button" aria-expanded="true" aria-controls="sidebar" title="Afficher / masquer les options">
|
<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="icon">☰</span>
|
||||||
<span class="label">Options</span>
|
<span class="label" data-i18n="options_btn">Options</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="brand">Metal from Europe</div>
|
<div class="brand">Metal from Europe</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
|
<select id="langSelect" class="lang-select" aria-label="Language">
|
||||||
|
<option value="fr">🇫🇷 FR</option>
|
||||||
|
<option value="en">🇬🇧 EN</option>
|
||||||
|
<option value="de">🇩🇪 DE</option>
|
||||||
|
<option value="es">🇪🇸 ES</option>
|
||||||
|
<option value="it">🇮🇹 IT</option>
|
||||||
|
<option value="pl">🇵🇱 PL</option>
|
||||||
|
<option value="nl">🇳🇱 NL</option>
|
||||||
|
<option value="ro">🇷🇴 RO</option>
|
||||||
|
<option value="pt">🇵🇹 PT</option>
|
||||||
|
<option value="cs">🇨🇿 CS</option>
|
||||||
|
<option value="sv">🇸🇪 SV</option>
|
||||||
|
</select>
|
||||||
<a href="https://discord.gg/P2DE57Ab" target="_blank" rel="noopener noreferrer" class="social-link" title="Rejoindre notre Discord">
|
<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">
|
<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"/>
|
<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"/>
|
||||||
|
|
@ -74,6 +87,7 @@
|
||||||
id="q"
|
id="q"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Rechercher (nom, genre, ville, pays)…"
|
placeholder="Rechercher (nom, genre, ville, pays)…"
|
||||||
|
data-i18n-placeholder="search_placeholder"
|
||||||
aria-label="Rechercher des groupes"
|
aria-label="Rechercher des groupes"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
|
|
@ -82,21 +96,21 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<span class="chip"><span class="dot"></span><b id="count">0</b> affichés</span>
|
<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> total</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> localités</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> localisés</span>
|
<span class="chip"><b id="geocoded">0</b> <span data-i18n="located">localisés</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="controls-head">
|
<div class="controls-head">
|
||||||
<div class="controls-title">Vue</div>
|
<div class="controls-title" data-i18n="view_section">Vue</div>
|
||||||
<div class="controls-actions">
|
<div class="controls-actions">
|
||||||
<button class="btn btn-compact" id="btnReset" type="button">Reset view</button>
|
<button class="btn btn-compact" id="btnReset" type="button" data-i18n="reset_view">Reset view</button>
|
||||||
<label class="switch" title="Activer la heatmap">
|
<label class="switch" title="Activer la heatmap">
|
||||||
<input type="checkbox" id="toggleHeat" />
|
<input type="checkbox" id="toggleHeat" />
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
<span class="switch-label">Heatmap</span>
|
<span class="switch-label" data-i18n="heatmap_label">Heatmap</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,18 +118,18 @@
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="controls-head">
|
<div class="controls-head">
|
||||||
<div class="controls-title">Filtres</div>
|
<div class="controls-title" data-i18n="filters_section">Filtres</div>
|
||||||
<button class="btn btn-compact" id="btnNoLocation" type="button">
|
<button class="btn btn-compact" id="btnNoLocation" type="button">
|
||||||
Sans coords <span class="badge" id="noLocCount">0</span>
|
<span data-i18n="without_coords_btn">Sans coords</span> <span class="badge" id="noLocCount">0</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Country -->
|
<!-- Country -->
|
||||||
<div class="filter-box">
|
<div class="filter-box">
|
||||||
<div class="filter-title">Pays</div>
|
<div class="filter-title" data-i18n="country_label">Pays</div>
|
||||||
<button class="dropdown-trigger" id="countryToggle" type="button" aria-expanded="false" aria-controls="countrySelect">
|
<button class="dropdown-trigger" id="countryToggle" type="button" aria-expanded="false" aria-controls="countrySelect">
|
||||||
<span class="label">Sélection</span>
|
<span class="label" data-i18n="selection_label">Sélection</span>
|
||||||
<span class="summary" id="countrySummary">Tous</span>
|
<span class="summary" id="countrySummary" data-i18n="all_label">Tous</span>
|
||||||
<span class="caret">▾</span>
|
<span class="caret">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="multiselect is-collapsed" id="countrySelect"></div>
|
<div class="multiselect is-collapsed" id="countrySelect"></div>
|
||||||
|
|
@ -123,22 +137,22 @@
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="filter-box">
|
<div class="filter-box">
|
||||||
<div class="filter-title">Statut</div>
|
<div class="filter-title" data-i18n="status_label">Statut</div>
|
||||||
<div class="chips" id="statusGrid"></div>
|
<div class="chips" id="statusGrid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Macro genres -->
|
<!-- Macro genres -->
|
||||||
<div class="filter-box">
|
<div class="filter-box">
|
||||||
<div class="filter-title">Genres</div>
|
<div class="filter-title" data-i18n="genres_label">Genres</div>
|
||||||
<div class="macro-grid" id="macroGenres"></div>
|
<div class="macro-grid" id="macroGenres"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Genre multiselect -->
|
<!-- Genre multiselect -->
|
||||||
<div class="filter-box">
|
<div class="filter-box">
|
||||||
<div class="filter-title">Sous-genres</div>
|
<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">
|
<button class="dropdown-trigger" id="genreToggle" type="button" aria-expanded="false" aria-controls="genreSelect">
|
||||||
<span class="label">Sélection</span>
|
<span class="label" data-i18n="selection_label">Sélection</span>
|
||||||
<span class="summary" id="genreSummary">Tous</span>
|
<span class="summary" id="genreSummary" data-i18n="all_label">Tous</span>
|
||||||
<span class="caret">▾</span>
|
<span class="caret">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="multiselect is-collapsed" id="genreSelect"></div>
|
<div class="multiselect is-collapsed" id="genreSelect"></div>
|
||||||
|
|
@ -146,10 +160,10 @@
|
||||||
|
|
||||||
<!-- Themes -->
|
<!-- Themes -->
|
||||||
<div class="filter-box" id="themeBox" style="display:none;">
|
<div class="filter-box" id="themeBox" style="display:none;">
|
||||||
<div class="filter-title">Thèmes</div>
|
<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">
|
<button class="dropdown-trigger" id="themeToggle" type="button" aria-expanded="false" aria-controls="themeSelect">
|
||||||
<span class="label">Sélection</span>
|
<span class="label" data-i18n="selection_label">Sélection</span>
|
||||||
<span class="summary" id="themeSummary">Tous</span>
|
<span class="summary" id="themeSummary" data-i18n="all_label">Tous</span>
|
||||||
<span class="caret">▾</span>
|
<span class="caret">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="multiselect is-collapsed" id="themeSelect"></div>
|
<div class="multiselect is-collapsed" id="themeSelect"></div>
|
||||||
|
|
@ -157,33 +171,33 @@
|
||||||
|
|
||||||
<!-- Timeline -->
|
<!-- Timeline -->
|
||||||
<div class="filter-box">
|
<div class="filter-box">
|
||||||
<div class="filter-title">Timeline (année de formation)</div>
|
<div class="filter-title" data-i18n="timeline_label">Timeline (année de formation)</div>
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
<div id="yearSlider"></div>
|
<div id="yearSlider"></div>
|
||||||
<div class="timeline-meta">
|
<div class="timeline-meta">
|
||||||
<span class="chip"><b id="yearMin">—</b></span>
|
<span class="chip"><b id="yearMin">—</b></span>
|
||||||
<span class="chip"><b id="yearMax">—</b></span>
|
<span class="chip"><b id="yearMax">—</b></span>
|
||||||
<span class="chip"><b id="yearHint">toutes</b></span>
|
<span class="chip"><b id="yearHint">—</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sort -->
|
<!-- Sort -->
|
||||||
<div class="filter-box">
|
<div class="filter-box">
|
||||||
<div class="filter-title">Tri de la liste</div>
|
<div class="filter-title" data-i18n="sort_label">Tri de la liste</div>
|
||||||
<select id="sortSelect" class="select" aria-label="Trier les groupes">
|
<select id="sortSelect" class="select" aria-label="Trier les groupes">
|
||||||
<option value="az">A → Z</option>
|
<option value="az" data-i18n="sort_az">A → Z</option>
|
||||||
<option value="status">Par statut</option>
|
<option value="status" data-i18n="sort_status">Par statut</option>
|
||||||
<option value="genre">Par genre</option>
|
<option value="genre" data-i18n="sort_genre">Par genre</option>
|
||||||
<option value="country">Par pays</option>
|
<option value="country" data-i18n="sort_country">Par pays</option>
|
||||||
<option value="year">Par année (récent → ancien)</option>
|
<option value="year" data-i18n="sort_year">Par année (récent → ancien)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div class="h">Liste</div>
|
<div class="h" data-i18n="list_section">Liste</div>
|
||||||
<div class="sub" id="selectionHint">—</div>
|
<div class="sub" id="selectionHint">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body" id="list"></div>
|
<div class="panel-body" id="list"></div>
|
||||||
|
|
@ -192,22 +206,22 @@
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main id="mapWrap">
|
<main id="mapWrap">
|
||||||
<div id="map" role="application" aria-label="Carte des groupes de Black Metal"></div>
|
<div id="map" role="application" aria-label="Carte des groupes de Metal"></div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer id="legalBar">
|
<footer id="legalBar">
|
||||||
<div class="legal-inner">
|
<div class="legal-inner">
|
||||||
<button class="legal-link" id="openLegal" type="button">Mentions légales</button>
|
<button class="legal-link" id="openLegal" type="button" data-i18n="legal_link">Mentions légales</button>
|
||||||
<span class="legal-dot">•</span>
|
<span class="legal-dot">•</span>
|
||||||
<button class="legal-link" id="openFaq" type="button">FAQ</button>
|
<button class="legal-link" id="openFaq" type="button" data-i18n="faq_link">FAQ</button>
|
||||||
|
|
||||||
<span class="legal-dot">•</span>
|
<span class="legal-dot">•</span>
|
||||||
|
|
||||||
<span class="legal-stats" aria-label="Statistiques de visites">
|
<span class="legal-stats" aria-label="Statistiques de visites">
|
||||||
<span class="legal-stat">Visites <b id="gcTotal">—</b></span>
|
<span class="legal-stat"><span data-i18n="visits_label">Visites</span> <b id="gcTotal">—</b></span>
|
||||||
<span class="legal-dot">/</span>
|
<span class="legal-dot">/</span>
|
||||||
<span class="legal-stat">Ce mois <b id="gcMonth">—</b></span>
|
<span class="legal-stat"><span data-i18n="this_month_label">Ce mois</span> <b id="gcMonth">—</b></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
@ -234,6 +248,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="./locales.js"></script>
|
||||||
<script src="./app.js"></script>
|
<script src="./app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
476
apps/web/site/locales.js
Normal file
476
apps/web/site/locales.js
Normal file
|
|
@ -0,0 +1,476 @@
|
||||||
|
/* locales.js — 11 langues pour Metal from Europe */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const H = "font-size:14px;color:rgba(231,226,218,0.95)";
|
||||||
|
const T = "color:rgba(162,176,194,0.90)";
|
||||||
|
const A = "color:rgba(198,26,26,0.85);text-decoration:none";
|
||||||
|
|
||||||
|
function lk(href, text) {
|
||||||
|
return `<a href="${href}" target="_blank" rel="noopener noreferrer" style="${A}">${text}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const L = {
|
||||||
|
contact: `<a href="mailto:contact@metalfrom.eu" style="${A}">contact@metalfrom.eu</a>`,
|
||||||
|
ma: lk("https://www.metal-archives.com/", "Metal Archives"),
|
||||||
|
geoapify: lk("https://www.geoapify.com/", "Geoapify"),
|
||||||
|
osm: lk("https://www.openstreetmap.org/", "OpenStreetMap"),
|
||||||
|
leaflet: lk("https://leafletjs.com/", "Leaflet"),
|
||||||
|
gc: lk("https://www.goatcounter.com/", "GoatCounter"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function lb(title, content) {
|
||||||
|
return `<div><b style="${H}">${title}</b><br/><span style="${T}">${content}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function faqBlock(q, a) {
|
||||||
|
return `<div><b style="${H}">${q}</b><br/><span style="${T}">${a}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegal(d) {
|
||||||
|
const blocks = [
|
||||||
|
lb(d.ed_title,
|
||||||
|
`${d.ed_name}<br/>${d.ed_status}<br/>${d.ed_contact} ${L.contact}`),
|
||||||
|
lb(d.host_title, d.host_text),
|
||||||
|
lb(d.ip_title, d.ip_text.replace("__MA__", L.ma)),
|
||||||
|
lb(d.prv_title, d.prv_text.replace("__GC__", L.gc)),
|
||||||
|
lb(d.pub_title, d.pub_name),
|
||||||
|
lb(d.thanks_title,
|
||||||
|
`${d.thanks_intro}<br/>
|
||||||
|
• ${d.thanks_map} : ${L.osm} + ${L.leaflet}<br/>
|
||||||
|
• ${d.thanks_geo} : Powered by ${L.geoapify}<br/>
|
||||||
|
• ${d.thanks_data} : ${L.ma}<br/>
|
||||||
|
• ${d.thanks_hb}`),
|
||||||
|
];
|
||||||
|
return `<div style="display:flex;flex-direction:column;gap:16px;">${blocks.join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFaq(d) {
|
||||||
|
const blocks = d.faq.map(([q, a]) => faqBlock(q, a));
|
||||||
|
return `<div style="display:flex;flex-direction:column;gap:14px;">${blocks.join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
Données par langue
|
||||||
|
================================================================ */
|
||||||
|
const RAW = {
|
||||||
|
|
||||||
|
fr: {
|
||||||
|
search_placeholder: "Rechercher (nom, genre, ville, pays)…",
|
||||||
|
displayed: "affichés", total_bands: "total", localities: "localités", located: "localisés",
|
||||||
|
view_section: "Vue", reset_view: "Reset vue", heatmap_label: "Heatmap",
|
||||||
|
filters_section: "Filtres", without_coords_btn: "Sans coords",
|
||||||
|
country_label: "Pays", all_label: "Tous", status_label: "Statut",
|
||||||
|
genres_label: "Genres", subgenres_label: "Sous-genres", selection_label: "Sélection",
|
||||||
|
themes_label: "Thèmes", timeline_label: "Timeline (année de formation)",
|
||||||
|
sort_label: "Tri de la liste", sort_az: "A → Z", sort_status: "Par statut",
|
||||||
|
sort_genre: "Par genre", sort_country: "Par pays", sort_year: "Par année (récent → ancien)",
|
||||||
|
list_section: "Liste", legal_link: "Mentions légales", faq_link: "FAQ",
|
||||||
|
visits_label: "Visites", this_month_label: "Ce mois", options_btn: "Options", close_btn: "Fermer",
|
||||||
|
all_years_label: "toutes", year_data_missing: "données manquantes",
|
||||||
|
year_data_unavailable: "Données d’année indisponibles", results_label: "résultat(s)",
|
||||||
|
loading_text: "Chargement…", without_coords_modal: "Groupes sans coordonnées",
|
||||||
|
group_s: "groupe", group_p: "groupes", location_fallback: "Localisation",
|
||||||
|
legal_title: "Mentions légales", faq_title: "FAQ",
|
||||||
|
// legal
|
||||||
|
ed_title: "Éditeur du site", ed_name: "Auteur : Nico", ed_status: "Statut : particulier", ed_contact: "Contact :",
|
||||||
|
host_title: "Hébergeur", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - France",
|
||||||
|
ip_title: "Propriété intellectuelle",
|
||||||
|
ip_text: "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 __MA__, avec l’autorisation des webmasters.",
|
||||||
|
prv_title: "Données personnelles",
|
||||||
|
prv_text: "Ce site ne collecte pas d’informations personnelles ni ne dépose de cookies de suivi. Des logs techniques peuvent être conservés par l’hébergeur. Le trafic est analysé via __GC__ (sans cookie).",
|
||||||
|
pub_title: "Responsable de publication", pub_name: "Nico",
|
||||||
|
thanks_title: "Remerciements",
|
||||||
|
thanks_intro: "Ce site repose sur des outils et services formidables :",
|
||||||
|
thanks_map: "Cartographie", thanks_geo: "Géocodage", thanks_data: "Données",
|
||||||
|
thanks_hb: "Remerciements spéciaux à HellBlazer (webmaster Metal Archives) pour l’autorisation de scraping.",
|
||||||
|
faq: [
|
||||||
|
["Q : D’où viennent les données ?", "R : Metal Archives + enrichissement géographique automatisé. Quelques corrections manuelles, mais c’est fastidieux."],
|
||||||
|
["Q : Puis-je corriger une erreur ?", "R : Oui, si elle concerne le site, contacte-moi via l’adresse dans les mentions légales. Pour les données, corrige directement sur Metal Archives."],
|
||||||
|
["Q : Pourquoi certains groupes ne sont pas visibles ?", "R : Il y a des erreurs inhérentes au géocodage automatique. La correction manuelle des localisations est fastidieuse."],
|
||||||
|
["Q : Je viens de créer mon groupe sur Metal Archives mais il n’apparaît pas.", "R : Aucune synchronisation automatique n’est implémentée pour l’instant."],
|
||||||
|
["Q : Est-ce que le site me traque ?", "R : Uniquement pour l’analyse de trafic via GoatCounter (sans cookie). Les adblockers bloquent ces appels sans problème."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
en: {
|
||||||
|
search_placeholder: "Search (name, genre, city, country)…",
|
||||||
|
displayed: "displayed", total_bands: "total", localities: "localities", located: "located",
|
||||||
|
view_section: "View", reset_view: "Reset view", heatmap_label: "Heatmap",
|
||||||
|
filters_section: "Filters", without_coords_btn: "Without coords",
|
||||||
|
country_label: "Country", all_label: "All", status_label: "Status",
|
||||||
|
genres_label: "Genres", subgenres_label: "Subgenres", selection_label: "Selection",
|
||||||
|
themes_label: "Themes", timeline_label: "Timeline (formation year)",
|
||||||
|
sort_label: "Sort list", sort_az: "A → Z", sort_status: "By status",
|
||||||
|
sort_genre: "By genre", sort_country: "By country", sort_year: "By year (recent → oldest)",
|
||||||
|
list_section: "List", legal_link: "Legal notice", faq_link: "FAQ",
|
||||||
|
visits_label: "Visits", this_month_label: "This month", options_btn: "Options", close_btn: "Close",
|
||||||
|
all_years_label: "all", year_data_missing: "data missing",
|
||||||
|
year_data_unavailable: "Year data unavailable", results_label: "result(s)",
|
||||||
|
loading_text: "Loading…", without_coords_modal: "Bands without coordinates",
|
||||||
|
group_s: "band", group_p: "bands", location_fallback: "Location",
|
||||||
|
legal_title: "Legal notice", faq_title: "FAQ",
|
||||||
|
ed_title: "Site Editor", ed_name: "Author: Nico", ed_status: "Status: individual", ed_contact: "Contact:",
|
||||||
|
host_title: "Hosting", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - France",
|
||||||
|
ip_title: "Intellectual Property",
|
||||||
|
ip_text: "Content (texts, visuals, aggregated data) is provided for informational purposes. Band logos and names remain the property of their authors. All data originates from __MA__, with the webmasters’ permission.",
|
||||||
|
prv_title: "Personal Data",
|
||||||
|
prv_text: "This site does not collect personal information or set tracking cookies. Technical logs may be kept by the hosting provider. Traffic is analyzed via __GC__ (cookie-free).",
|
||||||
|
pub_title: "Publication Manager", pub_name: "Nico",
|
||||||
|
thanks_title: "Acknowledgments",
|
||||||
|
thanks_intro: "This site relies on outstanding tools and services:",
|
||||||
|
thanks_map: "Mapping", thanks_geo: "Geocoding", thanks_data: "Data",
|
||||||
|
thanks_hb: "Special thanks to HellBlazer (Metal Archives webmaster) for granting scraping permission.",
|
||||||
|
faq: [
|
||||||
|
["Q: Where does the data come from?", "A: Metal Archives + automated geographic enrichment. Some manual corrections, but it’s tedious."],
|
||||||
|
["Q: Can I correct an error?", "A: Yes, for site issues contact me via the email in the legal notice. For data issues, correct directly on Metal Archives."],
|
||||||
|
["Q: Why are some bands not visible?", "A: Automatic geocoding has inherent errors. Manual correction of locations is tedious, so some data may be inaccurate."],
|
||||||
|
["Q: I just created my band on Metal Archives but it doesn’t appear.", "A: No automatic sync with Metal Archives is currently implemented."],
|
||||||
|
["Q: Does the site track me?", "A: Only for traffic analysis via GoatCounter (no cookies). Ad blockers block these requests without issue."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
de: {
|
||||||
|
search_placeholder: "Suchen (Name, Genre, Stadt, Land)…",
|
||||||
|
displayed: "angezeigt", total_bands: "gesamt", localities: "Orte", located: "geortet",
|
||||||
|
view_section: "Ansicht", reset_view: "Ansicht zurücksetzen", heatmap_label: "Heatmap",
|
||||||
|
filters_section: "Filter", without_coords_btn: "Ohne Koordinaten",
|
||||||
|
country_label: "Land", all_label: "Alle", status_label: "Status",
|
||||||
|
genres_label: "Genres", subgenres_label: "Subgenres", selection_label: "Auswahl",
|
||||||
|
themes_label: "Themen", timeline_label: "Zeitstrahl (Gründungsjahr)",
|
||||||
|
sort_label: "Liste sortieren", sort_az: "A → Z", sort_status: "Nach Status",
|
||||||
|
sort_genre: "Nach Genre", sort_country: "Nach Land", sort_year: "Nach Jahr (neueste → älteste)",
|
||||||
|
list_section: "Liste", legal_link: "Impressum", faq_link: "FAQ",
|
||||||
|
visits_label: "Besuche", this_month_label: "Diesen Monat", options_btn: "Optionen", close_btn: "Schließen",
|
||||||
|
all_years_label: "alle", year_data_missing: "Daten fehlen",
|
||||||
|
year_data_unavailable: "Jahresdaten nicht verfügbar", results_label: "Ergebnis(se)",
|
||||||
|
loading_text: "Laden…", without_coords_modal: "Bands ohne Koordinaten",
|
||||||
|
group_s: "Band", group_p: "Bands", location_fallback: "Standort",
|
||||||
|
legal_title: "Impressum", faq_title: "FAQ",
|
||||||
|
ed_title: "Websitebetreiber", ed_name: "Autor: Nico", ed_status: "Status: Privatperson", ed_contact: "Kontakt:",
|
||||||
|
host_title: "Hosting", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - Frankreich",
|
||||||
|
ip_title: "Geistiges Eigentum",
|
||||||
|
ip_text: "Inhalte (Texte, Grafiken, aggregierte Daten) dienen informativen Zwecken. Band-Logos und Namen bleiben Eigentum ihrer Urheber. Alle Daten stammen von __MA__, mit Genehmigung der Webmaster.",
|
||||||
|
prv_title: "Datenschutz",
|
||||||
|
prv_text: "Diese Website erhebt keine personenbezogenen Daten und setzt keine Tracking-Cookies. Technische Logs können vom Hosting-Anbieter gespeichert werden. Der Datenverkehr wird über __GC__ analysiert (ohne Cookie).",
|
||||||
|
pub_title: "Verantwortlicher", pub_name: "Nico",
|
||||||
|
thanks_title: "Danksagungen",
|
||||||
|
thanks_intro: "Diese Website nutzt herausragende Tools und Dienste:",
|
||||||
|
thanks_map: "Kartierung", thanks_geo: "Geocodierung", thanks_data: "Daten",
|
||||||
|
thanks_hb: "Besonderen Dank an HellBlazer (Metal Archives Webmaster) für die Scraping-Genehmigung.",
|
||||||
|
faq: [
|
||||||
|
["F: Woher kommen die Daten?", "A: Metal Archives + automatische geografische Anreicherung. Einige manuelle Korrekturen, aber das ist mühsam."],
|
||||||
|
["F: Kann ich einen Fehler korrigieren?", "A: Ja, bei Website-Problemen kontaktiere mich über die im Impressum angegebene E-Mail. Bei Datenproblemen bitte direkt auf Metal Archives korrigieren."],
|
||||||
|
["F: Warum sind manche Bands nicht sichtbar?", "A: Das automatische Geocoding ist fehleranfällig. Die manuelle Korrektur von Standorten ist aufwändig."],
|
||||||
|
["F: Ich habe meine Band auf Metal Archives erstellt, aber sie erscheint nicht.", "A: Derzeit ist keine automatische Synchronisierung mit Metal Archives implementiert."],
|
||||||
|
["F: Trackt mich die Website?", "A: Nur für Verkehrsanalysen via GoatCounter (ohne Cookie). Werbeblocker blockieren diese Anfragen problemlos."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
es: {
|
||||||
|
search_placeholder: "Buscar (nombre, género, ciudad, país)…",
|
||||||
|
displayed: "mostrados", total_bands: "total", localities: "localidades", located: "localizados",
|
||||||
|
view_section: "Vista", reset_view: "Restablecer vista", heatmap_label: "Mapa de calor",
|
||||||
|
filters_section: "Filtros", without_coords_btn: "Sin coordenadas",
|
||||||
|
country_label: "País", all_label: "Todos", status_label: "Estado",
|
||||||
|
genres_label: "Géneros", subgenres_label: "Subgéneros", selection_label: "Selección",
|
||||||
|
themes_label: "Temas", timeline_label: "Línea de tiempo (año de formación)",
|
||||||
|
sort_label: "Ordenar lista", sort_az: "A → Z", sort_status: "Por estado",
|
||||||
|
sort_genre: "Por género", sort_country: "Por país", sort_year: "Por año (reciente → antiguo)",
|
||||||
|
list_section: "Lista", legal_link: "Aviso legal", faq_link: "FAQ",
|
||||||
|
visits_label: "Visitas", this_month_label: "Este mes", options_btn: "Opciones", close_btn: "Cerrar",
|
||||||
|
all_years_label: "todos", year_data_missing: "datos no disponibles",
|
||||||
|
year_data_unavailable: "Datos de año no disponibles", results_label: "resultado(s)",
|
||||||
|
loading_text: "Cargando…", without_coords_modal: "Grupos sin coordenadas",
|
||||||
|
group_s: "grupo", group_p: "grupos", location_fallback: "Ubicación",
|
||||||
|
legal_title: "Aviso legal", faq_title: "FAQ",
|
||||||
|
ed_title: "Editor del sitio", ed_name: "Autor: Nico", ed_status: "Condición: particular", ed_contact: "Contacto:",
|
||||||
|
host_title: "Alojamiento", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - Francia",
|
||||||
|
ip_title: "Propiedad intelectual",
|
||||||
|
ip_text: "Los contenidos (textos, imágenes, datos agregados) se ofrecen con fines informativos. Los logotipos y nombres de grupos son propiedad de sus autores. Todos los datos provienen de __MA__, con permiso de los administradores.",
|
||||||
|
prv_title: "Datos personales",
|
||||||
|
prv_text: "Este sitio no recopila datos personales ni instala cookies de seguimiento. El proveedor de alojamiento puede conservar registros técnicos. El tráfico se analiza mediante __GC__ (sin cookies).",
|
||||||
|
pub_title: "Responsable de publicación", pub_name: "Nico",
|
||||||
|
thanks_title: "Agradecimientos",
|
||||||
|
thanks_intro: "Este sitio utiliza herramientas y servicios excepcionales:",
|
||||||
|
thanks_map: "Cartografía", thanks_geo: "Geocodificación", thanks_data: "Datos",
|
||||||
|
thanks_hb: "Agradecimiento especial a HellBlazer (webmaster de Metal Archives) por autorizar el scraping.",
|
||||||
|
faq: [
|
||||||
|
["P: ¿De dónde vienen los datos?", "R: Metal Archives + enriquecimiento geográfico automatizado. Algunas correcciones manuales, pero es tedioso."],
|
||||||
|
["P: ¿Puedo corregir un error?", "R: Sí, si es del sitio contáctame por el correo en el aviso legal. Para datos, corrígelo directamente en Metal Archives."],
|
||||||
|
["P: ¿Por qué algunos grupos no son visibles?", "R: El geocodificado automático tiene errores inherentes. La corrección manual de ubicaciones es tediosa."],
|
||||||
|
["P: Acabo de crear mi grupo en Metal Archives pero no aparece.", "R: Actualmente no hay sincronización automática con Metal Archives."],
|
||||||
|
["P: ¿El sitio me rastrea?", "R: Solo para análisis de tráfico via GoatCounter (sin cookies). Los bloqueadores de anuncios bloquean estas solicitudes sin problema."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
it: {
|
||||||
|
search_placeholder: "Cerca (nome, genere, città, paese)…",
|
||||||
|
displayed: "visualizzati", total_bands: "totale", localities: "località", located: "geolocalizzati",
|
||||||
|
view_section: "Vista", reset_view: "Reimposta vista", heatmap_label: "Mappa di calore",
|
||||||
|
filters_section: "Filtri", without_coords_btn: "Senza coordinate",
|
||||||
|
country_label: "Paese", all_label: "Tutti", status_label: "Stato",
|
||||||
|
genres_label: "Generi", subgenres_label: "Sottogeneri", selection_label: "Selezione",
|
||||||
|
themes_label: "Temi", timeline_label: "Timeline (anno di formazione)",
|
||||||
|
sort_label: "Ordina lista", sort_az: "A → Z", sort_status: "Per stato",
|
||||||
|
sort_genre: "Per genere", sort_country: "Per paese", sort_year: "Per anno (recente → antico)",
|
||||||
|
list_section: "Lista", legal_link: "Note legali", faq_link: "FAQ",
|
||||||
|
visits_label: "Visite", this_month_label: "Questo mese", options_btn: "Opzioni", close_btn: "Chiudi",
|
||||||
|
all_years_label: "tutti", year_data_missing: "dati mancanti",
|
||||||
|
year_data_unavailable: "Dati anno non disponibili", results_label: "risultato/i",
|
||||||
|
loading_text: "Caricamento…", without_coords_modal: "Band senza coordinate",
|
||||||
|
group_s: "band", group_p: "band", location_fallback: "Posizione",
|
||||||
|
legal_title: "Note legali", faq_title: "FAQ",
|
||||||
|
ed_title: "Editore del sito", ed_name: "Autore: Nico", ed_status: "Status: privato", ed_contact: "Contatto:",
|
||||||
|
host_title: "Hosting", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - Francia",
|
||||||
|
ip_title: "Proprietà intellettuale",
|
||||||
|
ip_text: "I contenuti (testi, immagini, dati aggregati) sono forniti a scopo informativo. Loghi e nomi delle band rimangono di proprietà dei rispettivi autori. Tutti i dati provengono da __MA__, con il permesso dei webmaster.",
|
||||||
|
prv_title: "Dati personali",
|
||||||
|
prv_text: "Questo sito non raccoglie dati personali né installa cookie di tracciamento. Il fornitore di hosting può conservare log tecnici. Il traffico è analizzato tramite __GC__ (senza cookie).",
|
||||||
|
pub_title: "Responsabile della pubblicazione", pub_name: "Nico",
|
||||||
|
thanks_title: "Ringraziamenti",
|
||||||
|
thanks_intro: "Questo sito si basa su strumenti e servizi eccezionali:",
|
||||||
|
thanks_map: "Cartografia", thanks_geo: "Geocodifica", thanks_data: "Dati",
|
||||||
|
thanks_hb: "Un ringraziamento speciale a HellBlazer (webmaster di Metal Archives) per l’autorizzazione allo scraping.",
|
||||||
|
faq: [
|
||||||
|
["D: Da dove provengono i dati?", "R: Metal Archives + arricchimento geografico automatizzato. Alcune correzioni manuali, ma è laborioso."],
|
||||||
|
["D: Posso correggere un errore?", "R: Sì, per problemi del sito contattami tramite l’email nelle note legali. Per i dati, correggili direttamente su Metal Archives."],
|
||||||
|
["D: Perché alcune band non sono visibili?", "R: Il geocodifica automatico ha errori inerenti. La correzione manuale delle posizioni è laboriosa."],
|
||||||
|
["D: Ho appena creato la mia band su Metal Archives ma non appare.", "R: Al momento non è implementata la sincronizzazione automatica con Metal Archives."],
|
||||||
|
["D: Il sito mi traccia?", "R: Solo per l’analisi del traffico tramite GoatCounter (senza cookie). I blocchi degli annunci bloccano queste richieste senza problemi."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
pl: {
|
||||||
|
search_placeholder: "Szukaj (nazwa, gatunek, miasto, kraj)…",
|
||||||
|
displayed: "wyświetlono", total_bands: "łącznie", localities: "miejscowości", located: "zlokalizowano",
|
||||||
|
view_section: "Widok", reset_view: "Resetuj widok", heatmap_label: "Mapa cieplna",
|
||||||
|
filters_section: "Filtry", without_coords_btn: "Bez współrzędnych",
|
||||||
|
country_label: "Kraj", all_label: "Wszystkie", status_label: "Status",
|
||||||
|
genres_label: "Gatunki", subgenres_label: "Podgatunki", selection_label: "Wybór",
|
||||||
|
themes_label: "Tematy", timeline_label: "Oś czasu (rok założenia)",
|
||||||
|
sort_label: "Sortuj listę", sort_az: "A → Z", sort_status: "Według statusu",
|
||||||
|
sort_genre: "Według gatunku", sort_country: "Według kraju", sort_year: "Według roku (najnowsze → najstarsze)",
|
||||||
|
list_section: "Lista", legal_link: "Informacje prawne", faq_link: "FAQ",
|
||||||
|
visits_label: "Odwiedziny", this_month_label: "W tym miesiącu", options_btn: "Opcje", close_btn: "Zamknij",
|
||||||
|
all_years_label: "wszystkie", year_data_missing: "brak danych",
|
||||||
|
year_data_unavailable: "Dane roku niedostępne", results_label: "wynik(ów)",
|
||||||
|
loading_text: "Ładowanie…", without_coords_modal: "Zespóły bez współrzędnych",
|
||||||
|
group_s: "zespół", group_p: "zespóły", location_fallback: "Lokalizacja",
|
||||||
|
legal_title: "Informacje prawne", faq_title: "FAQ",
|
||||||
|
ed_title: "Wydawca serwisu", ed_name: "Autor: Nico", ed_status: "Status: osoba prywatna", ed_contact: "Kontakt:",
|
||||||
|
host_title: "Hosting", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - Francja",
|
||||||
|
ip_title: "Własność intelektualna",
|
||||||
|
ip_text: "Treści (teksty, grafiki, zagregowane dane) są udostępniane w celach informacyjnych. Logo i nazwy zespołów pozostają własnością ich autorów. Wszystkie dane pochodzą z __MA__, za zgodą webmasterów.",
|
||||||
|
prv_title: "Dane osobowe",
|
||||||
|
prv_text: "Serwis nie zbiera danych osobowych ani nie instaluje plików cookie śledzenia. Dostawca hostingu może przechowywać logi techniczne. Ruch jest analizowany przez __GC__ (bez cookies).",
|
||||||
|
pub_title: "Redaktor odpowiedzialny", pub_name: "Nico",
|
||||||
|
thanks_title: "Podziękowania",
|
||||||
|
thanks_intro: "Serwis korzysta z doskonałych narzędzi i usług:",
|
||||||
|
thanks_map: "Kartografia", thanks_geo: "Geokodowanie", thanks_data: "Dane",
|
||||||
|
thanks_hb: "Szczególne podziękowania dla HellBlazera (webmaster Metal Archives) za udzielenie zgody na scraping.",
|
||||||
|
faq: [
|
||||||
|
["P: Skąd pochodzą dane?", "O: Metal Archives + zautomatyzowane wzbogacanie geograficzne. Kilka ręcznych poprawek, ale to żmdne."],
|
||||||
|
["P: Czy mogę poprawić błąd?", "O: Tak, jeśli dotyczy serwisu, skontaktuj się przez e-mail podany w informacjach prawnych. Dla danych, popraw bezpośrednio na Metal Archives."],
|
||||||
|
["P: Dlaczego niektóre zespóły nie są widoczne?", "O: Automatyczne geokodowanie ma nieodłączne błędy. Ręczna korekta lokalizacji jest żmdna."],
|
||||||
|
["P: Właśnie założyłem zespół na Metal Archives, ale nie jest widoczny.", "O: Automatyczna synchronizacja z Metal Archives nie jest obecnie zaimplementowana."],
|
||||||
|
["P: Czy serwis mnie śledzi?", "O: Tylko do analizy ruchu przez GoatCounter (bez cookies). Blokery reklam blokują te żądania bez problemu."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
nl: {
|
||||||
|
search_placeholder: "Zoeken (naam, genre, stad, land)…",
|
||||||
|
displayed: "weergegeven", total_bands: "totaal", localities: "locaties", located: "gelokaliseerd",
|
||||||
|
view_section: "Weergave", reset_view: "Weergave herstellen", heatmap_label: "Warmtekaart",
|
||||||
|
filters_section: "Filters", without_coords_btn: "Zonder coördinaten",
|
||||||
|
country_label: "Land", all_label: "Alle", status_label: "Status",
|
||||||
|
genres_label: "Genres", subgenres_label: "Subgenres", selection_label: "Selectie",
|
||||||
|
themes_label: "Thema’s", timeline_label: "Tijdlijn (oprichtingsjaar)",
|
||||||
|
sort_label: "Lijst sorteren", sort_az: "A → Z", sort_status: "Op status",
|
||||||
|
sort_genre: "Op genre", sort_country: "Op land", sort_year: "Op jaar (nieuwste → oudste)",
|
||||||
|
list_section: "Lijst", legal_link: "Juridische informatie", faq_link: "FAQ",
|
||||||
|
visits_label: "Bezoeken", this_month_label: "Deze maand", options_btn: "Opties", close_btn: "Sluiten",
|
||||||
|
all_years_label: "alle", year_data_missing: "gegevens ontbreken",
|
||||||
|
year_data_unavailable: "Jaargegevens niet beschikbaar", results_label: "resultaat/en",
|
||||||
|
loading_text: "Laden…", without_coords_modal: "Bands zonder coördinaten",
|
||||||
|
group_s: "band", group_p: "bands", location_fallback: "Locatie",
|
||||||
|
legal_title: "Juridische informatie", faq_title: "FAQ",
|
||||||
|
ed_title: "Sitebeheerder", ed_name: "Auteur: Nico", ed_status: "Status: particulier", ed_contact: "Contact:",
|
||||||
|
host_title: "Hosting", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - Frankrijk",
|
||||||
|
ip_title: "Intellectueel eigendom",
|
||||||
|
ip_text: "Inhoud (teksten, afbeeldingen, geaggregeerde gegevens) wordt aangeboden voor informatieve doeleinden. Logo’s en bandnamen blijven eigendom van hun auteurs. Alle gegevens zijn afkomstig van __MA__, met toestemming van de webmasters.",
|
||||||
|
prv_title: "Persoonsgegevens",
|
||||||
|
prv_text: "Deze site verzamelt geen persoonlijke gegevens en plaatst geen trackingcookies. De hostingprovider kan technische logs bewaren. Verkeer wordt geanalyseerd via __GC__ (zonder cookies).",
|
||||||
|
pub_title: "Publicatieverantwoordelijke", pub_name: "Nico",
|
||||||
|
thanks_title: "Met dank aan",
|
||||||
|
thanks_intro: "Deze site maakt gebruik van uitstekende tools en diensten:",
|
||||||
|
thanks_map: "Cartografie", thanks_geo: "Geocodering", thanks_data: "Gegevens",
|
||||||
|
thanks_hb: "Speciale dank aan HellBlazer (webmaster Metal Archives) voor de toestemming tot scraping.",
|
||||||
|
faq: [
|
||||||
|
["V: Waar komen de gegevens vandaan?", "A: Metal Archives + geautomatiseerde geografische verrijking. Enkele handmatige correcties, maar dat is vervelend."],
|
||||||
|
["V: Kan ik een fout corrigeren?", "A: Ja, voor siteproblemen neem contact op via het e-mailadres in de juridische informatie. Voor dataproblemen, corrigeer direct op Metal Archives."],
|
||||||
|
["V: Waarom zijn sommige bands niet zichtbaar?", "A: Automatisch geocoderen heeft inherente fouten. Handmatige correctie van locaties is vervelend."],
|
||||||
|
["V: Ik heb mijn band net aangemaakt op Metal Archives maar hij verschijnt niet.", "A: Automatische synchronisatie met Metal Archives is momenteel niet geïmplementeerd."],
|
||||||
|
["V: Volgt de site mij?", "A: Alleen voor verkeersanalyse via GoatCounter (geen cookies). Adblockers blokkeren deze verzoeken zonder probleem."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
ro: {
|
||||||
|
search_placeholder: "Căutare (nume, gen, oraş, țară)…",
|
||||||
|
displayed: "afişate", total_bands: "total", localities: "localități", located: "localizate",
|
||||||
|
view_section: "Vedere", reset_view: "Resetare vedere", heatmap_label: "Hartă termică",
|
||||||
|
filters_section: "Filtre", without_coords_btn: "Fără coordonate",
|
||||||
|
country_label: "Ţară", all_label: "Toate", status_label: "Stare",
|
||||||
|
genres_label: "Genuri", subgenres_label: "Subgenuri", selection_label: "Selecție",
|
||||||
|
themes_label: "Teme", timeline_label: "Cronologie (an de formare)",
|
||||||
|
sort_label: "Sortare listă", sort_az: "A → Z", sort_status: "După stare",
|
||||||
|
sort_genre: "După gen", sort_country: "După țară", sort_year: "După an (recent → vechi)",
|
||||||
|
list_section: "Listă", legal_link: "Mențiuni legale", faq_link: "FAQ",
|
||||||
|
visits_label: "Vizite", this_month_label: "Luna aceasta", options_btn: "Opțiuni", close_btn: "Închide",
|
||||||
|
all_years_label: "toți", year_data_missing: "date lipsă",
|
||||||
|
year_data_unavailable: "Date de an indisponibile", results_label: "rezultat(e)",
|
||||||
|
loading_text: "Se încarcă…", without_coords_modal: "Trupe fără coordonate",
|
||||||
|
group_s: "trupă", group_p: "trupe", location_fallback: "Locație",
|
||||||
|
legal_title: "Mențiuni legale", faq_title: "FAQ",
|
||||||
|
ed_title: "Editorul site-ului", ed_name: "Autor: Nico", ed_status: "Statut: persoană fizică", ed_contact: "Contact:",
|
||||||
|
host_title: "Găzduire", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - Franța",
|
||||||
|
ip_title: "Proprietate intelectuală",
|
||||||
|
ip_text: "Conținuturile (texte, imagini, date agregate) sunt oferite cu scop informativ. Logo-urile şi numele trupelor rămân proprietatea autorilor lor. Toate datele provin de la __MA__, cu permisiunea administratorilor.",
|
||||||
|
prv_title: "Date personale",
|
||||||
|
prv_text: "Acest site nu colectează date personale şi nu instalează cookie-uri de urmărire. Furnizorul de găzduire poate păstra jurnale tehnice. Traficul este analizat prin __GC__ (fără cookie-uri).",
|
||||||
|
pub_title: "Responsabil publicație", pub_name: "Nico",
|
||||||
|
thanks_title: "Mulțumiri",
|
||||||
|
thanks_intro: "Acest site se bazează pe instrumente şi servicii remarcabile:",
|
||||||
|
thanks_map: "Cartografiere", thanks_geo: "Geocodificare", thanks_data: "Date",
|
||||||
|
thanks_hb: "Mulțumiri speciale lui HellBlazer (webmaster Metal Archives) pentru permisiunea de scraping.",
|
||||||
|
faq: [
|
||||||
|
["Î: De unde provin datele?", "R: Metal Archives + îmbogățire geografică automatizată. Câteva corecții manuale, dar este obositor."],
|
||||||
|
["Î: Pot corecta o eroare?", "R: Da, dacă priveşte site-ul, contactați-mă prin e-mailul din mențiunile legale. Pentru date, corectare direct pe Metal Archives."],
|
||||||
|
["Î: De ce unele trupe nu sunt vizibile?", "R: Geocodificarea automată are erori inerente. Corectarea manuală a locațiilor este obositoare."],
|
||||||
|
["Î: Am creat trupa mea pe Metal Archives dar nu apare.", "R: Momentan nu există sincronizare automată cu Metal Archives."],
|
||||||
|
["Î: Mă urmăreşte site-ul?", "R: Doar pentru analiza traficului prin GoatCounter (fără cookie-uri). Blocatoarele de reclame blochează aceste solicitări fără probleme."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
pt: {
|
||||||
|
search_placeholder: "Pesquisar (nome, género, cidade, país)…",
|
||||||
|
displayed: "exibidos", total_bands: "total", localities: "localidades", located: "localizados",
|
||||||
|
view_section: "Vista", reset_view: "Repor vista", heatmap_label: "Mapa de calor",
|
||||||
|
filters_section: "Filtros", without_coords_btn: "Sem coordenadas",
|
||||||
|
country_label: "País", all_label: "Todos", status_label: "Estado",
|
||||||
|
genres_label: "Géneros", subgenres_label: "Subgéneros", selection_label: "Seleção",
|
||||||
|
themes_label: "Temas", timeline_label: "Linha do tempo (ano de formação)",
|
||||||
|
sort_label: "Ordenar lista", sort_az: "A → Z", sort_status: "Por estado",
|
||||||
|
sort_genre: "Por género", sort_country: "Por país", sort_year: "Por ano (recente → antigo)",
|
||||||
|
list_section: "Lista", legal_link: "Aviso legal", faq_link: "FAQ",
|
||||||
|
visits_label: "Visitas", this_month_label: "Este mês", options_btn: "Opções", close_btn: "Fechar",
|
||||||
|
all_years_label: "todos", year_data_missing: "dados em falta",
|
||||||
|
year_data_unavailable: "Dados de ano indisponíveis", results_label: "resultado(s)",
|
||||||
|
loading_text: "A carregar…", without_coords_modal: "Bandas sem coordenadas",
|
||||||
|
group_s: "banda", group_p: "bandas", location_fallback: "Localização",
|
||||||
|
legal_title: "Aviso legal", faq_title: "FAQ",
|
||||||
|
ed_title: "Editor do site", ed_name: "Autor: Nico", ed_status: "Estatuto: particular", ed_contact: "Contacto:",
|
||||||
|
host_title: "Alojamento", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - França",
|
||||||
|
ip_title: "Propriedade intelectual",
|
||||||
|
ip_text: "Os conteúdos (textos, imagens, dados agregados) são disponibilizados para fins informativos. Logótipos e nomes de bandas pertencem aos respetivos autores. Todos os dados provêm de __MA__, com autorização dos webmasters.",
|
||||||
|
prv_title: "Dados pessoais",
|
||||||
|
prv_text: "Este site não recolhe dados pessoais nem instala cookies de rastreamento. O fornecedor de alojamento pode conservar registos técnicos. O tráfego é analisado via __GC__ (sem cookies).",
|
||||||
|
pub_title: "Responsável pela publicação", pub_name: "Nico",
|
||||||
|
thanks_title: "Agradecimentos",
|
||||||
|
thanks_intro: "Este site baseia-se em ferramentas e serviços excelentes:",
|
||||||
|
thanks_map: "Cartografia", thanks_geo: "Geocodificação", thanks_data: "Dados",
|
||||||
|
thanks_hb: "Agradecimento especial ao HellBlazer (webmaster Metal Archives) pela autorização de scraping.",
|
||||||
|
faq: [
|
||||||
|
["P: De onde vêm os dados?", "R: Metal Archives + enriquecimento geográfico automatizado. Algumas correções manuais, mas é fastidioso."],
|
||||||
|
["P: Posso corrigir um erro?", "R: Sim, se for do site contacte-me pelo e-mail no aviso legal. Para dados, corrija diretamente no Metal Archives."],
|
||||||
|
["P: Porque é que alguns grupos não são visíveis?", "R: A geocodificação automática tem erros inerentes. A correção manual de localizações é fastidiosa."],
|
||||||
|
["P: Acabei de criar o meu grupo no Metal Archives mas não aparece.", "R: Atualmente não existe sincronização automática com o Metal Archives."],
|
||||||
|
["P: O site rastreia-me?", "R: Apenas para análise de tráfego via GoatCounter (sem cookies). Os bloqueadores de anúncios bloqueiam estes pedidos sem problema."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
cs: {
|
||||||
|
search_placeholder: "Hledat (název, žánr, město, země)…",
|
||||||
|
displayed: "zobrazeno", total_bands: "celkem", localities: "míst", located: "lokalizováno",
|
||||||
|
view_section: "Zobrazení", reset_view: "Obnovit zobrazení", heatmap_label: "Tepelná mapa",
|
||||||
|
filters_section: "Filtry", without_coords_btn: "Bez souřadnic",
|
||||||
|
country_label: "Země", all_label: "Vše", status_label: "Stav",
|
||||||
|
genres_label: "Žánry", subgenres_label: "Subžánry", selection_label: "Výběr",
|
||||||
|
themes_label: "Témata", timeline_label: "Časová osa (rok vzniku)",
|
||||||
|
sort_label: "Seřadit seznam", sort_az: "A → Z", sort_status: "Podle stavu",
|
||||||
|
sort_genre: "Podle žánru", sort_country: "Podle země", sort_year: "Podle roku (nejnovější → nejstarší)",
|
||||||
|
list_section: "Seznam", legal_link: "Právní informace", faq_link: "FAQ",
|
||||||
|
visits_label: "Návštěvy", this_month_label: "Tento měsíc", options_btn: "Možnosti", close_btn: "Zavřít",
|
||||||
|
all_years_label: "vše", year_data_missing: "chybějící data",
|
||||||
|
year_data_unavailable: "Data roku nedostupná", results_label: "výsledek/ků",
|
||||||
|
loading_text: "Načítání…", without_coords_modal: "Kapely bez souřadnic",
|
||||||
|
group_s: "kapela", group_p: "kapely", location_fallback: "Poloha",
|
||||||
|
legal_title: "Právní informace", faq_title: "FAQ",
|
||||||
|
ed_title: "Provozovatel webu", ed_name: "Autor: Nico", ed_status: "Stav: soukromá osoba", ed_contact: "Kontakt:",
|
||||||
|
host_title: "Hosting", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - Francie",
|
||||||
|
ip_title: "Duševní vlastnictví",
|
||||||
|
ip_text: "Obsah (texty, obrázky, agregovaná data) je poskytován pro informační účely. Loga a názvy kapel zůstávají majetkem jejich autorů. Veškerá data pocházejí z __MA__, se souhlasem webmasterů.",
|
||||||
|
prv_title: "Osobní údaje",
|
||||||
|
prv_text: "Tento web neshromažďuje osobní údaje ani neinstaluje sledovací cookies. Poskytovatel hostingu může uchovávat technické záznamy. Provoz je analyzován prostřednictvím __GC__ (bez cookies).",
|
||||||
|
pub_title: "Odpovědný redaktor", pub_name: "Nico",
|
||||||
|
thanks_title: "Poděkování",
|
||||||
|
thanks_intro: "Tento web využívá vynikající nástroje a služby:",
|
||||||
|
thanks_map: "Kartografie", thanks_geo: "Geokódování", thanks_data: "Data",
|
||||||
|
thanks_hb: "Zvláštní poděkování HellBlazerovi (webmaster Metal Archives) za povolení scrapingu.",
|
||||||
|
faq: [
|
||||||
|
["O: Odkud pocházejí data?", "A: Metal Archives + automatické geografické obohacení. Několik manuálních oprav, ale je to únavné."],
|
||||||
|
["O: Mohu opravit chybu?", "A: Ano, pokud se týka webu, kontaktujte mě na e-mail v právních informacích. Pro data opravte přímo na Metal Archives."],
|
||||||
|
["O: Proč některé kapely nejsou viditelné?", "A: Automatické geokódování má inherentní chyby. Ruční oprava polohy je únavná."],
|
||||||
|
["O: Právě jsem vytvořil kapelu na Metal Archives, ale nezobrazuje se.", "A: Automatická synchronizace s Metal Archives momentálně není implementována."],
|
||||||
|
["O: Sleduje mě web?", "A: Pouze pro analýzu provozu přes GoatCounter (bez cookies). Blokátory reklam tyto požadavky bez problémů zablokují."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
sv: {
|
||||||
|
search_placeholder: "Sök (namn, genre, stad, land)…",
|
||||||
|
displayed: "visade", total_bands: "totalt", localities: "orter", located: "lokaliserade",
|
||||||
|
view_section: "Vy", reset_view: "Återställ vy", heatmap_label: "Värmekarta",
|
||||||
|
filters_section: "Filter", without_coords_btn: "Utan koordinater",
|
||||||
|
country_label: "Land", all_label: "Alla", status_label: "Status",
|
||||||
|
genres_label: "Genrer", subgenres_label: "Undergenrer", selection_label: "Val",
|
||||||
|
themes_label: "Teman", timeline_label: "Tidslinje (grundat år)",
|
||||||
|
sort_label: "Sortera lista", sort_az: "A → Z", sort_status: "Efter status",
|
||||||
|
sort_genre: "Efter genre", sort_country: "Efter land", sort_year: "Efter år (nyaste → äldsta)",
|
||||||
|
list_section: "Lista", legal_link: "Juridisk information", faq_link: "FAQ",
|
||||||
|
visits_label: "Besök", this_month_label: "Denna månad", options_btn: "Alternativ", close_btn: "Stäng",
|
||||||
|
all_years_label: "alla", year_data_missing: "data saknas",
|
||||||
|
year_data_unavailable: "Årsdata ej tillgänglig", results_label: "resultat",
|
||||||
|
loading_text: "Laddar…", without_coords_modal: "Band utan koordinater",
|
||||||
|
group_s: "band", group_p: "band", location_fallback: "Plats",
|
||||||
|
legal_title: "Juridisk information", faq_title: "FAQ",
|
||||||
|
ed_title: "Webbplatsredaktör", ed_name: "Författare: Nico", ed_status: "Status: privatperson", ed_contact: "Kontakt:",
|
||||||
|
host_title: "Hosting", host_text: "OVH SAS – 2 rue Kellermann - 59100 Roubaix - Frankrike",
|
||||||
|
ip_title: "Immateriell egendom",
|
||||||
|
ip_text: "Innehåll (texter, bilder, aggregerade data) tillhandahålls för informationsändamål. Bandlogotyper och namn förblir upphovsrättshavarnas egendom. All data härstämmar från __MA__, med webmasternas tillåtelse.",
|
||||||
|
prv_title: "Personuppgifter",
|
||||||
|
prv_text: "Denna webbplats samlar inte in personuppgifter eller installerar spårningscookies. Hostingleverantören kan spara tekniska loggar. Trafiken analyseras via __GC__ (utan cookies).",
|
||||||
|
pub_title: "Publiceringsansvarig", pub_name: "Nico",
|
||||||
|
thanks_title: "Tack till",
|
||||||
|
thanks_intro: "Denna webbplats förlitar sig på utmärkta verktyg och tjänster:",
|
||||||
|
thanks_map: "Kartografi", thanks_geo: "Geokodning", thanks_data: "Data",
|
||||||
|
thanks_hb: "Särskilt tack till HellBlazer (webmaster Metal Archives) för tillståndet att scrapa.",
|
||||||
|
faq: [
|
||||||
|
["F: Varifån kommer data?", "S: Metal Archives + automatisk geografisk berikning. Några manuella korrigeringar, men det är tråkigt."],
|
||||||
|
["F: Kan jag korrigera ett fel?", "S: Ja, för sidproblem, kontakta mig via e-postadressen i juridisk information. För dataproblem, korrigera direkt på Metal Archives."],
|
||||||
|
["F: Varför är vissa band inte synliga?", "S: Automatisk geokodning har inneboënde fel. Manuell korrigering av platser är tråkig."],
|
||||||
|
["F: Jag skapade just mitt band på Metal Archives men det visas inte.", "S: Ingen automatisk synkronisering med Metal Archives är för närvarande implementerad."],
|
||||||
|
["F: Spårar webbplatsen mig?", "S: Endast för trafikanalys via GoatCounter (inga cookies). Annonsblockerare blockerar dessa förfrågningar utan problem."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Build LOCALES — add legal_html + faq_html to each entry */
|
||||||
|
window.LOCALES = {};
|
||||||
|
for (const [code, d] of Object.entries(RAW)) {
|
||||||
|
window.LOCALES[code] = Object.assign({}, d, {
|
||||||
|
legal_html: buildLegal(d),
|
||||||
|
faq_html: buildFaq(d),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -86,6 +86,23 @@ body::before {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-select {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(231,226,218,0.85);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 5px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
height: 36px;
|
||||||
|
transition: border-color 120ms ease;
|
||||||
|
}
|
||||||
|
.lang-select:hover { border-color: rgba(198,26,26,0.40); }
|
||||||
|
.lang-select:focus { border-color: rgba(198,26,26,0.60); }
|
||||||
|
.lang-select option { background: #121418; }
|
||||||
|
|
||||||
.social-link {
|
.social-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
FROM mcr.microsoft.com/playwright/python:v1.49.0-jammy
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY src ./src
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
CMD ["python", "src/run.py"]
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
playwright==1.49.0
|
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
requests==2.32.3
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import os, re, json
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
MA_LIST_URL = os.getenv("MA_LIST_URL", "https://www.metal-archives.com/lists/FR")
|
|
||||||
DB_DSN = os.getenv("DB_DSN")
|
|
||||||
STATE_PATH = os.getenv("MA_STATE_PATH", "/app/ma_state.json")
|
|
||||||
|
|
||||||
def db_conn():
|
|
||||||
return psycopg2.connect(DB_DSN)
|
|
||||||
|
|
||||||
def upsert_band(cur, b):
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO bands (ma_id, name, country, location_text, status, genre, data)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s::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.get("country"), b.get("location_text"),
|
|
||||||
b.get("status"), b.get("genre"), b.get("data_json","{}"))
|
|
||||||
)
|
|
||||||
|
|
||||||
def parse_ma_id(url: str):
|
|
||||||
m = re.search(r"/bands/[^/]+/(\d+)", (url or ""))
|
|
||||||
return int(m.group(1)) if m else None
|
|
||||||
|
|
||||||
def strip_html(s: str) -> str:
|
|
||||||
return re.sub(r"<[^>]+>", "", (s or "")).strip()
|
|
||||||
|
|
||||||
def parse_row(row):
|
|
||||||
# row = [ '<a href=".../bands/Name/123">Name</a>', 'Genre', 'Location', 'Status' ]
|
|
||||||
if not isinstance(row, list) or len(row) < 2:
|
|
||||||
return None
|
|
||||||
name_html = str(row[0])
|
|
||||||
m = re.search(r'href="([^"]+)"', name_html)
|
|
||||||
url = m.group(1) if m else None
|
|
||||||
name = strip_html(name_html)
|
|
||||||
genre = strip_html(str(row[1])) if len(row) > 1 else ""
|
|
||||||
loc = strip_html(str(row[2])) if len(row) > 2 else ""
|
|
||||||
status = strip_html(str(row[3])) if len(row) > 3 else ""
|
|
||||||
return name, url, genre, loc, status
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if not DB_DSN:
|
|
||||||
raise SystemExit("DB_DSN not set")
|
|
||||||
|
|
||||||
print(f"[worker] MA list: {MA_LIST_URL}")
|
|
||||||
print(f"[worker] using storage_state: {STATE_PATH}")
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True)
|
|
||||||
|
|
||||||
# charge cookies / storage state Cloudflare
|
|
||||||
context = browser.new_context(storage_state=STATE_PATH)
|
|
||||||
page = context.new_page()
|
|
||||||
|
|
||||||
# Debug minimal des XHR
|
|
||||||
xhr = []
|
|
||||||
def on_response(resp):
|
|
||||||
if resp.request.resource_type == "xhr":
|
|
||||||
u = resp.url
|
|
||||||
if "/browse/ajax-country/" in u or "/user/session" in u or "/cdn-cgi/" in u:
|
|
||||||
xhr.append(f"{resp.status} {u}")
|
|
||||||
page.on("response", on_response)
|
|
||||||
|
|
||||||
page.goto(MA_LIST_URL, wait_until="domcontentloaded")
|
|
||||||
page.wait_for_selector("table.display", timeout=60_000)
|
|
||||||
|
|
||||||
# Attendre la réponse DataTables (doit passer en 200 si cookie OK)
|
|
||||||
def ok_ajax(resp):
|
|
||||||
return ("/browse/ajax-country/" in resp.url) and resp.status == 200
|
|
||||||
|
|
||||||
try:
|
|
||||||
with page.expect_response(ok_ajax, timeout=60_000) as resp_info:
|
|
||||||
page.wait_for_timeout(1000)
|
|
||||||
dt = resp_info.value
|
|
||||||
except Exception:
|
|
||||||
print("[worker] ERROR: did not get 200 ajax-country within 60s")
|
|
||||||
print("[worker] xhr seen:", xhr[:20])
|
|
||||||
raise
|
|
||||||
|
|
||||||
j = dt.json()
|
|
||||||
rows = j.get("aaData") or j.get("data") or []
|
|
||||||
print(f"[worker] ajax ok. rows={len(rows)}")
|
|
||||||
|
|
||||||
bands = []
|
|
||||||
for row in rows[:5]:
|
|
||||||
parsed = parse_row(row)
|
|
||||||
if not parsed:
|
|
||||||
continue
|
|
||||||
name, url, genre, location, status = parsed
|
|
||||||
ma_id = parse_ma_id(url)
|
|
||||||
if not ma_id:
|
|
||||||
continue
|
|
||||||
bands.append({
|
|
||||||
"ma_id": ma_id,
|
|
||||||
"name": name,
|
|
||||||
"country": "France",
|
|
||||||
"genre": genre,
|
|
||||||
"location_text": location,
|
|
||||||
"status": status or None,
|
|
||||||
"data_json": json.dumps({"source":"ma_list_fr","url":url}),
|
|
||||||
})
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
with db_conn() as conn:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
for b in bands:
|
|
||||||
upsert_band(cur, b)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
print(f"[worker] inserted/updated: {len(bands)}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
URL = "https://www.metal-archives.com/lists/FR"
|
|
||||||
|
|
||||||
def main():
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=False) # IMPORTANT: visible
|
|
||||||
context = browser.new_context()
|
|
||||||
page = context.new_page()
|
|
||||||
|
|
||||||
print("Ouvre la page. Si Cloudflare challenge apparaît, résous-le dans la fenêtre.")
|
|
||||||
page.goto(URL, wait_until="domcontentloaded")
|
|
||||||
|
|
||||||
print("J'attends qu'un appel /browse/ajax-country/ passe en 200 (preuve que l'accès est OK)...")
|
|
||||||
|
|
||||||
def ok_ajax(resp):
|
|
||||||
return ("/browse/ajax-country/" in resp.url) and resp.status == 200
|
|
||||||
|
|
||||||
# Attends que DataTables charge réellement (XHR 200)
|
|
||||||
page.wait_for_response(ok_ajax, timeout=180_000)
|
|
||||||
|
|
||||||
context.storage_state(path="ma_state.json")
|
|
||||||
print("✅ ma_state.json sauvegardé. Tu peux fermer le navigateur.")
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
|
||||||
volumes:
|
|
||||||
- bm_redis_data:/data
|
|
||||||
networks:
|
|
||||||
- bm_internal
|
|
||||||
|
|
||||||
geocoder-worker:
|
|
||||||
build:
|
|
||||||
context: ../apps/geocoder
|
|
||||||
working_dir: /app
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
|
||||||
NOMINATIM_BASE: "https://nominatim.openstreetmap.org"
|
|
||||||
NOMINATIM_EMAIL: ${NOMINATIM_EMAIL}
|
|
||||||
NOMINATIM_USER_AGENT: ${NOMINATIM_USER_AGENT}
|
|
||||||
NOMINATIM_MIN_DELAY: "1.05"
|
|
||||||
NOMINATIM_JITTER: "0.35"
|
|
||||||
GEOCODE_MAX_PER_RUN: "5000"
|
|
||||||
command: ["python", "src/worker.py"]
|
|
||||||
networks:
|
|
||||||
- bm_internal
|
|
||||||
restart: unless-stopped
|
|
||||||
pgadmin:
|
|
||||||
image: dpage/pgadmin4:8
|
|
||||||
environment:
|
|
||||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
|
|
||||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
|
|
||||||
PGADMIN_CONFIG_SERVER_MODE: "True"
|
|
||||||
volumes:
|
|
||||||
- pgadmin_data:/var/lib/pgadmin
|
|
||||||
networks:
|
|
||||||
- bm_internal
|
|
||||||
- coolify
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=coolify
|
|
||||||
- traefik.http.routers.pgadmin.rule=Host(`pgadmin.bm.nicolasfryder.ovh`)
|
|
||||||
- traefik.http.routers.pgadmin.entrypoints=https
|
|
||||||
- traefik.http.routers.pgadmin.tls=true
|
|
||||||
- traefik.http.routers.pgadmin.tls.certresolver=letsencrypt
|
|
||||||
- traefik.http.services.pgadmin.loadbalancer.server.port=80
|
|
||||||
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: ../apps/api
|
|
||||||
environment:
|
|
||||||
PORT: "3000"
|
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
|
||||||
BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN}
|
|
||||||
volumes:
|
|
||||||
- ./overrides/api/src/server.js:/app/src/server.js:ro
|
|
||||||
networks:
|
|
||||||
- bm_internal
|
|
||||||
- coolify
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=coolify
|
|
||||||
- traefik.http.routers.bm-api.rule=Host(`bm.nicolasfryder.ovh`)
|
|
||||||
- traefik.http.routers.bm-api.entrypoints=https
|
|
||||||
- traefik.http.routers.bm-api.tls=true
|
|
||||||
- traefik.http.routers.bm-api.tls.certresolver=letsencrypt
|
|
||||||
- traefik.http.services.bm-api.loadbalancer.server.port=3000
|
|
||||||
|
|
||||||
web:
|
|
||||||
build:
|
|
||||||
context: ../apps/web
|
|
||||||
networks:
|
|
||||||
- coolify
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=coolify
|
|
||||||
- traefik.http.routers.bm-web.rule=Host(`metalfrom.eu`) || Host(`www.metalfrom.eu`)
|
|
||||||
- traefik.http.routers.bm-web.entrypoints=https
|
|
||||||
- traefik.http.routers.bm-web.tls=true
|
|
||||||
- traefik.http.routers.bm-web.tls.certresolver=letsencrypt
|
|
||||||
- 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.services.bm-web.loadbalancer.server.port=80
|
|
||||||
|
|
||||||
worker:
|
|
||||||
build:
|
|
||||||
context: ../apps/worker
|
|
||||||
environment:
|
|
||||||
DB_DSN: ${DATABASE_URL}
|
|
||||||
MA_LIST_URL: https://www.metal-archives.com/lists/FR
|
|
||||||
MA_STATE_PATH: /app/ma_state.json
|
|
||||||
volumes:
|
|
||||||
- worker_state:/app
|
|
||||||
networks:
|
|
||||||
- bm_internal
|
|
||||||
restart: "no"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
bm_internal:
|
|
||||||
driver: bridge
|
|
||||||
coolify:
|
|
||||||
external: true
|
|
||||||
name: coolify
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
bm_redis_data:
|
|
||||||
pgadmin_data:
|
|
||||||
worker_state:
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
-- Le schéma est désormais géré par les migrations numérotées dans apps/api/migrations/.
|
|
||||||
-- Ce fichier n'est plus utilisé. Les migrations sont appliquées automatiquement au
|
|
||||||
-- démarrage du service api (node src/migrate.js).
|
|
||||||
--
|
|
||||||
-- Pour ajouter une migration :
|
|
||||||
-- 1. Créer apps/api/migrations/NNN_description.sql
|
|
||||||
-- 2. Commiter et pousser → le prochain déploiement l'applique automatiquement
|
|
||||||
Loading…
Reference in a new issue