2694 lines
102 KiB
JavaScript
2694 lines
102 KiB
JavaScript
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 = Number.isFinite(stats.ai_score) ? Math.round(stats.ai_score) : stats.AIScore;
|
|
const userScore = Number.isFinite(stats.user_score) ? Math.round(stats.user_score) : stats.UserScore;
|
|
const totalVotes = Number.isFinite(stats.total_votes) ? stats.total_votes : stats.TotalVotes;
|
|
|
|
if (aiEl) aiEl.textContent = Number.isFinite(aiScore) ? String(aiScore) : "—";
|
|
if (userEl) userEl.textContent = Number.isFinite(userScore) ? String(userScore) : "—";
|
|
if (countEl) countEl.textContent = `${totalVotes || 0} votes`;
|
|
|
|
// Only update the score display if explicitly requested (e.g., after user votes)
|
|
if (updateScore) {
|
|
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 = Number.isFinite(stats.truth_score) ? Math.round(stats.truth_score) : stats.TruthScore;
|
|
if (marker && Number.isFinite(truthScoreValue)) marker.style.top = `${100 - truthScoreValue}%`;
|
|
if (label && Number.isFinite(truthScoreValue)) 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
|
|
? `<img src="${escapeHtml(heroImageSrc)}" data-fullsrc="${escapeHtml(heroFullSrc)}" alt="" loading="lazy" data-gallery-index="0" />`
|
|
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(post)}"></i><span>${escapeHtml(normCatLabel(post))}</span></div></div>`;
|
|
const arrowHtml = heroHasGallery
|
|
? `<button class="sw-modal-hero-arrow left" type="button" aria-label="Previous image"><i class="fa-solid fa-chevron-left"></i></button><button class="sw-modal-hero-arrow right" type="button" aria-label="Next image"><i class="fa-solid fa-chevron-right"></i></button>`
|
|
: "";
|
|
const indicatorHtml = heroHasGallery
|
|
? `<div class="sw-modal-gallery-indicator" aria-live="polite"><span class="sw-modal-gallery-current">1</span><span class="sw-modal-gallery-separator">/</span><span class="sw-modal-gallery-total">${heroGallery.length}</span></div>`
|
|
: "";
|
|
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 ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
|
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
|
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
|
<span>• ${escapeHtml(relTime)}</span>
|
|
`;
|
|
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);
|
|
updateTruth(modalWrap, post);
|
|
}
|
|
|
|
function renderComments(container, items) {
|
|
if (!container) return;
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
container.innerHTML = `<div class="sw-chat-line">— no comments yet</div>`;
|
|
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 `
|
|
<div class="sw-chat-bubble">
|
|
<div class="sw-chat-head">
|
|
<div class="sw-chat-avatar" data-username="${escapeHtml(who)}"></div>
|
|
<div class="sw-chat-meta">${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}</div>
|
|
</div>
|
|
<div class="sw-chat-text">${escapeHtml(body)}</div>
|
|
</div>
|
|
`;
|
|
})
|
|
.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 || {});
|
|
|
|
root.render(
|
|
React.createElement(
|
|
"div",
|
|
{
|
|
className: "sw-card-wrap",
|
|
style: { width: spec?.size?.w, height: spec?.size?.h },
|
|
},
|
|
React.createElement(CardRenderer, {
|
|
spec,
|
|
data,
|
|
themeTokens: tokens,
|
|
onAction: (action, value) => {
|
|
if (action?.type === "open_url" && value) {
|
|
try { window.open(value, "_blank"); } catch {}
|
|
}
|
|
},
|
|
className: "",
|
|
}),
|
|
React.createElement(
|
|
"div",
|
|
{ className: "sw-truth-mini-bar" },
|
|
React.createElement("div", {
|
|
className: "sw-truth-mini-marker",
|
|
style: { top: `${100 - score}%` },
|
|
})
|
|
),
|
|
React.createElement(
|
|
"div",
|
|
{ className: "sw-truth-mini-score", style: { background: truthColor(score) } },
|
|
`${score}`
|
|
),
|
|
)
|
|
);
|
|
|
|
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, ">")
|
|
.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 `
|
|
<div class="sw-modal-tags">
|
|
${uniq.map((t) => `<span class="sw-modal-tag">#${escapeHtml(t)}</span>`).join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `<div class="sw-cluster-source-empty">No related reports yet.</div>`;
|
|
}
|
|
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 ? `<span class="sw-cluster-source">${source}</span>` : "";
|
|
const href = url ? ` data-related-url="${url}"` : "";
|
|
return `
|
|
<div class="sw-cluster-source-item"${href}>
|
|
<div class="sw-cluster-source-title">${title}</div>
|
|
${meta}
|
|
</div>
|
|
`;
|
|
});
|
|
return rows.join("");
|
|
}
|
|
|
|
function heroImageForPost(post) {
|
|
const raw =
|
|
post?.image_medium ||
|
|
post?.image_large ||
|
|
post?.image_small ||
|
|
post?.image ||
|
|
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);
|
|
};
|
|
if (Array.isArray(post?.media_urls)) {
|
|
post.media_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 = `
|
|
<div class="sw-camera-modal-header" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;background:rgba(15,23,42,0.95);border-bottom:1px solid rgba(148,163,184,0.3);">
|
|
<div style="width:42px;height:42px;border-radius:12px;background:linear-gradient(135deg,rgba(56,189,248,0.25),rgba(59,130,246,0.25));border:1px solid rgba(56,189,248,0.6);display:flex;align-items:center;justify-content:center;">
|
|
<i class="fa-solid fa-video" style="font-size:18px;color:#38bdf8;"></i>
|
|
</div>
|
|
<div style="flex:1;min-width:0;">
|
|
<div style="font-size:1rem;font-weight:800;color:#f8fafc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(title)}</div>
|
|
<div style="font-size:0.78rem;color:#94a3b8;">${escapeHtml(region)}</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:0.35rem;padding:0.3rem 0.7rem;border-radius:999px;background:rgba(239,68,68,0.85);color:#fff;font-size:0.7rem;font-weight:800;letter-spacing:0.08em;">
|
|
<span style="width:6px;height:6px;border-radius:50%;background:#fff;animation:sw-camera-pulse 1.5s ease-in-out infinite;"></span>
|
|
LIVE
|
|
</div>
|
|
<button class="sw-camera-close" style="width:36px;height:36px;border-radius:50%;border:1px solid rgba(148,163,184,0.4);background:rgba(15,23,42,0.8);color:#e5e7eb;font-size:1.1rem;display:flex;align-items:center;justify-content:center;cursor:pointer;">
|
|
<i class="fa-solid fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div style="flex:1;display:flex;flex-direction:column;min-height:0;overflow:auto;">
|
|
<div style="position:relative;width:100%;padding-top:56.25%;background:linear-gradient(135deg,rgba(15,23,42,1),rgba(30,41,59,1));">
|
|
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1.5rem;padding:2rem;">
|
|
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,rgba(56,189,248,0.2),rgba(59,130,246,0.2));border:2px solid rgba(56,189,248,0.4);display:flex;align-items:center;justify-content:center;">
|
|
<i class="fa-solid fa-video" style="font-size:32px;color:#38bdf8;"></i>
|
|
</div>
|
|
<div style="text-align:center;">
|
|
<div style="font-size:1.1rem;font-weight:700;color:#f8fafc;margin-bottom:0.5rem;">Live Traffic Camera</div>
|
|
<div style="font-size:0.85rem;color:#94a3b8;">Click below to view the live stream</div>
|
|
</div>
|
|
${(embedUrl || videoUrl)
|
|
? `<a href="${escapeHtml(embedUrl || videoUrl)}" target="_blank" rel="noopener noreferrer" style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.75rem 1.5rem;background:linear-gradient(135deg,#0ea5e9,#3b82f6);color:#fff;font-weight:700;font-size:0.9rem;border-radius:999px;text-decoration:none;box-shadow:0 4px 15px rgba(14,165,233,0.4);">
|
|
<i class="fa-solid fa-external-link-alt"></i>
|
|
Open Live Stream
|
|
</a>`
|
|
: `<div style="color:#94a3b8;font-size:0.85rem;">Stream URL unavailable</div>`
|
|
}
|
|
</div>
|
|
</div>
|
|
<div style="padding:0.75rem 1rem;border-top:1px solid rgba(148,163,184,0.2);">
|
|
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.8rem;color:#94a3b8;margin-bottom:0.5rem;">
|
|
<i class="fa-solid fa-map-marker-alt" style="color:#38bdf8;"></i>
|
|
<span>${escapeHtml(region)}</span>
|
|
${coordsInfo ? `<span style="opacity:0.7;font-size:0.75rem;">${coordsInfo}</span>` : ""}
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:0.5rem;padding:0.75rem 1rem;border-top:1px solid rgba(148,163,184,0.2);flex-wrap:wrap;">
|
|
<button class="sw-camera-share sw-wall-btn" style="border:1px solid rgba(56,189,248,0.35);background:rgba(15,23,42,0.6);color:#e2f2ff;font-weight:800;font-size:0.75rem;padding:0.35rem 0.6rem;border-radius:999px;cursor:pointer;">
|
|
<i class="fa-solid fa-share-nodes"></i> Share
|
|
</button>
|
|
${embedUrl ? `<a href="${escapeHtml(embedUrl)}" target="_blank" rel="noopener noreferrer" class="sw-wall-btn" style="border:1px solid rgba(56,189,248,0.35);background:rgba(15,23,42,0.6);color:#e2f2ff;font-weight:800;font-size:0.75rem;padding:0.35rem 0.6rem;border-radius:999px;cursor:pointer;text-decoration:none;">
|
|
<i class="fa-solid fa-external-link"></i> Open External
|
|
</a>` : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 img = heroImageForPost(postData);
|
|
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
|
|
? `<div class="sw-stat-pill sw-heatmap-pill" data-action="cluster-heatmap">🔥 Heat map</div>`
|
|
: "";
|
|
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 = `
|
|
<div class="post-card sw-expanded-shell sw-modal sw-cluster-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
|
|
<div class="sw-modal-toprow">
|
|
<div class="sw-modal-left">
|
|
<div class="sw-modal-badge">MAIN NEWS</div>
|
|
<div class="sw-modal-meta">
|
|
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
|
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
|
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
|
<span>• ${escapeHtml(relTime)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="sw-modal-x" type="button" aria-label="Close">✕</button>
|
|
</div>
|
|
|
|
<div class="sw-cluster-title">${escapeHtml(postData?.cluster_title || postData?.title || "Untitled")}</div>
|
|
${tagPills}
|
|
|
|
<div class="sw-cluster-body">
|
|
<div class="sw-cluster-left">
|
|
<div class="sw-cluster-hero">
|
|
<div class="sw-truth-rail" data-score="${truth}">
|
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
|
|
<div class="sw-truth-rail-bar">
|
|
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
|
|
</div>
|
|
<div class="sw-truth-rail-score">${truth}</div>
|
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
|
|
</div>
|
|
<div class="sw-cluster-hero-media">
|
|
${
|
|
img
|
|
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
|
|
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="sw-cluster-actions">
|
|
<button class="post-card-btn" type="button" data-action="like">Like</button>
|
|
<button class="post-card-btn" type="button" data-action="share">Share</button>
|
|
<button class="post-card-btn" type="button">Fix</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sw-cluster-right">
|
|
<div class="sw-cluster-related-title">Related reports ${clusterCount ? `(${clusterCount})` : ""}</div>
|
|
<div class="sw-cluster-related-list">
|
|
${buildClusterItemsHtml(clusterItems)}
|
|
</div>
|
|
<div class="sw-cluster-generated">Generated news update</div>
|
|
<div class="sw-cluster-summary">
|
|
${escapeHtml(data?.summary || postData?.snippet || "")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sw-stat-row">
|
|
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
|
|
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
|
|
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
|
|
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
|
|
${heatmapPill}
|
|
</div>
|
|
|
|
<div class="post-card-comments sw-livechat">
|
|
<div class="sw-livechat-title">Live chat / comments</div>
|
|
<div class="sw-livechat-body">
|
|
<div class="sw-chat-line">— loading…</div>
|
|
</div>
|
|
<div class="sw-livechat-inputrow">
|
|
<input class="sw-livechat-input" placeholder="Write a comment…" />
|
|
<button class="sw-livechat-post" type="button">POST</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
`;
|
|
} else {
|
|
modalWrap.innerHTML = `
|
|
<div class="post-card sw-expanded-shell sw-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
|
|
<div class="sw-modal-toprow">
|
|
<div class="sw-modal-left">
|
|
${isCamera ? `
|
|
<div class="sw-modal-badge" style="background:linear-gradient(135deg,#dc2626,#ef4444);display:flex;align-items:center;gap:0.4rem;">
|
|
<span style="width:6px;height:6px;border-radius:50%;background:#fff;animation:pulse 1.5s infinite;"></span>
|
|
LIVE CAMERA
|
|
</div>
|
|
` : `
|
|
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
|
`}
|
|
<div class="sw-modal-meta">
|
|
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
|
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
|
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
|
<span>• ${escapeHtml(relTime)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="sw-modal-x" type="button" aria-label="Close">✕</button>
|
|
</div>
|
|
|
|
<div class="sw-modal-hero-row">
|
|
${!isCamera ? `
|
|
<div class="sw-truth-rail" data-score="${truth}">
|
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
|
|
<div class="sw-truth-rail-bar">
|
|
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
|
|
</div>
|
|
<div class="sw-truth-rail-score">${truth}</div>
|
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
|
|
</div>
|
|
` : ''}
|
|
<div class="sw-modal-hero" ${(isCamera || isVideo) ? 'style="flex:1;max-width:100%;"' : ''}>
|
|
${isCamera ? `
|
|
<div class="sw-camera-hero" style="position:relative;width:100%;padding-top:56.25%;background:#000;border-radius:12px;overflow:hidden;">
|
|
<video
|
|
controls
|
|
autoplay
|
|
muted
|
|
playsinline
|
|
loop
|
|
style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;"
|
|
src="${escapeHtml(getCameraStreamUrl(postData))}"
|
|
>
|
|
<source src="${escapeHtml(getCameraStreamUrl(postData))}" type="video/mp4">
|
|
</video>
|
|
<div class="sw-camera-live-badge" style="position:absolute;top:12px;left:12px;display:flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(220,38,38,0.9);border-radius:4px;font-size:11px;font-weight:700;color:#fff;text-transform:uppercase;">
|
|
<span style="width:6px;height:6px;border-radius:50%;background:#fff;animation:pulse 1.5s infinite;"></span>
|
|
LIVE
|
|
</div>
|
|
</div>
|
|
` : isVideo ? `
|
|
<div class="sw-video-hero" style="position:relative;width:100%;padding-top:56.25%;background:#000;border-radius:12px;overflow:hidden;">
|
|
<video
|
|
controls
|
|
playsinline
|
|
style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;"
|
|
src="${escapeHtml(normalizeMediaUrl(postData.video_url || ''))}"
|
|
>
|
|
<source src="${escapeHtml(normalizeMediaUrl(postData.video_url || ''))}" type="video/mp4">
|
|
</video>
|
|
<div class="sw-video-badge" style="position:absolute;top:12px;left:12px;display:flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(59,130,246,0.9);border-radius:4px;font-size:11px;font-weight:700;color:#fff;text-transform:uppercase;">
|
|
<i class="fa-solid fa-video"></i>
|
|
RECORDED
|
|
</div>
|
|
</div>
|
|
` : img
|
|
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
|
|
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="sw-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div>
|
|
${tagPills}
|
|
|
|
<div class="sw-modal-summary">
|
|
${escapeHtml(data?.summary || postData?.snippet || "")}
|
|
</div>
|
|
|
|
<div class="sw-stat-row">
|
|
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
|
|
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
|
|
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
|
|
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
|
|
${heatmapPill}
|
|
</div>
|
|
|
|
<div class="sw-modal-cta-row">
|
|
${isCamera ? `
|
|
<a class="sw-cta-primary sw-cta-camera" href="${escapeHtml(normalizeMediaUrl(postData.video_url || postData.embed_url || ''))}" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:0.5rem;text-decoration:none;background:linear-gradient(135deg,#dc2626,#ef4444);">
|
|
<i class="fa-solid fa-video"></i> Watch Live Stream
|
|
</a>
|
|
` : `
|
|
<button class="sw-cta-primary" type="button" ${url ? "" : "disabled"} data-url="${escapeHtml(url)}">
|
|
Open source
|
|
</button>
|
|
`}
|
|
|
|
<div class="sw-cta-actions">
|
|
${!isCamera ? `<button class="post-card-btn" type="button">Contact</button>` : ''}
|
|
<button class="post-card-btn" type="button" data-action="like">Like</button>
|
|
<button class="post-card-btn" type="button" data-action="share">Share</button>
|
|
${!isCamera ? `<button class="post-card-btn" type="button">Fix</button>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
${isAuthor ? `
|
|
<div class="sw-modal-author-actions" style="display:flex;gap:0.5rem;flex-wrap:wrap;padding:0.5rem 0;border-top:1px solid rgba(255,255,255,0.1);margin-top:0.5rem;">
|
|
<button class="post-card-btn sw-author-btn" type="button" data-action="edit-post" style="background:rgba(59,130,246,0.2);border-color:rgba(59,130,246,0.4);">
|
|
<i class="fa-solid fa-pen"></i> Edit
|
|
</button>
|
|
<button class="post-card-btn sw-author-btn" type="button" data-action="visibility" style="background:rgba(139,92,246,0.2);border-color:rgba(139,92,246,0.4);">
|
|
<i class="fa-solid fa-lock"></i> Visibility
|
|
</button>
|
|
<button class="post-card-btn sw-author-btn sw-danger-btn" type="button" data-action="delete-post" style="background:rgba(239,68,68,0.2);border-color:rgba(239,68,68,0.4);color:#fca5a5;">
|
|
<i class="fa-solid fa-trash"></i> Delete
|
|
</button>
|
|
</div>
|
|
<div class="sw-modal-edit-panel" style="display:none;padding:0.75rem;background:rgba(15,23,42,0.8);border-radius:8px;margin-top:0.5rem;">
|
|
<input type="text" class="sw-edit-title" placeholder="Title" style="width:100%;padding:0.5rem;background:rgba(30,41,59,0.8);border:1px solid rgba(148,163,184,0.3);border-radius:6px;color:#e2e8f0;margin-bottom:0.5rem;" />
|
|
<textarea class="sw-edit-snippet" placeholder="Description" rows="3" style="width:100%;padding:0.5rem;background:rgba(30,41,59,0.8);border:1px solid rgba(148,163,184,0.3);border-radius:6px;color:#e2e8f0;resize:vertical;"></textarea>
|
|
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
|
|
<button class="post-card-btn" type="button" data-action="save-edit" style="background:rgba(34,197,94,0.3);border-color:rgba(34,197,94,0.5);">Save</button>
|
|
<button class="post-card-btn" type="button" data-action="cancel-edit">Cancel</button>
|
|
</div>
|
|
</div>
|
|
<div class="sw-modal-visibility-panel" style="display:none;padding:0.75rem;background:rgba(15,23,42,0.8);border-radius:8px;margin-top:0.5rem;">
|
|
<label style="display:block;margin-bottom:0.5rem;color:#94a3b8;font-size:0.85rem;">Post Visibility</label>
|
|
<select class="sw-visibility-select" style="width:100%;padding:0.5rem;background:rgba(30,41,59,0.8);border:1px solid rgba(148,163,184,0.3);border-radius:6px;color:#e2e8f0;">
|
|
<option value="public">Public</option>
|
|
<option value="friends">Friends Only</option>
|
|
<option value="private">Private (Only Me)</option>
|
|
<option value="group">Group</option>
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
<input type="text" class="sw-visibility-group" placeholder="Group ID (if Group selected)" style="display:none;width:100%;padding:0.5rem;background:rgba(30,41,59,0.8);border:1px solid rgba(148,163,184,0.3);border-radius:6px;color:#e2e8f0;margin-top:0.5rem;" />
|
|
<input type="text" class="sw-visibility-users" placeholder="Usernames (comma separated, if Custom)" style="display:none;width:100%;padding:0.5rem;background:rgba(30,41,59,0.8);border:1px solid rgba(148,163,184,0.3);border-radius:6px;color:#e2e8f0;margin-top:0.5rem;" />
|
|
<label style="display:flex;align-items:center;gap:0.5rem;margin-top:0.5rem;color:#94a3b8;font-size:0.85rem;">
|
|
<input type="checkbox" class="sw-visibility-apply-images" checked /> Apply to images too
|
|
</label>
|
|
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
|
|
<button class="post-card-btn" type="button" data-action="save-visibility" style="background:rgba(34,197,94,0.3);border-color:rgba(34,197,94,0.5);">Save</button>
|
|
<button class="post-card-btn" type="button" data-action="cancel-visibility">Cancel</button>
|
|
</div>
|
|
</div>
|
|
<div class="sw-modal-delete-confirm" style="display:none;padding:0.75rem;background:rgba(127,29,29,0.5);border-radius:8px;margin-top:0.5rem;text-align:center;">
|
|
<p style="color:#fca5a5;margin-bottom:0.5rem;">Are you sure you want to delete this post?</p>
|
|
<div style="display:flex;gap:0.5rem;justify-content:center;">
|
|
<button class="post-card-btn sw-danger-btn" type="button" data-action="confirm-delete" style="background:rgba(239,68,68,0.4);border-color:rgba(239,68,68,0.6);color:#fca5a5;">Yes, Delete</button>
|
|
<button class="post-card-btn" type="button" data-action="cancel-delete">Cancel</button>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="post-card-comments sw-livechat">
|
|
<div class="sw-livechat-title">Live chat / comments</div>
|
|
<div class="sw-livechat-body">
|
|
<div class="sw-chat-line">— loading…</div>
|
|
</div>
|
|
<div class="sw-livechat-inputrow">
|
|
<input class="sw-livechat-input" placeholder="Write a comment…" />
|
|
<button class="sw-livechat-post" type="button">POST</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
bindHeroLightbox(modalWrap);
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (postID > 0) {
|
|
fetchPostTruth(postID).then((stats) => {
|
|
if (stats) updateTruthVoteUI(modalWrap, stats);
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 rail = modalWrap.querySelector(".sw-truth-rail");
|
|
if (rail && postID > 0 && !rail.hasAttribute("data-vote-bound")) {
|
|
rail.setAttribute("data-vote-bound", "1");
|
|
const doVote = async (vote) => {
|
|
if (!isAuthed()) {
|
|
requestAuth("Please sign in or create a free account to vote on truth.");
|
|
return;
|
|
}
|
|
if (rail.classList.contains("sw-truth-vote-disabled")) {
|
|
return; // Already voting
|
|
}
|
|
try {
|
|
rail.classList.add("sw-truth-vote-disabled");
|
|
const stats = await votePostTruth(postID, vote);
|
|
if (stats) {
|
|
updateTruthVoteUI(modalWrap, stats, true);
|
|
} else {
|
|
console.warn("votePostTruth returned no stats for post", postID);
|
|
}
|
|
} catch (err) {
|
|
console.error("Truth vote error:", err);
|
|
} finally {
|
|
rail.classList.remove("sw-truth-vote-disabled");
|
|
}
|
|
};
|
|
const voteUp = rail.querySelector(".sw-truth-rail-btn-up");
|
|
const voteDown = rail.querySelector(".sw-truth-rail-btn-down");
|
|
if (voteUp) voteUp.addEventListener("click", (e) => { e.stopPropagation(); doVote("true"); });
|
|
if (voteDown) voteDown.addEventListener("click", (e) => { e.stopPropagation(); doVote("false"); });
|
|
}
|
|
|
|
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 = "1";
|
|
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 = "1";
|
|
|
|
// Add connecting line if offset is significant
|
|
// DISABLED: No longer using pixel offsets
|
|
const hasOffset = false;
|
|
const lineHTML = '';
|
|
|
|
root.innerHTML = `
|
|
<div class="sw-template-mini-wrap"></div>
|
|
<div class="post-pin-pointer-small"></div>
|
|
${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;
|
|
|
|
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 = `
|
|
<div style="
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
padding: 4px 8px;
|
|
background: rgba(14, 165, 233, 0.75);
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(255,255,255,0.4);
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
|
">
|
|
<span style="font-size:12px;line-height:1;">${emoji}</span>
|
|
<span style="font-size:11px;font-weight:600;color:#fff;">${tempDisplay}</span>
|
|
</div>
|
|
`;
|
|
|
|
// 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 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
|
|
? '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
|
|
? '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()) : '';
|
|
|
|
// Marqueur pin avec image en haut et pointe en bas
|
|
root.innerHTML = `
|
|
<div class="sw-simple-marker ${isCamera ? 'sw-camera-marker' : ''} ${isLive ? 'sw-live-marker' : ''} ${isWeather ? 'sw-weather-marker' : ''}" style="
|
|
${isWeather ? 'width: auto; height: auto; padding: 4px 8px; border-radius: 12px;' : 'width: 32px; height: 32px; border-radius: 50%;'}
|
|
background: ${isWeather ? 'rgba(14, 165, 233, 0.75)' : color};
|
|
border: ${isWeather ? '1px solid rgba(255,255,255,0.4)' : isCamera ? '2px solid rgba(255,255,255,0.85)' : isLive ? '2px solid rgba(255,255,255,0.9)' : '2px solid white'};
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
${isWeather ? 'gap: 3px;' : ''}
|
|
overflow: hidden;
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
">
|
|
${isWeather ? `
|
|
<span style="font-size:12px;line-height:1;">${weatherEmoji}</span>
|
|
<span style="font-size:11px;font-weight:600;color:#fff;">${weatherTemp}</span>
|
|
` : isLive ? `<i class="fa-solid fa-broadcast-tower" style="font-size:14px;color:#fff;"></i>` : isCamera ? `<i class="fa-solid fa-video" style="font-size:14px;color:#fff;"></i>` : img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
|
<div style="
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: white;
|
|
opacity: 0.9;
|
|
"></div>
|
|
`}
|
|
</div>
|
|
${!isWeather ? `<div class="sw-simple-pin-point" style="
|
|
width: 0;
|
|
height: 0;
|
|
border-left: 8px solid transparent;
|
|
border-right: 8px solid transparent;
|
|
border-top: 16px solid ${pinColor};
|
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
|
margin-top: -2px;
|
|
z-index: 1;
|
|
"></div>` : ''}
|
|
<div class="sw-simple-hover-card" style="
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(-6px) scale(0.9);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
z-index: 1000;
|
|
min-width: 200px;
|
|
max-width: 280px;
|
|
background: rgba(8, 20, 40, 0.97);
|
|
backdrop-filter: blur(10px);
|
|
border-radius: 12px;
|
|
padding: 10px 12px;
|
|
border: 1px solid rgba(90, 190, 255, 0.7);
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
|
white-space: normal;
|
|
">
|
|
${isCamera ? `<div style="display:inline-flex;align-items:center;gap:4px;padding:2px 6px;margin-bottom:4px;border-radius:999px;background:rgba(239,68,68,0.85);color:#fff;font-size:9px;font-weight:800;">
|
|
<span style="width:5px;height:5px;border-radius:50%;background:#fff;"></span>LIVE
|
|
</div>` : isWeather ? `<div style="display:inline-flex;align-items:center;gap:4px;padding:2px 6px;margin-bottom:4px;border-radius:999px;background:linear-gradient(135deg,#0ea5e9,#3b82f6);color:#fff;font-size:9px;font-weight:800;">
|
|
${weatherEmoji} WEATHER
|
|
</div>` : ""}
|
|
<div style="font-weight: 900; font-size: 12px; color: #e8f5ff; margin-bottom: 4px;">
|
|
${escapeHtml(activePost?.title || "Untitled")}
|
|
</div>
|
|
<div style="font-size: 10px; color: rgba(232, 245, 255, 0.8);">
|
|
${escapeHtml((activePost?.snippet || "").substring(0, 80))}${(activePost?.snippet || "").length > 80 ? "..." : ""}
|
|
</div>
|
|
<div style="margin-top: 6px; font-size: 9px; color: rgba(232, 245, 255, 0.6);">
|
|
${isCamera ? `<i class="fa-solid fa-video" style="margin-right:4px;"></i>` : isWeather ? `${weatherEmoji} ` : ""}${normCatLabel(activePost)} • ${isCamera ? (activePost?.region || "Quebec") : isWeather ? (activePost?.city || '') : formatRelativeTime(activePost?.created_at || activePost?.CreatedAt)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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
|
|
root.addEventListener("mouseenter", () => {
|
|
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", () => {
|
|
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 = `
|
|
<div class="island-marker-container" style="
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
position: relative;
|
|
cursor: pointer;
|
|
">
|
|
<div class="island-avatar" style="
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
border: 3px solid white;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
">
|
|
${island.avatar_url
|
|
? `<img src="${toSmallAvatarUrl(island.avatar_url)}" style="width:100%;height:100%;object-fit:cover;" alt="${island.username}" />`
|
|
: `<div style="color:white;font-size:32px;font-weight:bold;">${island.username[0].toUpperCase()}</div>`
|
|
}
|
|
</div>
|
|
<div class="island-badge" style="
|
|
position: absolute;
|
|
top: -5px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(102, 126, 234, 0.95);
|
|
color: white;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
letter-spacing: 0.5px;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
|
">🏝️ ISLAND</div>
|
|
<div class="island-username" style="
|
|
margin-top: 8px;
|
|
text-align: center;
|
|
font-weight: bold;
|
|
color: white;
|
|
font-size: 12px;
|
|
text-shadow: 0 2px 4px rgba(0,0,0,0.6);
|
|
">@${island.username}</div>
|
|
<div class="island-pin-point" style="
|
|
width: 0;
|
|
height: 0;
|
|
border-left: 10px solid transparent;
|
|
border-right: 10px solid transparent;
|
|
border-top: 20px solid rgba(102, 126, 234, 0.8);
|
|
margin: 0 auto;
|
|
margin-top: 4px;
|
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
|
"></div>
|
|
</div>
|
|
`;
|
|
|
|
// 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]);
|
|
}
|