feat: flag-icons dropdown + admin live monitor

- Sélecteur de langue : dropdown custom avec flag-icons CDN (fi fi-xx), fermeture click-outside, aria-expanded
- Admin Monitor : nouvelle vue live avec status temps réel (crawl runs, géocodeur, jobs en attente)
- Log stream incrémental : fetch uniquement les nouveaux logs via min_id, buffer 500 entrées, filtre level + recherche texte client-side, flash animation sur nouvelles lignes
- Boutons inline Annuler pour runs actifs et jobs en attente
- API : GET /admin/api/live (agrégé), POST crawl-runs/:id/cancel, POST job-triggers/:id/cancel, param min_id sur GET /logs
- Refresh configurable 3/5/10/30s, pause/reprendre, vider le buffer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolas Fryder 2026-07-01 21:02:13 +02:00
parent dc9239c38e
commit 68e483762e
7 changed files with 490 additions and 31 deletions

View file

@ -11,6 +11,11 @@ const state = {
queueTimer: null,
geoTimer: null,
cmdLogTimer: null,
monitorTimer: null,
monitorLogs: [],
monitorLastId: 0,
monitorPaused: false,
monitorInterval: 5000,
};
// ------------------------------------------------------------------
@ -43,6 +48,7 @@ function showLogin() {
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; }
if (state.monitorTimer) { clearInterval(state.monitorTimer); state.monitorTimer = null; }
}
function showApp() {
@ -98,7 +104,7 @@ document.getElementById("logout-btn").addEventListener("click", async () => {
// ------------------------------------------------------------------
// Router
// ------------------------------------------------------------------
const VIEWS = ["dashboard", "bands", "queue", "runs", "logs", "geocoding", "conflicts", "jobs", "checkpoints", "audit"];
const VIEWS = ["dashboard", "bands", "queue", "runs", "monitor", "logs", "geocoding", "conflicts", "jobs", "checkpoints", "audit"];
function router() {
const hash = (location.hash || "#/dashboard").replace("#/", "");
@ -111,12 +117,14 @@ function router() {
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; }
if (state.monitorTimer) { clearInterval(state.monitorTimer); state.monitorTimer = null; }
const renderers = {
dashboard: renderDashboard,
bands: renderBands,
conflicts: renderConflicts,
queue: renderQueue,
runs: renderRuns,
monitor: renderMonitor,
logs: renderLogs,
geocoding: renderGeocoding,
jobs: renderJobs,
@ -558,7 +566,238 @@ async function loadRuns() {
}
// ------------------------------------------------------------------
// Logs
// Live Monitor
// ------------------------------------------------------------------
async function renderMonitor() {
state.monitorLogs = [];
state.monitorLastId = 0;
state.monitorPaused = false;
content().innerHTML = `
<div class="mon-header">
<div style="display:flex;align-items:center;gap:10px">
<span class="pulse-dot" id="mon-dot" style="background:var(--warn)"></span>
<span style="font-size:12px;color:var(--muted)" id="mon-ts">Connexion</span>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<label style="font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px">
Refresh
<select id="mon-interval" style="padding:4px 8px;font-size:12px;border-radius:8px;border:1px solid rgba(255,255,255,0.10);background:rgba(4,6,10,0.72);color:var(--text);outline:none">
<option value="3000">3 s</option>
<option value="5000" selected>5 s</option>
<option value="10000">10 s</option>
<option value="30000">30 s</option>
</select>
</label>
<button class="btn btn-mini" id="mon-pause-btn"> Pause</button>
<button class="btn btn-mini" id="mon-clear-btn">🗑 Vider logs</button>
</div>
</div>
<div class="grid grid-3" id="mon-status" style="margin-bottom:16px">
<div class="card"><div class="loading"></div></div>
<div class="card"><div class="loading"></div></div>
<div class="card"><div class="loading"></div></div>
</div>
<div class="card">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<h2 style="margin:0">Journal en direct</h2>
<select id="mon-level" style="padding:5px 8px;font-size:12px;border-radius:8px;border:1px solid rgba(255,255,255,0.10);background:rgba(4,6,10,0.72);color:var(--text);outline:none">
<option value="">Tous</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
<input type="text" id="mon-search" placeholder="Filtrer…" style="padding:5px 10px;font-size:12px;border-radius:8px;border:1px solid rgba(255,255,255,0.10);background:rgba(4,6,10,0.72);color:var(--text);width:200px;outline:none">
<span id="mon-count" style="font-size:11px;color:var(--muted);margin-left:auto"></span>
</div>
<div id="mon-stream" class="log-stream"></div>
</div>
`;
document.getElementById("mon-pause-btn").addEventListener("click", () => {
state.monitorPaused = !state.monitorPaused;
const btn = document.getElementById("mon-pause-btn");
btn.textContent = state.monitorPaused ? "▶ Reprendre" : "⏸ Pause";
btn.style.borderColor = state.monitorPaused ? "rgba(198,26,26,0.5)" : "";
});
document.getElementById("mon-clear-btn").addEventListener("click", () => {
state.monitorLogs = [];
state.monitorLastId = 0;
renderMonitorStream();
});
document.getElementById("mon-level").addEventListener("input", renderMonitorStream);
document.getElementById("mon-search").addEventListener("input", renderMonitorStream);
document.getElementById("mon-interval").addEventListener("change", (e) => {
state.monitorInterval = Number(e.target.value) || 5000;
if (state.monitorTimer) clearInterval(state.monitorTimer);
state.monitorTimer = setInterval(tickMonitor, state.monitorInterval);
});
await tickMonitor();
state.monitorTimer = setInterval(tickMonitor, state.monitorInterval);
}
async function tickMonitor() {
if (state.monitorPaused) return;
const dot = document.getElementById("mon-dot");
const tsEl = document.getElementById("mon-ts");
try {
const [live, logs] = await Promise.all([
api("/admin/api/live"),
api(`/admin/api/logs?pageSize=100${state.monitorLastId ? `&min_id=${state.monitorLastId}` : ""}`),
]);
renderMonitorStatus(live);
if (logs.items.length) {
const maxId = Math.max(...logs.items.map(x => x.id));
if (maxId > state.monitorLastId) state.monitorLastId = maxId;
state.monitorLogs = [...logs.items, ...state.monitorLogs].slice(0, 500);
renderMonitorStream(logs.items.length);
} else if (!state.monitorLogs.length) {
renderMonitorStream(0);
}
if (dot) { dot.style.background = "var(--ok)"; dot.classList.add("live"); }
if (tsEl) tsEl.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`;
} catch (e) {
if (dot) { dot.style.background = "var(--err)"; dot.classList.remove("live"); }
if (tsEl) tsEl.textContent = `Erreur : ${e.message}`;
}
}
function renderMonitorStatus(live) {
const el = document.getElementById("mon-status");
if (!el) return;
// --- Crawl runs ---
const runsHtml = live.active_runs.length
? live.active_runs.map(r => {
const elapsed = Math.round((Date.now() - new Date(r.started_at)) / 60000);
return `<div class="mon-run-row">
<div>
<span class="badge warn"> ${esc(r.run_type)}</span>
<div style="font-size:11px;color:var(--muted);margin-top:5px">
${elapsed}min · ${r.bands_seen} vus · ${r.bands_new} nouveaux · ${r.bands_enriched} enrichis
</div>
${r.error ? `<div style="font-size:11px;color:var(--err);margin-top:3px">${esc(r.error)}</div>` : ""}
</div>
<button class="btn btn-mini" style="border-color:rgba(198,26,26,0.4)" onclick="adminCancelRun(${r.id})"> Annuler</button>
</div>`;
}).join("")
: `<div class="empty" style="padding:16px 0">Aucun run actif</div>`;
// --- Géocodeur ---
const gTotal = live.geo_queue.reduce((s, x) => s + x.n, 0);
const gDone = live.geo_queue.find(x => x.status === "done")?.n || 0;
const gQ = live.geo_queue.find(x => x.status === "queued")?.n || 0;
const gErr = live.geo_queue.find(x => x.status === "error")?.n || 0;
const gProc = live.geo_queue.find(x => x.status === "processing")?.n || 0;
const gPct = gTotal ? Math.round((gDone / gTotal) * 100) : 0;
const geoHtml = `
<div style="background:rgba(255,255,255,0.06);border-radius:6px;height:6px;overflow:hidden;margin-bottom:8px">
<div style="width:${gPct}%;height:100%;background:linear-gradient(90deg,var(--blood),rgba(198,26,26,0.5));transition:width 0.5s ease"></div>
</div>
<div style="font-size:13px;font-weight:700;margin-bottom:6px">${gPct}% ${gDone.toLocaleString("fr-FR")} / ${gTotal.toLocaleString("fr-FR")}</div>
<div style="font-size:11px;color:var(--muted);display:flex;flex-wrap:wrap;gap:8px">
<span>${gQ} en attente</span>
${gProc ? `<span style="color:var(--warn)">⚡ ${gProc} en cours</span>` : ""}
${gErr ? `<span style="color:var(--err)">✗ ${gErr} erreurs</span>` : ""}
</div>
${live.geo_processing.map(p => `
<div style="margin-top:8px;padding:6px 8px;background:rgba(255,255,255,0.04);border-radius:7px;font-size:11px">
<span style="color:var(--warn)"></span> ${esc(p.name)} <span style="color:var(--muted)">(${esc(p.country || "?")})</span>
${p.tries > 1 ? `<span style="color:var(--warn);margin-left:6px">essai ${p.tries}</span>` : ""}
</div>`).join("")}
`;
// --- Jobs en attente ---
const jobsHtml = live.pending_jobs.length
? live.pending_jobs.map(j => {
const age = Math.round((Date.now() - new Date(j.created_at)) / 60000);
return `<div class="mon-run-row">
<div>
<span class="badge muted">${esc(j.job_type)}</span>
<div style="font-size:11px;color:var(--muted);margin-top:4px">par ${esc(j.requested_by || "?")} · ${age}min</div>
</div>
<button class="btn btn-mini" onclick="adminCancelJob(${j.id})"></button>
</div>`;
}).join("")
: `<div class="empty" style="padding:16px 0">Aucun job en attente</div>`;
el.innerHTML = `
<div class="card">
<h2 style="margin-bottom:10px">Crawl en cours
<span style="font-weight:400;color:var(--muted)">(${live.active_runs.length})</span>
</h2>
${runsHtml}
</div>
<div class="card">
<h2 style="margin-bottom:10px">Géocodeur</h2>
${geoHtml}
</div>
<div class="card">
<h2 style="margin-bottom:10px">Jobs en attente
<span style="font-weight:400;color:var(--muted)">(${live.pending_jobs.length})</span>
</h2>
${jobsHtml}
</div>
`;
}
function renderMonitorStream(newCount = 0) {
const stream = document.getElementById("mon-stream");
if (!stream) return;
const level = document.getElementById("mon-level")?.value || "";
const search = (document.getElementById("mon-search")?.value || "").toLowerCase();
const filtered = state.monitorLogs.filter(x => {
if (level && x.level !== level) return false;
if (search && !x.message.toLowerCase().includes(search)) return false;
return true;
});
const countEl = document.getElementById("mon-count");
if (countEl) {
countEl.textContent = `${filtered.length} entrée${filtered.length !== 1 ? "s" : ""}${state.monitorLogs.length !== filtered.length ? ` / ${state.monitorLogs.length} total` : ""}`;
}
const wasAtTop = stream.scrollTop < 40;
stream.innerHTML = filtered.map((x, i) => {
const lvlCls = x.level === "error" ? "err" : x.level === "warning" ? "warn" : "muted";
return `<div class="log-line${i < newCount ? " log-new" : ""}">
<span class="ts" style="color:var(--muted);flex-shrink:0">${fmtDate(x.created_at)}</span>
<span style="flex-shrink:0;width:56px;font-weight:700;color:var(--${lvlCls})">${esc(x.level)}</span>
<span style="word-break:break-all">${esc(x.message)}${x.ma_id ? ` <span style="color:var(--muted)">#${x.ma_id}</span>` : ""}</span>
</div>`;
}).join("") || `<div class="empty">Aucun log${level || search ? " (filtres actifs)" : ""}</div>`;
if (wasAtTop || newCount > 0) stream.scrollTop = 0;
}
async function adminCancelRun(id) {
if (!confirm(`Annuler le run #${id} ?`)) return;
try {
await api(`/admin/api/crawl-runs/${id}/cancel`, { method: "POST", body: "{}" });
await tickMonitor();
} catch (e) { alert(`Erreur : ${e.message}`); }
}
async function adminCancelJob(id) {
if (!confirm(`Annuler le job trigger #${id} ?`)) return;
try {
await api(`/admin/api/job-triggers/${id}/cancel`, { method: "POST", body: "{}" });
await tickMonitor();
} catch (e) { alert(`Erreur : ${e.message}`); }
}
// ------------------------------------------------------------------
// Logs (historique)
// ------------------------------------------------------------------
async function renderLogs() {
const s = state.logs;

View file

@ -35,7 +35,8 @@
<a href="#/conflicts" data-view="conflicts">Conflits</a>
<a href="#/queue" data-view="queue">Queue</a>
<a href="#/runs" data-view="runs">Crawl runs</a>
<a href="#/logs" data-view="logs">Logs</a>
<a href="#/monitor" data-view="monitor">Monitor</a>
<a href="#/logs" data-view="logs">Historique</a>
<a href="#/geocoding" data-view="geocoding">Géocodage</a>
<a href="#/jobs" data-view="jobs">Jobs</a>
<a href="#/checkpoints" data-view="checkpoints">Checkpoints</a>

View file

@ -291,3 +291,61 @@ tbody tr.clickable { cursor: pointer; }
.empty { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
.loading { color: var(--muted); font-size: 13px; padding: 20px; text-align: center; }
/* ===== MONITOR ===== */
.grid-3 { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.mon-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 10px;
}
.pulse-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
transition: background 300ms;
}
.pulse-dot.live { animation: pulse-anim 2s ease-in-out infinite; }
@keyframes pulse-anim {
0%, 100% { box-shadow: 0 0 0 0 rgba(63,174,90,0.5); }
50% { box-shadow: 0 0 0 5px rgba(63,174,90,0); }
}
.log-stream {
height: 420px;
overflow-y: auto;
font-family: ui-monospace, Consolas, monospace;
font-size: 12px;
scroll-behavior: smooth;
}
.log-stream .log-line {
display: flex;
gap: 10px;
padding: 3px 0;
border-top: 1px solid rgba(255,255,255,0.04);
word-break: break-all;
white-space: pre-wrap;
}
@keyframes log-flash {
from { background: rgba(63,174,90,0.10); }
to { background: transparent; }
}
.log-new { animation: log-flash 1.8s ease-out; }
.mon-run-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 8px 0;
border-top: 1px solid rgba(255,255,255,0.05);
}
.mon-run-row:first-child { border-top: none; padding-top: 0; }

View file

@ -325,7 +325,7 @@ export default async function adminRoutes(fastify, opts) {
// ------------------------------------------------------------------
fastify.get("/admin/api/logs", async (req, reply) => {
try {
const { level, run_id, since } = req.query || {};
const { level, run_id, since, min_id } = req.query || {};
const { page, pageSize, offset } = pagination(req.query || {});
const where = [];
const vals = [];
@ -333,6 +333,7 @@ export default async function adminRoutes(fastify, opts) {
if (level) { where.push(`level = $${i}`); vals.push(level); i++; }
if (run_id) { where.push(`run_id = $${i}`); vals.push(Number(run_id)); i++; }
if (since) { where.push(`created_at > $${i}`); vals.push(since); i++; }
if (min_id) { where.push(`id > $${i}`); vals.push(Number(min_id)); i++; }
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
vals.push(pageSize, offset);
@ -538,6 +539,89 @@ export default async function adminRoutes(fastify, opts) {
}
});
// ------------------------------------------------------------------
// Live status (agrégé pour le Monitor)
// ------------------------------------------------------------------
fastify.get("/admin/api/live", async (req, reply) => {
try {
const [active_runs, pending_jobs, geo_queue, geo_processing, checkpoints] = await Promise.all([
pool.query(`
SELECT id, run_type, countries, status, started_at,
bands_seen, bands_new, bands_updated, bands_enriched, error
FROM crawl_run WHERE status = 'running' ORDER BY started_at DESC
`),
pool.query(`
SELECT id, job_type, status, requested_by, created_at
FROM job_triggers WHERE status = 'pending' ORDER BY created_at ASC
`),
pool.query(`SELECT status, count(*)::int AS n FROM geocode_queue GROUP BY status ORDER BY n DESC`),
pool.query(`
SELECT gq.ma_id, b.name, b.country, gq.tries, gq.last_error, gq.updated_at
FROM geocode_queue gq JOIN bands b ON b.ma_id = gq.ma_id
WHERE gq.status = 'processing' LIMIT 3
`),
pool.query(`SELECT key, value, updated_at FROM crawl_checkpoint ORDER BY key`),
]);
return {
ok: true,
active_runs: active_runs.rows,
pending_jobs: pending_jobs.rows,
geo_queue: geo_queue.rows,
geo_processing: geo_processing.rows,
checkpoints: checkpoints.rows,
};
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur live status" });
}
});
// ------------------------------------------------------------------
// Annuler un crawl run spécifique
// ------------------------------------------------------------------
fastify.post("/admin/api/crawl-runs/:id/cancel", async (req, reply) => {
try {
const id = Number(req.params.id);
if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad id" });
const r = await pool.query(`
UPDATE crawl_run SET status='error', finished_at=now(), error='annulé manuellement par admin'
WHERE id=$1 AND status='running' RETURNING id, run_type
`, [id]);
if (!r.rows.length) return reply.code(404).send({ ok: false, error: "Run non trouvé ou déjà terminé" });
await pool.query(
`INSERT INTO crawl_log (level, message) VALUES ($1, $2)`,
["warning", `[admin:${req.adminUsername}] run #${id} (${r.rows[0].run_type}) annulé manuellement`]
).catch(() => {});
return { ok: true, id };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur annulation run" });
}
});
// ------------------------------------------------------------------
// Annuler un job trigger en attente
// ------------------------------------------------------------------
fastify.post("/admin/api/job-triggers/:id/cancel", async (req, reply) => {
try {
const id = Number(req.params.id);
if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad id" });
const r = await pool.query(`
UPDATE job_triggers SET status='cancelled', finished_at=now()
WHERE id=$1 AND status='pending' RETURNING id, job_type
`, [id]);
if (!r.rows.length) return reply.code(404).send({ ok: false, error: "Job non trouvé ou déjà traité" });
await pool.query(
`INSERT INTO crawl_log (level, message) VALUES ($1, $2)`,
["warning", `[admin:${req.adminUsername}] job trigger #${id} (${r.rows[0].job_type}) annulé`]
).catch(() => {});
return { ok: true, id };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ ok: false, error: "Erreur annulation job" });
}
});
// ------------------------------------------------------------------
// Journal d'audit (actions admin)
// ------------------------------------------------------------------

View file

@ -1406,15 +1406,46 @@ if (window.matchMedia("(max-width: 1200px)").matches) {
}, 100);
}
// Language selector
const langSelectEl = document.getElementById("langSelect");
if (langSelectEl) {
langSelectEl.value = currentLang;
langSelectEl.addEventListener("change", () => {
currentLang = langSelectEl.value;
localStorage.setItem("lang", currentLang);
applyI18n();
// Language dropdown (flag-icons custom widget)
const LANG_FLAGS = { en:"gb", fr:"fr", de:"de", es:"es", it:"it", pl:"pl", nl:"nl", ro:"ro", pt:"pt", cs:"cz", sv:"se" };
const LANG_NAMES = { en:"English", fr:"Français", de:"Deutsch", es:"Español", it:"Italiano", pl:"Polski", nl:"Nederlands", ro:"Română", pt:"Português", cs:"Čeština", sv:"Svenska" };
function syncLangTrigger(lang) {
const trigger = document.getElementById("langTrigger");
if (!trigger) return;
const flagEl = trigger.querySelector(".lang-flag");
if (flagEl) flagEl.className = `fi fi-${LANG_FLAGS[lang] || "un"} lang-flag`;
const codeEl = trigger.querySelector(".lang-code");
if (codeEl) codeEl.textContent = lang.toUpperCase();
document.querySelectorAll(".lang-opt").forEach(b =>
b.classList.toggle("current", b.dataset.lang === lang)
);
}
const langTrigger = document.getElementById("langTrigger");
const langOptions = document.getElementById("langOptions");
if (langTrigger && langOptions) {
langTrigger.addEventListener("click", (e) => {
e.stopPropagation();
const open = langOptions.classList.toggle("hidden") === false;
langTrigger.setAttribute("aria-expanded", String(open));
});
document.addEventListener("click", () => {
langOptions.classList.add("hidden");
langTrigger.setAttribute("aria-expanded", "false");
});
langOptions.querySelectorAll(".lang-opt").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
currentLang = btn.dataset.lang;
localStorage.setItem("lang", currentLang);
langOptions.classList.add("hidden");
langTrigger.setAttribute("aria-expanded", "false");
syncLangTrigger(currentLang);
applyI18n();
});
});
syncLangTrigger(currentLang);
}
applyI18n();

View file

@ -30,6 +30,7 @@
<script data-goatcounter="https://metalfromeurope.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css">
<link rel="stylesheet" href="./styles.css" />
</head>
@ -41,19 +42,26 @@
</button>
<div class="brand">Metal from Europe</div>
<div class="topbar-right">
<select id="langSelect" class="lang-select" aria-label="Language">
<option value="en">English</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
<option value="it">Italiano</option>
<option value="pl">Polski</option>
<option value="nl">Nederlands</option>
<option value="ro">Română</option>
<option value="pt">Português</option>
<option value="cs">Čeština</option>
<option value="sv">Svenska</option>
</select>
<div class="lang-dropdown">
<button class="lang-trigger" id="langTrigger" type="button" aria-haspopup="listbox" aria-expanded="false">
<span class="fi lang-flag"></span>
<span class="lang-code">EN</span>
<span class="lang-arrow"></span>
</button>
<div class="lang-options hidden" id="langOptions" role="listbox">
<button class="lang-opt" type="button" data-lang="en"><span class="fi fi-gb"></span>English</button>
<button class="lang-opt" type="button" data-lang="fr"><span class="fi fi-fr"></span>Français</button>
<button class="lang-opt" type="button" data-lang="de"><span class="fi fi-de"></span>Deutsch</button>
<button class="lang-opt" type="button" data-lang="es"><span class="fi fi-es"></span>Español</button>
<button class="lang-opt" type="button" data-lang="it"><span class="fi fi-it"></span>Italiano</button>
<button class="lang-opt" type="button" data-lang="pl"><span class="fi fi-pl"></span>Polski</button>
<button class="lang-opt" type="button" data-lang="nl"><span class="fi fi-nl"></span>Nederlands</button>
<button class="lang-opt" type="button" data-lang="ro"><span class="fi fi-ro"></span>Română</button>
<button class="lang-opt" type="button" data-lang="pt"><span class="fi fi-pt"></span>Português</button>
<button class="lang-opt" type="button" data-lang="cs"><span class="fi fi-cz"></span>Čeština</button>
<button class="lang-opt" type="button" data-lang="sv"><span class="fi fi-se"></span>Svenska</button>
</div>
</div>
<a href="https://discord.gg/P2DE57Ab" target="_blank" rel="noopener noreferrer" class="social-link" title="Rejoindre notre Discord">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>

View file

@ -86,22 +86,60 @@ body::before {
gap: 8px;
}
.lang-select {
.lang-dropdown { position: relative; }
.lang-trigger {
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;
padding: 5px 10px;
cursor: pointer;
outline: none;
height: 36px;
display: flex;
align-items: center;
gap: 6px;
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; }
.lang-trigger:hover { border-color: rgba(198,26,26,0.40); }
.lang-trigger:focus { outline: none; border-color: rgba(198,26,26,0.60); }
.lang-arrow { color: rgba(231,226,218,0.35); font-size: 10px; }
.lang-options {
position: absolute;
top: calc(100% + 4px);
right: 0;
background: rgba(10,12,20,0.98);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
min-width: 150px;
z-index: 999;
padding: 4px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
backdrop-filter: blur(8px);
}
.lang-options.hidden { display: none; }
.lang-opt {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
background: none;
border: none;
color: rgba(231,226,218,0.85);
font-size: 13px;
font-family: inherit;
cursor: pointer;
border-radius: 7px;
text-align: left;
transition: background 80ms;
}
.lang-opt:hover { background: rgba(255,255,255,0.08); }
.lang-opt.current { color: rgba(231,226,218,1); background: rgba(198,26,26,0.18); }
.social-link {
display: inline-flex;