diff --git a/src/api/client.js b/src/api/client.js index e6634e4..bb5ae07 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -415,9 +415,11 @@ export async function fetchPostTruth(postId) { } } -export async function votePostTruth(postId, vote) { - if (!postId) throw new Error("post_id required"); - const body = JSON.stringify({ post_id: postId, vote }); +export async function votePostTruth(postId, vote, guid = null) { + if (!postId && !guid) throw new Error("post_id or guid required"); + const payload = { post_id: postId, vote }; + if (guid) payload.guid = guid; + const body = JSON.stringify(payload); const res = await fetch(`${apiBase()}/post/truth-vote`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, @@ -523,7 +525,17 @@ export async function fetchSmartFeed(params = {}, { signal } = {}) { try { const data = await fetchJsonCached(url, { ttlMs: 10000, signal }); const items = Array.isArray(data) ? data : data.results || data.items || []; - console.log('[fetchSmartFeed] Got', items.length, 'posts, content_types:', [...new Set(items.map(p => p.content_type))]); + // DEBUG: Check tier values from API + const tierCounts = { premium: 0, regular: 0, none: 0 }; + items.forEach(p => { + if (p.tier === 'premium') tierCounts.premium++; + else if (p.tier === 'regular') tierCounts.regular++; + else tierCounts.none++; + }); + console.log('[fetchSmartFeed] Got', items.length, 'posts, TIER breakdown:', tierCounts, 'content_types:', [...new Set(items.map(p => p.content_type))]); + if (tierCounts.premium > 0) { + console.log('[fetchSmartFeed] Sample premium post:', items.find(p => p.tier === 'premium')); + } return await resolveAssetRefsInPosts(items); } catch (err) { if (err.name === "AbortError") return []; // silently ignore aborted requests @@ -766,14 +778,16 @@ export async function fetchProfile(username = "") { } } -export async function recordPostView(postId) { - if (!postId) return { ok: false }; +export async function recordPostView(postId, guid = null) { + if (!postId && !guid) return { ok: false }; try { + const body = { post_id: postId }; + if (guid) body.guid = guid; const res = await fetch(`${apiBase()}/post/view`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ post_id: postId }), + body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; @@ -782,14 +796,16 @@ export async function recordPostView(postId) { } } -export async function togglePostLike(postId, like = true) { - if (!postId) return { ok: false }; +export async function togglePostLike(postId, like = true, guid = null) { + if (!postId && !guid) return { ok: false }; try { + const body = { post_id: postId, like }; + if (guid) body.guid = guid; const res = await fetch(`${apiBase()}/post/like`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, credentials: "include", - body: JSON.stringify({ post_id: postId, like }), + body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; @@ -798,14 +814,16 @@ export async function togglePostLike(postId, like = true) { } } -export async function recordPostShare(postId) { - if (!postId) return { ok: false }; +export async function recordPostShare(postId, guid = null) { + if (!postId && !guid) return { ok: false }; try { + const body = { post_id: postId }; + if (guid) body.guid = guid; const res = await fetch(`${apiBase()}/post/share`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ post_id: postId }), + body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; @@ -814,10 +832,11 @@ export async function recordPostShare(postId) { } } -export async function fetchPostComments(postId, limit = 20) { - if (!postId) return []; +export async function fetchPostComments(postId, limit = 20, guid = null) { + if (!postId && !guid) return []; const qs = new URLSearchParams(); qs.set("post_id", String(postId)); + if (guid) qs.set("guid", guid); if (limit) qs.set("limit", String(limit)); try { const res = await fetch(`${apiBase()}/post/comments?${qs.toString()}`, { @@ -831,14 +850,17 @@ export async function fetchPostComments(postId, limit = 20) { } } -export async function createPostComment(postId, body) { - if (!postId || !body) return { ok: false }; +export async function createPostComment(postId, body, guid = null) { + if (!postId && !guid) return { ok: false }; + if (!body) return { ok: false }; try { + const reqBody = { post_id: postId, body }; + if (guid) reqBody.guid = guid; const res = await fetch(`${apiBase()}/post/comment`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, credentials: "include", - body: JSON.stringify({ post_id: postId, body }), + body: JSON.stringify(reqBody), }); const data = await res.json().catch(() => ({})); return { ...data, ok: res.ok }; diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index f42cc78..58701e9 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -4,6 +4,14 @@ import { createRoot } from "react-dom/client"; const SW_ANIM_MS = 840; +// Get consistent ID for a post (handles numeric IDs and UUID fallback) +function getPostId(p) { + const numId = p?.id ?? p?._id; + if (numId != null && numId !== 0) return numId; + if (p?.guid) return p.guid; + return numId; +} + import CardRenderer from "../Cards/CardRenderer"; import { cardTokens } from "../../theme/cardTokens"; import { getTemplateSpecForPost, getTemplateKeyForPost, adaptPostToTemplateData } from "./templateSpecs"; @@ -454,13 +462,11 @@ function mountCard(container, spec, data, theme, post) { ); } + // Render card with CardRenderer root.render( React.createElement( - "div", - { - className: "sw-card-wrap", - style: { width: spec?.size?.w, height: spec?.size?.h }, - }, + React.Fragment, + null, ...children ) ); @@ -1804,9 +1810,9 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { }); } - if (postID > 0 && !modalWrap.__swViewed) { + if ((postID > 0 || postData?.guid) && !modalWrap.__swViewed) { modalWrap.__swViewed = true; - recordPostView(postID).then((res) => { + recordPostView(postID, postData?.guid).then((res) => { const counts = res?.counts; if (counts) { updateStat(modalWrap, "views", counts.view_count); @@ -1869,11 +1875,13 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { } const shareBtn = modalWrap.querySelector('[data-action="share"]'); - if (shareBtn && postID > 0) { + if (shareBtn && (postID > 0 || postData?.guid)) { shareBtn.addEventListener("click", async () => { - const shareUrl = `${window.location.origin}/p/${postID}`; + const shareUrl = postData?.guid + ? `${window.location.origin}/n/${postData.guid}` + : `${window.location.origin}/p/${postID}`; const shareTitle = postData?.title || postData?.Title || "SocioWire"; - trackEvent("post_share_click", { post_id: postID, context: "map" }); + trackEvent("post_share_click", { post_id: postID || postData?.guid, context: "map" }); try { if (navigator.share) { await navigator.share({ title: shareTitle, url: shareUrl }); @@ -1881,7 +1889,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { await navigator.clipboard.writeText(shareUrl); } } catch {} - const res = await recordPostShare(postID); + const res = await recordPostShare(postID, postData?.guid); const counts = res?.counts; if (counts) { updateStat(modalWrap, "views", counts.view_count); @@ -1892,7 +1900,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { // Truth vote handlers (thumbs up/down in truth badge) const truthBadgeNew = modalWrap.querySelector('.sw-truth-badge'); - if (truthBadgeNew && postID > 0) { + if (truthBadgeNew && (postID > 0 || postData?.guid)) { const downvoteBtn = truthBadgeNew.querySelector('.sw-truth-downvote'); const upvoteBtn = truthBadgeNew.querySelector('.sw-truth-upvote'); const scoreDisplay = truthBadgeNew.querySelector('.sw-truth-badge-score'); @@ -1936,8 +1944,8 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { } try { - trackEvent("truth_vote_click", { post_id: postID, vote, context: "map" }); - const stats = await votePostTruth(postID, vote); + trackEvent("truth_vote_click", { post_id: postID || postData?.guid, vote, context: "map" }); + const stats = await votePostTruth(postID, vote, postData?.guid); if (stats) { currentVote = vote; updateButtonStyles(); @@ -2192,10 +2200,10 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { if (!body) return; trackEvent("post_comment_submit", { post_id: postID, context: "map" }); commentBtn.disabled = true; - const res = await createPostComment(postID, body); + const res = await createPostComment(postID, body, postData?.guid); if (res?.ok) { commentInput.value = ""; - const existing = await fetchPostComments(postID, 20); + const existing = await fetchPostComments(postID, 20, postData?.guid); renderComments(commentsBody, existing); const counts = res?.counts; if (counts) { @@ -2354,6 +2362,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the requestAnimationFrame(() => root.classList.add("sw-appear-in")); root.className = "post-pin post-pin--compact"; root.style.zIndex = "100"; + root.__post = post; function unmountIfAny() { @@ -2368,19 +2377,12 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the root.className = "post-pin post-pin--compact sw-appear sw-appear-in"; root.style.zIndex = "100"; - // Add connecting line if offset is significant - // DISABLED: No longer using pixel offsets - const hasOffset = false; - const lineHTML = ''; - - // Add slight random tilt for polaroid effect (based on post ID for consistency) const postId = post?.id || post?.ID || 0; - const tiltAngle = ((postId % 11) - 5) * 1.2; // Range: -6 to +6 degrees + const tiltAngle = ((postId % 11) - 5) * 1.2; root.innerHTML = `
- ${lineHTML} `; root.__swCompactReady = true; } @@ -2430,7 +2432,18 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the .setLngLat(lngLat) .addTo(map); - markersRef.current.push({ id: post.id, marker, el: root, post, type: "full" }); + // CRITICAL: Set z-index on the marker's container element (not just inner content) + // MapLibre GL wraps markers in a container - z-index must be on the container + try { + const markerEl = marker.getElement(); + if (markerEl) { + markerEl.style.zIndex = "100"; + } + } catch (e) { + console.warn('[createMarkerForPost] Could not set marker z-index:', e); + } + + markersRef.current.push({ id: getPostId(post), marker, el: root, post, type: "full" }); root.addEventListener("click", (ev) => { ev.stopPropagation(); @@ -2623,6 +2636,8 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe root.style.flexDirection = "column"; root.style.alignItems = "center"; root.style.zIndex = isWeatherPost ? "10" : "2"; + root.style.contain = "layout style paint"; // CSS containment for performance + root.style.willChange = "transform, opacity"; // Hint for GPU acceleration root.__post = post; function renderSimple() { @@ -2641,14 +2656,14 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe ? 'linear-gradient(135deg, rgba(56, 189, 248, 0.9), rgba(59, 130, 246, 0.9))' : isWeather ? weatherColor - : getMarkerColorForCategory(activePost?.category); + : getMarkerColorForCategory(activePost?.category, activePost); const pinColor = isLive ? livePin : (isCamera || isVideo) ? 'rgba(56, 189, 248, 0.9)' : isWeather ? weatherPin - : getMarkerColorForCategory(activePost?.category); + : getMarkerColorForCategory(activePost?.category, activePost); const img = normalizeImageUrl(activePost?.image_small || ""); root.__lastTemplateKey = getTemplateKeyForPost(activePost); root.__lastImage = img || ""; @@ -2911,7 +2926,15 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe .setLngLat(lngLat) .addTo(map); - markersRef.current.push({ id: post.id, marker, el: root, post, type: "simple" }); + // Set z-index on marker container - simple pins at z-index 1 (below cards at 100) + try { + const markerEl = marker.getElement(); + if (markerEl) { + markerEl.style.zIndex = "1"; + } + } catch (e) {} + + markersRef.current.push({ id: getPostId(post), marker, el: root, post, type: "simple" }); } /** diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 740e02c..c1c742e 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -8,8 +8,27 @@ import { prioritizePosts } from "./postPriority"; import { trackEvent } from "../../utils/analytics"; import { matchesContentFilter } from "../Filters/ContentFilters"; +// Generate stable numeric ID from a string (like guid or url_hash) +function hashStringToNumber(str) { + if (!str) return 0; + let hash = 0; + for (let i = 0; i < Math.min(str.length, 8); i++) { + hash = (hash << 4) + str.charCodeAt(i); + } + return Math.abs(hash); +} + function getId(p) { - return p?.id ?? p?._id ?? null; + // App posts (news, external apps) - use guid directly as-is + if (p?.app_id && p?.guid) return p.guid; + + // Native posts - use numeric id + const numId = p?.id ?? p?._id; + if (numId != null && numId !== 0) return numId; + + // Fallback: if has guid but no app_id, still use guid + if (p?.guid) return p.guid; + return numId; } function normalizePostId(post) { @@ -297,8 +316,8 @@ function buildClusterAreaFeatures(posts) { return features; } -const MAX_POOL_POSTS = 160; -const MAX_VISIBLE_POSTS = 45; +const MAX_POOL_POSTS = 1500; +const MAX_VISIBLE_POSTS = 1000; // High limit to allow navigation without losing posts function expandBounds(bounds, padRatio = 0) { if (!bounds) return null; @@ -793,7 +812,15 @@ export function usePostsEngine({ fullCardPosts = prioritized.fullCardPosts; simpleMarkerPosts = prioritized.simpleMarkerPosts; + // DEBUG: Log marker type assignments + console.log('[syncMarkers] fullCardPosts:', fullCardPosts.length, 'simpleMarkerPosts:', simpleMarkerPosts.length); + if (fullCardPosts.length > 0) { + console.log('[syncMarkers] First fullCard:', { id: getId(fullCardPosts[0]), tier: fullCardPosts[0]?.tier, title: fullCardPosts[0]?.title?.substring(0, 30) }); + } + // Combine both types for "wanted" tracking + // Use Set with String for O(1) lookup and avoid type mismatch (string vs number) + const fullCardIds = new Set(fullCardPosts.map(p => String(getId(p)))); const wanted = new Map(); for (const post of visible) { const id = getId(post); @@ -803,10 +830,10 @@ export function usePostsEngine({ if (ct === "weather") { continue; // Skip weather markers - they appear in the search bar widget } - // Tag avec le type de marqueur souhaité + // Tag avec le type de marqueur souhaité - use String for comparison const isFullCard = - id === forceFullPostId || - fullCardPosts.some(p => getId(p) === id); + String(id) === String(forceFullPostId) || + fullCardIds.has(String(id)); wanted.set(id, { post, type: isFullCard ? 'full' : 'simple' }); } } @@ -863,16 +890,56 @@ export function usePostsEngine({ // add missing markers - use appropriate type based on priority // Camera/video ALWAYS use simple markers regardless of priority + let createdFull = 0, createdSimple = 0; + let wantedFullCount = 0, wantedSimpleCount = 0, skippedCount = 0; for (const [id, { post, type }] of wanted.entries()) { - if (have.has(id)) continue; + if (type === 'full') wantedFullCount++; + else wantedSimpleCount++; + + if (have.has(id)) { + skippedCount++; + continue; + } const ct = (post?.content_type || '').toLowerCase(); if (type === 'full' && ct !== 'camera' && ct !== 'video') { + console.log('[syncMarkers] Creating FULL CARD marker for:', id, post?.tier, post?.title?.substring(0, 25)); createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme); + createdFull++; } else { createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRef, theme); + createdSimple++; } } + // Store wanted type counts for debug + if (typeof window !== 'undefined') { + window.__swMarkerDebugLoop = { wantedFullCount, wantedSimpleCount, skippedCount }; + } + console.log('[syncMarkers] CREATED markers: full=', createdFull, 'simple=', createdSimple); + + // Store debug info for visibility + if (typeof window !== 'undefined') { + // Count tier values in visible + let premiumCount = 0, regularCount = 0, noTierCount = 0; + for (const p of visible) { + if (p.tier === 'premium') premiumCount++; + else if (p.tier === 'regular') regularCount++; + else noTierCount++; + } + window.__swMarkerDebug = { + fullCardPosts: fullCardPosts.length, + simpleMarkerPosts: simpleMarkerPosts.length, + createdFull, + createdSimple, + wanted: wanted.size, + visible: visible.length, + premiumTier: premiumCount, + regularTier: regularCount, + noTier: noTierCount, + firstFullId: fullCardPosts[0] ? (fullCardPosts[0].id || fullCardPosts[0].guid) : 'none', + timestamp: Date.now() + }; + } }, [mapRef, markersRef, expandedElRef, theme, searchQuery, forceFullPostId, weatherEnabled] ); @@ -929,11 +996,8 @@ export function usePostsEngine({ return false; } - // Apply time filter (skip when showing search results - user wants all matches) - if (!searchResultsRef.current && tf && tf > 0) { - const postTime = effectivePostTime(p); - if (!matchesTimeFilter(postTime, tf)) return false; - } + // Time filter is now applied on backend (news-app Elasticsearch query) + // No frontend time filter needed - backend already filters by time_hours if (isClusterFocus) { const id = Number(getId(p)); @@ -1262,7 +1326,15 @@ export function usePostsEngine({ if (debugEnabled) { const count = Array.isArray(newPosts) ? newPosts.length : 0; const q = (searchQuery || "").trim(); - setDebugStatus(`tf:${timeHours || 0} fetched:${count} visible:${visible.length}${q ? ` q:${q}` : ""}`); + // Add tier breakdown to debug status + const tierCounts = { premium: 0, regular: 0 }; + if (Array.isArray(newPosts)) { + newPosts.forEach(p => { + if (p.tier === 'premium') tierCounts.premium++; + else if (p.tier === 'regular') tierCounts.regular++; + }); + } + setDebugStatus(`v151 tf:${timeHours || 0} f:${count} v:${visible.length} P:${tierCounts.premium} R:${tierCounts.regular}${q ? ` q:${q}` : ""}`); } const now = Date.now(); if (now - analyticsDebounceRef.current > 4000) { diff --git a/src/components/Posts/PostDetailModal.jsx b/src/components/Posts/PostDetailModal.jsx index 05e44ed..b93f1c1 100644 --- a/src/components/Posts/PostDetailModal.jsx +++ b/src/components/Posts/PostDetailModal.jsx @@ -624,7 +624,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe // Initial data fetch useEffect(() => { if (!postId) return; - recordPostView(postId).then(res => { + recordPostView(postId, post?.guid).then(res => { if (res?.counts) setCounts(c => ({ ...c, views: res.counts.view_count || c.views, likes: res.counts.like_count || c.likes })); }); fetchPostLikeState(postId).then(res => { if (res?.liked !== undefined) setLiked(res.liked); }); @@ -690,18 +690,18 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe const newLiked = !liked; setLiked(newLiked); setCounts(c => ({ ...c, likes: c.likes + (newLiked ? 1 : -1) })); - const res = await togglePostLike(postId, newLiked); + const res = await togglePostLike(postId, newLiked, post?.guid); if (res?.counts) setCounts(c => ({ ...c, likes: res.counts.like_count ?? c.likes })); - }, [postId, liked]); + }, [postId, liked, post?.guid]); const handleShare = useCallback(async () => { if (!postId) return; const shareUrl = `${window.location.origin}/p/${postId}`; if (navigator.share) { try { await navigator.share({ title: post?.title, url: shareUrl }); } catch {} } else { navigator.clipboard?.writeText(shareUrl); } - const res = await recordPostShare(postId); + const res = await recordPostShare(postId, post?.guid); if (res?.counts) setCounts(c => ({ ...c, shares: res.counts.share_count ?? c.shares })); - }, [postId, post?.title]); + }, [postId, post?.title, post?.guid]); const handleTruthVote = useCallback(async (vote) => { if (!postId) return; @@ -709,9 +709,9 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe window.dispatchEvent(new CustomEvent("sociowire:auth-required")); return; } - const res = await votePostTruth(postId, vote); + const res = await votePostTruth(postId, vote, post?.guid); if (res) setTruth({ score: res.truth_score ?? res.user_score ?? truth.score, count: res.vote_count ?? truth.count }); - }, [postId, truth, token]); + }, [postId, truth, token, post?.guid]); const handleComment = useCallback(async () => { const body = commentInput.trim(); @@ -727,7 +727,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe // Retry logic - try up to 3 times with delay let res = null; for (let attempt = 0; attempt < 3; attempt++) { - res = await createPostComment(postId, body); + res = await createPostComment(postId, body, post?.guid); if (res?.ok) break; if (attempt < 2) await new Promise(r => setTimeout(r, 500)); // wait before retry } @@ -905,7 +905,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe {/* Content Section */}