fix: prevent time filter reset when changing category

- Set autoWidenRef.current = true when user changes filter to prevent auto-widen
- Time filter now stays as user selected when changing category/subcategory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SocioWire 2026-01-09 04:49:40 +00:00
parent 642fc2d196
commit bdd6e2c691
7 changed files with 766 additions and 12 deletions

View File

@ -261,7 +261,7 @@ export default function App() {
<footer className="sw-footer"> <footer className="sw-footer">
<div className="sw-footer-inner"> <div className="sw-footer-inner">
<div className="sw-footer-brand">SOCIOWIRE</div> <div className="sw-footer-brand">SOCIOWIRE</div>
<div className="sw-footer-brand">© Sociowire v0.0002</div> <div className="sw-footer-brand">© Sociowire v0.0008</div>
<nav className="sw-footer-nav" aria-label="Site"> <nav className="sw-footer-nav" aria-label="Site">
<a href="/">Home</a> <a href="/">Home</a>
<a href="/#map">Live Map</a> <a href="/#map">Live Map</a>

View File

@ -40,6 +40,15 @@ function cacheKeyFor(url, headers = {}) {
return `GET:${url}`; return `GET:${url}`;
} }
// Invalidate cache entries matching a pattern
function invalidateCacheMatching(pattern) {
for (const key of responseCache.keys()) {
if (key.includes(pattern)) {
responseCache.delete(key);
}
}
}
function cloneJson(value) { function cloneJson(value) {
try { try {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
@ -805,22 +814,47 @@ export async function editPost(postId, payload, token) {
} }
} }
// Send debug log to server
function debugLog(msg) {
try {
const data = JSON.stringify({
level: "info",
msg: msg,
ts: new Date().toISOString(),
src: "delete"
});
const blob = new Blob([data], { type: "application/json" });
navigator.sendBeacon?.(`${apiBase()}/debug-log`, blob);
} catch {}
}
// Delete post // Delete post
export async function deletePost(postId, token) { export async function deletePost(postId, token) {
if (!postId) return { ok: false }; if (!postId) return { ok: false };
const qs = new URLSearchParams(); const qs = new URLSearchParams();
qs.set("id", String(postId)); qs.set("id", String(postId));
const url = `${apiBase()}/post?${qs.toString()}`;
debugLog(`START postId=${postId} url=${url}`);
const t0 = Date.now();
try { try {
const headers = { ...authHeaders() }; const headers = { ...authHeaders() };
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${apiBase()}/post?${qs.toString()}`, { const res = await fetch(url, {
method: "DELETE", method: "DELETE",
credentials: "include", credentials: "include",
headers, headers,
}); });
debugLog(`FETCH done ${Date.now() - t0}ms status=${res.status}`);
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
debugLog(`JSON done ${Date.now() - t0}ms ok=${res.ok}`);
if (res.ok) {
// Invalidate cache for this post
invalidateCacheMatching(`id=${postId}`);
invalidateCacheMatching(`post_id=${postId}`);
}
return { ok: res.ok, ...data }; return { ok: res.ok, ...data };
} catch { } catch (err) {
debugLog(`ERROR ${Date.now() - t0}ms err=${err?.message || err}`);
return { ok: false }; return { ok: false };
} }
} }

View File

@ -465,7 +465,14 @@ export default function MapView({
pendingOpenPostIdRef.current = id; pendingOpenPostIdRef.current = id;
pendingOpenFullRef.current = true; pendingOpenFullRef.current = true;
fetchPostById(id).then((post) => { fetchPostById(id).then((post) => {
if (!post) return; if (!post) {
// Post doesn't exist - show message and redirect
alert("This post no longer exists or was deleted.");
try { window.history.replaceState(null, "", "/"); } catch {}
pendingOpenPostIdRef.current = null;
pendingOpenFullRef.current = false;
return;
}
onSelectPost?.(post); onSelectPost?.(post);
openOverlayWhenReady(post, { fullScreen: true }); openOverlayWhenReady(post, { fullScreen: true });
pendingOpenPostIdRef.current = null; pendingOpenPostIdRef.current = null;
@ -653,20 +660,20 @@ export default function MapView({
useEffect(() => { useEffect(() => {
const wantId = pendingOpenPostIdRef.current; const wantId = pendingOpenPostIdRef.current;
if (!wantId || !Array.isArray(visiblePosts) || !visiblePosts.length) return; if (!wantId || !Array.isArray(visiblePosts) || !visiblePosts.length) return;
// If pendingOpenFullRef is true, we're waiting for fetchPostById to validate the post exists
// Don't intercept with cached data - let the fetch complete first
if (pendingOpenFullRef.current) return;
const map = mapRef.current; const map = mapRef.current;
const overlay = map?.__swCenteredOverlay; const overlay = map?.__swCenteredOverlay;
if (overlay && overlay.postId === wantId) { if (overlay && overlay.postId === wantId) {
pendingOpenPostIdRef.current = null; pendingOpenPostIdRef.current = null;
pendingOpenFullRef.current = false;
return; return;
} }
const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId); const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId);
if (!match) return; if (!match) return;
pendingOpenPostIdRef.current = null; pendingOpenPostIdRef.current = null;
const wantFull = pendingOpenFullRef.current;
onSelectPost?.(match); onSelectPost?.(match);
openOverlayWhenReady(match, { fullScreen: wantFull }); openOverlayWhenReady(match, { fullScreen: false });
pendingOpenFullRef.current = false;
}, [visiblePosts, onSelectPost, openOverlayWhenReady]); }, [visiblePosts, onSelectPost, openOverlayWhenReady]);
useEffect(() => { useEffect(() => {
@ -750,6 +757,12 @@ export default function MapView({
const postId = e.detail?.post_id || e.detail?.id; const postId = e.detail?.post_id || e.detail?.id;
if (postId) { if (postId) {
handlePostDelete({ post_id: postId }); handlePostDelete({ post_id: postId });
// Clear selected post and openedPostRef if it was deleted
const selId = selectedPost?.id || selectedPost?.ID;
if (selId && selId === postId) {
openedPostRef.current = null;
onSelectPost?.(null);
}
} }
}; };
window.addEventListener('live-post-updated', onLivePostUpdated); window.addEventListener('live-post-updated', onLivePostUpdated);
@ -758,7 +771,7 @@ export default function MapView({
window.removeEventListener('live-post-updated', onLivePostUpdated); window.removeEventListener('live-post-updated', onLivePostUpdated);
window.removeEventListener('live-post-deleted', onLivePostDeleted); window.removeEventListener('live-post-deleted', onLivePostDeleted);
}; };
}, [handlePostUpdate, handlePostDelete]); }, [handlePostUpdate, handlePostDelete, selectedPost, onSelectPost]);
useEffect(() => { useEffect(() => {
const onLiveOpen = (e) => { const onLiveOpen = (e) => {

View File

@ -8,7 +8,7 @@ import CardRenderer from "../Cards/CardRenderer";
import { cardTokens } from "../../theme/cardTokens"; import { cardTokens } from "../../theme/cardTokens";
import { getTemplateSpecForPost, getTemplateKeyForPost, adaptPostToTemplateData } from "./templateSpecs"; import { getTemplateSpecForPost, getTemplateKeyForPost, adaptPostToTemplateData } from "./templateSpecs";
import { getMarkerColorForCategory } from "./postPriority"; import { getMarkerColorForCategory } from "./postPriority";
import { loadAuth } from "../../auth/authStorage"; import { loadAuth, guessUsernameFromToken } from "../../auth/authStorage";
import { import {
recordPostShare, recordPostShare,
recordPostView, recordPostView,
@ -20,6 +20,9 @@ import {
fetchProfile, fetchProfile,
fetchPostTruth, fetchPostTruth,
votePostTruth, votePostTruth,
editPost,
deletePost,
updatePostVisibility,
} from "../../api/client"; } from "../../api/client";
import { trackEvent } from "../../utils/analytics"; import { trackEvent } from "../../utils/analytics";
@ -1039,13 +1042,23 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
const templateKey = getTemplateKeyForPost(postData); const templateKey = getTemplateKeyForPost(postData);
const relTime = formatRelativeTime(getPostTime(postData)); const relTime = formatRelativeTime(getPostTime(postData));
const author = (postData?.author || postData?.Author || "").toString().trim(); 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 sub = (postData?.sub_category || postData?.subCategory || "").toString().trim();
const metaLeft = author ? `by ${author}` : ""; const metaLeft = author ? `by ${author}` : "";
const metaRight = sub ? sub : ""; const metaRight = sub ? sub : "";
const img = heroImageForPost(postData); const img = heroImageForPost(postData);
const truth = truthScore(postData); const truth = truthScore(postData);
const url = data?.url || ""; const url = data?.url || "";
const postID = postData?.id || postData?.ID || 0; const postID = postData?.id || postData?.ID || postData?.post_id || 0;
const viewCount = readCount(postData, ["view_count", "views", "viewCount"]); const viewCount = readCount(postData, ["view_count", "views", "viewCount"]);
const likeCount = readCount(postData, ["like_count", "likes", "likeCount"]); const likeCount = readCount(postData, ["like_count", "likes", "likeCount"]);
const shareCount = readCount(postData, ["share_count", "shares", "shareCount"]); const shareCount = readCount(postData, ["share_count", "shares", "shareCount"]);
@ -1278,6 +1291,54 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
</div> </div>
</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="post-card-comments sw-livechat">
<div class="sw-livechat-title">Live chat / comments</div> <div class="sw-livechat-title">Live chat / comments</div>
<div class="sw-livechat-body"> <div class="sw-livechat-body">
@ -1459,6 +1520,16 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
} }
}; };
// 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) { if (likeBtn && postID > 0) {
likeBtn.addEventListener("click", async () => { likeBtn.addEventListener("click", async () => {
if (!isAuthed()) { if (!isAuthed()) {
@ -1505,6 +1576,184 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
}); });
} }
// 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"]'); const heatBtn = modalWrap.querySelector('[data-action="cluster-heatmap"]');
if (HEATMAP_ENABLED && heatBtn && isCluster) { if (HEATMAP_ENABLED && heatBtn && isCluster) {
heatBtn.addEventListener("click", () => { heatBtn.addEventListener("click", () => {

View File

@ -0,0 +1,254 @@
// markerMedia.js - Media, image, video, gallery handling
import { escapeHtml } from "./markerUtils";
export 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;
}
export 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;
}
export function resolvePostImage(post) {
return normalizeImageUrl(
post?.image_small ||
post?.image_medium ||
post?.image_large ||
post?.image ||
post?.media_url ||
""
);
}
export 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 = (post?.category || "").toString().trim();
return `/og/category?cat=${encodeURIComponent(cat || "NEWS")}`;
}
export function fullImageForPost(post) {
const raw =
post?.image_large ||
post?.image_medium ||
post?.image_small ||
post?.image ||
post?.media_url ||
"";
return normalizeImageUrl(raw);
}
export 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;
}
// Extract camera ID from Quebec 511 URL
export function extractCameraId(url) {
if (!url) return null;
const match = url.match(/[?&]id=(\d+)/);
return match ? match[1] : null;
}
// Build stream proxy URL for camera
export 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;
}
export 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>
`;
}
export 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();
}
}
export 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);
}
});
}
export 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((arrow) => {
const dir = arrow.classList.contains("sw-modal-hero-arrow-prev") ? -1 : 1;
arrow.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
setActive(activeIndex + dir);
});
});
// Touch swipe for gallery
let touchStartX = 0;
const touchThreshold = 50;
heroImg.addEventListener("touchstart", (event) => {
touchStartX = event.touches[0].clientX;
});
heroImg.addEventListener("touchend", (event) => {
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);
});
}
export 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 = "10000000";
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);
if (!opts.skipHistory && overlay.__swPushed) {
overlay.__swPushed = false;
try { history.back(); } catch {}
}
};
const onKey = (e) => {
if (e.key === "Escape") close();
};
const onPop = () => close({ skipHistory: true });
overlay.addEventListener("click", close);
document.addEventListener("keydown", onKey);
window.addEventListener("popstate", onPop);
try {
history.pushState({ sw: "image" }, "");
overlay.__swPushed = true;
} catch {}
document.body.appendChild(overlay);
}
export function buildClusterItemsHtml(items) {
if (!Array.isArray(items) || items.length === 0) return "";
const rows = items.slice(0, 10).map((item) => {
const title = escapeHtml(item?.title || "");
const source = escapeHtml(item?.source || "");
const url = item?.url || "";
const img = normalizeImageUrl(item?.image_small || item?.image_medium || item?.image_large || "");
return `
<a class="sw-cluster-row" href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${title}">
${img ? `<img src="${escapeHtml(img)}" class="sw-cluster-row-thumb" alt="" loading="lazy" />` : `<div class="sw-cluster-row-thumb sw-cluster-row-thumb--empty"></div>`}
<div class="sw-cluster-row-info">
<div class="sw-cluster-row-title">${title}</div>
<div class="sw-cluster-row-source">${source}</div>
</div>
</a>
`;
});
return rows.join("");
}

View File

@ -0,0 +1,204 @@
// markerUtils.js - Utility functions for markers
import { loadAuth, guessUsernameFromToken } from "../../auth/authStorage";
import { fetchProfile } from "../../api/client";
export const SW_ANIM_MS = 840;
export const HEATMAP_ENABLED = false;
const avatarCache = new Map();
const avatarInflight = new Map();
export 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 = url;
img.alt = username || "user";
el.appendChild(img);
} else {
el.textContent = initial;
}
}
export function hydrateAvatars(root) {
if (!root) return;
const avatarEls = root.querySelectorAll("[data-username]");
avatarEls.forEach((el) => {
const username = el.getAttribute("data-username")?.trim();
if (!username) return;
if (avatarCache.has(username)) {
applyAvatar(el, username, avatarCache.get(username));
return;
}
if (avatarInflight.has(username)) return;
avatarInflight.set(username, true);
fetchProfile(username)
.then((profile) => {
const url = profile?.profile_picture || profile?.picture || "";
avatarCache.set(username, url);
applyAvatar(el, username, url);
})
.catch(() => {
avatarCache.set(username, "");
applyAvatar(el, username, "");
})
.finally(() => avatarInflight.delete(username));
});
}
export function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function formatCount(value) {
const n = parseInt(value, 10);
if (Number.isNaN(n) || n <= 0) return "0";
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "K";
return String(n);
}
export function readCount(post, keys) {
for (const k of keys) {
const v = post?.[k];
if (v !== undefined && v !== null) {
const n = parseInt(v, 10);
if (!Number.isNaN(n) && n >= 0) return n;
}
}
return 0;
}
export function updateStat(modalWrap, statKey, value) {
const el = modalWrap?.querySelector(`[data-stat="${statKey}"]`);
if (el) el.textContent = formatCount(value);
}
export function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
export function isAuthed() {
try {
const auth = loadAuth();
return !!(auth?.access_token);
} catch {
return false;
}
}
export function getAuthInfo() {
let authToken = "";
let currentUsername = "";
try {
const auth = loadAuth();
authToken = auth?.access_token || "";
currentUsername = guessUsernameFromToken(authToken) || "";
} catch {}
return { authToken, currentUsername };
}
export function requestAuth(message, mode = "login") {
try {
window.dispatchEvent(
new CustomEvent("sociowire:request-auth", {
detail: { message, mode },
})
);
} catch {}
}
export function formatRelativeTime(createdAt) {
const created = typeof createdAt === "string" ? new Date(createdAt) : createdAt;
const now = new Date();
const diffSec = Math.floor((now.getTime() - created.getTime()) / 1000);
if (diffSec < 60) return "now";
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
if (diffSec < 604800) return `${Math.floor(diffSec / 86400)}d`;
return created.toLocaleDateString();
}
export function getPostTime(post) {
const raw =
post?.published_at ||
post?.publishedAt ||
post?.PublishedAt ||
post?.created_at ||
post?.createdAt ||
post?.CreatedAt ||
"";
if (!raw) return { date: new Date(), relTime: "now" };
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return { date: new Date(), relTime: "now" };
return { date: d, relTime: formatRelativeTime(d) };
}
export function truthScore(post) {
const rawTruth = post?.truth_score ?? post?.truthScore ?? post?.TruthScore ?? 0;
const rawAI = post?.truth_ai ?? post?.truthAI ?? post?.TruthAI ?? 0;
let score = 0;
if (typeof rawTruth === "number" && rawTruth > 0) {
score = rawTruth;
} else if (typeof rawAI === "number" && rawAI > 0) {
score = rawAI;
}
return clamp(Math.round(score), 0, 100);
}
export function truthColor(score) {
if (score >= 70) return "#22c55e";
if (score >= 40) return "#eab308";
return "#ef4444";
}
export function normCatLabel(post) {
const cat = (post?.category || "").toString().trim();
const sub = (post?.sub_category || post?.subCategory || "").toString().trim();
const c = cat.charAt(0).toUpperCase() + cat.slice(1).toLowerCase();
if (sub) return `${c}${sub}`;
return c;
}
export function categoryIconClass(post) {
const cat = (post?.category || "").toString().toLowerCase();
switch (cat) {
case "news":
return "fa-newspaper";
case "event":
return "fa-calendar";
case "market":
return "fa-store";
case "friends":
return "fa-users";
default:
return "fa-globe";
}
}
export function isClusterMainPost(post) {
if (!post) return false;
const clusterPostId = post.cluster_post_id ?? post.clusterPostId;
const postId = post.id ?? post.ID ?? post.post_id;
if (clusterPostId && postId && clusterPostId === postId) {
const members = post.cluster_members ?? post.clusterMembers ?? [];
return Array.isArray(members) && members.length > 1;
}
return false;
}
export function hashPostId(id) {
let n = typeof id === "number" ? id : parseInt(id, 10);
if (Number.isNaN(n)) n = 0;
n = ((n >>> 16) ^ n) * 0x45d9f3b;
n = ((n >>> 16) ^ n) * 0x45d9f3b;
n = (n >>> 16) ^ n;
return n >>> 0;
}

View File

@ -833,7 +833,7 @@ export function usePostsEngine({
replacePoolRef.current = true; replacePoolRef.current = true;
pendingFilterRef.current = `${mainFilter}|${subFilter}|${timeHours}`; pendingFilterRef.current = `${mainFilter}|${subFilter}|${timeHours}`;
allPostsRef.current = []; allPostsRef.current = [];
autoWidenRef.current = false; // Reset auto-widen when user manually changes filter autoWidenRef.current = true; // Prevent auto-widen when user manually changes filter (keep their time choice)
if (map && clustersEnabled && heatmapEnabled) { if (map && clustersEnabled && heatmapEnabled) {
updateClusterAreas(map, []); updateClusterAreas(map, []);
} }