import maplibregl from "maplibre-gl";
import React from "react";
import { createRoot } from "react-dom/client";
const SW_ANIM_MS = 840;
import CardRenderer from "../Cards/CardRenderer";
import { cardTokens } from "../../theme/cardTokens";
import { getTemplateSpecForPost, getTemplateKeyForPost, adaptPostToTemplateData } from "./templateSpecs";
import { getMarkerColorForCategory } from "./postPriority";
import { loadAuth, guessUsernameFromToken } from "../../auth/authStorage";
import {
recordPostShare,
recordPostView,
togglePostLike,
fetchPostById,
fetchPostComments,
createPostComment,
fetchPostLikeState,
fetchProfile,
fetchPostTruth,
votePostTruth,
editPost,
deletePost,
updatePostVisibility,
} from "../../api/client";
import { trackEvent } from "../../utils/analytics";
import { formatRelativeTime } from "../../utils/timezone";
const avatarCache = new Map();
const avatarInflight = new Map();
const HEATMAP_ENABLED = false;
/**
* Convert avatar URL to small variant (160x160)
* Asset URLs: ..._l.jpg -> ..._s.webp, ..._m.webp -> ..._s.webp
*/
function toSmallAvatarUrl(url) {
if (!url || typeof url !== "string") return url;
// Convert _l.jpg or _l.jpeg to _s.webp
if (url.includes("_l.jpg") || url.includes("_l.jpeg")) {
return url.replace(/_l\.jpe?g/i, "_s.webp");
}
// Convert _m.webp to _s.webp
if (url.includes("_m.webp")) {
return url.replace("_m.webp", "_s.webp");
}
return url;
}
function applyAvatar(el, username, url) {
if (!el) return;
const initial = (username || "U")[0]?.toUpperCase() || "U";
el.innerHTML = "";
if (url) {
const img = document.createElement("img");
img.src = toSmallAvatarUrl(url);
img.alt = username || "user";
el.appendChild(img);
} else {
el.textContent = initial;
}
}
function resolvePostImage(post) {
return normalizeImageUrl(
post?.image_small ||
post?.image_medium ||
post?.image_large ||
post?.image ||
post?.media_url ||
post?.image_url ||
""
);
}
function refreshMarkerFromPost(map, postId, fresh) {
if (!map || !fresh) return;
const ref = map.__swMarkersRef;
const list = ref?.current || [];
for (const item of list) {
if (!item || item.id !== postId) continue;
// Preserve fetched truth_score if fresh data doesn't have it
const oldTruth = item.post?.truth_score;
const nextPost = { ...(item.post || {}), ...(fresh || {}) };
if (Number.isFinite(oldTruth) && !Number.isFinite(Number(fresh?.truth_score))) {
nextPost.truth_score = oldTruth;
}
item.post = nextPost;
if (!item.el) continue;
item.el.__post = nextPost;
const nextKey = getTemplateKeyForPost(nextPost);
const nextImg = resolvePostImage(nextPost);
const nextTitle = (nextPost?.title || "").toString();
if (
item.el.__lastTemplateKey !== nextKey ||
item.el.__lastImage !== nextImg ||
item.el.__lastTitle !== nextTitle
) {
if (item.type === "simple" && item.el.__renderSimple) {
try { item.el.__renderSimple(); } catch {}
} else if (item.el.__renderCompact) {
try { item.el.__renderCompact(); } catch {}
}
}
}
}
async function loadAvatar(username) {
const key = (username || "").toLowerCase().trim();
if (!key) return "";
if (avatarCache.has(key)) return avatarCache.get(key);
if (avatarInflight.has(key)) return avatarInflight.get(key);
const p = (async () => {
const profile = await fetchProfile(username);
const url = (profile && profile.avatar_url) ? String(profile.avatar_url) : "";
avatarCache.set(key, url);
avatarInflight.delete(key);
return url;
})().catch(() => {
avatarCache.set(key, "");
avatarInflight.delete(key);
return "";
});
avatarInflight.set(key, p);
return p;
}
function hydrateAvatars(root) {
if (!root) return;
const nodes = root.querySelectorAll("[data-username]");
nodes.forEach((el) => {
const username = el.getAttribute("data-username") || "";
if (!username) return;
el.classList.add("sw-user-avatar");
applyAvatar(el, username, avatarCache.get(username.toLowerCase().trim()) || "");
loadAvatar(username).then((url) => {
applyAvatar(el, username, url);
});
});
}
export function clearAllMarkers(markersRef, expandedElRef) {
if (markersRef.current) {
for (const m of markersRef.current) {
try { if (m?.el && m.el.__swUnmount) m.el.__swUnmount(); } catch {}
try { m.marker.remove(); } catch {}
}
}
markersRef.current = [];
if (expandedElRef) expandedElRef.current = null;
}
export function applyOcclusionForExpanded() {}
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 formatCount(value) {
if (!Number.isFinite(value)) return "—";
if (value < 1000) return String(Math.max(0, Math.floor(value)));
if (value < 1000000) return `${(value / 1000).toFixed(1)}k`;
return `${(value / 1000000).toFixed(1)}m`;
}
function readCount(post, keys) {
for (const key of keys) {
const v = post?.[key];
if (Number.isFinite(v)) return v;
const n = Number(v);
if (!Number.isNaN(n) && Number.isFinite(n)) return n;
}
return null;
}
function updateStat(modalWrap, statKey, value) {
const el = modalWrap.querySelector(`[data-stat="${statKey}"] .sw-stat-num`);
if (el) el.textContent = formatCount(value);
}
function updateTruth(modalWrap, post) {
const rail = modalWrap.querySelector(".sw-truth-rail");
if (!rail) return;
const score = truthScore(post);
const marker = rail.querySelector(".sw-truth-rail-marker");
const label = rail.querySelector(".sw-truth-rail-score");
if (marker) marker.style.top = `${100 - score}%`;
if (label) label.textContent = `${score}`;
}
function updateTruthVoteUI(modalWrap, stats, updateScore = false) {
if (!modalWrap || !stats) return;
const aiEl = modalWrap.querySelector("[data-truth-ai]");
const userEl = modalWrap.querySelector("[data-truth-user]");
const countEl = modalWrap.querySelector("[data-truth-count]");
const aiScore = stats.ai_score !== null && Number.isFinite(stats.ai_score) ? Math.round(stats.ai_score) : null;
const userScore = stats.user_score !== null && Number.isFinite(stats.user_score) ? Math.round(stats.user_score) : null;
const totalVotes = Number.isFinite(stats.total_votes) ? stats.total_votes : (stats.TotalVotes || 0);
if (aiEl) aiEl.textContent = aiScore !== null ? String(aiScore) : "—";
if (userEl) userEl.textContent = userScore !== null ? String(userScore) : "—";
if (countEl) countEl.textContent = `${totalVotes} votes`;
// Only update the score display if explicitly requested AND we have a valid score
if (updateScore && stats.truth_score !== null && Number.isFinite(stats.truth_score)) {
const rail = modalWrap.querySelector(".sw-truth-rail");
const marker = rail ? rail.querySelector(".sw-truth-rail-marker") : null;
const label = rail ? rail.querySelector(".sw-truth-rail-score") : null;
const truthScoreValue = Math.round(stats.truth_score);
if (marker) marker.style.top = `${100 - truthScoreValue}%`;
if (label) label.textContent = `${truthScoreValue}`;
}
const vote = (stats.user_vote || stats.UserVote || "").toString();
const btns = modalWrap.querySelectorAll(".sw-truth-vote-btn");
btns.forEach((btn) => {
const v = btn.getAttribute("data-truth-vote");
btn.classList.toggle("is-active", vote && v === vote);
});
const railUp = modalWrap.querySelector(".sw-truth-rail-btn-up");
const railDown = modalWrap.querySelector(".sw-truth-rail-btn-down");
if (railUp) railUp.classList.toggle("is-active", vote === "true");
if (railDown) railDown.classList.toggle("is-active", vote === "false");
}
function isAuthed() {
try {
const auth = loadAuth();
return !!(auth && auth.access_token);
} catch {
return false;
}
}
function requestAuth(message, mode = "login") {
try {
window.dispatchEvent(
new CustomEvent("sociowire:auth-required", {
detail: { message, mode },
})
);
} catch {}
trackEvent("auth_required", { context: "map", mode: mode || "login" });
}
function applyPostDetails(modalWrap, post) {
if (!modalWrap || !post) return;
const title = (post?.title || post?.Title || "Untitled").toString();
const summary = (post?.snippet || post?.body || "").toString();
const img = heroImageForPost(post);
const fullImg = fullImageForPost(post);
const url = (post?.url || "").toString().trim();
const titleEl = modalWrap.querySelector(".sw-modal-title");
if (titleEl) titleEl.textContent = title;
const summaryEl = modalWrap.querySelector(".sw-modal-summary");
if (summaryEl) summaryEl.textContent = summary;
// Skip hero replacement for camera and video posts - they have video content
const isCamera = post?.content_type === 'camera';
const isVideo = post?.content_type === 'video';
if (!isCamera && !isVideo) {
const heroGallery = collectGalleryImages(post);
const heroImageSrc = heroGallery[0] || heroImageForPost(post);
const heroFullSrc = heroGallery[0] || fullImageForPost(post);
const heroHasGallery = heroGallery.length > 1;
const hero = modalWrap.querySelector(".sw-modal-hero");
const clusterHero = modalWrap.querySelector(".sw-cluster-hero-media");
if (hero || clusterHero) {
const target = hero || clusterHero;
const heroImageHtml = heroImageSrc
? `
`
: `
${escapeHtml(normCatLabel(post))}
`;
const arrowHtml = heroHasGallery
? ``
: "";
const indicatorHtml = heroHasGallery
? `1/${heroGallery.length}
`
: "";
// DEBUG v0.0.22 - send debug to server
try {
fetch('/api/debug-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'modal_gallery',
post_id: post?.id,
media_urls_raw: post?.media_urls,
media_urls_count: Array.isArray(post?.media_urls) ? post.media_urls.length : 0,
gallery_count: heroGallery.length,
gallery_items: heroGallery,
has_gallery: heroHasGallery,
content_type: post?.content_type
})
}).catch(() => {});
} catch {}
target.innerHTML = `${heroImageHtml}${arrowHtml}${indicatorHtml}`;
bindHeroLightbox(modalWrap);
if (heroHasGallery) {
initModalGallery(modalWrap, heroGallery);
}
}
}
const badge = modalWrap.querySelector(".sw-modal-badge");
if (badge) badge.textContent = normCatLabel(post);
const meta = modalWrap.querySelector(".sw-modal-meta");
if (meta) {
const author = (post?.author || post?.Author || "").toString().trim();
const sub = (post?.sub_category || post?.subCategory || "").toString().trim();
const relTime = formatRelativeTime(getPostTime(post));
const metaLeft = author ? `by ${author}` : "";
const metaRight = sub ? sub : "";
meta.innerHTML = `
${author ? `` : ""}
${metaLeft ? `${escapeHtml(metaLeft)}` : ""}
${metaRight ? `• ${escapeHtml(metaRight)}` : ""}
• ${escapeHtml(relTime)}
`;
hydrateAvatars(modalWrap);
}
setModalTags(modalWrap, post);
const cta = modalWrap.querySelector(".sw-cta-primary");
if (cta) {
if (url) {
cta.removeAttribute("disabled");
cta.setAttribute("data-url", url);
} else {
cta.setAttribute("disabled", "true");
cta.setAttribute("data-url", "");
}
}
const viewCount = readCount(post, ["view_count", "views", "viewCount"]);
const likeCount = readCount(post, ["like_count", "likes", "likeCount"]);
const shareCount = readCount(post, ["share_count", "shares", "shareCount"]);
const commentCount = readCount(post, ["comment_count", "comments", "commentCount"]);
updateStat(modalWrap, "views", viewCount);
updateStat(modalWrap, "likes", likeCount);
updateStat(modalWrap, "shares", shareCount);
updateStat(modalWrap, "comments", commentCount);
// Don't update truth here - let fetchPostTruth handle it to avoid race condition
}
function renderComments(container, items) {
if (!container) return;
if (!Array.isArray(items) || items.length === 0) {
container.innerHTML = `— no comments yet
`;
return;
}
container.innerHTML = items
.map((c) => {
const who = (c?.username || "anon").toString().trim();
const body = (c?.body || "").toString().trim();
const rawTs = (c?.created_at || "").toString().trim();
// Format timestamp in user's timezone
const ts = rawTs ? formatRelativeTime(rawTs) : "";
return `
${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}
${escapeHtml(body)}
`;
})
.join("");
hydrateAvatars(container);
}
function mountCard(container, spec, data, theme, post) {
let root = container.__swReactRoot;
if (!root) {
root = createRoot(container);
container.__swReactRoot = root;
}
const tokens = cardTokens?.[theme] || cardTokens.blue;
const score = truthScore(post || data || {});
const templateKey = getTemplateKeyForPost(post || {});
const isPolaroid = templateKey === 'polaroid';
// Build children array
const children = [
React.createElement(CardRenderer, {
key: "card",
spec,
data,
themeTokens: tokens,
onAction: (action, value) => {
if (action?.type === "open_url" && value) {
try { window.open(value, "_blank"); } catch {}
}
},
className: "",
}),
];
// For polaroid: just a small round indicator at bottom right
// For others: full truth bar on the side
if (isPolaroid) {
children.push(
React.createElement(
"div",
{
key: "truth-dot",
className: "sw-truth-dot",
style: {
position: 'absolute',
bottom: 6,
right: 6,
width: 18,
height: 18,
borderRadius: '50%',
background: truthColor(score),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 9,
fontWeight: 700,
color: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}
},
`${score}`
)
);
} else {
children.push(
React.createElement(
"div",
{ key: "truth-bar", className: "sw-truth-mini-bar" },
React.createElement("div", {
className: "sw-truth-mini-marker",
style: { top: `${100 - score}%` },
})
),
React.createElement(
"div",
{ key: "truth-score", className: "sw-truth-mini-score", style: { background: truthColor(score) } },
`${score}`
)
);
}
root.render(
React.createElement(
"div",
{
className: "sw-card-wrap",
style: { width: spec?.size?.w, height: spec?.size?.h },
},
...children
)
);
const rawScore = Number(
post?.truth_score ??
post?.truthScore ??
post?.truth_ai ??
post?.truthAi ??
post?.ai_score ??
post?.AIScore
);
const hasAI = Number.isFinite(
Number(post?.truth_ai ?? post?.truthAi ?? post?.ai_score ?? post?.AIScore)
);
const hasScore = Number.isFinite(rawScore) && !(rawScore === 50 && !hasAI);
if (!hasScore && post?.id) {
fetchPostTruth(post.id).then((stats) => {
if (!stats) return;
const nextScore = Number(stats.truth_score ?? stats.TruthScore);
if (!Number.isFinite(nextScore)) return;
post.truth_score = nextScore;
const markerEl = container.querySelector(".sw-truth-mini-marker");
const scoreEl = container.querySelector(".sw-truth-mini-score");
if (markerEl) markerEl.style.top = `${100 - nextScore}%`;
if (scoreEl) {
scoreEl.style.background = truthColor(nextScore);
scoreEl.textContent = `${Math.round(nextScore)}`;
}
});
}
return () => {
try { root.unmount(); } catch {}
container.__swReactRoot = null;
};
}
function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
// Extract camera ID from Quebec 511 URL like https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3978
function extractCameraId(url) {
if (!url) return null;
const match = url.match(/[?&]id=(\d+)/);
return match ? match[1] : null;
}
// Build stream proxy URL for camera
function getCameraStreamUrl(postData) {
const embedUrl = normalizeMediaUrl(postData?.embed_url || postData?.video_url || '');
const cameraId = extractCameraId(embedUrl);
if (cameraId) {
return `/live/api/stream/${cameraId}`;
}
return embedUrl;
}
function renderTagPills(tags) {
if (!Array.isArray(tags) || tags.length === 0) return "";
const uniq = [];
const seen = new Set();
for (const raw of tags) {
const t = (raw || "").toString().trim().toLowerCase();
if (!t || seen.has(t)) continue;
seen.add(t);
uniq.push(t);
if (uniq.length >= 6) break;
}
if (uniq.length === 0) return "";
return `
${uniq.map((t) => `#${escapeHtml(t)}`).join("")}
`;
}
function setModalTags(modalWrap, post) {
if (!modalWrap) return;
const tagsHtml = renderTagPills(post?.event_tags || post?.tags);
const existing = modalWrap.querySelector(".sw-modal-tags");
if (tagsHtml) {
if (existing) {
existing.outerHTML = tagsHtml;
} else {
const titleEl = modalWrap.querySelector(".sw-modal-title") || modalWrap.querySelector(".sw-cluster-title");
if (titleEl) titleEl.insertAdjacentHTML("afterend", tagsHtml);
}
} else if (existing) {
existing.remove();
}
}
function bindHeroLightbox(modalWrap) {
if (!modalWrap) return;
const img = modalWrap.querySelector(".sw-modal-hero img, .sw-cluster-hero-media img");
if (!img || img.dataset.zoomBound === "1") return;
img.dataset.zoomBound = "1";
img.style.cursor = "zoom-in";
img.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const src = (img.getAttribute("data-fullsrc") || img.getAttribute("src") || "").trim();
if (src) {
openImageLightbox(src);
}
});
}
function initModalGallery(modalWrap, gallery) {
if (!modalWrap || !Array.isArray(gallery) || gallery.length === 0) return;
const heroImg = modalWrap.querySelector(".sw-modal-hero img, .sw-cluster-hero-media img");
if (!heroImg) return;
const arrows = modalWrap.querySelectorAll(".sw-modal-hero-arrow");
let activeIndex = 0;
const indicator = modalWrap.querySelector(".sw-modal-gallery-indicator");
const currentIndicator = indicator?.querySelector(".sw-modal-gallery-current");
const totalIndicator = indicator?.querySelector(".sw-modal-gallery-total");
if (totalIndicator) {
totalIndicator.textContent = String(gallery.length);
}
const setActive = (idx) => {
if (!Number.isFinite(idx)) return;
const normalized = (idx + gallery.length) % gallery.length;
activeIndex = normalized;
heroImg.src = gallery[normalized];
heroImg.dataset.fullsrc = gallery[normalized];
heroImg.dataset.galleryIndex = String(normalized);
if (currentIndicator) {
currentIndicator.textContent = String(normalized + 1);
}
};
arrows.forEach((btn) => {
btn.addEventListener("click", (event) => {
event.stopPropagation();
const direction = btn.classList.contains("left") ? -1 : 1;
const nextIndex = (activeIndex + direction + gallery.length) % gallery.length;
setActive(nextIndex);
});
});
setActive(0);
let touchStartX = 0;
const touchThreshold = 30;
heroImg.addEventListener("touchstart", (event) => {
if (!event.touches?.length) return;
touchStartX = event.touches[0].clientX;
});
heroImg.addEventListener("touchend", (event) => {
if (!event.changedTouches?.length) return;
const dx = event.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) < touchThreshold) return;
const direction = dx > 0 ? -1 : 1;
const nextIndex = (activeIndex + direction + gallery.length) % gallery.length;
setActive(nextIndex);
});
}
function openImageLightbox(src) {
if (!src) return;
const overlay = document.createElement("div");
overlay.className = "sw-image-lightbox";
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.background = "rgba(8,10,16,0.86)";
overlay.style.zIndex = "6000"; /* Image lightbox - above post modal (5000), below auth (10000) */
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.padding = "16px";
const img = document.createElement("img");
img.src = src;
img.alt = "";
img.style.maxWidth = "96vw";
img.style.maxHeight = "96vh";
img.style.objectFit = "contain";
img.style.borderRadius = "14px";
img.style.boxShadow = "0 20px 50px rgba(0,0,0,0.45)";
overlay.appendChild(img);
const close = (opts = {}) => {
try { document.removeEventListener("keydown", onKey); } catch {}
try { window.removeEventListener("popstate", onPop); } catch {}
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
// When closing image, go back in history to return to post (not close post)
if (!opts.skipHistory && overlay.__swPushed) {
overlay.__swPushed = false;
try { history.back(); } catch {}
}
};
const onKey = (e) => {
// ESC closes image and returns to post (via history.back)
if (e.key === "Escape") close();
};
const onPop = () => close({ skipHistory: true });
overlay.addEventListener("click", close);
document.addEventListener("keydown", onKey);
window.addEventListener("popstate", onPop);
// Push history for image so back button closes image (returns to post)
try {
history.pushState({ sw: "image" }, "");
overlay.__swPushed = true;
} catch {}
document.body.appendChild(overlay);
}
function normCatLabel(post) {
const c = (post?.category || "").toString().toUpperCase();
if (c === "NEWS") return "NEWS";
if (c === "EVENT" || c === "EVENTS") return "EVENTS";
if (c === "FRIENDS") return "FRIENDS";
if (c === "MARKET") return "MARKET";
return "NEWS";
}
function categoryIconClass(post) {
const c = (post?.category || "NEWS").toString().toUpperCase();
switch (c) {
case "EVENT":
case "EVENTS":
return "fa-regular fa-calendar";
case "FRIENDS":
return "fa-solid fa-user-group";
case "MARKET":
return "fa-solid fa-store";
default:
return "fa-regular fa-newspaper";
}
}
function isClusterMainPost(post) {
const clusterPostId = Number(post?.cluster_post_id);
const postId = Number(post?.post_id || post?.id);
if (Number.isFinite(clusterPostId) && Number.isFinite(postId) && clusterPostId > 0 && postId > 0) {
return clusterPostId === postId;
}
const key = (post?.cluster_key || post?.clusterKey || "").toString().trim();
const members = Array.isArray(post?.cluster_members) ? post.cluster_members : [];
if (key && members.length > 0) return true;
const url = (post?.url || "").toString();
return url.includes("/cluster/");
}
function dispatchClusterHeatmap(post, opts = {}) {
if (!post) return;
const postId = Number(post?.id || post?.post_id || 0);
const members = Array.isArray(post?.cluster_members) ? post.cluster_members : [];
const items = Array.isArray(post?.cluster_items) ? post.cluster_items : [];
const points = items
.map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) }))
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
window.dispatchEvent(
new CustomEvent("sociowire:cluster-heatmap", {
detail: {
clusterKey: post?.cluster_key || post?.clusterKey || "",
postId,
members,
points,
items,
post,
center: { lat: Number(post?.lat), lon: Number(post?.lon) },
mode: opts.mode || "click",
skipPan: !!opts.skipPan,
},
})
);
}
function dispatchClusterHeatmapClear() {
window.dispatchEvent(new CustomEvent("sociowire:cluster-heatmap-clear"));
}
function buildClusterItemsHtml(items) {
if (!Array.isArray(items) || items.length === 0) {
return `No related reports yet.
`;
}
const rows = items.slice(0, 6).map((item) => {
const title = escapeHtml(item?.title || "Untitled");
const source = escapeHtml(item?.source || "");
const url = escapeHtml(item?.url || "");
const meta = source ? `${source}` : "";
const href = url ? ` data-related-url="${url}"` : "";
return `
`;
});
return rows.join("");
}
function heroImageForPost(post) {
const raw =
post?.image_medium ||
post?.image_large ||
post?.image_small ||
post?.image ||
post?.thumbnail_url ||
post?.media_url ||
"";
const v = normalizeImageUrl(raw);
if (v) return v;
const cat = normCatLabel(post);
return `/og/category?cat=${encodeURIComponent(cat)}`;
}
function fullImageForPost(post) {
const raw =
post?.image_large ||
post?.image_medium ||
post?.image_small ||
post?.image ||
post?.media_url ||
"";
return normalizeImageUrl(raw);
}
function collectGalleryImages(post) {
const result = [];
const seen = new Set();
const add = (value) => {
const normalized = normalizeImageUrl(value);
if (!normalized) return;
if (seen.has(normalized)) return;
seen.add(normalized);
result.push(normalized);
};
// Check both media_urls (frontend) and image_urls (backend API)
if (Array.isArray(post?.media_urls)) {
post.media_urls.forEach(add);
}
if (Array.isArray(post?.image_urls)) {
post.image_urls.forEach(add);
}
["image_large", "image_medium", "image_small", "image", "media_url"].forEach((key) => {
add(post?.[key]);
});
return result;
}
function getPostTime(post) {
const published =
post?.published_at ||
post?.publishedAt ||
post?.PublishedAt ||
"";
if (published) {
const d = new Date(String(published));
if (!Number.isNaN(d.getTime())) {
const now = Date.now();
if (d.getTime() <= now + 5 * 60 * 1000) {
return published;
}
}
}
return (
post?.created_at ||
post?.CreatedAt ||
post?.createdAt ||
published
);
}
// Known trusted sources get higher base scores
const SOURCE_REPUTATION = {
// Major news agencies
'reuters': 85, 'ap': 85, 'afp': 82,
// Government/official
'gov': 80, 'gouv': 80, 'canada.ca': 82, 'quebec.ca': 80,
// Major outlets
'bbc': 78, 'cbc': 78, 'radio-canada': 78, 'nytimes': 76, 'theguardian': 75,
'lapresse': 74, 'ledevoir': 74, 'journaldemontreal': 70,
// Tech
'techcrunch': 72, 'theverge': 70, 'arstechnica': 74,
// Social/user content (lower trust)
'twitter': 45, 'x.com': 45, 'facebook': 48, 'reddit': 50, 'tiktok': 42,
};
// Clickbait/sensational words reduce score
const CLICKBAIT_WORDS = [
'shocking', 'unbelievable', 'you won\'t believe', 'mind-blowing', 'insane',
'choquant', 'incroyable', 'hallucinant', 'fou', 'dingue',
'breaking', 'urgent', 'exclusive', 'leaked', 'secret',
'!!!', '???', 'exposed', 'révélé', 'scandale'
];
// Credibility indicators increase score
const CREDIBILITY_WORDS = [
'study', 'research', 'according to', 'report', 'official',
'étude', 'recherche', 'selon', 'rapport', 'officiel',
'confirmed', 'confirmé', 'announced', 'annoncé', 'statement'
];
function calculateInitialTruthScore(post) {
let score = 55; // Base score
let factors = 0;
// 1. Source reputation (weight: 40%)
const source = (post?.source || post?.Source || '').toLowerCase();
const domain = (post?.domain || post?.url || '').toLowerCase();
for (const [key, rep] of Object.entries(SOURCE_REPUTATION)) {
if (source.includes(key) || domain.includes(key)) {
score = rep;
factors++;
break;
}
}
// 2. Author info (weight: 15%)
const author = post?.author || post?.Author || '';
if (author) {
score += 3; // Has attributed author
factors++;
// Verified authors get bonus
if (post?.author_verified || post?.verified) {
score += 5;
}
}
// 3. Content analysis (weight: 25%)
const title = (post?.title || post?.headline || '').toLowerCase();
const content = (post?.content || post?.description || post?.summary || '').toLowerCase();
const text = title + ' ' + content;
// Check for clickbait
let clickbaitCount = 0;
for (const word of CLICKBAIT_WORDS) {
if (text.includes(word.toLowerCase())) {
clickbaitCount++;
}
}
if (clickbaitCount > 0) {
score -= Math.min(clickbaitCount * 4, 15); // Max -15 for clickbait
factors++;
}
// Check for credibility indicators
let credibilityCount = 0;
for (const word of CREDIBILITY_WORDS) {
if (text.includes(word.toLowerCase())) {
credibilityCount++;
}
}
if (credibilityCount > 0) {
score += Math.min(credibilityCount * 3, 10); // Max +10 for credibility
factors++;
}
// 4. Content quality indicators (weight: 20%)
// Longer content tends to be more detailed/trustworthy
const contentLength = (content || '').length;
if (contentLength > 500) score += 3;
else if (contentLength > 200) score += 1;
else if (contentLength < 50) score -= 3;
// Has image/media (more complete reporting)
if (post?.image || post?.media_url || post?.image_url) {
score += 2;
}
// Has URL to original source
if (post?.url || post?.link) {
score += 2;
}
// 5. Engagement signals (if available)
const likes = Number(post?.like_count || post?.likes || 0);
const shares = Number(post?.share_count || post?.shares || 0);
const views = Number(post?.view_count || post?.views || 0);
// High engagement can be positive or negative - slight positive bias for established content
if (views > 1000 && likes > 50) {
score += 2;
}
// Clamp to valid range
return clamp(Math.round(score), 25, 85); // Never go below 25 or above 85 without AI verification
}
function truthScore(post) {
// First check if we have an AI-verified score
const raw = Number(
post?.truth_score ??
post?.truthScore ??
post?.truth_ai ??
post?.truthAi ??
post?.ai_score ??
post?.AIScore
);
if (Number.isFinite(raw)) {
return clamp(Math.round(raw), 0, 100);
}
// Calculate initial score from available data
return calculateInitialTruthScore(post);
}
function truthColor(score) {
const s = clamp(score, 0, 100);
const hue = Math.round((s / 100) * 120);
return `hsl(${hue} 80% 42%)`;
}
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function normalizeImageUrl(raw) {
const value = (raw || "").toString().trim();
if (!value) return "";
if (value.startsWith("//")) return `https:${value}`;
if (value.startsWith("http://")) return `https://${value.slice(7)}`;
return value;
}
function normalizeMediaUrl(raw) {
const value = (raw || "").toString().trim();
if (!value) return "";
if (value.startsWith("//")) return `https:${value}`;
if (value.startsWith("http://")) return `https://${value.slice(7)}`;
return value;
}
function closeCenteredOverlay(map, opts = {}) {
try {
const ov = map?.__swCenteredOverlay;
if (!ov) return;
if (!opts.force && ov.openedAt && Date.now() - ov.openedAt < 700) return;
if (ov.closing) return;
ov.closing = true;
const skipHistory = !!opts.skipHistory || opts.reason === "switch";
if (ov.onKey) window.removeEventListener("keydown", ov.onKey);
if (ov.onAuth) window.removeEventListener("sociowire:auth-changed", ov.onAuth);
if (ov.onPop) window.removeEventListener("popstate", ov.onPop);
// animate out
try {
const mw = ov.modalWrapRef;
if (mw) {
mw.style.opacity = "0";
mw.style.transform = "scale(0.96)";
}
if (ov.backdrop) ov.backdrop.style.opacity = "0";
} catch {}
try { if (ov.unmountFull) ov.unmountFull(); } catch {}
try { if (ov.unmountMini) ov.unmountMini(); } catch {}
try {
const b = ov.backdrop;
if (b) setTimeout(() => { try { b.remove(); } catch {} }, 300);
} catch {}
map.__swCenteredOverlay = null;
try {
const st = ov.interactionState || {};
if (map) {
if (st.dragPan) map.dragPan?.enable?.();
if (st.scrollZoom) map.scrollZoom?.enable?.();
if (st.doubleClickZoom) map.doubleClickZoom?.enable?.();
if (st.touchZoomRotate) map.touchZoomRotate?.enable?.();
}
} catch {}
if (!skipHistory && ov.historyPushed && !ov.historyClosed) {
ov.historyClosed = true;
try { window.history.back(); } catch {}
}
if (map) {
try {
map.__swForceFetchAt = Date.now();
map.fire("moveend");
} catch {}
}
} catch {}
}
function openCameraOverlay({ map, post, theme, fullScreen, interactionState }) {
const postData = post || {};
const title = postData.title || "Live Camera";
const region = postData.region || "";
const embedUrl = normalizeMediaUrl(postData.embed_url || "");
const videoUrl = normalizeMediaUrl(postData.video_url || "");
const lat = postData.lat;
const lon = postData.lon;
const postID = postData.id || 0;
trackEvent("camera_open", {
post_id: postID,
source: postData.source || "",
region: region,
});
const backdrop = document.createElement("div");
backdrop.className = "sw-centered-backdrop sw-camera-backdrop";
if (fullScreen) backdrop.classList.add("sw-modal-fullscreen");
backdrop.style.cssText = `
position: fixed;
inset: 0;
z-index: 5000;
background: rgba(0,0,0,0.85);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
`;
const modalWrap = document.createElement("div");
modalWrap.className = "sw-camera-modal";
modalWrap.style.cssText = `
width: 100%;
max-width: 900px;
max-height: 90vh;
background: rgba(15, 23, 42, 0.98);
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.6);
box-shadow: 0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(56,189,248,0.3);
overflow: hidden;
display: flex;
flex-direction: column;
`;
const coordsInfo = (lat && lon) ? `(${Number(lat).toFixed(4)}, ${Number(lon).toFixed(4)})` : "";
modalWrap.innerHTML = `
Live Traffic Camera
Click below to view the live stream
${(embedUrl || videoUrl)
? `
Open Live Stream
`
: `
Stream URL unavailable
`
}
${escapeHtml(region)}
${coordsInfo ? `${coordsInfo}` : ""}
`;
const closeModal = () => {
try {
backdrop.remove();
map.__swCenteredOverlay = null;
if (interactionState.dragPan) map.dragPan?.enable?.();
if (interactionState.scrollZoom) map.scrollZoom?.enable?.();
if (interactionState.doubleClickZoom) map.doubleClickZoom?.enable?.();
if (interactionState.touchZoomRotate) map.touchZoomRotate?.enable?.();
} catch {}
};
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeModal();
});
const closeBtn = modalWrap.querySelector(".sw-camera-close");
if (closeBtn) closeBtn.addEventListener("click", closeModal);
const shareBtn = modalWrap.querySelector(".sw-camera-share");
if (shareBtn) {
shareBtn.addEventListener("click", () => {
const shareUrl = embedUrl || videoUrl || "";
if (navigator.share) {
navigator.share({ title, url: shareUrl }).catch(() => {});
} else if (navigator.clipboard && shareUrl) {
navigator.clipboard.writeText(shareUrl);
}
});
}
document.addEventListener("keydown", function escHandler(e) {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", escHandler);
}
});
backdrop.appendChild(modalWrap);
document.body.appendChild(backdrop);
map.__swCenteredOverlay = { el: backdrop, postId: postID };
}
export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
if (!map) return;
if (post?.content_type === "live") {
try {
window.dispatchEvent(new CustomEvent("livekit-open", { detail: post }));
} catch {}
return;
}
// Weather posts open in a dedicated WeatherModal
if (post?.content_type === "weather") {
try {
window.dispatchEvent(new CustomEvent("weather-open", { detail: post }));
} catch {}
return;
}
closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true });
const interactionState = {
dragPan: map.dragPan && map.dragPan.isEnabled ? map.dragPan.isEnabled() : null,
scrollZoom: map.scrollZoom && map.scrollZoom.isEnabled ? map.scrollZoom.isEnabled() : null,
doubleClickZoom: map.doubleClickZoom && map.doubleClickZoom.isEnabled ? map.doubleClickZoom.isEnabled() : null,
touchZoomRotate: map.touchZoomRotate && map.touchZoomRotate.isEnabled ? map.touchZoomRotate.isEnabled() : null,
};
try { if (map.dragPan) map.dragPan.disable(); } catch {}
try { if (map.scrollZoom) map.scrollZoom.disable(); } catch {}
try { if (map.doubleClickZoom) map.doubleClickZoom.disable(); } catch {}
try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {}
let postData = { ...(post || {}) };
// Debug: log post data to see if content_type is present
console.log('[openCenteredOverlay] postData:', {
id: postData.id,
content_type: postData.content_type,
source: postData.source,
title: postData.title?.substring(0, 30)
});
// Camera and video posts use the regular modal with video content
const isCamera = postData.content_type === 'camera';
const isVideo = postData.content_type === 'video';
if (isCamera) {
console.log('[openCenteredOverlay] Opening camera as regular post with video');
}
if (isVideo) {
console.log('[openCenteredOverlay] Opening saved video post:', postData.video_url);
}
const data = adaptPostToTemplateData(postData, { size: "full" });
const cat = normCatLabel(postData);
const templateKey = getTemplateKeyForPost(postData);
const relTime = formatRelativeTime(getPostTime(postData));
const author = (postData?.author || postData?.Author || "").toString().trim();
// Check if current user is the author
let currentUsername = "";
let authToken = "";
try {
const auth = loadAuth();
authToken = auth?.access_token || "";
// Username comes from JWT token, not stored directly
currentUsername = guessUsernameFromToken(authToken) || "";
} catch {}
const isAuthor = !!(currentUsername && author && currentUsername.toLowerCase() === author.toLowerCase());
const sub = (postData?.sub_category || postData?.subCategory || "").toString().trim();
const metaLeft = author ? `by ${author}` : "";
const metaRight = sub ? sub : "";
const heroGallery = collectGalleryImages(postData);
const img = heroGallery[0] || heroImageForPost(postData);
const heroFullSrc = heroGallery[0] || fullImageForPost(postData);
const heroHasGallery = heroGallery.length > 1;
const truth = truthScore(postData);
const url = data?.url || "";
const postID = postData?.id || postData?.ID || postData?.post_id || 0;
const viewCount = readCount(postData, ["view_count", "views", "viewCount"]);
const likeCount = readCount(postData, ["like_count", "likes", "likeCount"]);
const shareCount = readCount(postData, ["share_count", "shares", "shareCount"]);
const commentCount = readCount(postData, ["comment_count", "comments", "commentCount"]);
const isCluster = isClusterMainPost(postData);
const clusterItems = Array.isArray(postData?.cluster_items) ? postData.cluster_items : [];
const clusterCount = Array.isArray(postData?.cluster_members) ? postData.cluster_members.length : 0;
const tagPills = renderTagPills(postData?.event_tags || postData?.tags);
const heatmapPill = HEATMAP_ENABLED
? `🔥 Heat map
`
: "";
trackEvent("post_open", {
post_id: postID,
source: postData?.source || postData?.Source || "",
category: postData?.category || postData?.Category || "",
context: "map",
fullscreen: !!fullScreen,
});
// Backdrop catches outside click to close
const backdrop = document.createElement("div");
try { backdrop.classList.add("sw-centered-backdrop","sw-enter"); } catch {}
backdrop.classList.add("sw-centered-backdrop");
backdrop.className = "sw-centered-backdrop";
if (fullScreen) backdrop.classList.add("sw-modal-fullscreen");
backdrop.style.position = "fixed";
backdrop.style.inset = "0";
backdrop.style.zIndex = "5000";
backdrop.style.background = "rgba(0,0,0,0.08)";
backdrop.style.backdropFilter = "blur(1px)";
backdrop.style.display = "flex";
backdrop.style.alignItems = "center";
backdrop.style.justifyContent = "center";
backdrop.style.padding = "12px";
backdrop.style.pointerEvents = "auto";
// Wrapper uses existing styling hooks
const modalWrap = document.createElement("div");
modalWrap.classList.add("sw-centered-modal");
modalWrap.className = "post-pin--expanded sw-centered-modal";
if (fullScreen) modalWrap.classList.add("sw-modal-fullscreen");
modalWrap.style.pointerEvents = "auto";
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
if (isCluster) {
modalWrap.innerHTML = `
${escapeHtml(postData?.cluster_title || postData?.title || "Untitled")}
${tagPills}
Related reports ${clusterCount ? `(${clusterCount})` : ""}
${buildClusterItemsHtml(clusterItems)}
Generated news update
${escapeHtml(data?.summary || postData?.snippet || "")}
👁 ${formatCount(viewCount)}
❤️ ${formatCount(likeCount)}
🔁 ${formatCount(shareCount)}
💬 ${formatCount(commentCount)}
${heatmapPill}
`;
} else {
modalWrap.innerHTML = `
${isCamera ? `
LIVE CAMERA
` : `
${escapeHtml(cat)}
`}
${!isCamera ? `
` : ''}
${isCamera ? `
` : isVideo ? `
` : img
? `
})
${heroHasGallery ? `
1/${heroGallery.length}
` : ''}`
: `
${escapeHtml(normCatLabel(postData))}
`
}
${escapeHtml(data?.headline || postData?.title || "Untitled")}
${tagPills}
${escapeHtml(data?.summary || postData?.snippet || "")}
👁 ${formatCount(viewCount)}
❤️ ${formatCount(likeCount)}
🔁 ${formatCount(shareCount)}
💬 ${formatCount(commentCount)}
${heatmapPill}
${isAuthor ? `
Are you sure you want to delete this post?
` : ''}
`;
}
bindHeroLightbox(modalWrap);
if (heroHasGallery) {
initModalGallery(modalWrap, heroGallery);
}
backdrop.appendChild(modalWrap);
document.body.appendChild(backdrop);
hydrateAvatars(modalWrap);
const relatedLinks = modalWrap.querySelectorAll("[data-related-url]");
relatedLinks.forEach((el) => {
el.addEventListener("click", () => {
const target = el.getAttribute("data-related-url");
if (!target) return;
try {
window.open(target, "_blank", "noopener");
} catch {
window.location.href = target;
}
});
});
// Animate in using CSS classes only (no inline transitions that persist)
requestAnimationFrame(() => {
backdrop.classList.add("sw-open");
modalWrap.classList.add("sw-open");
// After animation completes, remove any inline transition to prevent jumps
setTimeout(() => {
try {
modalWrap.style.transition = "none";
backdrop.style.transition = "none";
} catch {}
}, 350);
});
// outside click closes (ignore drag/scroll on map)
let backdropDown = null;
const trackDown = (e) => {
backdropDown = {
x: e.clientX || 0,
y: e.clientY || 0,
t: Date.now(),
};
};
const shouldCloseBackdrop = (e) => {
if (map?.__swCenteredOverlay?.openedAt) {
if (Date.now() - map.__swCenteredOverlay.openedAt < 350) return false;
}
if (!backdropDown) return true;
const dx = (e.clientX || 0) - backdropDown.x;
const dy = (e.clientY || 0) - backdropDown.y;
const dist = Math.hypot(dx, dy);
const dt = Date.now() - backdropDown.t;
return dist < 8 && dt < 700;
};
backdrop.addEventListener("pointerdown", trackDown);
backdrop.addEventListener("mousedown", trackDown);
backdrop.addEventListener("touchstart", (e) => {
const touch = e.touches && e.touches[0];
if (!touch) return;
trackDown({ clientX: touch.clientX, clientY: touch.clientY });
}, { passive: true });
backdrop.addEventListener("click", (e) => {
if (!shouldCloseBackdrop(e)) return;
closeCenteredOverlay(map, { force: true });
});
// inside click doesn't close
const stopPropagation = (e) => e.stopPropagation();
modalWrap.addEventListener("click", stopPropagation);
modalWrap.addEventListener("pointerdown", stopPropagation);
modalWrap.addEventListener("mousedown", stopPropagation);
modalWrap.addEventListener("touchstart", stopPropagation, { passive: true });
// close button
const xbtn = modalWrap.querySelector(".sw-modal-x");
if (xbtn) xbtn.addEventListener("click", () => closeCenteredOverlay(map, { force: true }));
// ESC closes
const onKey = (e) => {
if (e.key === "Escape") closeCenteredOverlay(map, { force: true });
};
window.addEventListener("keydown", onKey);
// Back button support WITHOUT changing URL
// We push a fake history state so back button closes the modal
let onPop = null;
let historyPushed = false;
try {
// Only push history state without changing URL
window.history.pushState({ swModal: true, postId: postID }, "");
historyPushed = true;
onPop = (e) => {
// Check if we're returning to the modal state (from image) or actually closing
const state = e.state || window.history.state;
// If state still has swModal=true, we're just returning from image to post
// Don't close the post modal
if (state && state.swModal === true && state.postId === postID) {
return; // Stay in post modal
}
// Otherwise, we're closing the modal
const ov = map.__swCenteredOverlay;
if (ov && ov.postId === postID) {
ov.historyClosed = true;
closeCenteredOverlay(map, { skipHistory: true, force: true });
}
};
window.addEventListener("popstate", onPop);
} catch {}
// CTA open url
const cta = modalWrap.querySelector(".sw-cta-primary");
if (cta) {
cta.addEventListener("click", () => {
const u = cta.getAttribute("data-url") || "";
if (!u) return;
try { window.open(u, "_blank", "noopener,noreferrer"); } catch {}
});
}
if (postID > 0 && !modalWrap.__swViewed) {
modalWrap.__swViewed = true;
recordPostView(postID).then((res) => {
const counts = res?.counts;
if (counts) {
updateStat(modalWrap, "views", counts.view_count);
updateStat(modalWrap, "likes", counts.like_count);
updateStat(modalWrap, "shares", counts.share_count);
updateStat(modalWrap, "comments", counts.comment_count);
}
});
}
let liked = false;
const likeBtn = modalWrap.querySelector('[data-action="like"]');
const commentInput = modalWrap.querySelector(".sw-livechat-input");
const commentBtn = modalWrap.querySelector(".sw-livechat-post");
const applyAuthUI = () => {
const authed = isAuthed();
if (likeBtn) {
likeBtn.disabled = false;
likeBtn.textContent = authed && liked ? "Liked" : "Like";
}
if (commentInput) {
commentInput.readOnly = !authed;
commentInput.placeholder = authed ? "Write a comment…" : "Sign in to comment";
}
};
// Fetch initial like state
if (likeBtn && postID > 0 && isAuthed()) {
fetchPostLikeState(postID).then((res) => {
if (res?.ok) {
liked = !!res.liked;
likeBtn.textContent = liked ? "Liked" : "Like";
}
});
}
if (likeBtn && postID > 0) {
likeBtn.addEventListener("click", async () => {
if (!isAuthed()) {
requestAuth("Please sign in or create a free account to like this post.");
return;
}
trackEvent("post_like_click", { post_id: postID, context: "map" });
likeBtn.disabled = true;
const res = await togglePostLike(postID, !liked);
if (res?.ok) {
liked = !!res.liked;
likeBtn.textContent = liked ? "Liked" : "Like";
const counts = res?.counts;
if (counts) {
updateStat(modalWrap, "likes", counts.like_count);
updateStat(modalWrap, "shares", counts.share_count);
updateStat(modalWrap, "comments", counts.comment_count);
updateStat(modalWrap, "views", counts.view_count);
}
}
likeBtn.disabled = false;
});
}
const shareBtn = modalWrap.querySelector('[data-action="share"]');
if (shareBtn && postID > 0) {
shareBtn.addEventListener("click", async () => {
const shareUrl = `${window.location.origin}/p/${postID}`;
const shareTitle = postData?.title || postData?.Title || "SocioWire";
trackEvent("post_share_click", { post_id: postID, context: "map" });
try {
if (navigator.share) {
await navigator.share({ title: shareTitle, url: shareUrl });
} else if (navigator.clipboard) {
await navigator.clipboard.writeText(shareUrl);
}
} catch {}
const res = await recordPostShare(postID);
const counts = res?.counts;
if (counts) {
updateStat(modalWrap, "views", counts.view_count);
updateStat(modalWrap, "shares", counts.share_count);
}
});
}
// Truth vote handlers (thumbs up/down in truth badge)
const truthBadgeNew = modalWrap.querySelector('.sw-truth-badge');
if (truthBadgeNew && postID > 0) {
const downvoteBtn = truthBadgeNew.querySelector('.sw-truth-downvote');
const upvoteBtn = truthBadgeNew.querySelector('.sw-truth-upvote');
const scoreDisplay = truthBadgeNew.querySelector('.sw-truth-badge-score');
let currentVote = null;
let isVoting = false;
const updateTruthUI = (newScore) => {
if (scoreDisplay && Number.isFinite(newScore)) {
const score = Math.round(newScore);
scoreDisplay.textContent = String(score);
scoreDisplay.style.background = truthColor(score);
}
};
const doTruthVote = async (vote) => {
if (!isAuthed()) {
requestAuth("Please sign in or create a free account to vote on truth.");
return;
}
if (isVoting) return;
isVoting = true;
// Visual feedback
if (vote === 'true' && upvoteBtn) {
upvoteBtn.style.transform = 'scale(1.2)';
upvoteBtn.style.background = 'rgba(52, 211, 153, 0.5)';
} else if (vote === 'false' && downvoteBtn) {
downvoteBtn.style.transform = 'scale(1.2)';
downvoteBtn.style.background = 'rgba(239, 68, 68, 0.5)';
}
try {
trackEvent("truth_vote_click", { post_id: postID, vote, context: "map" });
const stats = await votePostTruth(postID, vote);
if (stats) {
currentVote = vote;
if (stats.truth_score !== null && Number.isFinite(stats.truth_score)) {
updateTruthUI(stats.truth_score);
}
}
} catch (err) {
console.error('Truth vote error:', err);
}
// Reset button visuals
setTimeout(() => {
if (upvoteBtn) {
upvoteBtn.style.transform = '';
upvoteBtn.style.background = currentVote === 'true' ? 'rgba(52, 211, 153, 0.4)' : 'rgba(52, 211, 153, 0.15)';
}
if (downvoteBtn) {
downvoteBtn.style.transform = '';
downvoteBtn.style.background = currentVote === 'false' ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.15)';
}
isVoting = false;
}, 200);
};
if (downvoteBtn) {
downvoteBtn.addEventListener('click', (e) => {
e.stopPropagation();
doTruthVote('false');
});
}
if (upvoteBtn) {
upvoteBtn.addEventListener('click', (e) => {
e.stopPropagation();
doTruthVote('true');
});
}
}
// Author action handlers (edit, visibility, delete)
if (isAuthor && postID > 0) {
const editBtn = modalWrap.querySelector('[data-action="edit-post"]');
const visibilityBtn = modalWrap.querySelector('[data-action="visibility"]');
const deleteBtn = modalWrap.querySelector('[data-action="delete-post"]');
const editPanel = modalWrap.querySelector('.sw-modal-edit-panel');
const visibilityPanel = modalWrap.querySelector('.sw-modal-visibility-panel');
const deleteConfirm = modalWrap.querySelector('.sw-modal-delete-confirm');
const editTitleInput = modalWrap.querySelector('.sw-edit-title');
const editSnippetInput = modalWrap.querySelector('.sw-edit-snippet');
const visibilitySelect = modalWrap.querySelector('.sw-visibility-select');
const visibilityGroup = modalWrap.querySelector('.sw-visibility-group');
const visibilityUsers = modalWrap.querySelector('.sw-visibility-users');
const visibilityApplyImages = modalWrap.querySelector('.sw-visibility-apply-images');
const hideAllPanels = () => {
if (editPanel) editPanel.style.display = 'none';
if (visibilityPanel) visibilityPanel.style.display = 'none';
if (deleteConfirm) deleteConfirm.style.display = 'none';
};
// Edit button
if (editBtn && editPanel) {
editBtn.addEventListener('click', () => {
hideAllPanels();
editTitleInput.value = postData?.title || '';
editSnippetInput.value = postData?.snippet || '';
editPanel.style.display = 'block';
});
}
// Save edit
const saveEditBtn = modalWrap.querySelector('[data-action="save-edit"]');
if (saveEditBtn) {
saveEditBtn.addEventListener('click', async () => {
saveEditBtn.disabled = true;
saveEditBtn.textContent = 'Saving...';
try {
const res = await editPost(postID, {
title: editTitleInput.value.trim(),
snippet: editSnippetInput.value.trim(),
}, authToken);
if (res?.ok) {
// Update local postData
postData.title = editTitleInput.value.trim();
postData.snippet = editSnippetInput.value.trim();
// Update modal display
const titleEl = modalWrap.querySelector('.sw-modal-title');
const summaryEl = modalWrap.querySelector('.sw-modal-summary');
if (titleEl) titleEl.textContent = postData.title;
if (summaryEl) summaryEl.textContent = postData.snippet;
hideAllPanels();
// Dispatch update event
try {
window.dispatchEvent(new CustomEvent('live-post-updated', { detail: res }));
} catch {}
}
} catch (err) {
console.error('Edit post error:', err);
}
saveEditBtn.disabled = false;
saveEditBtn.textContent = 'Save';
});
}
// Cancel edit
const cancelEditBtn = modalWrap.querySelector('[data-action="cancel-edit"]');
if (cancelEditBtn) {
cancelEditBtn.addEventListener('click', hideAllPanels);
}
// Visibility button
if (visibilityBtn && visibilityPanel) {
visibilityBtn.addEventListener('click', () => {
hideAllPanels();
visibilitySelect.value = postData?.visibility || 'public';
visibilityGroup.value = postData?.group_id || '';
visibilityUsers.value = '';
// Show/hide conditional inputs
visibilityGroup.style.display = visibilitySelect.value === 'group' ? 'block' : 'none';
visibilityUsers.style.display = visibilitySelect.value === 'custom' ? 'block' : 'none';
visibilityPanel.style.display = 'block';
});
}
// Visibility select change
if (visibilitySelect) {
visibilitySelect.addEventListener('change', () => {
visibilityGroup.style.display = visibilitySelect.value === 'group' ? 'block' : 'none';
visibilityUsers.style.display = visibilitySelect.value === 'custom' ? 'block' : 'none';
});
}
// Save visibility
const saveVisibilityBtn = modalWrap.querySelector('[data-action="save-visibility"]');
if (saveVisibilityBtn) {
saveVisibilityBtn.addEventListener('click', async () => {
saveVisibilityBtn.disabled = true;
saveVisibilityBtn.textContent = 'Saving...';
try {
const payload = {
visibility: visibilitySelect.value,
group_id: visibilityGroup.value.trim(),
users: visibilityUsers.value.split(',').map(u => u.trim()).filter(Boolean),
};
if (visibilityApplyImages?.checked) {
payload.image_visibility = visibilitySelect.value;
payload.image_group_id = visibilityGroup.value.trim();
payload.image_users = payload.users;
}
await updatePostVisibility(postID, payload, authToken);
postData.visibility = visibilitySelect.value;
hideAllPanels();
} catch (err) {
console.error('Update visibility error:', err);
}
saveVisibilityBtn.disabled = false;
saveVisibilityBtn.textContent = 'Save';
});
}
// Cancel visibility
const cancelVisibilityBtn = modalWrap.querySelector('[data-action="cancel-visibility"]');
if (cancelVisibilityBtn) {
cancelVisibilityBtn.addEventListener('click', hideAllPanels);
}
// Delete button
if (deleteBtn && deleteConfirm) {
deleteBtn.addEventListener('click', () => {
hideAllPanels();
deleteConfirm.style.display = 'block';
});
}
// Confirm delete
const confirmDeleteBtn = modalWrap.querySelector('[data-action="confirm-delete"]');
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', async () => {
if (!postID || postID <= 0) {
alert('Cannot delete: invalid post ID');
return;
}
confirmDeleteBtn.disabled = true;
confirmDeleteBtn.textContent = 'Deleting...';
try {
const res = await deletePost(postID, authToken);
if (res?.ok) {
// Force remove backdrop and close modal
try {
const backdrop = document.querySelector('.sw-centered-backdrop');
if (backdrop) backdrop.remove();
} catch {}
closeCenteredOverlay(map, { force: true });
// Then dispatch delete event
try {
window.dispatchEvent(new CustomEvent('live-post-deleted', { detail: { post_id: postID } }));
} catch {}
} else {
alert(res?.error || 'Failed to delete post. Please try again.');
confirmDeleteBtn.disabled = false;
confirmDeleteBtn.textContent = 'Yes, Delete';
}
} catch (err) {
alert('Error deleting post: ' + (err?.message || 'Unknown error'));
confirmDeleteBtn.disabled = false;
confirmDeleteBtn.textContent = 'Yes, Delete';
}
});
}
// Cancel delete
const cancelDeleteBtn = modalWrap.querySelector('[data-action="cancel-delete"]');
if (cancelDeleteBtn) {
cancelDeleteBtn.addEventListener('click', hideAllPanels);
}
}
const heatBtn = modalWrap.querySelector('[data-action="cluster-heatmap"]');
if (HEATMAP_ENABLED && heatBtn && isCluster) {
heatBtn.addEventListener("click", () => {
closeCenteredOverlay(map, { force: true });
dispatchClusterHeatmap(postData, { mode: "click" });
});
}
// Only refetch if initial data is missing critical fields
const hasCompleteData = postData?.title && (postData?.published_at || postData?.created_at);
if (postID > 0 && !hasCompleteData) {
fetchPostById(postID).then((fresh) => {
if (!fresh) return;
postData = { ...postData, ...fresh };
applyPostDetails(modalWrap, postData);
refreshMarkerFromPost(map, postID, postData);
});
}
const commentsBody = modalWrap.querySelector(".sw-livechat-body");
if (commentsBody && postID > 0) {
fetchPostComments(postID, 20).then((items) => renderComments(commentsBody, items));
}
if (commentBtn && commentInput && postID > 0) {
commentBtn.addEventListener("click", async () => {
if (!isAuthed()) {
requestAuth("Please sign in or create a free account to comment.");
return;
}
const body = (commentInput.value || "").trim();
if (!body) return;
trackEvent("post_comment_submit", { post_id: postID, context: "map" });
commentBtn.disabled = true;
const res = await createPostComment(postID, body);
if (res?.ok) {
commentInput.value = "";
const existing = await fetchPostComments(postID, 20);
renderComments(commentsBody, existing);
const counts = res?.counts;
if (counts) {
updateStat(modalWrap, "comments", counts.comment_count);
updateStat(modalWrap, "likes", counts.like_count);
updateStat(modalWrap, "shares", counts.share_count);
updateStat(modalWrap, "views", counts.view_count);
}
}
commentBtn.disabled = false;
});
commentInput.addEventListener("focus", () => {
if (!isAuthed()) {
requestAuth("Please sign in or create a free account to comment.");
}
});
}
if (postID > 0 && isAuthed()) {
fetchPostLikeState(postID).then((res) => {
if (res?.ok && res.liked) {
liked = true;
if (likeBtn) likeBtn.textContent = "Liked";
}
});
}
const truthBadge = modalWrap.querySelector(".sw-truth-badge");
if (truthBadge && postID > 0 && !truthBadge.hasAttribute("data-vote-bound")) {
truthBadge.setAttribute("data-vote-bound", "1");
let currentVote = null;
let isVoting = false;
const doVote = async (vote) => {
if (!isAuthed()) {
requestAuth("Please sign in or create a free account to vote on truth.");
return;
}
if (currentVote === vote || isVoting) return;
isVoting = true;
truthBadge.style.opacity = "0.5";
try {
const stats = await votePostTruth(postID, vote);
if (stats) {
currentVote = vote;
// Update badge score display
const scoreEl = truthBadge.querySelector(".sw-truth-badge-score");
if (scoreEl && stats.truth_score !== null && Number.isFinite(stats.truth_score)) {
const newScore = Math.round(stats.truth_score);
scoreEl.textContent = String(newScore);
scoreEl.style.background = truthColor(newScore);
}
}
} catch {}
isVoting = false;
truthBadge.style.opacity = "1";
};
// Click on badge cycles vote: none -> true -> false -> none
truthBadge.style.cursor = "pointer";
truthBadge.addEventListener("click", (e) => {
e.stopPropagation();
if (currentVote === null) doVote("true");
else if (currentVote === "true") doVote("false");
else doVote("true"); // Toggle back
});
// Fetch initial vote state
if (isAuthed()) {
fetchPostTruth(postID).then((stats) => {
if (stats?.user_vote || stats?.UserVote) {
currentVote = (stats.user_vote || stats.UserVote).toString();
}
}).catch(() => {});
}
}
applyAuthUI();
const onAuth = () => {
if (postID > 0 && isAuthed()) {
fetchPostLikeState(postID).then((res) => {
if (res?.ok && res.liked) {
liked = true;
} else {
liked = false;
}
applyAuthUI();
});
} else {
liked = false;
applyAuthUI();
}
};
window.addEventListener("sociowire:auth-changed", onAuth);
let unmountFull = null;
map.__swCenteredOverlay = {
postId: postData?.id,
modalWrapRef: modalWrap,
backdrop,
interactionState,
onKey,
onPop,
onAuth,
unmountFull,
unmountMini: null,
historyPushed,
historyClosed: false,
openedAt: Date.now(),
};
}
function hashPostId(id) {
// Simple hash function for stable offset calculation
const str = String(id);
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
}
function calculateSmartOffset(post, markersRef, map) {
if (!map || !markersRef.current) return { x: 0, y: 0 };
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) return { x: 0, y: 0 };
// Si le post est très récent (< 10 secondes), offset vers le HAUT pour compenser l'anchor bottom
const createdAt = post?.created_at || post?.CreatedAt || post?.createdAt;
if (createdAt) {
const ageSeconds = (Date.now() - new Date(createdAt).getTime()) / 1000;
if (ageSeconds < 10) {
return { x: 0, y: -110 }; // Offset vers le haut (négatif = monte)
}
}
const currentPoint = map.project([lon, lat]);
const PROXIMITY_THRESHOLD = 100; // pixels - larger to detect more overlaps
// Check distance in geographic coordinates (more stable across zoom levels)
const nearbyCount = markersRef.current.filter(marker => {
if (!marker.post || marker.post.id === post.id) return false;
const mLat = typeof marker.post.lat === "number" ? marker.post.lat : marker.post.latitude;
const mLon = typeof marker.post.lon === "number" ? marker.post.lon : marker.post.lng;
if (mLat == null || mLon == null) return false;
// Geographic distance (rough approximation)
const latDiff = Math.abs(lat - mLat);
const lonDiff = Math.abs(lon - mLon);
const geoDist = Math.sqrt(latDiff * latDiff + lonDiff * lonDiff);
// Small threshold for geographic clustering (~100m at equator)
return geoDist < 0.001;
}).length;
if (nearbyCount === 0) return { x: 0, y: 0 };
// Use deterministic hash-based offset so it's stable across zoom/pan
const hash = hashPostId(post.id);
const angle = (hash % 8) * 45 * (Math.PI / 180); // 8 positions, 45° apart
const offsetMagnitude = 25 + ((hash % 3) * 15); // 25, 40, or 55 pixels
return {
x: Math.cos(angle) * offsetMagnitude,
y: Math.sin(angle) * offsetMagnitude
};
}
export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
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) return;
const lngLat = [lon, lat];
// Calculate smart offset for nearby markers
// DISABLED: Using geographic coordinate variation instead of pixel offset
// const offset = calculateSmartOffset(post, markersRef, map);
const offset = { x: 0, y: 0 };
const root = document.createElement("div");
root.classList.add("sw-appear");
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
root.className = "post-pin post-pin--compact";
root.style.zIndex = "100";
root.__post = post;
function unmountIfAny() {
if (root.__swUnmount) {
try { root.__swUnmount(); } catch {}
root.__swUnmount = null;
}
}
function renderCompact() {
if (!root.__swCompactReady) {
root.className = "post-pin post-pin--compact sw-appear sw-appear-in";
root.style.zIndex = "100";
// Add connecting line if offset is significant
// DISABLED: No longer using pixel offsets
const hasOffset = false;
const lineHTML = '';
root.innerHTML = `
${lineHTML}
`;
root.__swCompactReady = true;
}
const wrap = root.querySelector(".sw-template-mini-wrap");
if (wrap) {
const activePost = root.__post || post;
const spec = getTemplateSpecForPost(activePost, "mini");
const data = adaptPostToTemplateData(activePost, { size: "mini" });
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
root.__lastImage = data?.image || "";
root.__lastTitle = data?.headline || "";
root.__swUnmount = mountCard(wrap, spec, data, theme, activePost);
}
clearOcclusion(markersRef);
}
renderCompact();
root.__renderCompact = renderCompact;
// Bring marker to front on hover so card is above other markers (compact cards = 200)
root.addEventListener("mouseenter", () => {
root.style.zIndex = "200";
});
root.addEventListener("mouseleave", () => {
root.style.zIndex = "100";
});
if (HEATMAP_ENABLED && isClusterMainPost(post)) {
const handleEnter = () => {
dispatchClusterHeatmap(root.__post || post, { mode: "hover", skipPan: true });
};
const handleLeave = () => {
dispatchClusterHeatmapClear();
};
root.addEventListener("mouseenter", handleEnter);
root.addEventListener("mouseleave", handleLeave);
root.__swClusterHover = { handleEnter, handleLeave };
}
const marker = new maplibregl.Marker({
element: root,
anchor: "bottom",
offset: [offset.x, offset.y]
})
.setLngLat(lngLat)
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post, type: "full" });
root.addEventListener("click", (ev) => {
ev.stopPropagation();
// Use root.__post to get the most up-to-date post data (may have been refreshed)
const currentPost = root.__post || post;
const existing = map.__swCenteredOverlay;
if (existing && existing.postId === currentPost?.id) {
if (existing.openedAt && Date.now() - existing.openedAt < 800) return;
closeCenteredOverlay(map, { force: true });
return;
}
openCenteredOverlay({ map, post: currentPost, theme, fullScreen: false });
if (expandedElRef) expandedElRef.current = null;
});
}
/**
* Weather marker - shows temperature and weather icon directly on map
*/
const WEATHER_ICONS = {
sun: "☀️", moon: "🌙", "cloud-sun": "⛅", "cloud-moon": "☁️",
cloud: "☁️", smog: "🌫️", "cloud-rain": "🌧️", "cloud-showers-heavy": "🌧️",
snowflake: "❄️", "cloud-sun-rain": "🌦️", "cloud-meatball": "🌨️",
bolt: "⛈️", "temperature-half": "🌡️"
};
function weatherCodeToIcon(code, isDay = true) {
if (code === 0) return isDay ? "sun" : "moon";
if (code <= 2) return isDay ? "cloud-sun" : "cloud-moon";
if (code === 3) return "cloud";
if (code <= 48) return "smog";
if (code <= 67) return "cloud-rain";
if (code <= 77) return "snowflake";
if (code <= 82) return "cloud-sun-rain";
if (code <= 86) return "cloud-meatball";
if (code <= 99) return "bolt";
return "temperature-half";
}
function parseWeatherFromTitle(title) {
// Parse "☀️ Montreal: 5°C - Clear sky" or "⛅ Paris: 12°C - Partly cloudy"
const match = title?.match(/:\s*(-?\d+)°C/);
const temp = match ? parseInt(match[1], 10) : null;
// Try to get city name
const cityMatch = title?.match(/^[^\w]*([A-Za-z\s]+):/);
const city = cityMatch ? cityMatch[1].trim() : "";
// Detect weather from emoji
let icon = "temperature-half";
if (title?.includes("☀️")) icon = "sun";
else if (title?.includes("🌙")) icon = "moon";
else if (title?.includes("⛅")) icon = "cloud-sun";
else if (title?.includes("☁️")) icon = "cloud";
else if (title?.includes("🌫️")) icon = "smog";
else if (title?.includes("🌧️")) icon = "cloud-rain";
else if (title?.includes("❄️")) icon = "snowflake";
else if (title?.includes("🌦️")) icon = "cloud-sun-rain";
else if (title?.includes("🌨️")) icon = "cloud-meatball";
else if (title?.includes("⛈️")) icon = "bolt";
return { temp, city, icon };
}
export function createWeatherMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
try {
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) return;
const lngLat = [lon, lat];
const { temp, city, icon } = parseWeatherFromTitle(post?.title || "");
const emoji = WEATHER_ICONS[icon] || "🌡️";
const tempDisplay = temp !== null ? `${temp}°` : "";
const cityDisplay = city || post?.city || "";
const root = document.createElement("div");
root.className = "sw-weather-pill sw-appear";
root.style.zIndex = "5";
root.style.cursor = "pointer";
root.__post = post;
requestAnimationFrame(() => {
try { root.classList.add("sw-appear-in"); } catch {}
});
root.innerHTML = `
${emoji}
${tempDisplay}
`;
// Click to open weather modal
root.addEventListener("click", (e) => {
e.stopPropagation();
try {
window.dispatchEvent(new CustomEvent("weather-open", { detail: post }));
} catch {}
});
// Hover effect - subtle
root.addEventListener("mouseenter", () => {
try {
root.style.opacity = "1";
} catch {}
});
root.addEventListener("mouseleave", () => {
try {
root.style.opacity = "";
} catch {}
});
const marker = new window.maplibregl.Marker({ element: root, anchor: "bottom" })
.setLngLat(lngLat)
.addTo(map);
const id = post?.id || post?.ID || `weather-${Date.now()}-${Math.random().toString(36).slice(2)}`;
if (!Array.isArray(markersRef.current)) markersRef.current = [];
// Remove existing marker with same id
const existing = markersRef.current.findIndex((m) => m?.id === id);
if (existing >= 0) {
try { markersRef.current[existing]?.marker?.remove?.(); } catch {}
markersRef.current.splice(existing, 1);
}
// Add with same structure as other markers (type, el, marker, post, id)
markersRef.current.push({
id,
marker,
el: root,
post: root.__post,
type: "weather"
});
} catch (err) {
console.warn("createWeatherMarkerForPost error:", err);
}
}
/**
* Crée un marqueur simple (juste icône circulaire) pour les posts secondaires
* S'ouvre en modal au clic, affiche mini-carte au hover
*/
export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
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) return;
const lngLat = [lon, lat];
// Pas d'offset pour les marqueurs simples (ils sont petits)
const root = document.createElement("div");
root.classList.add("sw-appear");
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
const isWeatherPost = post?.content_type === 'weather';
root.className = "post-pin--simple sw-appear sw-appear-in";
root.style.cursor = "pointer";
root.style.display = "flex";
root.style.flexDirection = "column";
root.style.alignItems = "center";
root.style.zIndex = isWeatherPost ? "10" : "2";
root.__post = post;
function renderSimple() {
const activePost = root.__post || post;
const isCamera = activePost?.content_type === 'camera';
const isVideo = activePost?.content_type === 'video';
const isLive = activePost?.content_type === 'live';
const isWeather = activePost?.content_type === 'weather';
const liveColor = 'linear-gradient(135deg, rgba(220,38,38,0.95), rgba(239,68,68,0.95))';
const livePin = 'rgba(220,38,38,0.95)';
const weatherColor = 'linear-gradient(135deg, #0ea5e9 0%, #3b82f6 100%)';
const weatherPin = '#3b82f6';
const color = isLive
? liveColor
: (isCamera || isVideo)
? 'linear-gradient(135deg, rgba(56, 189, 248, 0.9), rgba(59, 130, 246, 0.9))'
: isWeather
? weatherColor
: getMarkerColorForCategory(activePost?.category);
const pinColor = isLive
? livePin
: (isCamera || isVideo)
? 'rgba(56, 189, 248, 0.9)'
: isWeather
? weatherPin
: getMarkerColorForCategory(activePost?.category);
const img = normalizeImageUrl(activePost?.image_small || "");
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
root.__lastImage = img || "";
root.__lastTitle = (activePost?.title || "").toString();
// Weather emoji based on weather_icon or title
let weatherEmoji = '🌡️';
if (isWeather) {
const icon = activePost?.weather_icon || '';
const title = activePost?.title || '';
if (icon === 'sun' || title.includes('☀️')) weatherEmoji = '☀️';
else if (icon === 'moon' || title.includes('🌙')) weatherEmoji = '🌙';
else if (icon === 'cloud-sun' || title.includes('⛅')) weatherEmoji = '⛅';
else if (icon === 'cloud' || title.includes('☁️')) weatherEmoji = '☁️';
else if (icon === 'smog' || title.includes('🌫️')) weatherEmoji = '🌫️';
else if (icon.includes('rain') || title.includes('🌧️')) weatherEmoji = '🌧️';
else if (icon === 'snowflake' || title.includes('❄️')) weatherEmoji = '❄️';
else if (title.includes('🌦️')) weatherEmoji = '🌦️';
else if (title.includes('🌨️')) weatherEmoji = '🌨️';
else if (icon === 'bolt' || title.includes('⛈️')) weatherEmoji = '⛈️';
}
// Parse temp from title if not available: "☀️ Montreal: 5°C - Clear"
let weatherTemp = '';
if (isWeather) {
if (activePost?.temperature != null) {
weatherTemp = `${Math.round(activePost.temperature)}°`;
} else {
const titleMatch = (activePost?.title || '').match(/:\s*(-?\d+)°/);
if (titleMatch) weatherTemp = `${titleMatch[1]}°`;
}
}
// Weather city name for label
const weatherCity = isWeather ? (activePost?.city || (activePost?.title || '').split(':')[0].replace(/^[^\w\s]+/, '').trim()) : '';
// Camera/video thumbnail URL
const mediaThumbnail = (isCamera || isVideo) ? (activePost?.thumbnail_url || activePost?.image_small || '') : '';
// Camera pin - square thumbnail with red LED that pulses (bigger)
// Video pin - square thumbnail with play button overlay (bigger, no red LED)
root.innerHTML = isCamera ? `
${mediaThumbnail ? `

` : ``}
` : isVideo ? `
${mediaThumbnail ? `

` : ``}
` : `
${isWeather ? `
${weatherEmoji}
${weatherTemp}
` : isLive ? `
` : img ? `

` : `
`}
${!isWeather ? `` : ''}
`;
// Add hover card for all marker types
root.innerHTML += `
${isCamera ? `
LIVE
` : isWeather ? `
${weatherEmoji} WEATHER
` : ""}
${escapeHtml(activePost?.title || "Untitled")}
${escapeHtml((activePost?.snippet || "").substring(0, 80))}${(activePost?.snippet || "").length > 80 ? "..." : ""}
${isCamera ? `` : isWeather ? `${weatherEmoji} ` : ""}${normCatLabel(activePost)} • ${isCamera ? (activePost?.region || "Quebec") : isWeather ? (activePost?.city || '') : formatRelativeTime(activePost?.created_at || activePost?.CreatedAt)}
`;
}
renderSimple();
root.__renderSimple = renderSimple;
const isCluster = HEATMAP_ENABLED && isClusterMainPost(post);
const markerEl = root.querySelector(".sw-simple-marker");
const hoverCard = root.querySelector(".sw-simple-hover-card");
// Hover effects - bring marker to front so hover card is above other markers (simple pins = 150)
root.addEventListener("mouseenter", () => {
root.style.zIndex = "150";
if (isCluster) {
dispatchClusterHeatmap(root.__post || post, { mode: "hover", skipPan: true });
}
if (markerEl) {
markerEl.style.transform = "scale(1.2)";
markerEl.style.boxShadow = "0 4px 16px rgba(0,0,0,0.5)";
}
if (hoverCard) {
hoverCard.style.opacity = "1";
hoverCard.style.transform = "translateX(-50%) translateY(-6px) scale(1)";
}
});
root.addEventListener("mouseleave", () => {
root.style.zIndex = isWeatherPost ? "10" : "2";
if (isCluster) {
dispatchClusterHeatmapClear();
}
if (markerEl) {
markerEl.style.transform = "scale(1)";
markerEl.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)";
}
if (hoverCard) {
hoverCard.style.opacity = "0";
hoverCard.style.transform = "translateX(-50%) translateY(-6px) scale(0.9)";
}
});
// Click ouvre le modal
root.addEventListener("click", (ev) => {
ev.stopPropagation();
// Use root.__post to get the most up-to-date post data (may have been refreshed)
const currentPost = root.__post || post;
// Debug logging
console.log('[SimpleMarker click] currentPost:', {
id: currentPost?.id,
content_type: currentPost?.content_type,
source: currentPost?.source,
hasRootPost: !!root.__post,
rootPostContentType: root.__post?.content_type
});
const existing = map.__swCenteredOverlay;
if (existing && existing.postId === currentPost?.id) {
if (existing.openedAt && Date.now() - existing.openedAt < 800) return;
closeCenteredOverlay(map, { force: true });
return;
}
openCenteredOverlay({ map, post: currentPost, theme, fullScreen: false });
if (expandedElRef) expandedElRef.current = null;
});
const marker = new maplibregl.Marker({
element: root,
anchor: "bottom", // La pointe du pin est à la position exacte
})
.setLngLat(lngLat)
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post, type: "simple" });
}
/**
* ISLAND MARKER: Creates a circular avatar marker for user islands
* Islands are displayed with avatar, username, and 🏝️ badge
*/
export function createIslandMarker(island, mapRef, markersRef, onIslandClick, theme = "blue") {
const map = mapRef.current;
if (!map) return;
const lat = island.virtual_y; // Virtual coords
const lon = island.virtual_x;
if (lat == null || lon == null) {
console.warn("[createIslandMarker] Missing virtual coordinates", island);
return;
}
const root = document.createElement("div");
root.classList.add("island-marker", "sw-appear");
// Fade-in animation
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
// Marker circulaire avec avatar
root.innerHTML = `
${island.avatar_url
? `
})
`
: `
${island.username[0].toUpperCase()}
`
}
🏝️ ISLAND
@${island.username}
`;
// Hover effect
const avatar = root.querySelector(".island-avatar");
root.addEventListener("mouseenter", () => {
if (avatar) {
avatar.style.transform = "scale(1.1)";
avatar.style.boxShadow = "0 6px 20px rgba(102, 126, 234, 0.6)";
}
});
root.addEventListener("mouseleave", () => {
if (avatar) {
avatar.style.transform = "scale(1)";
avatar.style.boxShadow = "0 4px 12px rgba(0,0,0,0.4)";
}
});
// Click handler
root.addEventListener("click", (ev) => {
ev.stopPropagation();
console.log("[Island Marker] Clicked:", island.username, island.id);
if (onIslandClick) {
onIslandClick(island);
}
});
const marker = new maplibregl.Marker({
element: root,
anchor: "bottom",
})
.setLngLat([lon, lat])
.addTo(map);
markersRef.current.push({
id: island.id,
marker,
el: root,
island,
type: "island"
});
console.log("[Island Marker] Created for", island.username, "at", [lon, lat]);
}