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">
|
||||
<div className="sw-footer-inner">
|
||||
<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">
|
||||
<a href="/">Home</a>
|
||||
<a href="/#map">Live Map</a>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,15 @@ function cacheKeyFor(url, headers = {}) {
|
|||
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) {
|
||||
try {
|
||||
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
|
||||
export async function deletePost(postId, token) {
|
||||
if (!postId) return { ok: false };
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("id", String(postId));
|
||||
const url = `${apiBase()}/post?${qs.toString()}`;
|
||||
debugLog(`START postId=${postId} url=${url}`);
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const headers = { ...authHeaders() };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const res = await fetch(`${apiBase()}/post?${qs.toString()}`, {
|
||||
const res = await fetch(url, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers,
|
||||
});
|
||||
debugLog(`FETCH done ${Date.now() - t0}ms status=${res.status}`);
|
||||
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 };
|
||||
} catch {
|
||||
} catch (err) {
|
||||
debugLog(`ERROR ${Date.now() - t0}ms err=${err?.message || err}`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -465,7 +465,14 @@ export default function MapView({
|
|||
pendingOpenPostIdRef.current = id;
|
||||
pendingOpenFullRef.current = true;
|
||||
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);
|
||||
openOverlayWhenReady(post, { fullScreen: true });
|
||||
pendingOpenPostIdRef.current = null;
|
||||
|
|
@ -653,20 +660,20 @@ export default function MapView({
|
|||
useEffect(() => {
|
||||
const wantId = pendingOpenPostIdRef.current;
|
||||
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 overlay = map?.__swCenteredOverlay;
|
||||
if (overlay && overlay.postId === wantId) {
|
||||
pendingOpenPostIdRef.current = null;
|
||||
pendingOpenFullRef.current = false;
|
||||
return;
|
||||
}
|
||||
const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId);
|
||||
if (!match) return;
|
||||
pendingOpenPostIdRef.current = null;
|
||||
const wantFull = pendingOpenFullRef.current;
|
||||
onSelectPost?.(match);
|
||||
openOverlayWhenReady(match, { fullScreen: wantFull });
|
||||
pendingOpenFullRef.current = false;
|
||||
openOverlayWhenReady(match, { fullScreen: false });
|
||||
}, [visiblePosts, onSelectPost, openOverlayWhenReady]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -750,6 +757,12 @@ export default function MapView({
|
|||
const postId = e.detail?.post_id || e.detail?.id;
|
||||
if (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);
|
||||
|
|
@ -758,7 +771,7 @@ export default function MapView({
|
|||
window.removeEventListener('live-post-updated', onLivePostUpdated);
|
||||
window.removeEventListener('live-post-deleted', onLivePostDeleted);
|
||||
};
|
||||
}, [handlePostUpdate, handlePostDelete]);
|
||||
}, [handlePostUpdate, handlePostDelete, selectedPost, onSelectPost]);
|
||||
|
||||
useEffect(() => {
|
||||
const onLiveOpen = (e) => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import CardRenderer from "../Cards/CardRenderer";
|
|||
import { cardTokens } from "../../theme/cardTokens";
|
||||
import { getTemplateSpecForPost, getTemplateKeyForPost, adaptPostToTemplateData } from "./templateSpecs";
|
||||
import { getMarkerColorForCategory } from "./postPriority";
|
||||
import { loadAuth } from "../../auth/authStorage";
|
||||
import { loadAuth, guessUsernameFromToken } from "../../auth/authStorage";
|
||||
import {
|
||||
recordPostShare,
|
||||
recordPostView,
|
||||
|
|
@ -20,6 +20,9 @@ import {
|
|||
fetchProfile,
|
||||
fetchPostTruth,
|
||||
votePostTruth,
|
||||
editPost,
|
||||
deletePost,
|
||||
updatePostVisibility,
|
||||
} from "../../api/client";
|
||||
import { trackEvent } from "../../utils/analytics";
|
||||
|
||||
|
|
@ -1039,13 +1042,23 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
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 || 0;
|
||||
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"]);
|
||||
|
|
@ -1278,6 +1291,54 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
</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">
|
||||
|
|
@ -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) {
|
||||
likeBtn.addEventListener("click", async () => {
|
||||
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"]');
|
||||
if (HEATMAP_ENABLED && heatBtn && isCluster) {
|
||||
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;
|
||||
pendingFilterRef.current = `${mainFilter}|${subFilter}|${timeHours}`;
|
||||
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) {
|
||||
updateClusterAreas(map, []);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue