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:
parent
642fc2d196
commit
bdd6e2c691
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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("");
|
||||||
|
}
|
||||||
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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, []);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue