diff --git a/src/api/templates.js b/src/api/templates.js new file mode 100644 index 0000000..0ab5236 --- /dev/null +++ b/src/api/templates.js @@ -0,0 +1,31 @@ +const API_BASE = "/api"; + +const cache = new Map(); // key -> Promise + +export async function fetchDefaultTemplate(typeKey = "news") { + const t = (typeKey || "news").toLowerCase().trim(); + const res = await fetch(`${API_BASE}/templates/default?type=${encodeURIComponent(t)}`, { + credentials: "include", + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`fetchDefaultTemplate failed: ${res.status} ${text}`); + } + return res.json(); +} + +// One-per-type cached fetch +export function getDefaultTemplateCached(typeKey = "news") { + const t = (typeKey || "news").toLowerCase().trim(); + if (cache.has(t)) return cache.get(t); + + const p = fetchDefaultTemplate(t) + .then((tpl) => tpl) + .catch((err) => { + cache.delete(t); + throw err; + }); + + cache.set(t, p); + return p; +} diff --git a/src/components/Map/TemplateMarkerCard.jsx b/src/components/Map/TemplateMarkerCard.jsx new file mode 100644 index 0000000..c708464 --- /dev/null +++ b/src/components/Map/TemplateMarkerCard.jsx @@ -0,0 +1,65 @@ +import React, { useMemo } from "react"; +import CardRenderer from "../Cards/CardRenderer"; +import { cardTokens } from "../../theme/cardTokens"; + +function normTheme(t) { + if (t === "dark" || t === "blue" || t === "light") return t; + return "dark"; +} + +export default function TemplateMarkerCard({ + post, + template, + theme = "dark", + mode = "full", // "mini" or "full" + onOpenUrl, +}) { + const spec = mode === "mini" ? template?.mini_spec_json : template?.full_spec_json; + + const data = useMemo(() => { + return { + headline: post?.title || "Untitled", + source: post?.source || (post?.author ? `by ${post.author}` : "") || (post?.sub_category || ""), + summary: post?.snippet || post?.body || post?.content || "", + url: post?.url || "", + image: post?.image_large || post?.image_small || post?.image || post?.media_url || "", + }; + }, [post]); + + if (!spec) { + // fallback simple + return ( +
+
{data.headline}
+
{data.summary}
+
+ ); + } + + const tokens = cardTokens[normTheme(theme)] || cardTokens.dark; + + return ( + { + if (action?.type === "open_url" && value) { + if (onOpenUrl) onOpenUrl(value); + else window.open(value, "_blank", "noopener,noreferrer"); + } + }} + className="" + /> + ); +} diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index 277f7f9..cff2c63 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -1,9 +1,18 @@ import maplibregl from "maplibre-gl"; +import React from "react"; +import { createRoot } from "react-dom/client"; + +import TemplateMarkerCard from "./TemplateMarkerCard"; +import { getDefaultTemplateCached } from "../../api/templates"; export function clearAllMarkers(markersRef, expandedElRef) { if (markersRef.current) { for (const m of markersRef.current) { try { + if (m?.el && m.el.__reactRoot) { + try { m.el.__reactRoot.unmount(); } catch {} + m.el.__reactRoot = null; + } m.marker.remove(); } catch {} } @@ -63,6 +72,28 @@ export function clearOcclusion(markersRef) { } } +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function currentTheme() { + try { + const t = document?.body?.getAttribute("data-theme") || "dark"; + if (t === "dark" || t === "blue" || t === "light") return t; + } catch {} + return "dark"; +} + +function ensureReactRoot(el) { + if (el.__reactRoot) return el.__reactRoot; + el.__reactRoot = createRoot(el); + return el.__reactRoot; +} + export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) { const map = mapRef.current; if (!map) return; @@ -87,12 +118,6 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) { } const title = (post.title || "").trim() || "Untitled"; - const body = (post.snippet || "").trim() || (post.body || "").trim() || title; - - const author = (post.author || "").trim() || "unknown"; - const created = (post.created_at || "").toString().slice(0, 19); - const category = (post.category || "").toUpperCase() || ""; - const subCat = (post.sub_category || "").toString(); const root = document.createElement("div"); root.className = "post-pin post-pin--compact"; @@ -101,6 +126,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) { function renderCompact() { root.className = "post-pin post-pin--compact"; root.style.zIndex = "1"; + root.innerHTML = `
@@ -108,48 +134,50 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) {
`; + + if (root.__reactRoot) { + try { root.__reactRoot.unmount(); } catch {} + root.__reactRoot = null; + } + clearOcclusion(markersRef); } - function renderExpanded() { + async function renderExpanded() { root.className = "post-pin post-pin--expanded"; root.style.zIndex = "999999"; + root.innerHTML = `
-
-
- ${escapeHtml(category || "NEWS")} - ${subCat ? " · " + escapeHtml(subCat) : ""} -
-
${escapeHtml(created || "")}
-
- -
-
-
Image / Vidéo
-
- -
-
${escapeHtml(title)}
-
- by ${escapeHtml(author)} · lat ${lat.toFixed(3)} · lon ${lon.toFixed(3)} -
-
${escapeHtml(body)}
-
-
- -
- - - - -
- -
Live chat / comments (placeholder)
+
Loading template…
`; + const cardEl = root.querySelector(".post-card"); + if (!cardEl) return; + + const theme = currentTheme(); + + try { + const tpl = await getDefaultTemplateCached("news"); + + const rr = ensureReactRoot(cardEl); + rr.render( + React.createElement(TemplateMarkerCard, { + post, + template: tpl, + theme, + mode: "full", + }) + ); + } catch (e) { + cardEl.innerHTML = ` +
${escapeHtml(title)}
+
(template load failed)
+ `; + } + requestAnimationFrame(() => { applyOcclusionForExpanded(markersRef, root); }); @@ -182,11 +210,3 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) { } }); } - -function escapeHtml(str) { - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} diff --git a/src/components/Map/markerManager.js.bak.20251218-144108 b/src/components/Map/markerManager.js.bak.20251218-144108 new file mode 100644 index 0000000..277f7f9 --- /dev/null +++ b/src/components/Map/markerManager.js.bak.20251218-144108 @@ -0,0 +1,192 @@ +import maplibregl from "maplibre-gl"; + +export function clearAllMarkers(markersRef, expandedElRef) { + if (markersRef.current) { + for (const m of markersRef.current) { + try { + m.marker.remove(); + } catch {} + } + } + markersRef.current = []; + if (expandedElRef) expandedElRef.current = null; +} + +function rectsOverlap(a, b) { + return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom); +} + +export function applyOcclusionForExpanded(markersRef, expandedEl) { + if (!expandedEl) return; + + const expandedCard = expandedEl.querySelector(".post-card"); + if (!expandedCard) return; + + const PAD_X = 26; + const PAD_TOP = 20; + const PAD_BOTTOM = 140; + + const raw = expandedCard.getBoundingClientRect(); + const expandedRect = { + left: raw.left - PAD_X, + right: raw.right + PAD_X, + top: raw.top - PAD_TOP, + bottom: raw.bottom + PAD_BOTTOM, + }; + + const list = markersRef.current || []; + for (const item of list) { + const el = item?.el; + if (!el || el === expandedEl) continue; + + const r = el.getBoundingClientRect(); + const isBelow = r.top >= raw.top; + if (!isBelow) { + el.style.visibility = "visible"; + el.style.pointerEvents = "auto"; + continue; + } + + const hide = rectsOverlap(expandedRect, r); + el.style.visibility = hide ? "hidden" : "visible"; + el.style.pointerEvents = hide ? "none" : "auto"; + } +} + +export function clearOcclusion(markersRef) { + const list = markersRef.current || []; + for (const item of list) { + const el = item?.el; + if (!el) continue; + el.style.visibility = "visible"; + el.style.pointerEvents = "auto"; + } +} + +export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) { + const map = mapRef.current; + if (!map) return; + + const lat = + typeof post.lat === "number" + ? post.lat + : typeof post.latitude === "number" + ? post.latitude + : null; + + const lon = + typeof post.lon === "number" + ? post.lon + : typeof post.lng === "number" + ? post.lng + : null; + + if (lat == null || lon == null) { + console.warn("post sans coordonnée:", post); + return; + } + + const title = (post.title || "").trim() || "Untitled"; + const body = (post.snippet || "").trim() || (post.body || "").trim() || title; + + const author = (post.author || "").trim() || "unknown"; + const created = (post.created_at || "").toString().slice(0, 19); + const category = (post.category || "").toUpperCase() || ""; + const subCat = (post.sub_category || "").toString(); + + const root = document.createElement("div"); + root.className = "post-pin post-pin--compact"; + root.style.zIndex = "1"; + + function renderCompact() { + root.className = "post-pin post-pin--compact"; + root.style.zIndex = "1"; + root.innerHTML = ` +
+
+
${escapeHtml(title)}
+
+
+ `; + clearOcclusion(markersRef); + } + + function renderExpanded() { + root.className = "post-pin post-pin--expanded"; + root.style.zIndex = "999999"; + root.innerHTML = ` +
+
+
+ ${escapeHtml(category || "NEWS")} + ${subCat ? " · " + escapeHtml(subCat) : ""} +
+
${escapeHtml(created || "")}
+
+ +
+
+
Image / Vidéo
+
+ +
+
${escapeHtml(title)}
+
+ by ${escapeHtml(author)} · lat ${lat.toFixed(3)} · lon ${lon.toFixed(3)} +
+
${escapeHtml(body)}
+
+
+ +
+ + + + +
+ +
Live chat / comments (placeholder)
+
+
+ `; + + requestAnimationFrame(() => { + applyOcclusionForExpanded(markersRef, root); + }); + } + + renderCompact(); + root.__renderCompact = renderCompact; + + const marker = new maplibregl.Marker({ element: root, anchor: "bottom" }) + .setLngLat([lon, lat]) + .addTo(map); + + markersRef.current.push({ id: post.id, marker, el: root, post }); + + root.addEventListener("click", (ev) => { + ev.stopPropagation(); + + if (expandedElRef.current && expandedElRef.current !== root) { + if (expandedElRef.current.__renderCompact) expandedElRef.current.__renderCompact(); + expandedElRef.current = null; + } + + const isExpanded = root.classList.contains("post-pin--expanded"); + if (isExpanded) { + renderCompact(); + expandedElRef.current = null; + } else { + renderExpanded(); + expandedElRef.current = root; + } + }); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/src/components/Map/markerManager.js.bak.20251218-144255 b/src/components/Map/markerManager.js.bak.20251218-144255 new file mode 100644 index 0000000..8b51510 --- /dev/null +++ b/src/components/Map/markerManager.js.bak.20251218-144255 @@ -0,0 +1,220 @@ +import maplibregl from "maplibre-gl"; +import React from "react"; +import { createRoot } from "react-dom/client"; + +import TemplateMarkerCard from "./TemplateMarkerCard"; +import { getDefaultTemplateCached } from "../../api/templates"; + +export function clearAllMarkers(markersRef, expandedElRef) { + if (markersRef.current) { + for (const m of markersRef.current) { + try { + // unmount react if present + if (m?.el && m.el.__reactRoot) { + try { m.el.__reactRoot.unmount(); } catch {} + m.el.__reactRoot = null; + } + m.marker.remove(); + } catch {} + } + } + markersRef.current = []; + if (expandedElRef) expandedElRef.current = null; +} + +function rectsOverlap(a, b) { + return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom); +} + +export function applyOcclusionForExpanded(markersRef, expandedEl) { + if (!expandedEl) return; + + // our expanded DOM contains .post-card + const expandedCard = expandedEl.querySelector(".post-card"); + if (!expandedCard) return; + + const PAD_X = 26; + const PAD_TOP = 20; + const PAD_BOTTOM = 140; + + const raw = expandedCard.getBoundingClientRect(); + const expandedRect = { + left: raw.left - PAD_X, + right: raw.right + PAD_X, + top: raw.top - PAD_TOP, + bottom: raw.bottom + PAD_BOTTOM, + }; + + const list = markersRef.current || []; + for (const item of list) { + const el = item?.el; + if (!el || el === expandedEl) continue; + + const r = el.getBoundingClientRect(); + const isBelow = r.top >= raw.top; + if (!isBelow) { + el.style.visibility = "visible"; + el.style.pointerEvents = "auto"; + continue; + } + + const hide = rectsOverlap(expandedRect, r); + el.style.visibility = hide ? "hidden" : "visible"; + el.style.pointerEvents = hide ? "none" : "auto"; + } +} + +export function clearOcclusion(markersRef) { + const list = markersRef.current || []; + for (const item of list) { + const el = item?.el; + if (!el) continue; + el.style.visibility = "visible"; + el.style.pointerEvents = "auto"; + } +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function currentTheme() { + try { + const t = document?.body?.getAttribute("data-theme") || "dark"; + if (t === "dark" || t === "blue" || t === "light") return t; + } catch {} + return "dark"; +} + +function ensureReactRoot(el) { + if (el.__reactRoot) return el.__reactRoot; + el.__reactRoot = createRoot(el); + return el.__reactRoot; +} + +export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) { + const map = mapRef.current; + if (!map) return; + + const lat = + typeof post.lat === "number" + ? post.lat + : typeof post.latitude === "number" + ? post.latitude + : null; + + const lon = + typeof post.lon === "number" + ? post.lon + : typeof post.lng === "number" + ? post.lng + : null; + + if (lat == null || lon == null) { + console.warn("post sans coordonnée:", post); + return; + } + + const title = (post.title || "").trim() || "Untitled"; + + const root = document.createElement("div"); + root.className = "post-pin post-pin--compact"; + root.style.zIndex = "1"; + + const mount = document.createElement("div"); + mount.className = "post-react-mount"; + root.appendChild(mount); + + function renderCompact() { + root.className = "post-pin post-pin--compact"; + root.style.zIndex = "1"; + + // compact stays HTML (fast + matches your CSS) + root.innerHTML = ` +
+
+
${escapeHtml(title)}
+
+
+ `; + // compact uses no React; clean old root if any + if (root.__reactRoot) { + try { root.__reactRoot.unmount(); } catch {} + root.__reactRoot = null; + } + clearOcclusion(markersRef); + } + + async function renderExpanded() { + root.className = "post-pin post-pin--expanded"; + root.style.zIndex = "999999"; + + // Create expanded shell that matches existing occlusion CSS selectors + root.innerHTML = ` +
+
Loading template…
+
+
+ `; + + // Render React inside .post-card (replace loading) + const cardEl = root.querySelector(".post-card"); + if (!cardEl) return; + + const theme = currentTheme(); + + try { + const tpl = await getDefaultTemplateCached("news"); // default type for now + const rr = ensureReactRoot(cardEl); + rr.render( + + ); + } catch (e) { + // fallback if template fetch fails + cardEl.innerHTML = ` +
${escapeHtml(title)}
+
(template load failed)
+ `; + } + + requestAnimationFrame(() => { + applyOcclusionForExpanded(markersRef, root); + }); + } + + renderCompact(); + root.__renderCompact = renderCompact; + + const marker = new maplibregl.Marker({ element: root, anchor: "bottom" }) + .setLngLat([lon, lat]) + .addTo(map); + + markersRef.current.push({ id: post.id, marker, el: root, post }); + + root.addEventListener("click", (ev) => { + ev.stopPropagation(); + + if (expandedElRef.current && expandedElRef.current !== root) { + if (expandedElRef.current.__renderCompact) expandedElRef.current.__renderCompact(); + expandedElRef.current = null; + } + + const isExpanded = root.classList.contains("post-pin--expanded"); + if (isExpanded) { + renderCompact(); + expandedElRef.current = null; + } else { + renderExpanded(); + expandedElRef.current = root; + } + }); +}