diff --git a/src/api/client.js b/src/api/client.js index ca35a12..07c354e 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -246,6 +246,40 @@ export async function fetchPostById(postId) { } } +export async function fetchPostTruth(postId) { + if (!postId) return null; + const qs = new URLSearchParams(); + qs.set("post_id", String(postId)); + try { + const res = await fetch(`${API_BASE}/post/truth?${qs.toString()}`, { + credentials: "include", + headers: { ...authHeaders() }, + }); + if (!res.ok) return null; + const data = await res.json().catch(() => null); + return data?.truth || null; + } catch { + return null; + } +} + +export async function votePostTruth(postId, vote) { + if (!postId) throw new Error("post_id required"); + const body = JSON.stringify({ post_id: postId, vote }); + const res = await fetch(`${API_BASE}/post/truth-vote`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + credentials: "include", + body, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || "truth vote failed"); + } + const data = await res.json().catch(() => ({})); + return data?.truth || null; +} + export async function updatePostVisibility(postId, payload = {}, token) { if (!postId) throw new Error("post_id required"); const headers = { "Content-Type": "application/json" }; diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index f659c09..3195c33 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -71,6 +71,8 @@ export default function MapView({ const overlayTimerRef = useRef(null); const pendingOpenPostIdRef = useRef(null); const pendingOpenFullRef = useRef(false); + const searchFitQueryRef = useRef(""); + const searchUserMovedRef = useRef(false); const openOverlayWhenReady = useCallback( (post, opts = {}) => { @@ -95,7 +97,7 @@ export default function MapView({ [mapRef, theme] ); - const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } = + const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate } = usePostsEngine({ theme, mapRef, @@ -312,12 +314,18 @@ export default function MapView({ ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); - if (msg.type === "new_post" && msg.post) handleIncomingPost(msg.post); + if (msg.type === "new_post" && msg.post) { + handleIncomingPost(msg.post); + return; + } + if (msg.type === "post_update") { + handlePostUpdate(msg); + } } catch {} }; return () => ws && ws.close(); - }, [handleIncomingPost]); + }, [handleIncomingPost, handlePostUpdate]); const handleFlyToMe = () => { const map = mapRef.current; @@ -422,13 +430,15 @@ export default function MapView({ } }; - // Zoom intelligent: quand on cherche, zoom pour voir tous les résultats + // Zoom intelligent: quand on cherche, zoom pour voir tous les résultats (une seule fois) useEffect(() => { if (!searchQuery || !visiblePosts.length) return; if (skipAutoZoomRef.current) { skipAutoZoomRef.current = false; return; } + if (searchUserMovedRef.current) return; + if (searchFitQueryRef.current === searchQuery) return; const map = mapRef.current; if (!map) return; @@ -472,12 +482,29 @@ export default function MapView({ duration: 1000 } ); + searchFitQueryRef.current = searchQuery; } }, 300); // Délai pour laisser les markers se créer return () => clearTimeout(timer); }, [searchQuery, visiblePosts, mapRef]); + useEffect(() => { + searchFitQueryRef.current = ""; + searchUserMovedRef.current = false; + }, [searchQuery]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (!searchQuery) return; + const handleMove = () => { + searchUserMovedRef.current = true; + }; + map.on("movestart", handleMove); + return () => map.off("movestart", handleMove); + }, [mapRef, searchQuery]); + const handleProfileVisitIsland = (island) => { console.log('[MapView] Visiting island from profile:', island.username); setSelectedProfile(null); // Close profile modal diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index c2526b0..8624e47 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -18,6 +18,8 @@ import { createPostComment, fetchPostLikeState, fetchProfile, + fetchPostTruth, + votePostTruth, } from "../../api/client"; import { trackEvent } from "../../utils/analytics"; @@ -126,6 +128,38 @@ function updateTruth(modalWrap, post) { if (label) label.textContent = `${score}`; } +function updateTruthVoteUI(modalWrap, stats) { + if (!modalWrap || !stats) return; + const aiEl = modalWrap.querySelector("[data-truth-ai]"); + const userEl = modalWrap.querySelector("[data-truth-user]"); + const countEl = modalWrap.querySelector("[data-truth-count]"); + const rail = modalWrap.querySelector(".sw-truth-rail"); + const marker = rail ? rail.querySelector(".sw-truth-rail-marker") : null; + const label = rail ? rail.querySelector(".sw-truth-rail-score") : null; + + const aiScore = Number.isFinite(stats.ai_score) ? Math.round(stats.ai_score) : stats.AIScore; + const userScore = Number.isFinite(stats.user_score) ? Math.round(stats.user_score) : stats.UserScore; + const truthScoreValue = Number.isFinite(stats.truth_score) ? Math.round(stats.truth_score) : stats.TruthScore; + const totalVotes = Number.isFinite(stats.total_votes) ? stats.total_votes : stats.TotalVotes; + + if (aiEl) aiEl.textContent = Number.isFinite(aiScore) ? String(aiScore) : "—"; + if (userEl) userEl.textContent = Number.isFinite(userScore) ? String(userScore) : "—"; + if (countEl) countEl.textContent = `${totalVotes || 0} votes`; + if (marker && Number.isFinite(truthScoreValue)) marker.style.top = `${100 - truthScoreValue}%`; + if (label && Number.isFinite(truthScoreValue)) label.textContent = `${truthScoreValue}`; + + const vote = (stats.user_vote || stats.UserVote || "").toString(); + const btns = modalWrap.querySelectorAll(".sw-truth-vote-btn"); + btns.forEach((btn) => { + const v = btn.getAttribute("data-truth-vote"); + btn.classList.toggle("is-active", vote && v === vote); + }); + const railUp = modalWrap.querySelector(".sw-truth-rail-btn-up"); + const railDown = modalWrap.querySelector(".sw-truth-rail-btn-down"); + if (railUp) railUp.classList.toggle("is-active", vote === "true"); + if (railDown) railDown.classList.toggle("is-active", vote === "false"); +} + function isAuthed() { try { const auth = loadAuth(); @@ -272,6 +306,34 @@ function mountCard(container, spec, data, theme, post) { ) ); + const rawScore = Number( + post?.truth_score ?? + post?.truthScore ?? + post?.truth_ai ?? + post?.truthAi ?? + post?.ai_score ?? + post?.AIScore + ); + const hasAI = Number.isFinite( + Number(post?.truth_ai ?? post?.truthAi ?? post?.ai_score ?? post?.AIScore) + ); + const hasScore = Number.isFinite(rawScore) && !(rawScore === 50 && !hasAI); + if (!hasScore && post?.id) { + fetchPostTruth(post.id).then((stats) => { + if (!stats) return; + const nextScore = Number(stats.truth_score ?? stats.TruthScore); + if (!Number.isFinite(nextScore)) return; + post.truth_score = nextScore; + const markerEl = container.querySelector(".sw-truth-mini-marker"); + const scoreEl = container.querySelector(".sw-truth-mini-score"); + if (markerEl) markerEl.style.top = `${100 - nextScore}%`; + if (scoreEl) { + scoreEl.style.background = truthColor(nextScore); + scoreEl.textContent = `${Math.round(nextScore)}`; + } + }); + } + return () => { try { root.unmount(); } catch {} }; @@ -353,16 +415,18 @@ function getPostTime(post) { } function truthScore(post) { - const raw = Number(post?.truth_score || post?.truthScore); + const raw = Number( + post?.truth_score ?? + post?.truthScore ?? + post?.truth_ai ?? + post?.truthAi ?? + post?.ai_score ?? + post?.AIScore + ); if (Number.isFinite(raw)) { return clamp(Math.round(raw), 0, 100); } - const seed = `${post?.id || ""}|${post?.url || ""}|${post?.title || ""}`; - let h = 0; - for (let i = 0; i < seed.length; i++) { - h = (h * 31 + seed.charCodeAt(i)) >>> 0; - } - return 35 + (h % 61); + return 50; } function truthColor(score) { @@ -414,6 +478,16 @@ function closeCenteredOverlay(map, opts = {}) { map.__swCenteredOverlay = null; + try { + const st = ov.interactionState || {}; + if (map) { + if (st.dragPan) map.dragPan?.enable?.(); + if (st.scrollZoom) map.scrollZoom?.enable?.(); + if (st.doubleClickZoom) map.doubleClickZoom?.enable?.(); + if (st.touchZoomRotate) map.touchZoomRotate?.enable?.(); + } + } catch {} + if (!skipHistory && ov.historyPushed && !ov.historyClosed) { ov.historyClosed = true; try { window.history.back(); } catch {} @@ -426,6 +500,17 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true }); + const interactionState = { + dragPan: map.dragPan && map.dragPan.isEnabled ? map.dragPan.isEnabled() : null, + scrollZoom: map.scrollZoom && map.scrollZoom.isEnabled ? map.scrollZoom.isEnabled() : null, + doubleClickZoom: map.doubleClickZoom && map.doubleClickZoom.isEnabled ? map.doubleClickZoom.isEnabled() : null, + touchZoomRotate: map.touchZoomRotate && map.touchZoomRotate.isEnabled ? map.touchZoomRotate.isEnabled() : null, + }; + try { if (map.dragPan) map.dragPan.disable(); } catch {} + try { if (map.scrollZoom) map.scrollZoom.disable(); } catch {} + try { if (map.doubleClickZoom) map.doubleClickZoom.disable(); } catch {} + try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {} + let postData = { ...(post || {}) }; const data = adaptPostToTemplateData(postData); const cat = normCatLabel(postData); @@ -494,12 +579,12 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
-
TRUE
+
${truth}
-
FALSE
+
${ @@ -509,7 +594,6 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { }
-
${escapeHtml(data?.headline || postData?.title || "Untitled")}
@@ -612,7 +696,11 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { closeCenteredOverlay(map, { force: true }); }); // inside click doesn't close - modalWrap.addEventListener("click", (e) => e.stopPropagation()); + const stopPropagation = (e) => e.stopPropagation(); + modalWrap.addEventListener("click", stopPropagation); + modalWrap.addEventListener("pointerdown", stopPropagation); + modalWrap.addEventListener("mousedown", stopPropagation); + modalWrap.addEventListener("touchstart", stopPropagation, { passive: true }); // close button const xbtn = modalWrap.querySelector(".sw-modal-x"); @@ -668,6 +756,12 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { }); } + if (postID > 0) { + fetchPostTruth(postID).then((stats) => { + if (stats) updateTruthVoteUI(modalWrap, stats); + }); + } + let liked = false; const likeBtn = modalWrap.querySelector('[data-action="like"]'); const commentInput = modalWrap.querySelector(".sw-livechat-input"); @@ -784,6 +878,26 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { }); } + const rail = modalWrap.querySelector(".sw-truth-rail"); + if (rail && postID > 0) { + const doVote = async (vote) => { + if (!isAuthed()) { + requestAuth("Please sign in or create a free account to vote on truth."); + return; + } + try { + rail.classList.add("sw-truth-vote-disabled"); + const stats = await votePostTruth(postID, vote); + if (stats) updateTruthVoteUI(modalWrap, stats); + } catch {} + rail.classList.remove("sw-truth-vote-disabled"); + }; + const voteUp = rail.querySelector(".sw-truth-rail-btn-up"); + const voteDown = rail.querySelector(".sw-truth-rail-btn-down"); + if (voteUp) voteUp.addEventListener("click", () => doVote("true")); + if (voteDown) voteDown.addEventListener("click", () => doVote("false")); + } + applyAuthUI(); const onAuth = () => { if (postID > 0 && isAuthed()) { @@ -808,6 +922,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { postId: postData?.id, modalWrapRef: modalWrap, backdrop, + interactionState, onKey, onPop, onAuth, diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 569e3ec..0a542fd 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -533,7 +533,7 @@ export function usePostsEngine({ ); const refreshVisibleAndMarkers = useCallback( - (tf) => { + (tf, force = false) => { const vAll = computeVisible(tf); const map = mapRef.current; let v = (searchQuery || "").trim() ? vAll : filterByBounds(vAll, map); @@ -543,7 +543,10 @@ export function usePostsEngine({ } // reduce wall blink: don't update if same ids - setVisiblePosts((prev) => (sameIdList(prev, v) ? prev : v)); + setVisiblePosts((prev) => { + if (force) return v; + return sameIdList(prev, v) ? prev : v; + }); // keep status silent (no annoying bubbles) setStatus(""); @@ -843,5 +846,85 @@ export function usePostsEngine({ [mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers] ); - return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost }; + const handlePostUpdate = useCallback( + (payload) => { + const postId = payload?.post_id || payload?.postId || payload?.id; + if (!postId) return; + + const counts = payload?.counts || null; + const truth = payload?.truth || null; + const truthScore = Number( + truth?.truth_score ?? + truth?.TruthScore ?? + payload?.truth_score ?? + payload?.truthScore + ); + + const updated = []; + let changed = false; + for (const p of allPostsRef.current || []) { + if ((p?.id || p?.ID) !== postId) { + updated.push(p); + continue; + } + const next = { ...p }; + if (counts) { + if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count; + if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count; + if (Number.isFinite(counts.share_count)) next.share_count = counts.share_count; + if (Number.isFinite(counts.comment_count)) next.comment_count = counts.comment_count; + } + if (Number.isFinite(truthScore)) next.truth_score = truthScore; + updated.push(next); + changed = true; + } + if (changed) { + allPostsRef.current = updated; + refreshVisibleAndMarkers(timeFilter, true); + } + + try { + if (Number.isFinite(truthScore)) { + const markers = markersRef.current || []; + for (const item of markers) { + if (!item || item.id !== postId) continue; + const el = item.el; + if (!el) continue; + const marker = el.querySelector(".sw-truth-mini-marker"); + const scoreEl = el.querySelector(".sw-truth-mini-score"); + if (marker) marker.style.top = `${100 - truthScore}%`; + if (scoreEl) { + scoreEl.style.background = `hsl(${Math.round((truthScore / 100) * 120)} 80% 42%)`; + scoreEl.textContent = `${Math.round(truthScore)}`; + } + } + } + const map = mapRef.current; + const ov = map?.__swCenteredOverlay; + if (ov && ov.postId === postId && ov.modalWrapRef) { + const wrap = ov.modalWrapRef; + if (counts) { + const mapStat = (key, value) => { + if (!Number.isFinite(value)) return; + const el = wrap.querySelector(`[data-stat="${key}"] .sw-stat-num`); + if (el) el.textContent = String(value); + }; + mapStat("views", counts.view_count); + mapStat("likes", counts.like_count); + mapStat("shares", counts.share_count); + mapStat("comments", counts.comment_count); + } + if (Number.isFinite(truthScore)) { + const marker = wrap.querySelector(".sw-truth-rail-marker"); + const label = wrap.querySelector(".sw-truth-rail-score"); + if (marker) marker.style.top = `${100 - truthScore}%`; + if (label) label.textContent = `${Math.round(truthScore)}`; + } + } + } catch {} + }, + [mapRef, refreshVisibleAndMarkers, timeFilter] + ); + + return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate }; } diff --git a/src/styles/mapMarkers.css b/src/styles/mapMarkers.css index 6d2f21d..3fca9a8 100644 --- a/src/styles/mapMarkers.css +++ b/src/styles/mapMarkers.css @@ -598,18 +598,22 @@ body[data-theme="light"] .sw-chat-bubble::before{ .sw-modal-hero-row{ display:flex; - gap:10px; + gap:16px; align-items: stretch; min-width: 0; width: 100%; + padding-left: 0; } .sw-truth-rail{ - width: 16px; - min-width: 16px; + width: 22px; + min-width: 22px; height: 180px; min-height: 180px; - margin-left: 3px; + margin-left: -2px; + margin-right: 10px; + position: relative; + z-index: 2; display:flex; flex-direction:column; align-items:center; @@ -619,6 +623,8 @@ body[data-theme="light"] .sw-chat-bubble::before{ letter-spacing: .08em; text-transform: uppercase; color:#e2e8f0; + pointer-events: auto; + touch-action: manipulation; } .sw-truth-rail-bar{ @@ -629,6 +635,7 @@ body[data-theme="light"] .sw-chat-bubble::before{ border: 2px solid rgba(0,0,0,0.45); position: relative; box-shadow: inset 0 0 10px rgba(0,0,0,0.35); + margin: 0 auto; } .sw-truth-rail-marker{ @@ -651,12 +658,122 @@ body[data-theme="light"] .sw-chat-bubble::before{ padding: 4px 6px; border-radius: 999px; box-shadow: 0 6px 12px rgba(0,0,0,0.35); + margin: 0 auto; + position: relative; + left: -6px; +} + +.sw-truth-rail-btn{ + width: 100%; + border: 0; + background: rgba(56,189,248,0.16); + color:#e8f5ff; + font-size: 9px; + font-weight: 900; + letter-spacing: .06em; + text-transform: uppercase; + padding: 6px 2px; + border-radius: 10px; + cursor: pointer; + text-align: center; + pointer-events: auto; + touch-action: manipulation; + position: relative; + left: -8px; +} + +.sw-truth-rail-btn-down{ + background: rgba(239,68,68,0.18); +} + +.sw-truth-rail-btn.is-active{ + background: rgba(34,197,94,0.35); + color: #eafff3; +} + +.sw-truth-rail-btn-down.is-active{ + background: rgba(239,68,68,0.35); + color: #ffecec; +} + +.sw-truth-vote-disabled{ + opacity: 0.7; + pointer-events: none; } .sw-modal-hero-row{ display:flex; gap:12px; align-items: stretch; + min-width: 0; + width: 100%; +} + +.sw-truth-vote-row{ + display:flex; + gap:12px; + align-items:center; + justify-content:space-between; + background: rgba(3, 14, 32, 0.55); + border: 1px solid rgba(90, 190, 255, 0.22); + border-radius: 14px; + padding: 10px 12px; + margin: 6px 0 10px; +} + +.sw-truth-vote-meta{ + display:flex; + flex-direction:column; + gap:4px; + min-width:0; +} + +.sw-truth-vote-title{ + font-size: 11px; + font-weight: 900; + letter-spacing: .08em; + text-transform: uppercase; + color: #c7e3ff; +} + +.sw-truth-vote-scores{ + display:flex; + gap:10px; + font-size: 12px; + color:#e8f5ff; + font-weight: 700; +} + +.sw-truth-vote-count{ + font-size: 11px; + color: rgba(215,233,255,0.7); +} + +.sw-truth-vote-actions{ + display:flex; + gap:8px; +} + +.sw-truth-vote-btn{ + background: rgba(56,189,248,0.14); + border: 1px solid rgba(56,189,248,0.35); + color: #e8f5ff; + font-weight: 900; + padding: 6px 10px; + border-radius: 10px; + cursor:pointer; +} + +.sw-truth-vote-btn.is-active{ + background: rgba(34,197,94,0.25); + border-color: rgba(34,197,94,0.6); + color:#eafff3; +} + +.sw-truth-vote-btn[data-truth-vote="false"].is-active{ + background: rgba(239,68,68,0.25); + border-color: rgba(239,68,68,0.6); + color:#ffecec; } .sw-card-wrap{