commit d4e1bf2e153cd1f503285b3d28ec4b24a23a676d Author: Nicolas Fryder Date: Sat Jun 27 08:53:39 2026 +0000 chore: initial commit — snapshot VPS 2026-06-27 - apps/api : API REST Node.js/Fastify + PostgreSQL/PostGIS - apps/geocoder : Worker Python géocodage Nominatim - apps/worker : Scraper Python Metal-Archives - apps/web : Frontend statique Leaflet/clustering/heatmap - infra/ : docker-compose + init.sql Déployé via Coolify + Traefik sur VPS OVH. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b54d0ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +infra/.venv/ +infra/temp/ +infra/ma_state.json +infra/geocode-enqueue.log +infra/ma_debug.* +apps/api/node_modules/ +__pycache__/ +*.pyc +*.pyo diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..f254fef --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,9 @@ +FROM node:20-alpine + +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev + +COPY src ./src +ENV PORT=3000 +CMD ["node", "src/server.js"] diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..68441bd --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,16 @@ +{ + "name": "bm-api", + "version": "0.0.1", + "type": "module", + "main": "src/server.js", + "scripts": { + "dev": "node src/server.js", + "start": "node src/server.js" + }, + "dependencies": { + "fastify": "^4.28.1", + "@fastify/rate-limit": "^9.0.0", + "@fastify/helmet": "^11.1.1", + "pg": "^8.12.0" + } +} diff --git a/apps/api/src/index.js b/apps/api/src/index.js new file mode 100644 index 0000000..23f8bc0 --- /dev/null +++ b/apps/api/src/index.js @@ -0,0 +1,111 @@ +import Fastify from "fastify"; +import pg from "pg"; +import zlib from "node:zlib"; + +const { Pool } = pg; +const fastify = Fastify({ logger: true }); + +// IMPORTANT: permettre le raw body (pour gzip) +fastify.addContentTypeParser("*", { parseAs: "buffer" }, (req, body, done) => done(null, body)); + +const PORT = Number(process.env.PORT || 3000); +const DATABASE_URL = process.env.DATABASE_URL; +const IMPORT_TOKEN = process.env.BM_IMPORT_TOKEN || ""; + +let pool = null; +if (DATABASE_URL) pool = new Pool({ connectionString: DATABASE_URL }); + +fastify.get("/", async () => ({ ok: true, service: "bm-api" })); +fastify.get("/health", async () => ({ ok: true })); + +fastify.get("/db", async () => { + if (!pool) return { ok: false, error: "DATABASE_URL not set" }; + const r = await pool.query("select now() as now, current_database() as db"); + return { ok: true, ...r.rows[0] }; +}); + +fastify.get("/stats", async () => { + if (!pool) return { ok: false, error: "DATABASE_URL not set" }; + const r = await pool.query(`select (select count(*)::int from bands) as bands`); + return { ok: true, ...r.rows[0], updated_at: new Date().toISOString() }; +}); + +fastify.post("/admin/import", async (req, reply) => { + const auth = String(req.headers.authorization || ""); + const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + + if (!IMPORT_TOKEN || token !== IMPORT_TOKEN) { + return reply.code(401).send({ ok: false, error: "unauthorized" }); + } + if (!pool) return reply.code(500).send({ ok: false, error: "DATABASE_URL not set" }); + + const ct = String(req.headers["content-type"] || ""); + let payload; + + // req.body est Buffer (grâce au parser "*") + const buf = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ""); + + try { + if (ct.includes("application/json")) { + payload = JSON.parse(buf.toString("utf-8")); + } else if (ct.includes("application/gzip") || ct.includes("application/x-gzip")) { + payload = JSON.parse(zlib.gunzipSync(buf).toString("utf-8")); + } else { + // fallback: tenter JSON direct + payload = JSON.parse(buf.toString("utf-8")); + } + } catch (e) { + req.log.error(e); + return reply.code(400).send({ ok: false, error: "invalid body/json" }); + } + + if (!payload || !Array.isArray(payload.bands)) { + return reply.code(400).send({ ok: false, error: "expected { bands: [...] }" }); + } + + const client = await pool.connect(); + try { + await client.query("begin"); + let upserted = 0; + + for (const b of payload.bands) { + if (!b?.ma_id || !b?.name) continue; + + await client.query( + ` + insert into bands (ma_id, name, country, location_text, status, genre, data) + values ($1,$2,$3,$4,$5,$6,$7::jsonb) + on conflict (ma_id) do update set + name=excluded.name, + country=excluded.country, + location_text=excluded.location_text, + status=excluded.status, + genre=excluded.genre, + data=excluded.data + `, + [ + b.ma_id, + b.name, + b.country || null, + b.location_text || null, + b.status || null, + b.genre || null, + JSON.stringify(b.data || {}), + ] + ); + + upserted++; + } + + await client.query("commit"); + return { ok: true, upserted }; + } catch (e) { + await client.query("rollback"); + req.log.error(e); + return reply.code(500).send({ ok: false, error: "import failed" }); + } finally { + client.release(); + } +}); + +fastify.listen({ port: PORT, host: "0.0.0.0" }); diff --git a/apps/api/src/server.js b/apps/api/src/server.js new file mode 100644 index 0000000..72c88a0 --- /dev/null +++ b/apps/api/src/server.js @@ -0,0 +1,497 @@ +import Fastify from "fastify"; +import pg from "pg"; +import crypto from "crypto"; + +const { Pool } = pg; + +const fastify = Fastify({ logger: true }); + +const PORT = Number(process.env.PORT || 3000); +const DATABASE_URL = process.env.DATABASE_URL; +const BM_IMPORT_TOKEN = (process.env.BM_IMPORT_TOKEN || "").trim(); + +let pool = null; +if (DATABASE_URL) pool = new Pool({ connectionString: DATABASE_URL }); + +function requirePool() { + if (!pool) throw new Error("DATABASE_URL not set"); + return pool; +} + +function authBearer(req) { + const h = req.headers.authorization || ""; + const m = h.match(/^Bearer\s+(.+)$/i); + return m ? m[1] : null; +} + +function requireAdmin(req, reply) { + const t = authBearer(req); + if (!BM_IMPORT_TOKEN || !t || t !== BM_IMPORT_TOKEN) { + reply.code(401).send({ ok: false, error: "unauthorized" }); + return false; + } + return true; +} + +function normStatus(s) { + const v = String(s || "").replace(/\s+/g, " ").trim(); + return v || "Unknown"; +} + +fastify.get("/", async () => { + return ` + + + +BM API + + + +

BM backend ✅

+ +

Database: ${DATABASE_URL ? "configured" : "NOT SET"}

+ +`; +}); + +fastify.get("/api/health", async () => ({ ok: true })); +fastify.get("/api/db", async () => { + const p = requirePool(); + const r = await p.query("select now() as now, current_database() as db"); + return { ok: true, ...r.rows[0] }; +}); + +/** + * Stats globales (utile pour UI) + */ +fastify.get("/api/stats", async () => { + const p = requirePool(); + const q = ` + SELECT + count(*)::int as total, + count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded, + count(*) FILTER (WHERE geom IS NULL)::int as no_location, + count(*) FILTER (WHERE enriched = true)::int as enriched + FROM bands; + `; + const r = await p.query(q); + return { ok: true, ...r.rows[0] }; +}); + +/** + * Liste des pays + counts (pour multi-select UI) + */ +fastify.get("/api/countries", async () => { + const p = requirePool(); + const q = ` + SELECT + country, + count(*)::int as total, + count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded, + count(*) FILTER (WHERE enriched = true)::int as enriched + FROM bands + GROUP BY country + ORDER BY total DESC; + `; + const r = await p.query(q); + return { ok: true, items: r.rows }; +}); + +/** + * Statuts + counts (filtre UI) + */ +fastify.get("/api/statuses", async () => { + const p = requirePool(); + const q = ` + SELECT + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + count(*)::int as total, + count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded + FROM bands + GROUP BY 1 + ORDER BY total DESC; + `; + const r = await p.query(q); + return { ok: true, items: r.rows }; +}); + +/** + * CLUSTERS ENDPOINT - Viewport-based clustering for performance + * + * Query params: + * - bbox=minLon,minLat,maxLon,maxLat (required) - viewport bounds + * - zoom=N (required) - current zoom level + * - countries=FR,DE,IT (optional) + * - status=Active,Split-up (optional) + * - genre=black (optional, ILIKE) + * - year_min, year_max (optional) + * + * Returns clusters with count and representative bands + */ +fastify.get("/api/clusters", async (req) => { + const p = requirePool(); + const { + bbox, + zoom, + countries, + status, + genre, + year_min, + year_max, + } = req.query || {}; + + if (!bbox || !zoom) { + return { ok: false, error: "bbox and zoom are required" }; + } + + const [minLon, minLat, maxLon, maxLat] = String(bbox).split(",").map(Number); + const zoomLevel = Number(zoom); + + if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) { + return { ok: false, error: "Invalid bbox or zoom values" }; + } + + // Calculate grid cell size based on zoom level + // Higher zoom = smaller cells = more detail + // At zoom 4 (continental) ~2 degrees, at zoom 10 (city) ~0.05 degrees + const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel)); + + const where = ["geom IS NOT NULL"]; + const vals = []; + let i = 1; + + // Bounding box filter using PostGIS + where.push(`ST_X(geom) >= $${i} AND ST_X(geom) <= $${i+1} AND ST_Y(geom) >= $${i+2} AND ST_Y(geom) <= $${i+3}`); + vals.push(minLon, maxLon, minLat, maxLat); + i += 4; + + if (countries) { + const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); + if (cs.length) { + where.push(`country = ANY($${i}::text[])`); + vals.push(cs); + i++; + } + } + + if (status) { + const st = String(status).split(",").map(s => s.trim()).filter(Boolean); + if (st.length) { + where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); + vals.push(st); + i++; + } + } + + if (genre) { + where.push(`genre ILIKE $${i}`); + vals.push(`%${String(genre).trim()}%`); + i++; + } + + if (year_min) { + where.push(`formed_year >= $${i}`); + vals.push(Number(year_min)); + i++; + } + + if (year_max) { + where.push(`formed_year <= $${i}`); + vals.push(Number(year_max)); + i++; + } + + const whereSql = where.join(" AND "); + + // For high zoom levels (>= 12), return individual bands + if (zoomLevel >= 12) { + const sql = ` + SELECT + ma_id, + name, + url, + country, + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + genre, + location_text, + formed_year, + ST_Y(geom)::float8 as lat, + ST_X(geom)::float8 as lon + FROM bands + WHERE ${whereSql} + ORDER BY ma_id ASC + LIMIT 2000; + `; + const r = await p.query(sql, vals); + return { ok: true, type: "bands", items: r.rows, count: r.rows.length }; + } + + // For lower zoom levels, return aggregated clusters + const sql = ` + WITH grid_cells AS ( + SELECT + floor(ST_X(geom) / $${i})::int as cell_x, + floor(ST_Y(geom) / $${i})::int as cell_y, + ma_id, + name, + url, + country, + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + genre, + location_text, + formed_year, + ST_Y(geom)::float8 as lat, + ST_X(geom)::float8 as lon + FROM bands + WHERE ${whereSql} + ), + clusters AS ( + SELECT + cell_x, + cell_y, + count(*)::int as band_count, + avg(lat)::float8 as center_lat, + avg(lon)::float8 as center_lon, + array_agg(json_build_object( + 'ma_id', ma_id, + 'name', name, + 'url', url, + 'country', country, + 'status', status, + 'genre', genre, + 'location_text', location_text, + 'formed_year', formed_year, + 'lat', lat, + 'lon', lon + ) ORDER BY name) as bands + FROM grid_cells + GROUP BY cell_x, cell_y + ) + SELECT + center_lat as lat, + center_lon as lon, + band_count as count, + CASE + WHEN band_count <= 5 THEN bands + ELSE bands[1:5] + END as sample_bands + FROM clusters + ORDER BY band_count DESC + LIMIT 1000; + `; + + vals.push(cellSize); + const r = await p.query(sql, vals); + + return { + ok: true, + type: "clusters", + items: r.rows, + total_clusters: r.rows.length, + cell_size: cellSize, + zoom: zoomLevel + }; +}); + +/** + * Bands (flux principal UI) - LEGACY endpoint, kept for compatibility + * For better performance, use /api/clusters with viewport + */ +fastify.get("/api/bands", async (req) => { + const p = requirePool(); + const { + countries, + geocoded, + q, + status, + only_black, + limit, + offset, + } = req.query || {}; + + const lim = Math.min(Number(limit || 20000), 50000); + const off = Math.max(Number(offset || 0), 0); + + const where = []; + const vals = []; + let i = 1; + + if (countries) { + const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); + if (cs.length) { + where.push(`country = ANY($${i}::text[])`); + vals.push(cs); + i++; + } + } + + if (geocoded === "1") where.push(`geom IS NOT NULL`); + if (geocoded === "0") where.push(`geom IS NULL`); + + if (status) { + const st = String(status).split(",").map(s => s.trim()).filter(Boolean); + if (st.length) { + where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); + vals.push(st); + i++; + } + } + + if (only_black === "1") { + where.push(`genre ILIKE '%black%'`); + } + + if (q) { + const qq = `%${String(q).trim()}%`; + where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`); + vals.push(qq); + i++; + } + + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + const sql = ` + SELECT + ma_id, + name, + url, + country, + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + genre, + location_text, + enriched, + formed_year, + themes, + CASE WHEN geom IS NOT NULL THEN ST_Y(geom)::float8 ELSE NULL END as lat, + CASE WHEN geom IS NOT NULL THEN ST_X(geom)::float8 ELSE NULL END as lon + FROM bands + ${whereSql} + ORDER BY ma_id ASC + LIMIT ${lim} OFFSET ${off}; + `; + const r = await p.query(sql, vals); + return { ok: true, items: r.rows, limit: lim, offset: off }; +}); + +/** + * Get minimal data for initial load (facets only, no band details) + * Used to populate filters without loading all bands + */ +fastify.get("/api/facets", async () => { + const p = requirePool(); + + // Get all facet data in parallel + const [statusRes, countryRes, genreRes, yearRes] = await Promise.all([ + p.query(` + SELECT COALESCE(NULLIF(trim(status), ''), 'Unknown') as value, count(*)::int as count + FROM bands WHERE geom IS NOT NULL + GROUP BY 1 ORDER BY count DESC + `), + p.query(` + SELECT COALESCE(country, '??') as value, count(*)::int as count + FROM bands WHERE geom IS NOT NULL + GROUP BY 1 ORDER BY count DESC + `), + p.query(` + SELECT genre as value, count(*)::int as count + FROM bands WHERE geom IS NOT NULL AND genre IS NOT NULL AND genre != '' + GROUP BY 1 ORDER BY count DESC + LIMIT 500 + `), + p.query(` + SELECT MIN(formed_year)::int as min_year, MAX(formed_year)::int as max_year + FROM bands WHERE geom IS NOT NULL AND formed_year >= 1900 AND formed_year <= extract(year from now()) + `) + ]); + + return { + ok: true, + statuses: statusRes.rows, + countries: countryRes.rows, + genres: genreRes.rows, + year_range: yearRes.rows[0] || { min_year: null, max_year: null } + }; +}); + +/** + * Détail d'un groupe (pour modal "fiche") + */ +fastify.get("/api/band/:ma_id", async (req, reply) => { + const p = requirePool(); + const id = Number(req.params.ma_id); + if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad ma_id" }); + + const r = await p.query( + `SELECT ma_id, name, url, country, status, genre, location_text, enriched, data, + formed_year, themes, + CASE WHEN geom IS NOT NULL THEN ST_Y(geom)::float8 ELSE NULL END as lat, + CASE WHEN geom IS NOT NULL THEN ST_X(geom)::float8 ELSE NULL END as lon, + geocoded_at + FROM bands + WHERE ma_id = $1 + LIMIT 1`, + [id] + ); + if (!r.rows.length) return reply.code(404).send({ ok: false, error: "not found" }); + return { ok: true, item: r.rows[0] }; +}); + +/** + * ADMIN import + */ +fastify.post("/admin/import", async (req, reply) => { + if (!requireAdmin(req, reply)) return; + + const body = req.body; + if (!body || typeof body !== "object") return reply.code(400).send({ ok: false, error: "invalid body/json" }); + const bands = Array.isArray(body.bands) ? body.bands : null; + if (!bands) return reply.code(400).send({ ok: false, error: "missing bands[]" }); + + const p = requirePool(); + + let upserted = 0; + for (const b of bands) { + if (!b || typeof b !== "object") continue; + const ma_id = Number(b.ma_id); + if (!Number.isFinite(ma_id)) continue; + + const name = b.name ?? null; + const url = b.url ?? (b.data?.url ?? null); + const country = b.country ?? null; + const status = b.status ?? null; + const genre = b.genre ?? null; + const location_text = b.location_text ?? b.location ?? null; + + const hasData = b.data && typeof b.data === "object"; + const data = hasData ? b.data : null; + const enriched = hasData ? true : (b.enriched ?? null); + + await p.query( + ` + INSERT INTO bands (ma_id, name, url, country, status, genre, location_text, data, enriched) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8, COALESCE($9,false)) + ON CONFLICT (ma_id) DO UPDATE SET + name = COALESCE(EXCLUDED.name, bands.name), + url = COALESCE(EXCLUDED.url, bands.url), + country = COALESCE(EXCLUDED.country, bands.country), + status = COALESCE(EXCLUDED.status, bands.status), + genre = COALESCE(EXCLUDED.genre, bands.genre), + location_text = COALESCE(EXCLUDED.location_text, bands.location_text), + data = COALESCE(EXCLUDED.data, bands.data), + enriched = COALESCE(EXCLUDED.enriched, bands.enriched) + `, + [ma_id, name, url, country, status, genre, location_text, data, enriched] + ); + upserted++; + } + + return reply.send({ ok: true, upserted }); +}); + +fastify.listen({ port: PORT, host: "0.0.0.0" }); diff --git a/apps/geocoder/Dockerfile b/apps/geocoder/Dockerfile new file mode 100644 index 0000000..10e8e65 --- /dev/null +++ b/apps/geocoder/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Copier et installer les dépendances en premier (meilleur cache Docker) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copier le code de l'application +COPY . . + +# Le command sera spécifié dans docker-compose.yml diff --git a/apps/geocoder/requirements.txt b/apps/geocoder/requirements.txt new file mode 100644 index 0000000..3b469ea --- /dev/null +++ b/apps/geocoder/requirements.txt @@ -0,0 +1,2 @@ +psycopg2-binary==2.9.9 +requests==2.32.3 diff --git a/apps/geocoder/src/enqueue.py b/apps/geocoder/src/enqueue.py new file mode 100644 index 0000000..18fcef4 --- /dev/null +++ b/apps/geocoder/src/enqueue.py @@ -0,0 +1,109 @@ +import os +import psycopg2 + +COUNTRY_FALLBACK = { + "FR": "France", + "DE": "Germany", + "IT": "Italy", + "GB": "United Kingdom", + "ES": "Spain", + "SE": "Sweden", + "NO": "Norway", + "FI": "Finland", + "PL": "Poland", + "NL": "Netherlands", + "BE": "Belgium", + "CH": "Switzerland", + "AT": "Austria", + "PT": "Portugal", + "GR": "Greece", + "CZ": "Czech Republic", + "SK": "Slovakia", + "SI": "Slovenia", + "HU": "Hungary", + "UA": "Ukraine", + "RU": "Russia", + "DK": "Denmark", +} + +def make_query(location_text: str, country_code: str) -> str: + loc = (location_text or "").strip() + cc = (country_code or "").strip().upper() + ctry = COUNTRY_FALLBACK.get(cc, cc) + return f"{loc}, {ctry}" if loc else ctry + +def main(): + dsn = os.environ["DATABASE_URL"] + batch = int(os.environ.get("GEOCODE_ENQUEUE_BATCH", "5000")) + + # Retry policy + retry_after_hours = int(os.environ.get("GEOCODE_RETRY_AFTER_HOURS", "72")) # 72h = 3 jours + force_retry = os.environ.get("GEOCODE_FORCE_RETRY", "0").strip() in ("1", "true", "yes", "on") + + conn = psycopg2.connect(dsn) + conn.autocommit = True + + with conn.cursor() as cur: + # On sélectionne : + # - bands non géocodés avec location_text + # - soit pas encore en queue, soit queue en error/queued et next_run_at <= now() + # - et si erreur récente: on respecte retry_after_hours sauf si force_retry + cur.execute( + """ + SELECT + b.ma_id, b.country, b.location_text + FROM bands b + LEFT JOIN geocode_queue q ON q.ma_id = b.ma_id + WHERE b.geom IS NULL + AND b.location_text IS NOT NULL AND b.location_text <> '' + AND ( + q.ma_id IS NULL + OR (q.status IN ('queued','error') AND q.next_run_at <= now()) + ) + AND ( + %s + OR b.geocode_error IS NULL + OR b.geocode_error_at IS NULL + OR b.geocode_error_at < now() - (%s || ' hours')::interval + ) + ORDER BY + COALESCE(q.next_run_at, now()) ASC, + b.ma_id ASC + LIMIT %s + """, + (force_retry, retry_after_hours, batch), + ) + + rows = cur.fetchall() + enq = 0 + + for ma_id, country, location_text in rows: + qtxt = make_query(location_text, country) + + cur.execute( + """ + INSERT INTO geocode_queue (ma_id, query, country, status, next_run_at) + VALUES (%s, %s, %s, 'queued', now()) + ON CONFLICT (ma_id) DO UPDATE + SET query = EXCLUDED.query, + country = EXCLUDED.country, + status = CASE + WHEN geocode_queue.status IN ('done','processing') THEN geocode_queue.status + ELSE 'queued' + END, + next_run_at = CASE + WHEN geocode_queue.status IN ('done','processing') THEN geocode_queue.next_run_at + ELSE now() + END, + updated_at = now() + """, + (ma_id, qtxt, country), + ) + enq += 1 + + print(f"[enqueue] queued_or_updated={enq} (selected={len(rows)}) force_retry={force_retry} retry_after_hours={retry_after_hours}") + + conn.close() + +if __name__ == "__main__": + main() diff --git a/apps/geocoder/src/worker.py b/apps/geocoder/src/worker.py new file mode 100644 index 0000000..cb28414 --- /dev/null +++ b/apps/geocoder/src/worker.py @@ -0,0 +1,200 @@ +import os +import time +import random +import json +import requests +import psycopg2 + +NOMINATIM_BASE = os.environ.get("NOMINATIM_BASE", "https://nominatim.openstreetmap.org").rstrip("/") +NOMINATIM_EMAIL = os.environ.get("NOMINATIM_EMAIL", "").strip() +USER_AGENT = os.environ.get("NOMINATIM_USER_AGENT", "bm-geocoder/0.1 (contact: you@example.com)").strip() + +MIN_DELAY = float(os.environ.get("NOMINATIM_MIN_DELAY", "1.05")) # >= 1s +JITTER = float(os.environ.get("NOMINATIM_JITTER", "0.35")) +MAX_PER_RUN = int(os.environ.get("GEOCODE_MAX_PER_RUN", "100000")) + +_last = 0.0 + +def polite_sleep(): + global _last + elapsed = time.monotonic() - _last + wait = max(0.0, MIN_DELAY - elapsed) + random.uniform(0.0, JITTER) + time.sleep(wait) + _last = time.monotonic() + +def nominatim_search(query: str) -> dict | None: + # doc: /search + email conseillé pour gros volume :contentReference[oaicite:1]{index=1} + params = { + "q": query, + "format": "jsonv2", + "limit": 1, + "addressdetails": 1, + } + if NOMINATIM_EMAIL: + params["email"] = NOMINATIM_EMAIL + + headers = {"User-Agent": USER_AGENT} # requis par policy :contentReference[oaicite:2]{index=2} + + polite_sleep() + r = requests.get(f"{NOMINATIM_BASE}/search", params=params, headers=headers, timeout=30) + if r.status_code in (403, 429, 503): + raise RuntimeError(f"nominatim_throttle status={r.status_code} body={r.text[:200]}") + r.raise_for_status() + arr = r.json() + if not arr: + return None + return arr[0] + +def main(): + dsn = os.environ["DATABASE_URL"] + conn = psycopg2.connect(dsn) + conn.autocommit = True + + processed = 0 + with conn.cursor() as cur: + while processed < MAX_PER_RUN: + # prend 1 job à faire + cur.execute( + """ + SELECT ma_id, query, country, tries + FROM geocode_queue + WHERE status IN ('queued','error') + AND next_run_at <= now() + ORDER BY next_run_at ASC, ma_id ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + """ + ) + row = cur.fetchone() + if not row: + print("[worker] nothing to do. sleeping 60s") + time.sleep(60) + continue + + ma_id, query, country, tries = row + + # marque processing + cur.execute( + """ + UPDATE geocode_queue + SET status='processing', updated_at=now() + WHERE ma_id=%s + """, + (ma_id,), + ) + + # 1) cache ? + cur.execute("SELECT lat, lon, raw FROM geocode_cache WHERE query=%s", (query,)) + cached = cur.fetchone() + if cached and cached[0] is not None and cached[1] is not None: + lat, lon, raw = cached + cur.execute( + """ + UPDATE bands + SET lat=%s, lon=%s, + geom=ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography, + geocoded_at=now(), + geocode_provider='nominatim-cache', + geocode_query=%s, + geocode_raw=%s, + geocode_error=NULL, + geocode_error_at=NULL + WHERE ma_id=%s + """, + (lat, lon, lon, lat, query, json.dumps(raw), ma_id), + ) + cur.execute("UPDATE geocode_queue SET status='done', updated_at=now() WHERE ma_id=%s", (ma_id,)) + processed += 1 + print(f"[worker] ma_id={ma_id} cache HIT -> done") + continue + + # 2) requête nominatim + try: + res = nominatim_search(query) + if not res: + cur.execute( + """ + UPDATE bands + SET geocode_error=%s, geocode_error_at=now() + WHERE ma_id=%s + """, + ("no_result", ma_id), + ) + cur.execute( + """ + UPDATE geocode_queue + SET status='error', tries=tries+1, last_error=%s, + next_run_at=now() + interval '7 days', + updated_at=now() + WHERE ma_id=%s + """, + ("no_result", ma_id), + ) + processed += 1 + print(f"[worker] ma_id={ma_id} no_result -> postpone") + continue + + lat = float(res["lat"]) + lon = float(res["lon"]) + + # écrit cache + cur.execute( + """ + INSERT INTO geocode_cache(query, provider, lat, lon, geom, raw, updated_at) + VALUES (%s,'nominatim',%s,%s,ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography,%s,now()) + ON CONFLICT (query) DO UPDATE + SET lat=EXCLUDED.lat, lon=EXCLUDED.lon, geom=EXCLUDED.geom, + raw=EXCLUDED.raw, updated_at=now() + """, + (query, lat, lon, lon, lat, json.dumps(res)), + ) + + # écrit bands + cur.execute( + """ + UPDATE bands + SET lat=%s, lon=%s, + geom=ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography, + geocoded_at=now(), + geocode_provider='nominatim', + geocode_query=%s, + geocode_raw=%s, + geocode_error=NULL, + geocode_error_at=NULL + WHERE ma_id=%s + """, + (lat, lon, lon, lat, query, json.dumps(res), ma_id), + ) + + cur.execute("UPDATE geocode_queue SET status='done', updated_at=now() WHERE ma_id=%s", (ma_id,)) + processed += 1 + print(f"[worker] ma_id={ma_id} OK lat={lat} lon={lon}") + + except Exception as e: + # backoff progressif + backoff_minutes = min(60, 5 * (tries + 1)) + cur.execute( + """ + UPDATE bands + SET geocode_error=%s, geocode_error_at=now() + WHERE ma_id=%s + """, + (str(e)[:400], ma_id), + ) + cur.execute( + """ + UPDATE geocode_queue + SET status='error', tries=tries+1, last_error=%s, + next_run_at=now() + (%s || ' minutes')::interval, + updated_at=now() + WHERE ma_id=%s + """, + (str(e)[:400], backoff_minutes, ma_id), + ) + processed += 1 + print(f"[worker] ma_id={ma_id} ERROR {e} -> retry in {backoff_minutes}m") + + conn.close() + +if __name__ == "__main__": + main() diff --git a/apps/web/docker-compose.yml b/apps/web/docker-compose.yml new file mode 100644 index 0000000..a6ca731 --- /dev/null +++ b/apps/web/docker-compose.yml @@ -0,0 +1,51 @@ +services: + bm-web: + image: nginx:alpine + container_name: bm-web + networks: + - stack-vps_admin + volumes: + - ./site:/usr/share/nginx/html:ro + labels: + - traefik.enable=true + - traefik.docker.network=stack-vps_admin + + # ✅ règle de matching (OBLIGATOIRE) + - traefik.http.routers.bm-web.rule=Host(`metalfrom.eu`) || Host(`www.metalfrom.eu`) + + - traefik.http.routers.bm-web.entrypoints=websecure + - traefik.http.routers.bm-web.tls=true + - traefik.http.routers.bm-web.tls.certresolver=le + + # ✅ force cert apex + www + - traefik.http.routers.bm-web.tls.domains[0].main=metalfrom.eu + - traefik.http.routers.bm-web.tls.domains[0].sans=www.metalfrom.eu + - traefik.http.routers.bm-web.service=bm-web + - traefik.http.services.bm-web.loadbalancer.server.port=80 + quizz: + image: nginx:alpine + container_name: quizz + networks: + - stack-vps_admin + volumes: + - ./quizz-site:/usr/share/nginx/html:ro + labels: + - traefik.enable=true + - traefik.docker.network=stack-vps_admin + + # ✅ règle de matching (OBLIGATOIRE) + - traefik.http.routers.quizz.rule=Host(`quizz.nicolasfryder.ovh`) + + - traefik.http.routers.quizz.entrypoints=websecure + - traefik.http.routers.quizz.tls=true + - traefik.http.routers.quizz.tls.certresolver=le + + # ✅ force cert apex + www + - traefik.http.routers.quizz.tls.domains[0].main=quizz.nicolasfryder.ovh + - traefik.http.routers.quizz.service=quizz + - traefik.http.services.quizz.loadbalancer.server.port=80 + restart: unless-stopped + +networks: + stack-vps_admin: + external: true diff --git a/apps/web/quizz-site/axolotl.jpeg b/apps/web/quizz-site/axolotl.jpeg new file mode 100644 index 0000000..3dc14d8 Binary files /dev/null and b/apps/web/quizz-site/axolotl.jpeg differ diff --git a/apps/web/quizz-site/echidne.jpeg b/apps/web/quizz-site/echidne.jpeg new file mode 100644 index 0000000..7d91d6a Binary files /dev/null and b/apps/web/quizz-site/echidne.jpeg differ diff --git a/apps/web/quizz-site/index.html b/apps/web/quizz-site/index.html new file mode 100644 index 0000000..3716d3c --- /dev/null +++ b/apps/web/quizz-site/index.html @@ -0,0 +1,696 @@ + + + + + +✨ Le Quiz des Équipes + + + + + +
+
+ + +
+
+
🎉 Weekend Anniversaire
+

Qui es-tu
ce weekend ?

+

Sélectionne ton prénom pour découvrir
dans quelle équipe tu es.

+
+
+ 🔍 + +
+
+
+ + +
+
+
+
+
+
+
+
+ +
+
+ + +
+
+
+ +
+
Bienvenue dans l'équipe
+
+
+
+ +
+
+ +
+ + + + diff --git a/apps/web/quizz-site/poule-de-soie.jpeg b/apps/web/quizz-site/poule-de-soie.jpeg new file mode 100644 index 0000000..8907821 Binary files /dev/null and b/apps/web/quizz-site/poule-de-soie.jpeg differ diff --git a/apps/web/quizz-site/ratel.jpeg b/apps/web/quizz-site/ratel.jpeg new file mode 100644 index 0000000..74285a7 Binary files /dev/null and b/apps/web/quizz-site/ratel.jpeg differ diff --git a/apps/web/quizz-site/saiga.jpeg b/apps/web/quizz-site/saiga.jpeg new file mode 100644 index 0000000..783c2e5 Binary files /dev/null and b/apps/web/quizz-site/saiga.jpeg differ diff --git a/apps/web/site/app.js b/apps/web/site/app.js new file mode 100644 index 0000000..2c0182a --- /dev/null +++ b/apps/web/site/app.js @@ -0,0 +1,1752 @@ +/* Black Metal Map UI — clusters/heat/timeline/filter/sort/highlight */ +/* Performance-optimized with viewport-based loading */ + +const API_BASE = "https://bm.nicolasfryder.ovh"; +const DARK_TILES = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"; +const DARK_ATTRIB = '© OpenStreetMap contributors © CARTO'; + +// --- Configuration --- +const USE_VIEWPORT_LOADING = true; // Enable viewport-based loading +const INITIAL_LOAD_LIMIT = 5000; // Initial bands to load for filters +const VIEWPORT_DEBOUNCE_MS = 300; // Debounce for viewport changes + +// --- State --- +let allBands = []; // all loaded bands (geocoded + non geocoded) +let geocodedBands = []; // subset lat/lon ok +let noLocationBands = []; // subset missing coords +let viewportBands = []; // bands in current viewport + +let filtered = []; +let markersById = new Map(); // ma_id -> marker +let listElById = new Map(); // ma_id -> element +let coordIndex = new Map(); // "lat,lon" -> bands[] + +let heatLayer = null; +let clusterLayer = null; +let currentMode = "clusters"; + +// All heat points (for full heatmap even when viewing clusters) +let allHeatPoints = []; +let heatPointsLoaded = false; + +const statusEnabled = new Map(); +const genreEnabled = new Map(); +const countryEnabled = new Map(); +const macroGenreEnabled = new Map(); +const themeEnabled = new Map(); + +let yearRange = { min: null, max: null }; // global +let yearFilter = { min: null, max: null }; // current + +let macroActiveCount = 0; + +// Facet data from server (for filters) +let facetData = null; + +// Loading state +let isLoadingViewport = false; +let pendingViewportLoad = false; +let currentViewportData = []; + +// --- Utils --- +const $ = (id) => document.getElementById(id); +const appEl = $("app"); + +async function loadGoatCounterFooterStats() { + const totalEl = document.getElementById("gcTotal"); + const monthEl = document.getElementById("gcMonth"); + if (!totalEl || !monthEl) return; + + // 1er jour du mois courant (ce mois-ci) + const d = new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const startOfMonth = `${yyyy}-${mm}-01`; + + const base = "https://metalfromeurope.goatcounter.com/counter/"; + const totalUrl = `${base}TOTAL.json`; + const monthUrl = `${base}TOTAL.json?start=${encodeURIComponent(startOfMonth)}`; + + try { + const [t, m] = await Promise.all([ + fetch(totalUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))), + fetch(monthUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))), + ]); + + // GoatCounter renvoie `count` comme string formatée (séparateurs milliers). :contentReference[oaicite:2]{index=2} + totalEl.textContent = t.count ?? "—"; + monthEl.textContent = m.count ?? "—"; + } catch (e) { + console.warn("GoatCounter footer stats failed:", e); + totalEl.textContent = "—"; + monthEl.textContent = "—"; + } +} + +// Debounce function for search +let searchDebounceTimer = null; +function debounce(func, delay) { + return function(...args) { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => func.apply(this, args), delay); + }; +} + +function escapeHtml(s) { + return String(s ?? "").replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); +} + +function norm(s) { + if (s == null || s === undefined || s === "") return ""; + return String(s).trim(); +} + +function parseYear(val) { + if (val == null) return null; + const n = Number(val); + if (Number.isFinite(n)) return n; + const m = String(val).match(/(19|20)\d{2}/); + return m ? Number(m[0]) : null; +} + +function splitThemes(val) { + if (!val) return []; + if (Array.isArray(val)) return val.map(norm).filter(Boolean); + return String(val).split(/[,;/]/).map(norm).filter(Boolean); +} + +function currentQuery() { + return norm($("q").value).toLowerCase(); +} + +function bandMatchesQuery(b, q) { + if (!q) return true; + const themes = Array.isArray(b.themes) ? b.themes.join(" ") : ""; + const hay = `${b.name} ${b.genre} ${b.status} ${b.country} ${b.location_text} ${themes}`.toLowerCase(); + return hay.includes(q); +} + +function bandMatchesStatus(b) { + const s = norm(b.status); + if (!s) { + // If no status, check if "Unknown" is enabled + if (!statusEnabled.has("Unknown")) return true; + return statusEnabled.get("Unknown") === true; + } + if (!statusEnabled.has(s)) return true; + return statusEnabled.get(s) === true; +} + +function bandMatchesCountry(b) { + const c = norm(b.country); + if (!c) { + // If no country, check if "??" is enabled + if (!countryEnabled.has("??")) return true; + return countryEnabled.get("??") === true; + } + if (!countryEnabled.has(c)) return true; + return countryEnabled.get(c) === true; +} + +function bandMatchesMacroGenre(b) { + if (macroActiveCount === 0) return true; + const g = norm(b.genre).toLowerCase(); + if (!g) return false; + return MACRO_GENRES.some(m => macroGenreEnabled.get(m.key) === true && m.terms.some(t => g.includes(t))); +} + +function bandMatchesGenre(b) { + if (!bandMatchesMacroGenre(b)) return false; + const g = norm(b.genre); + if (!g) { + // If no genre, check if "Unknown" is enabled + if (!genreEnabled.has("Unknown")) return true; + return genreEnabled.get("Unknown") === true; + } + if (!genreEnabled.has(g)) return true; + return genreEnabled.get(g) === true; +} + +function bandMatchesTheme(b) { + if (!themeEnabled.size) return true; + const allOn = Array.from(themeEnabled.values()).every(v => v === true); + if (allOn) return true; + const tokens = Array.isArray(b.themes) ? b.themes : []; + if (!tokens.length) return false; + return tokens.some(t => themeEnabled.get(t) === true); +} + +function bandMatchesYear(b) { + const y = b.formed_year; + + // If no filter is set, pass all + if (yearFilter.min == null || yearFilter.max == null) return true; + + // If yearRange not initialized yet, pass all + if (yearRange.min == null || yearRange.max == null) return true; + + // If filter is at full range, pass all (including those without years) + const isFullRange = (yearFilter.min === yearRange.min && yearFilter.max === yearRange.max); + + if (isFullRange) { + return true; + } + + // If user has narrowed the range, only show bands with valid years in range + if (!Number.isFinite(y)) { + // Band has no year and user has filtered by year -> exclude + return false; + } + + return y >= yearFilter.min && y <= yearFilter.max; +} + +function keyFromLatLon(lat, lon) { + const la = Number(lat).toFixed(6); + const lo = Number(lon).toFixed(6); + return `${la},${lo}`; +} + +const MACRO_GENRES = [ + { key: "Black", terms: ["black"] }, + { key: "Death", terms: ["death"] }, + { key: "Doom/Stoner/Sludge", terms: ["doom", "stoner", "sludge"] }, + { key: "Electronic/Industrial", terms: ["electronic", "industrial", "electro"] }, + { key: "Experimental/Avant-garde", terms: ["experimental", "avant", "avant-garde", "avantgarde"] }, + { key: "Folk/Viking/Pagan", terms: ["folk", "viking", "pagan"] }, + { key: "Gothic", terms: ["gothic"] }, + { key: "Grindcore", terms: ["grind"] }, + { key: "Groove", terms: ["groove"] }, + { key: "Heavy", terms: ["heavy"] }, + { key: "Metalcore/Deathcore", terms: ["metalcore", "deathcore"] }, + { key: "Power", terms: ["power"] }, + { key: "Progressive", terms: ["progressive", "prog"] }, + { key: "Speed", terms: ["speed"] }, + { key: "Symphonic", terms: ["symphonic"] }, + { key: "Thrash", terms: ["thrash"] }, +]; + +// --- API --- +async function apiGet(path) { + const url = `${API_BASE}${path}`; + const r = await fetch(url, { mode: "cors" }); + if (!r.ok) throw new Error(`API ${path} failed: ${r.status}`); + return await r.json(); +} + +async function loadStats() { + // tolérant : si /api/stats n'existe pas encore, on ignore + try { + const j = await apiGet("/api/stats"); + if (j && j.ok) { + $("total").textContent = String(j.total || 0); + $("geocoded").textContent = String(j.geocoded || 0); + $("noLocCount").textContent = String(j.no_location || 0); + } + } catch (_) { + // Silently ignore stats errors + } +} + +/** + * Load facet data for filters (fast, minimal data) + */ +async function loadFacets() { + try { + const j = await apiGet("/api/facets"); + if (j.ok) { + facetData = j; + console.log("Facets loaded:", { + statuses: j.statuses?.length, + countries: j.countries?.length, + genres: j.genres?.length, + yearRange: j.year_range + }); + } + } catch (e) { + console.warn("Failed to load facets, will compute from bands:", e); + } +} + +/** + * Parse band data from API response + */ +function parseBandData(x) { + let status = x.status || x.data?.status || x.data?.band_status || x.data?.status_label; + + return { + ma_id: Number(x.ma_id), + name: norm(x.name), + url: x.url || (x.data?.url) || (x.data?.band_page?.url) || null, + country: norm(x.country || x.data?.country), + status: norm(status), + genre: norm(x.genre || x.data?.genre || x.data?.genres || x.data?.style), + themes: splitThemes(x.themes || x.data?.themes || x.data?.theme || x.data?.thematic), + location_text: norm(x.location_text || x.data?.location_text || x.data?.location), + formed_year: parseYear(x.formed_year ?? x.data?.formed_year ?? x.data?.formed ?? x.data?.formation_year ?? x.data?.year_formed), + lat: (x.lat == null ? null : Number(x.lat)), + lon: (x.lon == null ? null : Number(x.lon)), + data: x.data || {}, + geocoded_at: x.geocoded_at || null, + }; +} + +/** + * Load initial sample of bands for quick startup + */ +async function loadBands() { + // Load a limited set initially for faster startup + const limit = USE_VIEWPORT_LOADING ? INITIAL_LOAD_LIMIT : 200000; + const j = await apiGet(`/api/bands?limit=${limit}`); + const items = j.items || j.bands || []; + + console.log(`Loaded ${items.length} bands initially`); + + allBands = items.map(parseBandData); + + geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon)); + noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon))); + + // Update counts + $("total").textContent = String(allBands.length); + $("geocoded").textContent = String(geocodedBands.length); + $("noLocCount").textContent = String(noLocationBands.length); +} + +/** + * Load all heat points for the full heatmap (done once in background) + */ +async function loadAllHeatPoints() { + if (heatPointsLoaded) return; + + try { + console.log("Loading all bands for heatmap..."); + const j = await apiGet("/api/bands?limit=200000"); + const items = j.items || []; + + allHeatPoints = []; + + // If allBands is empty, populate it + if (!allBands.length) { + allBands = items.map(parseBandData); + geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon)); + noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon))); + } + + // Index by coords for heatmap intensity + const coordCounts = new Map(); + + for (const x of items) { + const lat = x.lat == null ? null : Number(x.lat); + const lon = x.lon == null ? null : Number(x.lon); + + if (Number.isFinite(lat) && Number.isFinite(lon)) { + const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; + coordCounts.set(key, (coordCounts.get(key) || 0) + 1); + } + } + + // Convert to heat points + for (const [key, count] of coordCounts) { + const [lat, lon] = key.split(",").map(Number); + allHeatPoints.push([lat, lon, count]); + } + + heatPointsLoaded = true; + console.log(`Loaded ${allHeatPoints.length} unique heat points from ${items.length} bands`); + + // Update stats + $("total").textContent = String(items.length); + $("geocoded").textContent = String(geocodedBands.length); + $("noLocCount").textContent = String(noLocationBands.length); + + // Rebuild heatmap if in heat mode + if (currentMode === "heat") { + buildHeatLayer(allHeatPoints); + } + } catch (e) { + console.error("Failed to load heat points:", e); + } +} + +/** + * Load bands for current viewport from server + */ +async function loadViewportBands() { + if (!USE_VIEWPORT_LOADING) return; + if (isLoadingViewport) { + pendingViewportLoad = true; + return; + } + + isLoadingViewport = true; + + try { + const bounds = map.getBounds(); + const zoom = map.getZoom(); + + // Expand bounds slightly to preload edges + const expandFactor = 0.2; + const latDiff = (bounds.getNorth() - bounds.getSouth()) * expandFactor; + const lonDiff = (bounds.getEast() - bounds.getWest()) * expandFactor; + + const bbox = [ + bounds.getWest() - lonDiff, + bounds.getSouth() - latDiff, + bounds.getEast() + lonDiff, + bounds.getNorth() + latDiff + ].join(","); + + // Build query params for filters + const params = new URLSearchParams({ + bbox, + zoom: String(Math.floor(zoom)) + }); + + // Add active filters + const activeStatuses = Array.from(statusEnabled.entries()) + .filter(([k, v]) => v) + .map(([k]) => k); + if (activeStatuses.length && activeStatuses.length < statusEnabled.size) { + params.set("status", activeStatuses.join(",")); + } + + const activeCountries = Array.from(countryEnabled.entries()) + .filter(([k, v]) => v) + .map(([k]) => k); + if (activeCountries.length && activeCountries.length < countryEnabled.size) { + params.set("countries", activeCountries.join(",")); + } + + if (yearFilter.min != null && yearFilter.max != null) { + if (yearFilter.min !== yearRange.min) params.set("year_min", String(yearFilter.min)); + if (yearFilter.max !== yearRange.max) params.set("year_max", String(yearFilter.max)); + } + + // Add genre filter if macro genres are active + if (macroActiveCount > 0) { + const activeTerms = MACRO_GENRES + .filter(m => macroGenreEnabled.get(m.key) === true) + .flatMap(m => m.terms); + if (activeTerms.length) { + params.set("genre", activeTerms[0]); // API supports single genre filter + } + } + + const url = `/api/clusters?${params.toString()}`; + console.log("Loading viewport:", url); + + const j = await apiGet(url); + + if (j.ok) { + currentViewportData = j.items || []; + + if (j.type === "bands") { + // High zoom: got individual bands + viewportBands = currentViewportData.map(parseBandData); + rebuildLayersFromBands(viewportBands); + } else { + // Low zoom: got clusters + rebuildLayersFromClusters(currentViewportData); + } + + $("count").textContent = String(j.count || currentViewportData.length || 0); + } + } catch (e) { + console.error("Failed to load viewport bands:", e); + } finally { + isLoadingViewport = false; + + if (pendingViewportLoad) { + pendingViewportLoad = false; + setTimeout(loadViewportBands, 100); + } + } +} + +/** + * Create a cluster marker (zoomable, not final location) + * These have a dashed border to indicate they can be zoomed + * Burgundy/dark red color, always white text + */ +function createClusterMarker(lat, lon, count, bands) { + // Size based on count (logarithmic scale) + const size = Math.min(24 + Math.log2(count + 1) * 8, 55); + + // Constant burgundy/dark red color for all clusters + const color = 'rgba(120, 40, 60, 0.92)'; + const borderColor = 'rgba(180, 80, 100, 0.9)'; + const textColor = '#fff'; + + // Create custom div icon with count - dashed border indicates "zoomable" + const icon = L.divIcon({ + className: 'cluster-marker cluster-zoomable', + html: `
${count > 999 ? Math.round(count/1000) + 'k' : count}
`, + iconSize: [size, size], + iconAnchor: [size/2, size/2] + }); + + const marker = L.marker([lat, lon], { icon }); + + // Store data for click handling + marker.__bands = bands; + marker.__count = count; + marker.__isCluster = true; // Flag to identify as zoomable cluster + + // Click handler - always zoom for clusters + marker.on("click", (e) => { + L.DomEvent.stopPropagation(e); + map.setView([lat, lon], map.getZoom() + 2, { animate: true }); + }); + + return marker; +} + +/** + * Create a location marker (final level - shows popup with all bands) + * These have a solid border and bright red color to indicate they show details + */ +function createLocationMarker(lat, lon, count, bands, locationName) { + // Size based on count (smaller than clusters) + const size = Math.min(18 + Math.log2(count + 1) * 6, 42); + + // Bright red color for final locations + let color, borderColor; + if (count === 1) { + color = 'rgba(180, 30, 30, 0.9)'; + borderColor = 'rgba(255, 100, 100, 0.9)'; + } else if (count < 5) { + color = 'rgba(198, 26, 26, 0.9)'; + borderColor = 'rgba(255, 120, 120, 0.9)'; + } else if (count < 20) { + color = 'rgba(210, 50, 50, 0.9)'; + borderColor = 'rgba(255, 150, 150, 0.9)'; + } else { + color = 'rgba(220, 80, 80, 0.9)'; + borderColor = 'rgba(255, 180, 180, 0.95)'; + } + + // Create custom div icon with count - solid border indicates "clickable for details" + const icon = L.divIcon({ + className: 'cluster-marker cluster-location', + html: `
${count}
`, + iconSize: [size, size], + iconAnchor: [size/2, size/2] + }); + + const marker = L.marker([lat, lon], { icon }); + + // Store data + marker.__bands = bands; + marker.__count = count; + marker.__isCluster = false; // Flag to identify as final location + marker.__locationName = locationName; + + // Build popup HTML with ALL bands (scrollable) + const bandListHtml = bands.map(b => { + const maUrl = b.url || (b.ma_id ? `https://www.metal-archives.com/bands/x/${b.ma_id}` : null); + const genre = b.genre || '—'; + const status = b.status || '—'; + const year = Number.isFinite(b.formed_year) ? b.formed_year : '—'; + + if (maUrl) { + return ` +
${escapeHtml(b.name || 'Unknown')}
+
${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}
+
`; + } + return `
+
${escapeHtml(b.name || 'Unknown')}
+
${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}
+
`; + }).join(""); + + const popupHtml = ` +
+
+ 📍 ${escapeHtml(locationName || 'Localisation')} + ${count} groupe${count > 1 ? 's' : ''} +
+
+ ${bandListHtml} +
+
+ `; + + // Bind popup + marker.bindPopup(popupHtml, { + maxWidth: 380, + maxHeight: 400, + className: 'location-popup' + }); + + // Click handler - update sidebar list AND open popup + marker.on("click", (e) => { + L.DomEvent.stopPropagation(e); + const hint = locationName ? `${count} groupe(s) — ${locationName}` : `${count} groupe(s)`; + renderList(bands, hint); + // Small delay to ensure popup opens correctly + setTimeout(() => marker.openPopup(), 10); + }); + + return marker; +} + +/** + * Check if all bands share the same location_text + */ +function bandsShareLocation(bands) { + if (!bands || bands.length === 0) return false; + if (bands.length === 1) return true; + + const firstLoc = norm(bands[0]?.location_text || ''); + if (!firstLoc) return false; + + return bands.every(b => norm(b?.location_text || '') === firstLoc); +} + +/** + * Rebuild layers from cluster data (server-side aggregated) + */ +function rebuildLayersFromClusters(clusters) { + markersById.clear(); + coordIndex.clear(); + clusterLayer.clearLayers(); + + for (const cluster of clusters) { + const { lat, lon, count, sample_bands, location_text } = cluster; + + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + + // Parse sample bands + const bands = (sample_bands || []).map(b => { + if (typeof b === 'string') { + try { return JSON.parse(b); } catch { return null; } + } + return b; + }).filter(Boolean); + + const bandCount = count || bands.length; + + // Check if all bands share the same location + const allSameLocation = bandsShareLocation(bands); + const locationName = allSameLocation ? (location_text || bands[0]?.location_text || null) : null; + + // Use location marker (popup) if all bands share same location + // Use cluster marker (zoom) if bands come from different locations + const useLocationMarker = allSameLocation; + + const marker = useLocationMarker + ? createLocationMarker(lat, lon, bandCount, bands, locationName) + : createClusterMarker(lat, lon, bandCount, bands); + + clusterLayer.addLayer(marker); + + // Store reference for each band in cluster + for (const b of bands) { + if (b.ma_id) markersById.set(b.ma_id, marker); + } + } + + if (currentMode === "clusters") { + if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer); + } + + $("unique").textContent = String(clusters.length); + + // Render list with visible bands + const visibleBands = clusters.flatMap(c => c.sample_bands || []) + .map(b => typeof b === 'string' ? JSON.parse(b) : b) + .filter(Boolean); + renderList(visibleBands.slice(0, 200)); +} + +/** + * Rebuild layers from individual band data + */ +function rebuildLayersFromBands(bands) { + markersById.clear(); + coordIndex.clear(); + clusterLayer.clearLayers(); + + // Index by coords + for (const b of bands) { + if (!Number.isFinite(b.lat) || !Number.isFinite(b.lon)) continue; + const k = keyFromLatLon(b.lat, b.lon); + if (!coordIndex.has(k)) coordIndex.set(k, []); + coordIndex.get(k).push(b); + } + + for (const [k, coordBands] of coordIndex.entries()) { + const [la, lo] = k.split(",").map(Number); + + if (!Number.isFinite(la) || !Number.isFinite(lo)) continue; + + const locationName = coordBands[0]?.location_text || null; + + // Always use location marker for individual bands (final level) + const marker = createLocationMarker(la, lo, coordBands.length, coordBands, locationName); + + for (const b of coordBands) markersById.set(b.ma_id, marker); + + clusterLayer.addLayer(marker); + } + + if (currentMode === "clusters") { + if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer); + } + + $("count").textContent = String(bands.length); + $("unique").textContent = String(coordIndex.size); + renderList(bands); +} + +/** + * Rebuild layers from locally filtered data (legacy mode) + */ +function rebuildLayers() { + markersById.clear(); + coordIndex.clear(); + + clusterLayer.clearLayers(); + + if (currentMode === "clusters") { + if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer); + } else if (map.hasLayer(clusterLayer)) { + map.removeLayer(clusterLayer); + } + + // index by coords + for (const b of filtered) { + const k = keyFromLatLon(b.lat, b.lon); + if (!coordIndex.has(k)) coordIndex.set(k, []); + coordIndex.get(k).push(b); + } + + console.log(`Rebuilding layers: ${filtered.length} bands, ${coordIndex.size} unique coords`); + + let markerCount = 0; + const heatPoints = []; + let heatMax = 1; + for (const [k, bands] of coordIndex.entries()) { + const [la, lo] = k.split(",").map(Number); + + // Validate coordinates + if (!Number.isFinite(la) || !Number.isFinite(lo)) { + console.warn(`Invalid coords for key ${k}:`, { la, lo }); + continue; + } + + const intensity = bands.length; + heatPoints.push([la, lo, intensity]); + if (intensity > heatMax) heatMax = intensity; + + const locationName = bands[0]?.location_text || null; + + // Use location marker (final level) for all in legacy mode + const marker = createLocationMarker(la, lo, bands.length, bands, locationName); + markerCount++; + + for (const b of bands) markersById.set(b.ma_id, marker); + + clusterLayer.addLayer(marker); + } + + console.log(`Added ${markerCount} markers to clusterLayer`); + + // heatmap (use all points if loaded, otherwise use filtered) + if (heatPointsLoaded) { + buildHeatLayer(allHeatPoints); + } else { + buildHeatLayer(heatPoints, heatMax); + } + + if (currentMode === "heat") { + if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer); + } else if (heatLayer && map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } + + // counters + $("count").textContent = String(filtered.length); + $("unique").textContent = String(coordIndex.size); +} + +// --- Map init --- +const map = L.map("map", { + preferCanvas: true, + tap: true, // Enable tap for mobile + touchZoom: true, + dragging: true, + zoomControl: false // Disable default, we'll add our own on the right +}).setView([50.2, 10.2], 4); + +// Add zoom control on the right side +L.control.zoom({ + position: 'topright' +}).addTo(map); + +L.tileLayer(DARK_TILES, { maxZoom: 19, attribution: DARK_ATTRIB }).addTo(map); + +// Force map to render properly on mobile +setTimeout(() => { + if (map && map.invalidateSize) { + map.invalidateSize(true); + } +}, 100); + +// Also invalidate on window resize +window.addEventListener('resize', () => { + if (map && map.invalidateSize) { + map.invalidateSize(); + } +}); + +// Invalidate when page becomes visible (mobile browsers) +document.addEventListener('visibilitychange', () => { + if (!document.hidden && map && map.invalidateSize) { + setTimeout(() => map.invalidateSize(true), 100); + } +}); + +// Layer for custom cluster markers +clusterLayer = L.layerGroup(); +map.addLayer(clusterLayer); + +// Debounced viewport change handler +let viewportDebounceTimer = null; +function onViewportChange() { + if (!USE_VIEWPORT_LOADING) return; + + clearTimeout(viewportDebounceTimer); + viewportDebounceTimer = setTimeout(() => { + loadViewportBands(); + }, VIEWPORT_DEBOUNCE_MS); +} + +// Listen for map movements +map.on("moveend", onViewportChange); +map.on("zoomend", onViewportChange); + +function buildHeatLayer(points, maxIntensity) { + if (!points || !points.length) { + if (heatLayer) map.removeLayer(heatLayer); + return; + } + + // Calcul des statistiques pour calibrer l'échelle + const intensities = points.map(p => p[2]).sort((a, b) => b - a); + const actualMax = intensities[0] || 1; + const p95 = intensities[Math.floor(intensities.length * 0.05)] || 1; // 95e percentile + const p90 = intensities[Math.floor(intensities.length * 0.10)] || 1; // 90e percentile + const p75 = intensities[Math.floor(intensities.length * 0.25)] || 1; // 75e percentile + const p50 = intensities[Math.floor(intensities.length * 0.5)] || 1; // Médiane + + // Utilise le 75e percentile pour une heatmap moins saturée + // Multiplié par 2 pour étaler davantage les couleurs + const max = Math.max(15, (maxIntensity || p75) * 2); + + console.log(`Heatmap: ${points.length} points, actualMax=${actualMax}, p95=${p95}, p90=${p90}, p75=${p75}, p50=${p50}, using max=${max}`); + + if (heatLayer) map.removeLayer(heatLayer); + heatLayer = L.heatLayer(points, { + radius: 20, // Réduit de 28 à 20 pour moins de chevauchement + blur: 18, // Réduit de 22 à 18 + maxZoom: 10, // Augmenté de 8 à 10 pour garder l'effet plus longtemps + minOpacity: 0.15, // Réduit de 0.25 à 0.15 pour moins de saturation + max: max, + gradient: { + 0.0: '#000033', // Bleu très foncé (presque invisible) + 0.15: '#000066', // Bleu foncé + 0.3: '#0066cc', // Bleu moyen + 0.45: '#00ccff', // Cyan + 0.58: '#44ff44', // Vert + 0.72: '#ffff00', // Jaune + 0.86: '#ff8800', // Orange + 1.0: '#ff0000' // Rouge + } + }); + + if (currentMode === "heat") { + map.addLayer(heatLayer); + } +} + +function setMode(mode) { + // mode: "clusters" | "heat" + currentMode = mode; + const cOn = mode === "clusters"; + const hOn = mode === "heat"; + + if (cOn) { + if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer); + if (heatLayer && map.hasLayer(heatLayer)) map.removeLayer(heatLayer); + } else { + if (map.hasLayer(clusterLayer)) map.removeLayer(clusterLayer); + if (!heatPointsLoaded) { + loadAllHeatPoints(); + } else { + buildHeatLayer(allHeatPoints); + if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer); + } + } +} + +// --- UI widgets (multiselect) --- +function buildMultiSelect(containerId, items, enabledMap, labelFn, options = {}) { + const container = $(containerId); + if (!container) return; + + const summaryEl = options.summaryEl || null; + const emptyLabel = options.emptyLabel || "Aucun élément"; + + container.innerHTML = ` +
+ +
+ + +
+
+
+ `; + + const searchEl = container.querySelector(".ms-search"); + const listEl = container.querySelector(".ms-list"); + + function updateSummary() { + if (!summaryEl) return; + const total = items.length; + if (!total) { + summaryEl.textContent = "Aucun"; + return; + } + const selected = items.filter(it => enabledMap.get(it.key) !== false).length; + if (selected === total) summaryEl.textContent = "Tous"; + else if (selected === 0) summaryEl.textContent = "Aucun"; + else summaryEl.textContent = `${selected}/${total}`; + } + + function render() { + const q = norm(searchEl.value).toLowerCase(); + listEl.innerHTML = ""; + + if (!items.length) { + listEl.innerHTML = `
${escapeHtml(emptyLabel)}
`; + updateSummary(); + return; + } + + const filteredItems = items.filter(it => labelFn(it).toLowerCase().includes(q)); + if (!filteredItems.length) { + listEl.innerHTML = `
Aucun résultat
`; + updateSummary(); + return; + } + + for (const it of filteredItems) { + const key = it.key; + const on = enabledMap.get(key) === true; + const div = document.createElement("div"); + div.className = `ms-item ${on ? "on" : ""}`; + div.dataset.key = key; + div.innerHTML = ` +
${escapeHtml(labelFn(it))}
+
${it.count}
+ `; + div.addEventListener("click", () => { + enabledMap.set(key, !(enabledMap.get(key) === true)); + render(); + applyFilters(); + }); + listEl.appendChild(div); + } + + updateSummary(); + } + + container.querySelector('[data-act="all"]').addEventListener("click", () => { + for (const it of items) enabledMap.set(it.key, true); + render(); + applyFilters(); + }); + container.querySelector('[data-act="none"]').addEventListener("click", () => { + for (const it of items) enabledMap.set(it.key, false); + render(); + applyFilters(); + }); + searchEl.addEventListener("input", render); + + render(); +} + +function buildStatusToggles(statusItems) { + const grid = $("statusGrid"); + grid.innerHTML = ""; + for (const it of statusItems) { + statusEnabled.set(it.key, true); + const btn = document.createElement("div"); + btn.className = "toggle on"; + btn.dataset.status = it.key; + btn.innerHTML = `${escapeHtml(it.key)}${it.count}`; + btn.addEventListener("click", () => { + const cur = statusEnabled.get(it.key) === true; + statusEnabled.set(it.key, !cur); + btn.classList.toggle("on", !cur); + applyFilters(); + }); + grid.appendChild(btn); + } +} + +function buildMacroGenreButtons(genreFacets = []) { + const container = $("macroGenres"); + if (!container) return; + container.innerHTML = ""; + macroActiveCount = 0; + + const counts = new Map(); + for (const m of MACRO_GENRES) counts.set(m.key, 0); + + // Calculate macro genre counts from genre facets + for (const facet of genreFacets) { + const g = (facet.key || "").toLowerCase(); + if (!g) continue; + for (const m of MACRO_GENRES) { + if (m.terms.some(t => g.includes(t))) { + counts.set(m.key, (counts.get(m.key) || 0) + facet.count); + } + } + } + + for (const m of MACRO_GENRES) { + macroGenreEnabled.set(m.key, false); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "macro-btn"; + btn.dataset.key = m.key; + btn.innerHTML = `${escapeHtml(m.key)}${counts.get(m.key) || 0}`; + btn.addEventListener("click", () => { + const cur = macroGenreEnabled.get(m.key) === true; + macroGenreEnabled.set(m.key, !cur); + btn.classList.toggle("on", !cur); + macroActiveCount += cur ? -1 : 1; + applyFilters(); + }); + container.appendChild(btn); + } +} + +function setupDropdown(triggerId, panelId) { + const trigger = $(triggerId); + const panel = $(panelId); + if (!trigger || !panel) return; + trigger.addEventListener("click", () => { + const nowCollapsed = panel.classList.toggle("is-collapsed"); + trigger.setAttribute("aria-expanded", String(!nowCollapsed)); + }); +} + +// --- List + details + highlight --- +function sortBands(arr) { + const mode = $("sortSelect")?.value || "az"; + const a = [...arr]; + + if (mode === "az") { + a.sort((x,y) => (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" })); + } else if (mode === "status") { + a.sort((x,y) => (x.status || "").localeCompare(y.status || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" })); + } else if (mode === "genre") { + a.sort((x,y) => (x.genre || "").localeCompare(y.genre || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" })); + } else if (mode === "country") { + a.sort((x,y) => (x.country || "").localeCompare(y.country || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" })); + } else if (mode === "year") { + a.sort((x,y) => (Number.isFinite(y.formed_year) ? y.formed_year : -1) - (Number.isFinite(x.formed_year) ? x.formed_year : -1)); + } + + return a; +} + +function setActive(ma_id) { + // list highlight + for (const [id, el] of listElById.entries()) el.classList.toggle("active", id === ma_id); + + // marker highlight + const m = markersById.get(ma_id); + if (m && m.setStyle) { + m.setStyle({ radius: 8, fillOpacity: 0.85, weight: 2 }); + // reset others + for (const [id, mm] of markersById.entries()) { + if (id !== ma_id && mm.setStyle) mm.setStyle({ radius: 6, fillOpacity: 0.55, weight: 1 }); + } + } +} + +function renderList(items, hintOverride) { + const list = $("list"); + list.innerHTML = ""; + listElById.clear(); + + const sorted = sortBands(items); + $("selectionHint").textContent = hintOverride || `${sorted.length} résultat(s)`; + + const q = currentQuery(); + + for (const b of sorted.slice(0, 4000)) { + const div = document.createElement("div"); + div.className = "band"; + div.dataset.id = String(b.ma_id); + + const name = escapeHtml(b.name || "Unknown"); + const genre = escapeHtml(b.genre || "—"); + const loc = escapeHtml(b.location_text || "—"); + const st = escapeHtml(b.status || "—"); + const yr = Number.isFinite(b.formed_year) ? String(b.formed_year) : "—"; + + div.innerHTML = ` +
${name}
+
+
${escapeHtml(b.country || "—")}${st} • ${yr}
+
${genre}
+
${loc}
+
+ `; + + div.addEventListener("mouseenter", () => { + setActive(b.ma_id); + const m = markersById.get(b.ma_id); + if (m) m.openPopup?.(); + }); + + div.addEventListener("click", () => { + setActive(b.ma_id); + const m = markersById.get(b.ma_id); + if (m && m.getLatLng) { + map.setView(m.getLatLng(), Math.max(map.getZoom(), 8), { animate: true }); + m.openPopup?.(); + } else if (Number.isFinite(b.lat) && Number.isFinite(b.lon)) { + map.setView([b.lat, b.lon], Math.max(map.getZoom(), 10), { animate: true }); + } + }); + + list.appendChild(div); + listElById.set(b.ma_id, div); + + // highlight visuel dans liste + if (q && bandMatchesQuery(b, q)) { + div.style.boxShadow = "0 0 0 1px rgba(198,26,26,0.15) inset"; + } + } +} + +function applyFilters() { + const q = currentQuery(); + + console.log("Applying filters...", { + query: q, + useViewportLoading: USE_VIEWPORT_LOADING + }); + + if (USE_VIEWPORT_LOADING) { + // With viewport loading, trigger a reload from server + loadViewportBands(); + } else { + // Legacy mode: filter locally loaded data + filtered = geocodedBands + .filter(b => bandMatchesQuery(b, q)) + .filter(b => bandMatchesCountry(b)) + .filter(b => bandMatchesStatus(b)) + .filter(b => bandMatchesGenre(b)) + .filter(b => bandMatchesTheme(b)) + .filter(b => bandMatchesYear(b)); + + console.log(`Filtered: ${filtered.length} bands match filters`); + + rebuildLayers(); + renderList(filtered); + } +} + +// --- Modal for no coords --- +const modalBackdrop = $("modalBackdrop"); +const modalBody = $("modalBody"); +const modalClose = $("modalClose"); +const modalTitle = $("modalTitle"); + +function openModal(title, bands) { + modalTitle.textContent = title; + modalBody.innerHTML = ""; + + const q = currentQuery(); + const items = bands + .filter(b => bandMatchesQuery(b, q)) + .filter(b => bandMatchesCountry(b)) + .filter(b => bandMatchesStatus(b)) + .filter(b => bandMatchesGenre(b)) + .filter(b => bandMatchesTheme(b)) + .filter(b => bandMatchesYear(b)); + + if (!items.length) { + modalBody.innerHTML = `
Aucun résultat
`; + } else { + const sorted = sortBands(items); + for (const b of sorted.slice(0, 8000)) { + const div = document.createElement("div"); + div.className = "band"; + const maUrl = b.url || (b.ma_id ? `https://www.metal-archives.com/bands/x/${b.ma_id}` : "#"); + div.innerHTML = ` +
+ + ${escapeHtml(b.name)} +
+
+
${escapeHtml(b.country||"—")}${escapeHtml(b.status||"—")}
+
Genre: ${escapeHtml(b.genre||"—")}
+
Lieu: ${escapeHtml(b.location_text||"—")}
+
+ `; + modalBody.appendChild(div); + } + } + + modalBackdrop.classList.add("on"); + modalBackdrop.setAttribute("aria-hidden", "false"); +} + +async function loadNoLocationBands() { + try { + modalBody.innerHTML = "
Chargement...
"; + + const j = await apiGet("/api/bands?limit=10000&geocoded=0"); + const items = j.items || []; + + modalBody.innerHTML = ""; + + if (!items.length) { + modalBody.innerHTML = `
Aucun groupe sans coordonnées
`; + return; + } + + for (const x of items.slice(0, 500)) { + const div = document.createElement("div"); + div.className = "band"; + const maUrl = x.ma_id ? `https://www.metal-archives.com/bands/x/${x.ma_id}` : "#"; + div.innerHTML = ` +
+ + ${escapeHtml(x.name || "Unknown")} +
+
+
${escapeHtml(x.country || "—")}${escapeHtml(x.status || "—")}
+
Genre: ${escapeHtml(x.genre || "—")}
+
Lieu: ${escapeHtml(x.location_text || "—")}
+
+ `; + modalBody.appendChild(div); + } + + if (items.length > 500) { + const more = document.createElement("div"); + more.style.cssText = "padding:10px;color:rgba(162,176,194,0.85);text-align:center;"; + more.textContent = `... et ${items.length - 500} autres`; + modalBody.appendChild(more); + } + } catch (e) { + modalBody.innerHTML = `
Erreur: ${escapeHtml(e.message)}
`; + } +} + +function closeModal() { + modalBackdrop.classList.remove("on"); + modalBackdrop.setAttribute("aria-hidden", "true"); +} + +modalClose?.addEventListener("click", closeModal); +modalBackdrop?.addEventListener("click", (e) => { + if (e.target === modalBackdrop) closeModal(); +}); + +// Info modal (legal/FAQ) +const infoBackdrop = $("infoBackdrop"); +const infoBody = $("infoBody"); +const infoClose = $("infoClose"); +const infoTitle = $("infoTitle"); + +const LEGAL_HTML = ` +
+
+ Éditeur du site
+ Auteur: Nico
+ Statut : particulier
+ Contact : contact@metalfrom.eu +
+
+ Hébergeur
+ OVH SAS – 2 rue Kellermann - 59100 Roubaix - France +
+
+ Propriété intellectuelle
+ Les contenus (textes, visuels, données agrégées) sont proposés à titre informatif. Les logos et noms de groupes restent la propriété de leurs auteurs. L'ensemble des données provient du site https://www.metal-archives.com/, avec l'autorisation des webmasters +
+
+ Données personnelles
+ Ce site ne collecte pas d'informations personnelles ni ne dépose de cookies de suivi. Des logs techniques (adresses IP, user agent, horodatage) peuvent être conservés par l'hébergeur à des fins de sécurité et de dépannage. +
+
+ Responsable de publication
+ Nico +
+
+`; + +const FAQ_HTML = ` +
+
+ Q : D'où viennent les données ?
+ R : Metal Archives + enrichissement géographique automatisés. Quelques changements à la main, mais c'est fastidieux. +
+
+ Q : Puis-je corriger une erreur ?
+ R : Oui si elle est en rapport avec le site, contacte-moi via l'adresse indiquée dans les mentions légales. Si elle est en rapport avec les données, alors c'est sur metal archives qu'il faut le changer, et lors de la prochaine synchronisation (manuelle) ce sera corrigé (on espère) +
+
+ Q : Pourquoi certains groupes ne sont pas visibles?
+ R : Il y a des erreurs inhérentes au géocodage automatique. La correction manuelle des localisation étant fastidieuse, il est inévitable que certaines données soient faussées. +
+
+ Q : Je viens de créer mon groupe sur Metal Archives mais il n'apparaît pas
+ R : Pour le moment aucune synchronisation automatique avec Metal Archives n'est implémentée. Si les Webmasters de Metal Archive souhaitent mettre cela en place, je suis évidemment à l'écoute +
+
+ Q : Est-ce que le site me traque ou collecte mes données?
+ R : La seule forme de traçage effectuée par le site est à des fins d'analyse de trafic, effectuée par GoatCounter (goatcounter.com) pour savoir à peu près d'où vous venez, sur quel matos vous regardez le site et autres petites infos. A ma connaissance GoatCounter ne dépose aucun cookie sur vos machines, et moi non plus. Les appels à l'API de goatcounter sont néanmoins bloqués par les adblocker chez moi, donc aucun souci pour vous si vous souhaitez ne pas participer ! +
+
+`; + +function openInfoModal(title, html) { + if (!infoBackdrop || !infoBody || !infoTitle) return; + infoTitle.textContent = title; + infoBody.innerHTML = html; + infoBackdrop.classList.add("on"); + infoBackdrop.setAttribute("aria-hidden", "false"); +} + +function closeInfoModal() { + if (!infoBackdrop) return; + infoBackdrop.classList.remove("on"); + infoBackdrop.setAttribute("aria-hidden", "true"); +} + +if (infoClose) infoClose.addEventListener("click", closeInfoModal); +if (infoBackdrop) { + infoBackdrop.addEventListener("click", (e) => { + if (e.target === infoBackdrop) closeInfoModal(); + }); +} + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + closeModal(); + closeInfoModal(); + } +}); + +// Sidebar toggle +function setSidebarCollapsed(collapsed) { + if (!appEl) return; + appEl.classList.toggle("sidebar-collapsed", collapsed); + const toggle = $("sidebarToggle"); + if (toggle) { + toggle.setAttribute("aria-expanded", String(!collapsed)); + // Update button text + const label = toggle.querySelector(".label"); + if (label) { + label.textContent = collapsed ? "Options" : "Fermer"; + } + } + // Refresh map size after sidebar animation + setTimeout(() => map?.invalidateSize?.(), 220); +} + +const sidebarToggle = $("sidebarToggle"); +if (sidebarToggle) { + sidebarToggle.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const collapsed = appEl.classList.contains("sidebar-collapsed"); + setSidebarCollapsed(!collapsed); + }); +} + +// Note: Sidebar closing is now controlled only via the toggle button +// We removed the overlay auto-close behavior since users want to interact +// with the map while the sidebar is open + +// Collapse sidebar by default on smaller screens +if (window.matchMedia("(max-width: 1200px)").matches) { + setTimeout(() => { + setSidebarCollapsed(true); + }, 100); +} + +// Legal/FAQ links +const openLegal = $("openLegal"); +if (openLegal) openLegal.addEventListener("click", () => openInfoModal("Mentions légales", LEGAL_HTML)); + +const openFaq = $("openFaq"); +if (openFaq) openFaq.addEventListener("click", () => openInfoModal("FAQ", FAQ_HTML)); + +// Setup dropdowns +setupDropdown("countryToggle", "countrySelect"); +setupDropdown("genreToggle", "genreSelect"); +setupDropdown("themeToggle", "themeSelect"); + +// --- Buttons / controls --- +$("btnNoLocation")?.addEventListener("click", () => { + modalTitle.textContent = "Groupes sans coordonnées"; + modalBackdrop.classList.add("on"); + modalBackdrop.setAttribute("aria-hidden", "false"); + loadNoLocationBands(); +}); + +// Search with suggestions +const searchInput = $("q"); +const suggestionsEl = $("searchSuggestions"); + +function showSearchSuggestions(query) { + if (!query || query.length < 2 || !allBands.length) { + suggestionsEl?.classList.remove("visible"); + return; + } + + const matches = allBands + .filter(b => bandMatchesQuery(b, query)) + .slice(0, 8); // Show max 8 suggestions + + if (!matches.length) { + suggestionsEl?.classList.remove("visible"); + return; + } + + if (suggestionsEl) { + suggestionsEl.innerHTML = matches.map(b => { + const statusText = b.status || "Unknown"; + const yearText = Number.isFinite(b.formed_year) ? b.formed_year : "—"; + return ` +
+
${escapeHtml(b.name)}
+
+ ${escapeHtml(b.country || "—")} • ${escapeHtml(statusText)} • ${yearText} • ${escapeHtml(b.genre || "—")} +
+
+ `; + }).join(""); + + // Add click handlers + suggestionsEl.querySelectorAll(".suggestion-item").forEach(el => { + el.addEventListener("click", () => { + const bandId = Number(el.dataset.id); + const band = allBands.find(b => b.ma_id === bandId); + if (band && Number.isFinite(band.lat) && Number.isFinite(band.lon)) { + const marker = markersById.get(bandId); + if (marker && marker.getLatLng) { + map.setView(marker.getLatLng(), 10, { animate: true }); + marker.openPopup(); + setActive(bandId); + } else { + map.setView([band.lat, band.lon], 12, { animate: true }); + } + } + suggestionsEl.classList.remove("visible"); + if (searchInput) searchInput.value = band?.name || ""; + }); + }); + + suggestionsEl.classList.add("visible"); + } +} + +const debouncedSearch = debounce(() => { + applyFilters(); +}, 400); // 400ms delay + +const debouncedSuggestions = debounce((query) => { + showSearchSuggestions(query); +}, 200); // 200ms delay for suggestions + +searchInput?.addEventListener("input", (e) => { + const query = e.target.value.toLowerCase().trim(); + debouncedSuggestions(query); + debouncedSearch(); +}); + +searchInput?.addEventListener("focus", () => { + const query = searchInput.value.toLowerCase().trim(); + if (query.length >= 2) { + showSearchSuggestions(query); + } +}); + +// Close suggestions when clicking outside +document.addEventListener("click", (e) => { + if (!searchInput?.contains(e.target) && !suggestionsEl?.contains(e.target)) { + suggestionsEl?.classList.remove("visible"); + } +}); + +$("sortSelect")?.addEventListener("change", () => { + // Re-render list with current data + const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c]) + .map(b => typeof b === 'string' ? JSON.parse(b) : b) + .filter(Boolean); + renderList(visibleBands.slice(0, 200)); +}); + +const heatToggle = $("toggleHeat"); +if (heatToggle) { + heatToggle.addEventListener("change", () => setMode(heatToggle.checked ? "heat" : "clusters")); +} + +$("btnReset")?.addEventListener("click", () => { + if (filtered.length) { + const pts = filtered.map(b => L.latLng(b.lat, b.lon)); + const bounds = L.latLngBounds(pts); + if (bounds.isValid()) map.fitBounds(bounds.pad(0.12)); + } else if (geocodedBands.length) { + const pts = geocodedBands.map(b => L.latLng(b.lat, b.lon)); + const bounds = L.latLngBounds(pts); + if (bounds.isValid()) map.fitBounds(bounds.pad(0.12)); + } else { + map.setView([50.2, 10.2], 4, { animate: true }); + } +}); + +map.on("click", () => { + const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c]) + .map(b => typeof b === 'string' ? JSON.parse(b) : b) + .filter(Boolean); + renderList(visibleBands.slice(0, 200)); +}); + +// --- Boot --- +function computeFacetCounts(arr, getKey) { + const m = new Map(); + for (const b of arr) { + const val = getKey(b); + const k = val ? norm(val) : "Unknown"; + m.set(k, (m.get(k) || 0) + 1); + } + return [...m.entries()] + .map(([key, count]) => ({ key, count })) + .sort((a,b) => b.count - a.count); +} + +function computeTokenFacetCounts(arr, getTokens) { + const m = new Map(); + for (const b of arr) { + const tokens = getTokens(b) || []; + if (!tokens.length) { + // Count bands without tokens as "Unknown" + m.set("Unknown", (m.get("Unknown") || 0) + 1); + } else { + for (const t of tokens) { + const k = norm(t) || "Unknown"; + m.set(k, (m.get(k) || 0) + 1); + } + } + } + return [...m.entries()] + .map(([key, count]) => ({ key, count })) + .sort((a,b) => b.count - a.count); +} + +/** + * Build timeline slider from year range (from facets or computed) + */ +function buildTimelineFromRange() { + const slider = $("yearSlider"); + slider.innerHTML = ""; + + if (!yearRange.min || !yearRange.max) { + $("yearMin").textContent = "—"; + $("yearMax").textContent = "—"; + $("yearHint").textContent = "données manquantes"; + slider.innerHTML = `
Données d'année indisponibles
`; + return; + } + + const { min, max } = yearRange; + + console.log(`Timeline: ${min} - ${max}`); + + $("yearMin").textContent = String(min); + $("yearMax").textContent = String(max); + $("yearHint").textContent = "toutes"; + + noUiSlider.create(slider, { + start: [min, max], + connect: true, + step: 1, + range: { min, max }, + behaviour: "tap-drag", + tooltips: false, + }); + + slider.noUiSlider.on("update", (vals) => { + const a = Math.round(Number(vals[0])); + const b = Math.round(Number(vals[1])); + yearFilter = { min: a, max: b }; + $("yearHint").textContent = (a === min && b === max) ? "toutes" : `${a} → ${b}`; + }); + + slider.noUiSlider.on("change", () => { + console.log(`Year filter changed: ${yearFilter.min} - ${yearFilter.max}`); + applyFilters(); + }); +} + +async function boot() { + try { + console.log("Boot: Loading data..."); + console.log("Viewport loading mode:", USE_VIEWPORT_LOADING ? "ENABLED" : "DISABLED"); + + // Wait for fonts to load (can affect layout) + if (document.fonts && document.fonts.ready) { + await document.fonts.ready; + } + + // Load facets and stats first (fast) + await Promise.all([ + loadFacets(), + loadStats() + ]); + loadGoatCounterFooterStats(); // pas besoin d'await + // Build filters from facets if available, otherwise load bands first + let statuses, genres, countries, themes; + + if (facetData) { + console.log("Using server-side facets for filters"); + statuses = facetData.statuses.map(s => ({ key: s.value, count: s.count })); + countries = facetData.countries.map(c => ({ key: c.value, count: c.count })); + genres = facetData.genres.map(g => ({ key: g.value, count: g.count })); + themes = []; // Themes need to be computed from bands + + // Set year range from facets + if (facetData.year_range) { + yearRange = { + min: facetData.year_range.min_year, + max: facetData.year_range.max_year + }; + yearFilter = { ...yearRange }; + } + } else { + // Fallback: load bands and compute facets locally + console.log("Loading bands for local facet computation..."); + await loadBands(); + statuses = computeFacetCounts(allBands, b => b.status); + genres = computeFacetCounts(allBands, b => b.genre); + countries = computeFacetCounts(allBands, b => b.country); + themes = computeTokenFacetCounts(allBands, b => b.themes); + + } + + console.log(`Boot: Facets ready - ${statuses.length} statuses, ${countries.length} countries, ${genres.length} genres`); + + // Init enabled maps = true + for (const it of genres) genreEnabled.set(it.key, true); + for (const it of countries) countryEnabled.set(it.key, true); + for (const it of themes) themeEnabled.set(it.key, true); + + buildStatusToggles(statuses); + buildMacroGenreButtons(genres); + buildMultiSelect("genreSelect", genres, genreEnabled, (it) => it.key, { summaryEl: $("genreSummary") }); + buildMultiSelect("countrySelect", countries, countryEnabled, (it) => it.key, { summaryEl: $("countrySummary") }); + + if (themes.length) { + $("themeBox").style.display = ""; + buildMultiSelect("themeSelect", themes, themeEnabled, (it) => it.key, { summaryEl: $("themeSummary"), emptyLabel: "Aucun thème" }); + } else { + $("themeBox").style.display = "none"; + } + + // Build timeline from facet data or computed range + buildTimelineFromRange(); + + // Set mode BEFORE applying filters + setMode(heatToggle && heatToggle.checked ? "heat" : "clusters"); + + // Force map size calculation + console.log("Forcing map size recalculation..."); + if (map && map.invalidateSize) { + map.invalidateSize(true); + } + + // Initial data load + console.log("Boot: Loading initial viewport data..."); + + if (USE_VIEWPORT_LOADING) { + // Load viewport data + await loadViewportBands(); + } else { + // Legacy: use all loaded bands + if (!allBands.length) await loadBands(); + applyFilters(); + } + + console.log("Boot: Initial load complete"); + + // Fit to Europe by default + setTimeout(() => { + if (map && map.invalidateSize) { + map.invalidateSize(true); + } + // Default view: Europe + map.setView([50.2, 10.2], 4); + }, 100); + + // Load all bands in background for heatmap and search + setTimeout(() => loadAllHeatPoints(), 1000); + + } catch (err) { + console.error("Boot error:", err); + $("list").innerHTML = `
Erreur de chargement: ${escapeHtml(err.message || String(err))}
`; + } +} + +// Make sure DOM is ready before booting +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); +} else { + boot(); +} diff --git a/apps/web/site/apple-touch-icon.png b/apps/web/site/apple-touch-icon.png new file mode 100644 index 0000000..b44c05d Binary files /dev/null and b/apps/web/site/apple-touch-icon.png differ diff --git a/apps/web/site/favicon-96x96.png b/apps/web/site/favicon-96x96.png new file mode 100644 index 0000000..3620c8e Binary files /dev/null and b/apps/web/site/favicon-96x96.png differ diff --git a/apps/web/site/favicon.ico b/apps/web/site/favicon.ico new file mode 100644 index 0000000..9bd7801 Binary files /dev/null and b/apps/web/site/favicon.ico differ diff --git a/apps/web/site/favicon.svg b/apps/web/site/favicon.svg new file mode 100644 index 0000000..69864ef --- /dev/null +++ b/apps/web/site/favicon.svg @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/apps/web/site/index.html b/apps/web/site/index.html new file mode 100644 index 0000000..6fdd136 --- /dev/null +++ b/apps/web/site/index.html @@ -0,0 +1,239 @@ + + + + + + Metal from Europe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
Metal from Europe
+ +
+ +
+ + +
+
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/site/site.webmanifest b/apps/web/site/site.webmanifest new file mode 100644 index 0000000..13ddd73 --- /dev/null +++ b/apps/web/site/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "Metal From Eu", + "short_name": "MetalEU", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/apps/web/site/styles.css b/apps/web/site/styles.css new file mode 100644 index 0000000..9d93ace --- /dev/null +++ b/apps/web/site/styles.css @@ -0,0 +1,1346 @@ +:root { + --bg: #05060a; + --text: #e9eef5; + --muted: #a2b0c2; + --border: rgba(255,255,255,0.08); + --blood: #c61a1a; + --bone: #e7e2da; + --glow: rgba(198,26,26,0.22); + --sidebar-width: 420px; + --topbar-height: 64px; + --footer-height: 32px; +} + +* { + box-sizing: border-box; +} + +html, body { + height: 100%; + margin: 0; + background: var(--bg); + color: var(--text); +} + +body { + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + overflow: hidden; + display: grid; + grid-template-rows: var(--topbar-height) 1fr var(--footer-height); + height: 100vh; + height: 100dvh; /* Use dynamic viewport height for mobile */ + position: fixed; + width: 100%; + top: 0; + left: 0; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + background: + radial-gradient(1200px 800px at 18% 12%, rgba(198,26,26,0.12), transparent 56%), + radial-gradient(900px 700px at 90% 20%, rgba(231,226,218,0.05), transparent 60%), + radial-gradient(1400px 900px at 50% 110%, rgba(0,0,0,0.8), transparent 60%), + repeating-linear-gradient(135deg, rgba(255,255,255,0.018) 0 2px, transparent 2px 6px); + pointer-events: none; + mix-blend-mode: screen; + opacity: 0.55; +} + +#app { + min-height: 0; + position: relative; + width: 100%; + height: 100%; +} + +/* ===== TOPBAR ===== */ +#topbar { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 18px; + height: var(--topbar-height); + background: linear-gradient(90deg, rgba(6,8,14,0.96), rgba(12,14,22,0.88)); + border-bottom: 1px solid var(--border); + box-shadow: 0 12px 50px rgba(0,0,0,0.35); + z-index: 5; +} + +.brand { + font-family: "UnifrakturCook", serif; + font-size: 26px; + letter-spacing: 0.6px; + text-shadow: 0 0 24px rgba(198,26,26,0.16); + user-select: none; + line-height: 1; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.social-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 36px; + height: 36px; + padding: 0 10px; + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.60); + color: rgba(231,226,218,0.85); + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease, color 120ms ease; + text-decoration: none; + white-space: nowrap; +} + +.social-link:hover { + transform: translateY(-1px); + border-color: rgba(198,26,26,0.40); + background: rgba(198,26,26,0.10); + color: var(--bone); +} + +.social-link svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.ma-text { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.2px; +} + +.external-icon { + width: 14px !important; + height: 14px !important; + opacity: 0.7; +} + +.ma-link:hover { + border-color: rgba(198,26,26,0.60); + background: rgba(198,26,26,0.18); +} + +.ma-link .external-icon { + opacity: 0.85; +} + +.sidebar-toggle { + display: inline-flex; + align-items: center; + gap: 10px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.62); + color: rgba(231,226,218,0.92); + padding: 8px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 800; + cursor: pointer; + user-select: none; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.sidebar-toggle .icon { + font-size: 16px; +} + +.sidebar-toggle:hover { + transform: translateY(-1px); + border-color: rgba(198,26,26,0.40); + background: rgba(198,26,26,0.10); +} + +/* ===== SIDEBAR ===== */ +#sidebar { + position: fixed; + left: 0; + top: var(--topbar-height); + bottom: var(--footer-height); + width: var(--sidebar-width); + height: auto; + min-height: 0; + background: linear-gradient(180deg, rgba(8,10,16,0.94), rgba(7,9,21,0.96)); + border-right: 1px solid var(--border); + box-shadow: 12px 0 60px rgba(0,0,0,0.35); + overflow: auto; + padding: 24px 20px 24px 20px; + backdrop-filter: blur(12px); + z-index: 1000; + transform: translateX(0); + transition: transform 220ms ease, opacity 180ms ease; +} + +.sidebar-inner { + min-height: 0; +} + +#app.sidebar-collapsed #sidebar { + transform: translateX(-100%); + opacity: 0; + pointer-events: none; +} + +/* Sidebar overlay removed - sidebar now only closes via toggle button */ + +/* ===== MAP ===== */ +#mapWrap { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + touch-action: pan-x pan-y; + overflow: hidden; +} + +#map { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100% !important; + height: 100% !important; + touch-action: pan-x pan-y; +} + +#map, +.leaflet-container { + background: #05060a; + height: 100% !important; + width: 100% !important; +} + +/* Force Leaflet to respect container height */ +.leaflet-container { + position: absolute !important; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +/* ===== SEARCH ===== */ +.topbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.search { + flex: 1; + position: relative; +} + +.search-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: rgba(4,6,10,0.95); + border: 1px solid rgba(255,255,255,0.10); + border-radius: 12px; + max-height: 300px; + overflow-y: auto; + z-index: 1000; + display: none; + box-shadow: 0 10px 40px rgba(0,0,0,0.40); + backdrop-filter: blur(10px); +} + +.search-suggestions.visible { + display: block; +} + +.suggestion-item { + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid rgba(255,255,255,0.05); + transition: background 120ms ease; +} + +.suggestion-item:last-child { + border-bottom: none; +} + +.suggestion-item:hover { + background: rgba(198,26,26,0.10); +} + +.suggestion-name { + font-weight: 700; + font-size: 13px; + color: var(--text); + margin-bottom: 4px; +} + +.suggestion-meta { + font-size: 11px; + color: rgba(162,176,194,0.85); +} + +input { + width: 100%; + padding: 13px 14px 13px 42px; + border-radius: 16px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.72); + color: var(--text); + outline: none; + font-size: 14px; + box-shadow: 0 0 0 1px rgba(0,0,0,0.35) inset, 0 10px 30px rgba(0,0,0,0.18); + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; +} + +input:focus { + border-color: rgba(198,26,26,0.55); + box-shadow: 0 0 0 1px rgba(0,0,0,0.35) inset, 0 0 0 3px rgba(198,26,26,0.10), 0 10px 30px rgba(0,0,0,0.22); + transform: translateY(-1px); +} + +input::placeholder { + color: rgba(162,176,194,0.68); +} + +.search::before { + content: "⌕"; + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: rgba(231,226,218,0.75); + font-size: 16px; + opacity: 0.9; + pointer-events: none; +} + +/* ===== STATS CHIPS ===== */ +.stats { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 0 0 16px 0; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.55); + box-shadow: 0 10px 30px rgba(0,0,0,0.14); + font-size: 11px; + color: rgba(231,226,218,0.85); + user-select: none; +} + +.chip b { + color: var(--bone); + font-weight: 900; + letter-spacing: 0.2px; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--blood); + box-shadow: 0 0 16px var(--glow); +} + +/* ===== CONTROLS ===== */ +.controls { + background: rgba(4,6,10,0.35); + border-radius: 18px; + padding: 12px; + margin-bottom: 16px; +} + +.controls-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 0; +} + +.controls-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.controls-title { + font-weight: 900; + font-size: 13px; + letter-spacing: 0.3px; + color: rgba(231,226,218,0.92); + user-select: none; + text-transform: uppercase; +} + +.row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* ===== BUTTONS ===== */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.62); + color: rgba(231,226,218,0.92); + padding: 10px 14px; + border-radius: 12px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + user-select: none; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; + white-space: nowrap; +} + +.btn-compact { + padding: 8px 10px; + font-size: 12px; + border-radius: 10px; +} + +.btn:hover { + transform: translateY(-1px); + border-color: rgba(198,26,26,0.40); + background: rgba(198,26,26,0.10); +} + +.btn:active { + transform: translateY(0px); +} + +.btn-mini { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.62); + color: rgba(231,226,218,0.92); + padding: 6px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 700; + cursor: pointer; + user-select: none; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; + white-space: nowrap; +} + +.btn-mini:hover { + transform: translateY(-1px); + border-color: rgba(198,26,26,0.40); + background: rgba(198,26,26,0.10); +} + +.btn .badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 7px; + border-radius: 999px; + background: rgba(198,26,26,0.22); + border: 1px solid rgba(198,26,26,0.35); + color: var(--bone); + font-weight: 900; + font-size: 11px; +} + +/* ===== SWITCH ===== */ +.switch { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.62); + cursor: pointer; + user-select: none; + font-size: 12px; + font-weight: 800; + color: rgba(231,226,218,0.92); + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.switch:hover { + transform: translateY(-1px); + border-color: rgba(198,26,26,0.40); + background: rgba(198,26,26,0.10); +} + +.switch input { + display: none; +} + +.switch .slider { + position: relative; + width: 34px; + height: 18px; + background: rgba(255,255,255,0.10); + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.12); + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.30); + transition: background 120ms ease, border-color 120ms ease; +} + +.switch .slider::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: rgba(231,226,218,0.85); + box-shadow: 0 2px 8px rgba(0,0,0,0.35); + transition: transform 120ms ease, background 120ms ease; +} + +.switch input:checked + .slider { + background: rgba(198,26,26,0.35); + border-color: rgba(198,26,26,0.50); +} + +.switch input:checked + .slider::after { + transform: translateX(16px); + background: rgba(231,226,218,0.95); +} + +.switch-label { + white-space: nowrap; +} + +/* ===== PANEL (LIST) ===== */ +.panel { + background: rgba(4,6,10,0.40); + border-radius: 18px; + overflow: hidden; + box-shadow: 0 18px 55px rgba(0,0,0,0.30); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(90deg, rgba(198,26,26,0.08), transparent 55%); +} + +.panel-header .h { + font-weight: 900; + font-size: 14px; + letter-spacing: 0.3px; + color: rgba(231,226,218,0.92); + user-select: none; + text-transform: uppercase; +} + +.panel-header .sub { + font-size: 12px; + color: rgba(162,176,194,0.78); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 260px; +} + +.panel-body { + padding: 8px 10px; + max-height: calc(100vh - 480px); + overflow: auto; +} + +/* ===== BAND ITEMS ===== */ +.band { + padding: 12px 14px; + border-bottom: 1px solid rgba(255,255,255,0.04); + border-radius: 12px; + cursor: pointer; + transition: background 120ms ease, box-shadow 120ms ease; +} + +.band:last-child { + border-bottom: none; +} + +.band:hover { + background: rgba(198,26,26,0.06); + box-shadow: 0 0 0 1px rgba(198,26,26,0.10) inset; +} + +.band.active { + background: rgba(198,26,26,0.14); + box-shadow: 0 0 0 1px rgba(198,26,26,0.20) inset, 0 0 22px rgba(198,26,26,0.18); +} + +.name { + display: flex; + align-items: center; + gap: 12px; + font-weight: 900; + font-size: 14px; + letter-spacing: 0.15px; +} + +.sigil { + width: 8px; + height: 8px; + background: var(--blood); + box-shadow: 0 0 18px var(--glow); + transform: rotate(45deg); + flex: 0 0 auto; +} + +a { + color: var(--text); + text-decoration: none; +} + +a:hover { + color: var(--bone); + text-decoration: underline; +} + +.meta { + margin-top: 8px; + font-size: 12px; + color: rgba(162,176,194,0.88); + line-height: 1.5; +} + +.meta b { + color: rgba(231,226,218,0.92); + font-weight: 700; +} + +/* ===== FILTERS ===== */ +.grid2 { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +.filter-box { + background: rgba(4,6,10,0.35); + border-radius: 16px; + padding: 14px; +} + +.filter-box + .filter-box { + margin-top: 12px; +} + +.filter-title { + font-weight: 900; + font-size: 12px; + letter-spacing: 0.3px; + color: rgba(231,226,218,0.92); + margin-bottom: 12px; + text-transform: uppercase; +} + +/* ===== SELECT ===== */ +.select { + width: 100%; + padding: 11px 12px; + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.72); + color: var(--text); + font-size: 13px; + outline: none; + cursor: pointer; +} + +.select:hover { + border-color: rgba(198,26,26,0.30); +} + +/* ===== TOGGLES (STATUS) ===== */ +.chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.55); + cursor: pointer; + user-select: none; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; + font-size: 12px; + color: rgba(231,226,218,0.92); + font-weight: 700; +} + +.toggle:hover { + transform: translateY(-1px); + border-color: rgba(198,26,26,0.35); + background: rgba(198,26,26,0.08); +} + +.toggle .box { + width: 14px; + height: 14px; + border-radius: 4px; + border: 1px solid rgba(231,226,218,0.35); + background: rgba(0,0,0,0.25); + position: relative; + box-shadow: 0 0 0 1px rgba(0,0,0,0.20) inset; + flex: 0 0 auto; +} + +.toggle.on .box { + border-color: rgba(198,26,26,0.55); + background: rgba(198,26,26,0.18); + box-shadow: 0 0 0 1px rgba(0,0,0,0.20) inset, 0 0 18px rgba(198,26,26,0.14); +} + +.toggle.on .box::after { + content: ""; + position: absolute; + inset: 3px; + background: rgba(231,226,218,0.80); + border-radius: 2px; + opacity: 0.85; +} + +.count-badge { + color: rgba(162,176,194,0.90); + font-weight: 800; + font-size: 11px; +} + +/* ===== MACRO GENRES ===== */ +.macro-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.macro-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.55); + color: rgba(231,226,218,0.92); + font-size: 12px; + font-weight: 800; + cursor: pointer; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; + user-select: none; +} + +.macro-btn:hover { + transform: translateY(-1px); + border-color: rgba(198,26,26,0.35); + background: rgba(198,26,26,0.08); +} + +.macro-btn.on { + border-color: rgba(198,26,26,0.50); + background: rgba(198,26,26,0.18); + box-shadow: 0 0 0 1px rgba(0,0,0,0.20) inset, 0 0 18px rgba(198,26,26,0.14); +} + +/* ===== MULTISELECT ===== */ +.multiselect { + border: 1px solid rgba(255,255,255,0.08); + background: rgba(4,6,10,0.50); + border-radius: 14px; + overflow: hidden; +} + +.multiselect.is-collapsed { + display: none; +} + +.dropdown-trigger { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.60); + color: rgba(231,226,218,0.90); + font-size: 12px; + font-weight: 800; + cursor: pointer; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; + margin-bottom: 10px; +} + +.dropdown-trigger:hover { + transform: translateY(-1px); + border-color: rgba(198,26,26,0.35); + background: rgba(198,26,26,0.10); +} + +.dropdown-trigger .summary { + color: rgba(162,176,194,0.90); + font-weight: 700; +} + +.dropdown-trigger .caret { + color: rgba(231,226,218,0.70); + font-size: 12px; +} + +.ms-head { + display: flex; + gap: 8px; + align-items: center; + padding: 10px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +.ms-search { + flex: 1; + padding: 9px 12px; + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(0,0,0,0.30); + color: var(--text); + font-size: 12px; + outline: none; +} + +.ms-search::placeholder { + color: rgba(162,176,194,0.60); +} + +.ms-actions { + display: flex; + gap: 6px; +} + +.ms-list { + max-height: 200px; + overflow: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.ms-empty { + padding: 10px 12px; + border-radius: 10px; + border: 1px dashed rgba(255,255,255,0.10); + color: rgba(162,176,194,0.80); + font-size: 12px; + text-align: center; +} + +.ms-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.06); + background: rgba(0,0,0,0.15); + cursor: pointer; + user-select: none; + font-size: 13px; + transition: border-color 120ms ease, background 120ms ease; +} + +.ms-item:hover { + border-color: rgba(198,26,26,0.30); + background: rgba(198,26,26,0.08); +} + +.ms-item.on { + border-color: rgba(198,26,26,0.40); + background: rgba(198,26,26,0.14); +} + +/* ===== TIMELINE ===== */ +.timeline { + padding: 8px 0 0 0; +} + +.timeline-meta { + margin-top: 14px; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.timeline-empty { + padding: 10px 12px; + border-radius: 12px; + border: 1px dashed rgba(255,255,255,0.10); + color: rgba(162,176,194,0.85); + font-size: 12px; + text-align: center; +} + +.noUi-target { + border: 1px solid rgba(255,255,255,0.10); + background: rgba(0,0,0,0.25); + box-shadow: none; +} + +.noUi-connect { + background: rgba(198,26,26,0.35); +} + +.noUi-handle { + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(4,6,10,0.90); + box-shadow: 0 0 18px rgba(198,26,26,0.14); +} + +/* ===== LEAFLET CUSTOMIZATION ===== */ +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: rgba(4,6,10,0.92); + color: var(--text); + border: 1px solid rgba(255,255,255,0.10); + box-shadow: 0 20px 60px rgba(0,0,0,0.55); + backdrop-filter: blur(10px); +} + +.leaflet-popup-content a { + color: #e9eef5; + text-decoration: none; + transition: color 0.2s ease; +} + +.leaflet-popup-content a:hover { + color: #c61a1a; + text-decoration: underline; +} + +/* Zoom controls - move to right side and style to match UI */ +.leaflet-control-zoom { + border: none !important; + box-shadow: none !important; + margin: 12px !important; +} + +.leaflet-right .leaflet-control-zoom { + margin-right: 12px !important; +} + +.leaflet-control-zoom a { + width: 36px !important; + height: 36px !important; + line-height: 36px !important; + font-size: 18px !important; + font-weight: 700 !important; + color: rgba(231,226,218,0.92) !important; + background: rgba(4,6,10,0.85) !important; + border: 1px solid rgba(255,255,255,0.12) !important; + border-radius: 10px !important; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease !important; + backdrop-filter: blur(8px); +} + +.leaflet-control-zoom a, +.leaflet-control-zoom a:link, +.leaflet-control-zoom a:visited, +.leaflet-control-zoom a:hover, +.leaflet-control-zoom a:active, +.leaflet-control-zoom a:focus { + text-decoration: none !important; +} + +.leaflet-control-zoom a:hover { + transform: translateY(-1px) !important; + border-color: rgba(198,26,26,0.45) !important; + background: rgba(198,26,26,0.15) !important; + color: var(--bone) !important; +} + +.leaflet-control-zoom a:active { + transform: translateY(0) !important; +} + +.leaflet-control-zoom-in { + border-radius: 10px 10px 0 0 !important; + margin-bottom: 0 !important; + border-bottom: none !important; +} + +.leaflet-control-zoom-out { + border-radius: 0 0 10px 10px !important; + margin-top: -1px !important; +} + +.leaflet-control-zoom a.leaflet-disabled { + color: rgba(162,176,194,0.40) !important; + background: rgba(4,6,10,0.60) !important; + cursor: not-allowed !important; +} + +.leaflet-control-attribution { + background: rgba(4,6,10,0.70) !important; + color: rgba(162,176,194,0.85) !important; + border: 1px solid rgba(255,255,255,0.08); +} + +.leaflet-control-attribution a { + color: rgba(162,176,194,0.85) !important; +} + +.marker-cluster-small, +.marker-cluster-medium, +.marker-cluster-large { + background-color: rgba(198,26,26,0.16) !important; +} + +.marker-cluster-small div, +.marker-cluster-medium div, +.marker-cluster-large div { + background-color: rgba(198,26,26,0.30) !important; + color: var(--text) !important; + border: 1px solid rgba(198,26,26,0.42); + box-shadow: 0 0 0 1px rgba(0,0,0,0.25) inset, 0 0 18px rgba(198,26,26,0.16); + font-weight: 900; +} + +/* ===== LEGAL BAR ===== */ +#legalBar { + display: flex; + align-items: center; + justify-content: center; + height: var(--footer-height); + background: rgba(4,6,10,0.85); + border-top: 1px solid var(--border); + font-size: 11px; + color: rgba(162,176,194,0.85); +} + +.legal-inner { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.legal-link { + border: none; + background: transparent; + color: rgba(231,226,218,0.85); + cursor: pointer; + font-size: 11px; + font-weight: 700; + padding: 4px 6px; + border-radius: 8px; + transition: color 120ms ease, background 120ms ease; +} + +.legal-link:hover { + color: var(--bone); + background: rgba(198,26,26,0.10); +} + +.legal-dot { + color: rgba(162,176,194,0.70); +} +/* --- GoatCounter footer stats (add-on) --- */ +.legal-inner { + flex-wrap: wrap; /* add-on: évite que ça déborde sur mobile */ +} + +.legal-stats { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.55); +} + +.legal-stat { + display: inline-flex; + align-items: baseline; + gap: 6px; + font-size: 11px; + color: rgba(231,226,218,0.85); +} + +.legal-stat b { + color: var(--bone); + font-weight: 900; +} + + +/* ===== MODAL ===== */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.60); + backdrop-filter: blur(6px); + display: none; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 9999; +} + +.modal-backdrop.on { + display: flex; +} + +.modal { + width: min(960px, 96vw); + max-height: min(88vh, 900px); + overflow: hidden; + border-radius: 18px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.92); + box-shadow: 0 30px 90px rgba(0,0,0,0.70); + backdrop-filter: blur(12px); +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(90deg, rgba(198,26,26,0.08), transparent 55%); +} + +.modal-head .h { + font-weight: 900; + font-size: 14px; + color: rgba(231,226,218,0.92); + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.modal-head .x { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.10); + background: rgba(4,6,10,0.55); + cursor: pointer; + user-select: none; + font-weight: 900; + font-size: 16px; + color: rgba(231,226,218,0.90); + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.modal-head .x:hover { + transform: scale(1.05); + border-color: rgba(198,26,26,0.40); + background: rgba(198,26,26,0.10); +} + +.modal-body { + padding: 12px 14px; + overflow: auto; + max-height: calc(88vh - 66px); +} + +/* ===== SCROLLBAR STYLING ===== */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(0,0,0,0.20); +} + +::-webkit-scrollbar-thumb { + background: rgba(198,26,26,0.30); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(198,26,26,0.45); +} + +/* ===== RESPONSIVE ===== */ +@media (max-width: 1200px) { + :root { + --sidebar-width: 380px; + --topbar-height: 60px; + } + + .brand { + font-size: 24px; + } +} + +@media (max-width: 992px) { + :root { + --sidebar-width: 320px; + --topbar-height: 58px; + } + + .brand { + font-size: 18px; + } + + .topbar-right { + gap: 4px; + } + + .social-link { + min-width: 32px; + height: 32px; + padding: 0 8px; + } + + .social-link svg { + width: 16px; + height: 16px; + } + + .ma-text { + font-size: 11px; + } + + .external-icon { + width: 12px !important; + height: 12px !important; + } + + #sidebar { + padding: 18px 16px; + } + + /* Darker overlay on mobile for better contrast */ + #app::before { + background: rgba(0,0,0,0.5); + } + + .stats { + gap: 6px; + } + + .chip { + padding: 5px 8px; + font-size: 10px; + } + + .controls { + padding: 10px; + } + + .btn { + padding: 7px 10px; + font-size: 11px; + } + + .btn-compact { + padding: 6px 8px; + font-size: 11px; + } +} + +@media (max-width: 640px) { + :root { + --sidebar-width: 280px; + --topbar-height: 52px; + } + + .brand { + font-size: 16px; + } + + .sidebar-toggle .label { + display: none; + } + + .topbar-right { + gap: 3px; + } + + .social-link { + min-width: 28px; + height: 28px; + padding: 0 6px; + } + + .social-link svg { + width: 14px; + height: 14px; + } + + .ma-text { + font-size: 10px; + } + + .external-icon { + width: 10px !important; + height: 10px !important; + } + + .switch { + padding: 4px 6px; + } + + #sidebar { + width: 280px; + } + + #app.sidebar-collapsed #sidebar { + width: 280px; + } + + /* Ensure map still takes full space on very small screens */ + #mapWrap { + min-height: 300px; + } +} \ No newline at end of file diff --git a/apps/web/site/web-app-manifest-192x192.png b/apps/web/site/web-app-manifest-192x192.png new file mode 100644 index 0000000..1bfaef2 Binary files /dev/null and b/apps/web/site/web-app-manifest-192x192.png differ diff --git a/apps/web/site/web-app-manifest-512x512.png b/apps/web/site/web-app-manifest-512x512.png new file mode 100644 index 0000000..783880f Binary files /dev/null and b/apps/web/site/web-app-manifest-512x512.png differ diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile new file mode 100644 index 0000000..e10475f --- /dev/null +++ b/apps/worker/Dockerfile @@ -0,0 +1,9 @@ +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"] diff --git a/apps/worker/requirements.txt b/apps/worker/requirements.txt new file mode 100644 index 0000000..17f490d --- /dev/null +++ b/apps/worker/requirements.txt @@ -0,0 +1,4 @@ +playwright==1.49.0 +psycopg2-binary==2.9.9 +requests==2.32.3 +python-dotenv==1.0.1 diff --git a/apps/worker/src/run.py b/apps/worker/src/run.py new file mode 100644 index 0000000..cf1df90 --- /dev/null +++ b/apps/worker/src/run.py @@ -0,0 +1,122 @@ +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 = [ 'Name', '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() diff --git a/infra/.env b/infra/.env new file mode 100644 index 0000000..d4bdae6 --- /dev/null +++ b/infra/.env @@ -0,0 +1,16 @@ +BM_DB_PASSWORD=Astatke2510!db +BM_DOMAIN=bm.nicolasfryder.ovh +NOMINATIM_EMAIL=tonmail@tondomaine.tld +NOMINATIM_USER_AGENT=bm-map/0.1 (contact: tonmail@tondomaine.tld) +DATABASE_URL=postgresql://bm:Astatke2510!db@db:5432/bm + +# Recommandé (code-server sous-domaine dédié) +BM_CODE_DOMAIN=code.bm.nicolasfryder.ovh +CODESERVER_PASSWORD=Astatke2510!cod + +TZ=Europe/Paris +PUID=1001 +PGID=1001 +BM_IMPORT_TOKEN=c4b99106fcd09badce25810e974ec450af127a4725cb774f03b72217a04eb338 +PGADMIN_EMAIL=nicolasfryder@gmail.com +PGADMIN_PASSWORD=Astatke2510! diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..07bf6b9 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,13 @@ +BM_DB_PASSWORD= +DATABASE_URL=postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm +NOMINATIM_EMAIL= +NOMINATIM_USER_AGENT=bm-map/0.1 +BM_IMPORT_TOKEN= +PGADMIN_EMAIL= +PGADMIN_PASSWORD= +CODESERVER_PASSWORD= +BM_DOMAIN=bm.nicolasfryder.ovh +BM_CODE_DOMAIN=code.bm.nicolasfryder.ovh +TZ=Europe/Paris +PUID=1001 +PGID=1001 diff --git a/infra/bootstrap_ma.py b/infra/bootstrap_ma.py new file mode 100644 index 0000000..eeea216 --- /dev/null +++ b/infra/bootstrap_ma.py @@ -0,0 +1,27 @@ +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() diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..ad89254 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,152 @@ +services: + db: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_DB: bm + POSTGRES_USER: bm + POSTGRES_PASSWORD: ${BM_DB_PASSWORD} + volumes: + - bm_pg_data:/var/lib/postgresql/data + networks: + - bm_internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bm -d bm"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - bm_redis_data:/data + networks: + - bm_internal + + # ✅ SÉCURISÉ : Pre-build avec Dockerfile + geocoder-enqueue: + build: + context: ../apps/geocoder + working_dir: /app + environment: + DATABASE_URL: ${DATABASE_URL} + GEOCODE_ENQUEUE_BATCH: "90000" + command: ["python", "src/enqueue.py"] + networks: + - bm_internal + restart: "no" + + # ✅ SÉCURISÉ : Pre-build avec Dockerfile + 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 + - traefik_net + labels: + - traefik.enable=true + - traefik.docker.network=stack-vps_admin + - traefik.http.routers.pgadmin.rule=Host(`pgadmin.bm.nicolasfryder.ovh`) + - traefik.http.routers.pgadmin.entrypoints=websecure + - traefik.http.routers.pgadmin.tls=true + - traefik.http.routers.pgadmin.tls.certresolver=le + - traefik.http.services.pgadmin.loadbalancer.server.port=80 + + api: + build: + context: ../apps/api + environment: + PORT: "3000" + DATABASE_URL: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm + BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN} + depends_on: + db: + condition: service_healthy + volumes: + - ./overrides/api/src/server.js:/app/src/server.js:ro + networks: + - bm_internal + - traefik_net + labels: + - traefik.enable=true + - traefik.http.routers.bm-api.rule=Host(`bm.nicolasfryder.ovh`) + - traefik.http.routers.bm-api.entrypoints=websecure + - traefik.http.routers.bm-api.tls=true + - traefik.http.routers.bm-api.tls.certresolver=le + + # ✅ SÉCURISÉ : Double auth + rate limiting + code: + image: lscr.io/linuxserver/code-server:latest + environment: + PUID: "1001" + PGID: "1001" + TZ: "Europe/Paris" + PASSWORD: ${CODESERVER_PASSWORD} + SUDO_PASSWORD: ${CODESERVER_PASSWORD} + DEFAULT_WORKSPACE: /workspace + volumes: + - bm_codeserver_config:/config + - /srv/stacks/bm:/workspace + networks: + - bm_internal + - traefik_net + labels: + - traefik.enable=true + - traefik.docker.network=stack-vps_admin + - traefik.http.routers.bm-code.rule=Host(`code.bm.nicolasfryder.ovh`) + - traefik.http.routers.bm-code.entrypoints=websecure + - traefik.http.routers.bm-code.tls=true + # ✅ AJOUTÉ : Double auth + rate limiting + - traefik.http.routers.bm-code.middlewares=code-auth@file,code-ratelimit@file + - traefik.http.services.bm-code.loadbalancer.server.port=8443 + + worker: + build: + context: ../apps/worker + environment: + DB_DSN: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm + MA_LIST_URL: https://www.metal-archives.com/lists/FR + MA_STATE_PATH: /app/ma_state.json + depends_on: + db: + condition: service_healthy + volumes: + - /srv/stacks/bm/infra/ma_state.json:/app/ma_state.json:ro + networks: + - bm_internal + restart: "no" + +networks: + traefik_net: + external: true + name: stack-vps_admin + bm_internal: + driver: bridge + +volumes: + bm_pg_data: + bm_redis_data: + bm_codeserver_config: + pgadmin_data: diff --git a/infra/docker-compose.yml.backup b/infra/docker-compose.yml.backup new file mode 100644 index 0000000..29e9b3b --- /dev/null +++ b/infra/docker-compose.yml.backup @@ -0,0 +1,158 @@ +services: + db: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_DB: bm + POSTGRES_USER: bm + POSTGRES_PASSWORD: ${BM_DB_PASSWORD} + volumes: + - bm_pg_data:/var/lib/postgresql/data + networks: + - bm_internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bm -d bm"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - bm_redis_data:/data + networks: + - bm_internal + geocoder-enqueue: + image: python:3.12-slim + working_dir: /app + volumes: + - ../apps/geocoder:/app:ro + environment: + DATABASE_URL: ${DATABASE_URL} + GEOCODE_ENQUEUE_BATCH: "90000" + command: ["sh", "-lc", "pip install -r requirements.txt && python src/enqueue.py"] + networks: + - bm_internal + restart: "no" + + geocoder-worker: + image: python:3.12-slim + working_dir: /app + volumes: + - ../apps/geocoder:/app:ro + 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: ["sh", "-lc", "pip install -r requirements.txt && 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 + - traefik_net + labels: + - traefik.enable=true + - traefik.docker.network=stack-vps_admin + - traefik.http.routers.pgadmin.rule=Host(`pgadmin.bm.nicolasfryder.ovh`) + - traefik.http.routers.pgadmin.entrypoints=websecure + - traefik.http.routers.pgadmin.tls=true + - traefik.http.routers.pgadmin.tls.certresolver=le + - traefik.http.routers.pgadmin.middlewares=auth@file + - traefik.http.services.pgadmin.loadbalancer.server.port=80 + api: + build: + context: ../apps/api + environment: + PORT: "3000" + DATABASE_URL: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm + BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN} + depends_on: + db: + condition: service_healthy + volumes: + - ./overrides/api/src/server.js:/app/src/server.js:ro + + networks: + - bm_internal + - traefik_net + labels: + - traefik.enable=true + - traefik.http.routers.bm-api.rule=Host(`bm.nicolasfryder.ovh`) + - traefik.http.routers.bm-api.entrypoints=websecure + - traefik.http.routers.bm-api.tls=true + - traefik.http.routers.bm-api.tls.certresolver=le + + # CORS middleware + - traefik.http.middlewares.bm-cors.headers.accessControlAllowOriginList=https://metalfrom.eu,https://www.metalfrom.eu + - traefik.http.middlewares.bm-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS + - traefik.http.middlewares.bm-cors.headers.accessControlAllowHeaders=* + - traefik.http.middlewares.bm-cors.headers.accessControlAllowCredentials=true + - traefik.http.middlewares.bm-cors.headers.addVaryHeader=true + + - traefik.http.routers.bm-api.middlewares=bm-cors@docker + + code: + image: lscr.io/linuxserver/code-server:latest + environment: + PUID: "1001" + PGID: "1001" + TZ: "Europe/Paris" + PASSWORD: ${CODESERVER_PASSWORD} + SUDO_PASSWORD: ${CODESERVER_PASSWORD} + DEFAULT_WORKSPACE: /workspace + volumes: + - bm_codeserver_config:/config + - /srv/stacks/bm:/workspace + networks: + - bm_internal + - traefik_net + labels: + - traefik.enable=true + - traefik.docker.network=stack-vps_admin + - traefik.http.routers.bm-code.rule=Host(`code.bm.nicolasfryder.ovh`) + - traefik.http.routers.bm-code.entrypoints=websecure + - traefik.http.routers.bm-code.tls=true + - traefik.http.services.bm-code.loadbalancer.server.port=8443 + + worker: + build: + context: ../apps/worker + environment: + DB_DSN: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm + MA_LIST_URL: https://www.metal-archives.com/lists/FR + MA_STATE_PATH: /app/ma_state.json + depends_on: + db: + condition: service_healthy + volumes: + - /srv/stacks/bm/infra/ma_state.json:/app/ma_state.json:ro + networks: + - bm_internal + restart: "no" + +networks: + traefik_net: + external: true + name: stack-vps_admin + bm_internal: + driver: bridge + +volumes: + bm_pg_data: + bm_redis_data: + bm_codeserver_config: + pgadmin_data: + diff --git a/infra/init.sql b/infra/init.sql new file mode 100644 index 0000000..15a49c2 --- /dev/null +++ b/infra/init.sql @@ -0,0 +1,42 @@ +CREATE EXTENSION IF NOT EXISTS postgis; + +CREATE TABLE IF NOT EXISTS bands ( + id BIGSERIAL PRIMARY KEY, + ma_id BIGINT UNIQUE NOT NULL, + name TEXT NOT NULL, + country TEXT, + location_text TEXT, + status TEXT, + genre TEXT, + formed_year INT, + lat DOUBLE PRECISION, + lon DOUBLE PRECISION, + geom GEOGRAPHY(Point, 4326), + data JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Keep geom in sync if lat/lon set +CREATE OR REPLACE FUNCTION bands_set_geom() RETURNS trigger AS $$ +BEGIN + IF NEW.lat IS NOT NULL AND NEW.lon IS NOT NULL THEN + NEW.geom := ST_SetSRID(ST_MakePoint(NEW.lon, NEW.lat), 4326)::geography; + ELSE + NEW.geom := NULL; + END IF; + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_bands_set_geom ON bands; +CREATE TRIGGER trg_bands_set_geom +BEFORE INSERT OR UPDATE OF lat, lon ON bands +FOR EACH ROW EXECUTE FUNCTION bands_set_geom(); + +CREATE INDEX IF NOT EXISTS idx_bands_country ON bands (country); +CREATE INDEX IF NOT EXISTS idx_bands_status ON bands (status); +CREATE INDEX IF NOT EXISTS idx_bands_genre ON bands (genre); +CREATE INDEX IF NOT EXISTS idx_bands_geom ON bands USING GIST (geom); +CREATE INDEX IF NOT EXISTS idx_bands_data_gin ON bands USING GIN (data); diff --git a/infra/overrides/api/src/server.js b/infra/overrides/api/src/server.js new file mode 100644 index 0000000..3f3595f --- /dev/null +++ b/infra/overrides/api/src/server.js @@ -0,0 +1,718 @@ +import Fastify from "fastify"; +import pg from "pg"; +import rateLimit from "@fastify/rate-limit"; +import helmet from "@fastify/helmet"; +import { timingSafeEqual } from "crypto"; + +const { Pool } = pg; + +const fastify = Fastify({ + logger: true, + bodyLimit: 10485760 // 10 MB max pour éviter DoS mémoire +}); + +// Headers de sécurité +await fastify.register(helmet, { + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false +}); + +// CORS middleware +fastify.addHook('onRequest', async (request, reply) => { + const origin = request.headers.origin; + const allowedOrigins = ['https://metalfrom.eu', 'https://www.metalfrom.eu']; + + if (allowedOrigins.includes(origin)) { + reply.header('Access-Control-Allow-Origin', origin); + } + + reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + reply.header('Access-Control-Allow-Credentials', 'true'); + + if (request.method === 'OPTIONS') { + reply.status(204).send(); + } +}); + +// Rate limiting global +await fastify.register(rateLimit, { + max: 1000, + timeWindow: '1 minute', + cache: 10000 +}); + +// Gestion d'erreurs globale +fastify.setErrorHandler((error, request, reply) => { + fastify.log.error(error); + reply.status(500).send({ + ok: false, + error: 'Une erreur est survenue' + }); +}); + +const PORT = Number(process.env.PORT || 3000); +const DATABASE_URL = process.env.DATABASE_URL; +const BM_IMPORT_TOKEN = (process.env.BM_IMPORT_TOKEN || "").trim(); + +let pool = null; +if (DATABASE_URL) { + pool = new Pool({ + connectionString: DATABASE_URL, + statement_timeout: 60000 + }); +} + +function requirePool() { + if (!pool) throw new Error("DATABASE_URL not set"); + return pool; +} + +function authBearer(req) { + const h = req.headers.authorization || ""; + const m = h.match(/^Bearer\s+(.+)$/i); + return m ? m[1] : null; +} + +function requireAdmin(req, reply) { + const provided = authBearer(req); + + if (!BM_IMPORT_TOKEN || !provided || provided.length !== BM_IMPORT_TOKEN.length) { + reply.code(401).send({ ok: false, error: "unauthorized" }); + return false; + } + + const valid = timingSafeEqual( + Buffer.from(provided), + Buffer.from(BM_IMPORT_TOKEN) + ); + + if (!valid) { + reply.code(401).send({ ok: false, error: "unauthorized" }); + return false; + } + + return true; +} + +function sanitizeSearchString(str, fieldName = 'champ') { + const trimmed = String(str).trim(); + + if (trimmed.length < 2 || trimmed.length > 100) { + throw new Error(`${fieldName} doit faire entre 2 et 100 caractères`); + } + + if (/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(trimmed)) { + throw new Error(`${fieldName} contient des caractères invalides`); + } + + if (/%%%|_{10,}|%{10,}/.test(trimmed)) { + throw new Error(`${fieldName} contient des patterns invalides`); + } + + return trimmed; +} + +fastify.get("/", async () => { + return { ok: true, service: "BM API" }; +}); + +fastify.get("/api/health", async () => ({ ok: true })); + +fastify.get("/api/db", async (req, reply) => { + try { + const p = requirePool(); + const r = await p.query("SELECT now() as now, current_database() as db"); + return { ok: true, ...r.rows[0] }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur de connexion à la base de données' }); + } +}); + +fastify.get("/api/stats", async (req, reply) => { + try { + const p = requirePool(); + const q = ` + SELECT + count(*)::int as total, + count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded, + count(*) FILTER (WHERE geom IS NULL)::int as no_location, + count(*) FILTER (WHERE enriched = true)::int as enriched + FROM bands; + `; + const r = await p.query(q); + return { ok: true, ...r.rows[0] }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des stats' }); + } +}); + +fastify.get("/api/countries", async (req, reply) => { + try { + const p = requirePool(); + const q = ` + SELECT + country, + count(*)::int as total, + count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded, + count(*) FILTER (WHERE enriched = true)::int as enriched + FROM bands + GROUP BY country + ORDER BY total DESC; + `; + const r = await p.query(q); + return { ok: true, items: r.rows }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des pays' }); + } +}); + +fastify.get("/api/statuses", async (req, reply) => { + try { + const p = requirePool(); + const q = ` + SELECT + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + count(*)::int as total, + count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded + FROM bands + GROUP BY 1 + ORDER BY total DESC; + `; + const r = await p.query(q); + return { ok: true, items: r.rows }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des statuts' }); + } +}); + +fastify.get("/api/clusters", async (req, reply) => { + try { + const p = requirePool(); + const { + bbox, + zoom, + countries, + status, + genre, + year_min, + year_max, + } = req.query || {}; + + if (!bbox || !zoom) { + return reply.code(400).send({ ok: false, error: "bbox and zoom are required" }); + } + + const bboxParts = String(bbox).split(",").map(Number); + if (bboxParts.length !== 4) { + return reply.code(400).send({ ok: false, error: "bbox must have 4 values" }); + } + + const [minLon, minLat, maxLon, maxLat] = bboxParts; + const zoomLevel = Number(zoom); + + if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) { + return reply.code(400).send({ ok: false, error: "Invalid bbox or zoom values" }); + } + + if (minLat < -90 || maxLat > 90 || minLon < -180 || maxLon > 180) { + return reply.code(400).send({ ok: false, error: "Coordinates out of range" }); + } + + if (minLon >= maxLon || minLat >= maxLat) { + return reply.code(400).send({ ok: false, error: "Invalid bbox bounds" }); + } + + if (zoomLevel < 0 || zoomLevel > 22) { + return reply.code(400).send({ ok: false, error: "Zoom level must be between 0 and 22" }); + } + + const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel)); + + const where = ["geom IS NOT NULL"]; + const vals = []; + let i = 1; + + where.push(`ST_X(geom::geometry) >= $${i} AND ST_X(geom::geometry) <= $${i+1} AND ST_Y(geom::geometry) >= $${i+2} AND ST_Y(geom::geometry) <= $${i+3}`); + vals.push(minLon, maxLon, minLat, maxLat); + i += 4; + + if (countries) { + const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); + if (cs.length > 100) { + return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" }); + } + if (cs.length) { + where.push(`country = ANY($${i}::text[])`); + vals.push(cs); + i++; + } + } + + if (status) { + const st = String(status).split(",").map(s => s.trim()).filter(Boolean); + if (st.length > 50) { + return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" }); + } + if (st.length) { + where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); + vals.push(st); + i++; + } + } + + if (genre) { + try { + const genreQuery = sanitizeSearchString(genre, 'Genre'); + where.push(`genre ILIKE $${i}`); + vals.push(`%${genreQuery}%`); + i++; + } catch (err) { + return reply.code(400).send({ ok: false, error: err.message }); + } + } + + if (year_min) { + const yearMin = Number(year_min); + if (!Number.isFinite(yearMin) || yearMin < 1800 || yearMin > 2100) { + return reply.code(400).send({ ok: false, error: 'year_min invalide' }); + } + where.push(`formed_year >= $${i}`); + vals.push(yearMin); + i++; + } + + if (year_max) { + const yearMax = Number(year_max); + if (!Number.isFinite(yearMax) || yearMax < 1800 || yearMax > 2100) { + return reply.code(400).send({ ok: false, error: 'year_max invalide' }); + } + where.push(`formed_year <= $${i}`); + vals.push(yearMax); + i++; + } + + const whereSql = where.join(" AND "); + + if (zoomLevel >= 12) { + const sql = ` + SELECT + ma_id, + name, + country, + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + genre, + location_text, + formed_year, + lat, + lon + FROM bands + WHERE ${whereSql} + ORDER BY ma_id ASC + LIMIT 2000; + `; + const r = await p.query(sql, vals); + return { + ok: true, + type: "bands", + items: r.rows, + count: r.rows.length + }; + } + + const sql = ` + WITH grid_cells AS ( + SELECT + floor(ST_X(geom::geometry) / $${i})::int as cell_x, + floor(ST_Y(geom::geometry) / $${i})::int as cell_y, + ma_id, + name, + country, + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + genre, + location_text, + formed_year, + lat, + lon + FROM bands + WHERE ${whereSql} + ), + clusters AS ( + SELECT + cell_x, + cell_y, + count(*)::int as band_count, + avg(lat)::float8 as center_lat, + avg(lon)::float8 as center_lon, + array_agg(json_build_object( + 'ma_id', ma_id, + 'name', name, + 'country', country, + 'status', status, + 'genre', genre, + 'location_text', location_text, + 'formed_year', formed_year, + 'lat', lat, + 'lon', lon + ) ORDER BY name) as bands + FROM grid_cells + GROUP BY cell_x, cell_y + ) + SELECT + center_lat as lat, + center_lon as lon, + band_count as count, + CASE + WHEN band_count <= 5 THEN bands + ELSE bands[1:5] + END as sample_bands + FROM clusters + ORDER BY band_count DESC + LIMIT 1000; + `; + vals.push(cellSize); + + const r = await p.query(sql, vals); + return { + ok: true, + type: "clusters", + items: r.rows, + total_clusters: r.rows.length, + cell_size: cellSize, + zoom: zoomLevel + }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des clusters' }); + } +}); + +fastify.get("/api/bands", async (req, reply) => { + try { + const p = requirePool(); + const { + countries, + geocoded, + q, + status, + only_black, + limit, + offset, + } = req.query || {}; + + const lim = Math.min(Number(limit || 1000), 150000); + const off = Math.max(Number(offset || 0), 0); + + if (off > 1000000) { + return reply.code(400).send({ + ok: false, + error: "Offset trop grand (max 1 million)" + }); + } + + if (lim > 10000) { + fastify.log.warn(`Large query: ${lim} rows requested from ${req.ip}`); + } + + const where = []; + const vals = []; + let i = 1; + + if (countries) { + const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); + if (cs.length > 100) { + return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" }); + } + if (cs.length) { + where.push(`country = ANY($${i}::text[])`); + vals.push(cs); + i++; + } + } + + if (geocoded === "1") where.push(`geom IS NOT NULL`); + if (geocoded === "0") where.push(`geom IS NULL`); + + if (status) { + const st = String(status).split(",").map(s => s.trim()).filter(Boolean); + if (st.length > 50) { + return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" }); + } + if (st.length) { + where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); + vals.push(st); + i++; + } + } + + if (only_black === "1") { + where.push(`genre ILIKE '%black%'`); + } + + if (q) { + try { + const query = sanitizeSearchString(q, 'Recherche'); + const qq = `%${query}%`; + where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`); + vals.push(qq); + i++; + } catch (err) { + return reply.code(400).send({ ok: false, error: err.message }); + } + } + + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + + const sql = ` + SELECT + ma_id, + name, + country, + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + genre, + location_text, + enriched, + formed_year, + themes, + lat, + lon + FROM bands + ${whereSql} + ORDER BY ma_id ASC + LIMIT $${i} OFFSET $${i+1}; + `; + vals.push(lim, off); + + const r = await p.query(sql, vals); + return { + ok: true, + items: r.rows, + limit: lim, + offset: off + }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur lors de la recherche' }); + } +}); + +fastify.get("/api/facets", async (req, reply) => { + try { + const p = requirePool(); + + const [statusRes, countryRes, genreRes, yearRes] = await Promise.all([ + p.query(` + SELECT + COALESCE(NULLIF(trim(status), ''), 'Unknown') as value, + count(*)::int as count + FROM bands + WHERE geom IS NOT NULL + GROUP BY 1 + ORDER BY count DESC + `), + p.query(` + SELECT + COALESCE(country, '??') as value, + count(*)::int as count + FROM bands + WHERE geom IS NOT NULL + GROUP BY 1 + ORDER BY count DESC + `), + p.query(` + SELECT + genre as value, + count(*)::int as count + FROM bands + WHERE geom IS NOT NULL + AND genre IS NOT NULL + AND genre != '' + GROUP BY 1 + ORDER BY count DESC + LIMIT 500 + `), + p.query(` + SELECT + MIN(formed_year)::int as min_year, + MAX(formed_year)::int as max_year + FROM bands + WHERE geom IS NOT NULL + AND formed_year >= 1900 + AND formed_year <= extract(year from now()) + `) + ]); + + return { + ok: true, + statuses: statusRes.rows, + countries: countryRes.rows, + genres: genreRes.rows, + year_range: yearRes.rows[0] || { min_year: null, max_year: null } + }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des facettes' }); + } +}); + +fastify.get("/api/band/:ma_id", async (req, reply) => { + try { + const p = requirePool(); + const id = Number(req.params.ma_id); + + if (!Number.isFinite(id) || id < 0) { + return reply.code(400).send({ ok: false, error: "bad ma_id" }); + } + + const r = await p.query( + `SELECT + ma_id, + name, + country, + status, + genre, + location_text, + enriched, + data, + formed_year, + themes, + lat, + lon, + geocoded_at + FROM bands + WHERE ma_id = $1 + LIMIT 1`, + [id] + ); + + if (!r.rows.length) { + return reply.code(404).send({ ok: false, error: "not found" }); + } + + return { ok: true, item: r.rows[0] }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération du groupe' }); + } +}); + +fastify.register(async function(adminRoutes) { + await adminRoutes.register(rateLimit, { + max: 10, + timeWindow: '1 minute', + keyGenerator: (req) => { + return authBearer(req) || req.ip; + } + }); + + adminRoutes.post("/admin/import", async (req, reply) => { + if (!requireAdmin(req, reply)) return; + + try { + const body = req.body; + + if (!body || typeof body !== "object") { + return reply.code(400).send({ ok: false, error: "invalid body/json" }); + } + + const bands = Array.isArray(body.bands) ? body.bands : null; + + if (!bands) { + return reply.code(400).send({ ok: false, error: "missing bands[]" }); + } + + if (bands.length > 1000) { + return reply.code(400).send({ + ok: false, + error: "Max 1000 bands par import" + }); + } + + const p = requirePool(); + let upserted = 0; + + for (const b of bands) { + if (!b || typeof b !== "object") continue; + + const ma_id = Number(b.ma_id); + if (!Number.isFinite(ma_id)) continue; + + const name = b.name ?? null; + const country = b.country ?? null; + const status = b.status ?? null; + const genre = b.genre ?? null; + const location_text = b.location_text ?? b.location ?? null; + const hasData = b.data && typeof b.data === "object"; + const data = hasData ? b.data : null; + const enriched = hasData ? true : (b.enriched ?? null); + + await p.query( + ` + INSERT INTO bands (ma_id, name, country, status, genre, location_text, data, enriched) + VALUES ($1,$2,$3,$4,$5,$6,$7, COALESCE($8,false)) + ON CONFLICT (ma_id) DO UPDATE SET + name = COALESCE(EXCLUDED.name, bands.name), + country = COALESCE(EXCLUDED.country, bands.country), + status = COALESCE(EXCLUDED.status, bands.status), + genre = COALESCE(EXCLUDED.genre, bands.genre), + location_text = COALESCE(EXCLUDED.location_text, bands.location_text), + data = COALESCE(EXCLUDED.data, bands.data), + enriched = COALESCE(EXCLUDED.enriched, bands.enriched) + `, + [ma_id, name, country, status, genre, location_text, data, enriched] + ); + upserted++; + } + + return reply.send({ ok: true, upserted }); + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur lors de l\'import' }); + } + }); + + adminRoutes.get("/admin/enrich/next", async (req, reply) => { + if (!requireAdmin(req, reply)) return; + + try { + const p = requirePool(); + const limitRaw = Number(req.query?.limit ?? 50); + const limit = Math.max(1, Math.min(Number.isFinite(limitRaw) ? limitRaw : 50, 500)); + const country = (req.query?.country ?? "").toString().trim().toUpperCase(); + + const params = []; + let where = "WHERE (data->'band_page') IS NULL"; + + if (country) { + params.push(country); + where += ` AND country = $${params.length}`; + } + + params.push(limit); + + const sql = ` + SELECT + ma_id, + (data->>'url') AS url + FROM bands + ${where} + ORDER BY ma_id ASC + LIMIT $${params.length} + `; + + const r = await p.query(sql, params); + const items = (r.rows || []).filter((x) => x.url); + + return { ok: true, count: items.length, items }; + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ ok: false, error: 'Erreur lors de la récupération des groupes à enrichir' }); + } + }); +}); + +fastify.listen({ port: PORT, host: "0.0.0.0" }); \ No newline at end of file diff --git a/infra/overrides/api/src/server_a.js b/infra/overrides/api/src/server_a.js new file mode 100644 index 0000000..783ea71 --- /dev/null +++ b/infra/overrides/api/src/server_a.js @@ -0,0 +1,521 @@ +import Fastify from "fastify"; +import pg from "pg"; + +const { Pool } = pg; + +const fastify = Fastify({ logger: true }); + +// CORS middleware +fastify.addHook('onRequest', async (request, reply) => { + const origin = request.headers.origin; + const allowedOrigins = ['https://metalfrom.eu', 'https://www.metalfrom.eu']; + + if (allowedOrigins.includes(origin)) { + reply.header('Access-Control-Allow-Origin', origin); + } + reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + reply.header('Access-Control-Allow-Credentials', 'true'); + + // Handle preflight + if (request.method === 'OPTIONS') { + reply.status(204).send(); + } +}); + +const PORT = Number(process.env.PORT || 3000); +const DATABASE_URL = process.env.DATABASE_URL; +const BM_IMPORT_TOKEN = (process.env.BM_IMPORT_TOKEN || "").trim(); + +let pool = null; +if (DATABASE_URL) pool = new Pool({ connectionString: DATABASE_URL }); + +function requirePool() { + if (!pool) throw new Error("DATABASE_URL not set"); + return pool; +} + +function authBearer(req) { + const h = req.headers.authorization || ""; + const m = h.match(/^Bearer\s+(.+)$/i); + return m ? m[1] : null; +} + +function requireAdmin(req, reply) { + const t = authBearer(req); + if (!BM_IMPORT_TOKEN || !t || t !== BM_IMPORT_TOKEN) { + reply.code(401).send({ ok: false, error: "unauthorized" }); + return false; + } + return true; +} + +fastify.get("/", async () => { + return ` + + + +BM API + + + +

