This commit is contained in:
Your Name 2025-12-18 14:53:39 -05:00
parent 1e49157a3a
commit e52e451d98
5 changed files with 573 additions and 45 deletions

31
src/api/templates.js Normal file
View File

@ -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;
}

View File

@ -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 (
<div
style={{
minWidth: 260,
maxWidth: 320,
background: "rgba(8, 20, 40, 0.97)",
borderRadius: 14,
padding: "10px 12px",
border: "1px solid rgba(90, 190, 255, 0.9)",
color: "#e8f5ff",
}}
>
<div style={{ fontWeight: 900, fontSize: 13, marginBottom: 4 }}>{data.headline}</div>
<div style={{ opacity: 0.9, fontSize: 11 }}>{data.summary}</div>
</div>
);
}
const tokens = cardTokens[normTheme(theme)] || cardTokens.dark;
return (
<CardRenderer
spec={spec}
data={data}
themeTokens={tokens}
onAction={(action, value) => {
if (action?.type === "open_url" && value) {
if (onOpenUrl) onOpenUrl(value);
else window.open(value, "_blank", "noopener,noreferrer");
}
}}
className=""
/>
);
}

View File

@ -1,9 +1,18 @@
import maplibregl from "maplibre-gl"; 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) { export function clearAllMarkers(markersRef, expandedElRef) {
if (markersRef.current) { if (markersRef.current) {
for (const m of markersRef.current) { for (const m of markersRef.current) {
try { try {
if (m?.el && m.el.__reactRoot) {
try { m.el.__reactRoot.unmount(); } catch {}
m.el.__reactRoot = null;
}
m.marker.remove(); m.marker.remove();
} catch {} } catch {}
} }
@ -63,6 +72,28 @@ export function clearOcclusion(markersRef) {
} }
} }
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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) { export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
@ -87,12 +118,6 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) {
} }
const title = (post.title || "").trim() || "Untitled"; 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"); const root = document.createElement("div");
root.className = "post-pin post-pin--compact"; root.className = "post-pin post-pin--compact";
@ -101,6 +126,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) {
function renderCompact() { function renderCompact() {
root.className = "post-pin post-pin--compact"; root.className = "post-pin post-pin--compact";
root.style.zIndex = "1"; root.style.zIndex = "1";
root.innerHTML = ` root.innerHTML = `
<div class="post-pin-bubble"> <div class="post-pin-bubble">
<div class="post-pin-dot"></div> <div class="post-pin-dot"></div>
@ -108,48 +134,50 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) {
</div> </div>
<div class="post-pin-pointer-small"></div> <div class="post-pin-pointer-small"></div>
`; `;
if (root.__reactRoot) {
try { root.__reactRoot.unmount(); } catch {}
root.__reactRoot = null;
}
clearOcclusion(markersRef); clearOcclusion(markersRef);
} }
function renderExpanded() { async function renderExpanded() {
root.className = "post-pin post-pin--expanded"; root.className = "post-pin post-pin--expanded";
root.style.zIndex = "999999"; root.style.zIndex = "999999";
root.innerHTML = ` root.innerHTML = `
<div class="post-card"> <div class="post-card">
<div class="post-card-header"> <div class="post-card-body" style="font-size:11px; opacity:.9;">Loading template</div>
<div class="post-card-cat">
${escapeHtml(category || "NEWS")}
${subCat ? " · " + escapeHtml(subCat) : ""}
</div>
<div class="post-card-time">${escapeHtml(created || "")}</div>
</div>
<div class="post-card-main">
<div class="post-card-media">
<div class="post-card-media-label">Image / Vidéo</div>
</div>
<div class="post-card-info">
<div class="post-card-title">${escapeHtml(title)}</div>
<div class="post-card-meta">
by ${escapeHtml(author)} · lat ${lat.toFixed(3)} · lon ${lon.toFixed(3)}
</div>
<div class="post-card-body">${escapeHtml(body)}</div>
</div>
</div>
<div class="post-card-footer">
<button class="post-card-btn">Live</button>
<button class="post-card-btn">Like</button>
<button class="post-card-btn">Share</button>
<button class="post-card-btn">Fix</button>
</div>
<div class="post-card-comments">Live chat / comments (placeholder)</div>
</div> </div>
<div class="post-card-pointer"></div> <div class="post-card-pointer"></div>
`; `;
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 = `
<div style="font-weight:800; font-size:13px; margin-bottom:4px;">${escapeHtml(title)}</div>
<div style="font-size:11px; opacity:.9;">(template load failed)</div>
`;
}
requestAnimationFrame(() => { requestAnimationFrame(() => {
applyOcclusionForExpanded(markersRef, root); applyOcclusionForExpanded(markersRef, root);
}); });
@ -182,11 +210,3 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) {
} }
}); });
} }
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@ -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 = `
<div class="post-pin-bubble">
<div class="post-pin-dot"></div>
<div class="post-pin-text">${escapeHtml(title)}</div>
</div>
<div class="post-pin-pointer-small"></div>
`;
clearOcclusion(markersRef);
}
function renderExpanded() {
root.className = "post-pin post-pin--expanded";
root.style.zIndex = "999999";
root.innerHTML = `
<div class="post-card">
<div class="post-card-header">
<div class="post-card-cat">
${escapeHtml(category || "NEWS")}
${subCat ? " · " + escapeHtml(subCat) : ""}
</div>
<div class="post-card-time">${escapeHtml(created || "")}</div>
</div>
<div class="post-card-main">
<div class="post-card-media">
<div class="post-card-media-label">Image / Vidéo</div>
</div>
<div class="post-card-info">
<div class="post-card-title">${escapeHtml(title)}</div>
<div class="post-card-meta">
by ${escapeHtml(author)} · lat ${lat.toFixed(3)} · lon ${lon.toFixed(3)}
</div>
<div class="post-card-body">${escapeHtml(body)}</div>
</div>
</div>
<div class="post-card-footer">
<button class="post-card-btn">Live</button>
<button class="post-card-btn">Like</button>
<button class="post-card-btn">Share</button>
<button class="post-card-btn">Fix</button>
</div>
<div class="post-card-comments">Live chat / comments (placeholder)</div>
</div>
<div class="post-card-pointer"></div>
`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 = `
<div class="post-pin-bubble">
<div class="post-pin-dot"></div>
<div class="post-pin-text">${escapeHtml(title)}</div>
</div>
<div class="post-pin-pointer-small"></div>
`;
// 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 = `
<div class="post-card">
<div class="post-card-body" style="font-size:11px; opacity:.9;">Loading template…</div>
</div>
<div class="post-card-pointer"></div>
`;
// 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(
<TemplateMarkerCard
post={post}
template={tpl}
theme={theme}
mode="full"
/>
);
} catch (e) {
// fallback if template fetch fails
cardEl.innerHTML = `
<div style="font-weight:800; font-size:13px; margin-bottom:4px;">${escapeHtml(title)}</div>
<div style="font-size:11px; opacity:.9;">(template load failed)</div>
`;
}
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;
}
});
}