BM backend ✅

+ +

Database: ${DATABASE_URL ? "configured" : "NOT SET"}

+ +`; +}); + +fastify.get("/api/health", async () => ({ ok: true })); +fastify.get("/api/db", async () => { + const p = requirePool(); + const r = await p.query("select now() as now, current_database() as db"); + return { ok: true, ...r.rows[0] }; +}); + +/** + * Stats globales (utile pour UI) + */ +fastify.get("/api/stats", async () => { + const p = requirePool(); + const q = ` + SELECT + count(*)::int as total, + count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded, + count(*) FILTER (WHERE geom IS NULL)::int as no_location, + count(*) FILTER (WHERE enriched = true)::int as enriched + FROM bands; + `; + const r = await p.query(q); + return { ok: true, ...r.rows[0] }; +}); + +/** + * Liste des pays + counts (pour multi-select UI) + */ +fastify.get("/api/countries", async () => { + const p = requirePool(); + const q = ` + SELECT + country, + count(*)::int as total, + count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded, + count(*) FILTER (WHERE enriched = true)::int as enriched + FROM bands + GROUP BY country + ORDER BY total DESC; + `; + const r = await p.query(q); + return { ok: true, items: r.rows }; +}); + +/** + * Statuts + counts (filtre UI) + */ +fastify.get("/api/statuses", async () => { + const p = requirePool(); + const q = ` + SELECT + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + count(*)::int as total, + count(*) FILTER (WHERE geom IS NOT NULL)::int as geocoded + FROM bands + GROUP BY 1 + ORDER BY total DESC; + `; + const r = await p.query(q); + return { ok: true, items: r.rows }; +}); + +/** + * CLUSTERS ENDPOINT - Viewport-based clustering for performance + */ +fastify.get("/api/clusters", async (req) => { + const p = requirePool(); + const { + bbox, + zoom, + countries, + status, + genre, + year_min, + year_max, + } = req.query || {}; + + if (!bbox || !zoom) { + return { ok: false, error: "bbox and zoom are required" }; + } + + const [minLon, minLat, maxLon, maxLat] = String(bbox).split(",").map(Number); + const zoomLevel = Number(zoom); + + if ([minLon, minLat, maxLon, maxLat, zoomLevel].some(v => !Number.isFinite(v))) { + return { ok: false, error: "Invalid bbox or zoom values" }; + } + + const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel)); + + const where = ["geom IS NOT NULL"]; + const vals = []; + let i = 1; + + where.push(`ST_X(geom::geometry) >= $${i} AND ST_X(geom::geometry) <= $${i+1} AND ST_Y(geom::geometry) >= $${i+2} AND ST_Y(geom::geometry) <= $${i+3}`); + vals.push(minLon, maxLon, minLat, maxLat); + i += 4; + + if (countries) { + const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); + if (cs.length) { + where.push(`country = ANY($${i}::text[])`); + vals.push(cs); + i++; + } + } + + if (status) { + const st = String(status).split(",").map(s => s.trim()).filter(Boolean); + if (st.length) { + where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); + vals.push(st); + i++; + } + } + + if (genre) { + where.push(`genre ILIKE $${i}`); + vals.push(`%${String(genre).trim()}%`); + i++; + } + + if (year_min) { + where.push(`formed_year >= $${i}`); + vals.push(Number(year_min)); + i++; + } + + if (year_max) { + where.push(`formed_year <= $${i}`); + vals.push(Number(year_max)); + i++; + } + + const whereSql = where.join(" AND "); + + // For high zoom levels (>= 12), return individual bands + if (zoomLevel >= 12) { + const sql = ` + SELECT + ma_id, + name, + country, + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + genre, + location_text, + formed_year, + lat, + lon + FROM bands + WHERE ${whereSql} + ORDER BY ma_id ASC + LIMIT 2000; + `; + const r = await p.query(sql, vals); + return { ok: true, type: "bands", items: r.rows, count: r.rows.length }; + } + + // For lower zoom levels, return aggregated clusters + const sql = ` + WITH grid_cells AS ( + SELECT + floor(ST_X(geom::geometry) / $${i})::int as cell_x, + floor(ST_Y(geom::geometry) / $${i})::int as cell_y, + ma_id, + name, + country, + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + genre, + location_text, + formed_year, + lat, + lon + FROM bands + WHERE ${whereSql} + ), + clusters AS ( + SELECT + cell_x, + cell_y, + count(*)::int as band_count, + avg(lat)::float8 as center_lat, + avg(lon)::float8 as center_lon, + array_agg(json_build_object( + 'ma_id', ma_id, + 'name', name, + 'country', country, + 'status', status, + 'genre', genre, + 'location_text', location_text, + 'formed_year', formed_year, + 'lat', lat, + 'lon', lon + ) ORDER BY name) as bands + FROM grid_cells + GROUP BY cell_x, cell_y + ) + SELECT + center_lat as lat, + center_lon as lon, + band_count as count, + CASE + WHEN band_count <= 5 THEN bands + ELSE bands[1:5] + END as sample_bands + FROM clusters + ORDER BY band_count DESC + LIMIT 1000; + `; + + vals.push(cellSize); + const r = await p.query(sql, vals); + + return { + ok: true, + type: "clusters", + items: r.rows, + total_clusters: r.rows.length, + cell_size: cellSize, + zoom: zoomLevel + }; +}); + +/** + * Bands (flux principal UI) - LEGACY endpoint + */ +fastify.get("/api/bands", async (req) => { + const p = requirePool(); + const { + countries, + geocoded, + q, + status, + only_black, + limit, + offset, + } = req.query || {}; + + const lim = Math.min(Number(limit || 20000), 150000); + const off = Math.max(Number(offset || 0), 0); + + const where = []; + const vals = []; + let i = 1; + + if (countries) { + const cs = String(countries).split(",").map(s => s.trim().toUpperCase()).filter(Boolean); + if (cs.length) { + where.push(`country = ANY($${i}::text[])`); + vals.push(cs); + i++; + } + } + + if (geocoded === "1") where.push(`geom IS NOT NULL`); + if (geocoded === "0") where.push(`geom IS NULL`); + + if (status) { + const st = String(status).split(",").map(s => s.trim()).filter(Boolean); + if (st.length) { + where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`); + vals.push(st); + i++; + } + } + + if (only_black === "1") { + where.push(`genre ILIKE '%black%'`); + } + + if (q) { + const qq = `%${String(q).trim()}%`; + where.push(`(name ILIKE $${i} OR genre ILIKE $${i} OR location_text ILIKE $${i} OR COALESCE(status,'') ILIKE $${i})`); + vals.push(qq); + i++; + } + + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + const sql = ` + SELECT + ma_id, + name, + country, + COALESCE(NULLIF(trim(status), ''), 'Unknown') as status, + genre, + location_text, + enriched, + formed_year, + themes, + lat, + lon + FROM bands + ${whereSql} + ORDER BY ma_id ASC + LIMIT ${lim} OFFSET ${off}; + `; + const r = await p.query(sql, vals); + return { ok: true, items: r.rows, limit: lim, offset: off }; +}); + +/** + * Get minimal data for initial load (facets only) + */ +fastify.get("/api/facets", async () => { + const p = requirePool(); + + const [statusRes, countryRes, genreRes, yearRes] = await Promise.all([ + p.query(` + SELECT COALESCE(NULLIF(trim(status), ''), 'Unknown') as value, count(*)::int as count + FROM bands WHERE geom IS NOT NULL + GROUP BY 1 ORDER BY count DESC + `), + p.query(` + SELECT COALESCE(country, '??') as value, count(*)::int as count + FROM bands WHERE geom IS NOT NULL + GROUP BY 1 ORDER BY count DESC + `), + p.query(` + SELECT genre as value, count(*)::int as count + FROM bands WHERE geom IS NOT NULL AND genre IS NOT NULL AND genre != '' + GROUP BY 1 ORDER BY count DESC + LIMIT 500 + `), + p.query(` + SELECT MIN(formed_year)::int as min_year, MAX(formed_year)::int as max_year + FROM bands WHERE geom IS NOT NULL AND formed_year >= 1900 AND formed_year <= extract(year from now()) + `) + ]); + + return { + ok: true, + statuses: statusRes.rows, + countries: countryRes.rows, + genres: genreRes.rows, + year_range: yearRes.rows[0] || { min_year: null, max_year: null } + }; +}); + +/** + * Détail d'un groupe (pour modal "fiche") + */ +fastify.get("/api/band/:ma_id", async (req, reply) => { + const p = requirePool(); + const id = Number(req.params.ma_id); + if (!Number.isFinite(id)) return reply.code(400).send({ ok: false, error: "bad ma_id" }); + + const r = await p.query( + `SELECT ma_id, name, country, status, genre, location_text, enriched, data, + formed_year, themes, lat, lon, geocoded_at + FROM bands + WHERE ma_id = $1 + LIMIT 1`, + [id] + ); + if (!r.rows.length) return reply.code(404).send({ ok: false, error: "not found" }); + return { ok: true, item: r.rows[0] }; +}); + +/** + * ADMIN import + */ +fastify.post("/admin/import", async (req, reply) => { + if (!requireAdmin(req, reply)) return; + + const body = req.body; + if (!body || typeof body !== "object") return reply.code(400).send({ ok: false, error: "invalid body/json" }); + const bands = Array.isArray(body.bands) ? body.bands : null; + if (!bands) return reply.code(400).send({ ok: false, error: "missing bands[]" }); + + const p = requirePool(); + + let upserted = 0; + for (const b of bands) { + if (!b || typeof b !== "object") continue; + const ma_id = Number(b.ma_id); + if (!Number.isFinite(ma_id)) continue; + + const name = b.name ?? null; + const country = b.country ?? null; + const status = b.status ?? null; + const genre = b.genre ?? null; + const location_text = b.location_text ?? b.location ?? null; + + const hasData = b.data && typeof b.data === "object"; + const data = hasData ? b.data : null; + const enriched = hasData ? true : (b.enriched ?? null); + + await p.query( + ` + INSERT INTO bands (ma_id, name, country, status, genre, location_text, data, enriched) + VALUES ($1,$2,$3,$4,$5,$6,$7, COALESCE($8,false)) + ON CONFLICT (ma_id) DO UPDATE SET + name = COALESCE(EXCLUDED.name, bands.name), + country = COALESCE(EXCLUDED.country, bands.country), + status = COALESCE(EXCLUDED.status, bands.status), + genre = COALESCE(EXCLUDED.genre, bands.genre), + location_text = COALESCE(EXCLUDED.location_text, bands.location_text), + data = COALESCE(EXCLUDED.data, bands.data), + enriched = COALESCE(EXCLUDED.enriched, bands.enriched) + `, + [ma_id, name, country, status, genre, location_text, data, enriched] + ); + upserted++; + } + + return reply.send({ ok: true, upserted }); +}); + +/** + * GET /admin/enrich/next?limit=50&country=FR + * Renvoie les bands dont data.band_page est absent (non enrichis). + */ +fastify.get("/admin/enrich/next", async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const p = requirePool(); + + const limitRaw = Number(req.query?.limit ?? 50); + const limit = Math.max(1, Math.min(Number.isFinite(limitRaw) ? limitRaw : 50, 500)); + + const country = (req.query?.country ?? "").toString().trim().toUpperCase(); + + const params = []; + let where = "WHERE (data->'band_page') IS NULL"; + + if (country) { + params.push(country); + where += ` AND country = $${params.length}`; + } + + params.push(limit); + + const sql = ` + SELECT ma_id, (data->>'url') AS url + FROM bands + ${where} + ORDER BY ma_id ASC + LIMIT $${params.length} + `; + + const r = await p.query(sql, params); + const items = (r.rows || []).filter((x) => x.url); + + return { ok: true, count: items.length, items }; +}); + +fastify.listen({ port: PORT, host: "0.0.0.0" }); diff --git a/infra/temp b/infra/temp new file mode 100644 index 0000000..04a773e --- /dev/null +++ b/infra/temp @@ -0,0 +1,164 @@ +services: + db: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_DB: bm + POSTGRES_USER: bm + POSTGRES_PASSWORD: ${BM_DB_PASSWORD} + volumes: + - bm_pg_data:/var/lib/postgresql/data + networks: + - bm_internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bm -d bm"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - bm_redis_data:/data + networks: + - bm_internal + + # ✅ SÉCURISÉ : Pre-build avec Dockerfile + geocoder-enqueue: + build: + context: ../apps/geocoder + working_dir: /app + environment: + DATABASE_URL: ${DATABASE_URL} + GEOCODE_ENQUEUE_BATCH: "90000" + command: ["python", "src/enqueue.py"] + networks: + - bm_internal + restart: "no" + + # ✅ SÉCURISÉ : Pre-build avec Dockerfile + 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 + + # ✅ SÉCURISÉ : Double auth + rate limiting + 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 + - traefik_net + labels: + - traefik.enable=true + - traefik.docker.network=stack-vps_admin + - traefik.http.routers.pgadmin.rule=Host(`pgadmin.bm.nicolasfryder.ovh`) + - traefik.http.routers.pgadmin.entrypoints=websecure + - traefik.http.routers.pgadmin.tls=true + - traefik.http.routers.pgadmin.tls.certresolver=le + # ✅ CHANGÉ : Utilise le nouveau middleware avec rate limiting + - traefik.http.routers.pgadmin.middlewares=pgadmin-auth@file,pgadmin-ratelimit@file + - traefik.http.services.pgadmin.loadbalancer.server.port=80 + + api: + build: + context: ../apps/api + environment: + PORT: "3000" + DATABASE_URL: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm + BM_IMPORT_TOKEN: ${BM_IMPORT_TOKEN} + depends_on: + db: + condition: service_healthy + volumes: + - ./overrides/api/src/server.js:/app/src/server.js:ro + networks: + - bm_internal + - traefik_net + labels: + - traefik.enable=true + - traefik.http.routers.bm-api.rule=Host(`bm.nicolasfryder.ovh`) + - traefik.http.routers.bm-api.entrypoints=websecure + - traefik.http.routers.bm-api.tls=true + - traefik.http.routers.bm-api.tls.certresolver=le + + # CORS middleware + - traefik.http.middlewares.bm-cors.headers.accessControlAllowOriginList=https://metalfrom.eu,https://www.metalfrom.eu + - traefik.http.middlewares.bm-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS + - traefik.http.middlewares.bm-cors.headers.accessControlAllowHeaders=* + - traefik.http.middlewares.bm-cors.headers.accessControlAllowCredentials=true + - traefik.http.middlewares.bm-cors.headers.addVaryHeader=true + + - traefik.http.routers.bm-api.middlewares=bm-cors@docker + + # ✅ SÉCURISÉ : Double auth + rate limiting + code: + image: lscr.io/linuxserver/code-server:latest + environment: + PUID: "1001" + PGID: "1001" + TZ: "Europe/Paris" + PASSWORD: ${CODESERVER_PASSWORD} + SUDO_PASSWORD: ${CODESERVER_PASSWORD} + DEFAULT_WORKSPACE: /workspace + volumes: + - bm_codeserver_config:/config + - /srv/stacks/bm:/workspace + networks: + - bm_internal + - traefik_net + labels: + - traefik.enable=true + - traefik.docker.network=stack-vps_admin + - traefik.http.routers.bm-code.rule=Host(`code.bm.nicolasfryder.ovh`) + - traefik.http.routers.bm-code.entrypoints=websecure + - traefik.http.routers.bm-code.tls=true + # ✅ AJOUTÉ : Double auth + rate limiting + - traefik.http.routers.bm-code.middlewares=code-auth@file,code-ratelimit@file + - traefik.http.services.bm-code.loadbalancer.server.port=8443 + + worker: + build: + context: ../apps/worker + environment: + DB_DSN: postgresql://bm:${BM_DB_PASSWORD}@db:5432/bm + MA_LIST_URL: https://www.metal-archives.com/lists/FR + MA_STATE_PATH: /app/ma_state.json + depends_on: + db: + condition: service_healthy + volumes: + - /srv/stacks/bm/infra/ma_state.json:/app/ma_state.json:ro + networks: + - bm_internal + restart: "no" + +networks: + traefik_net: + external: true + name: stack-vps_admin + bm_internal: + driver: bridge + +volumes: + bm_pg_data: + bm_redis_data: + bm_codeserver_config: + pgadmin_data: