From ffeaee6e88b5ca489d5c1d2b0e1960b9391d2d63 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 24 Dec 2025 23:02:11 -0500 Subject: [PATCH] Improve post modal auth flow and share handling --- src/api/client.js | 129 +++++++++++ src/auth/AuthContext.jsx | 17 ++ src/components/Layout/TopBar.jsx | 19 +- src/components/Map/MapView.jsx | 116 +++++++++- src/components/Map/markerManager.js | 319 +++++++++++++++++++++++++-- src/components/Map/usePostsEngine.js | 58 +++-- src/styles/auth-modal.css | 2 +- src/styles/mapMarkers.css | 80 ++++++- 8 files changed, 682 insertions(+), 58 deletions(-) diff --git a/src/api/client.js b/src/api/client.js index b3b4762..0b00a09 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -2,8 +2,21 @@ * SocioWire frontend API client */ +import { loadAuth } from "../auth/authStorage"; + const API_BASE = "/api"; +function authHeaders() { + try { + const auth = loadAuth(); + const token = auth?.access_token; + if (!token) return {}; + return { Authorization: `Bearer ${token}` }; + } catch { + return {}; + } +} + /** * Récupère la liste des posts, avec filtres optionnels. */ @@ -129,6 +142,25 @@ export async function createPost(payload, token) { return res.json().catch(() => ({})); } +/** + * Récupère un post par ID (détails + counts) + */ +export async function fetchPostById(postId) { + if (!postId) return null; + const qs = new URLSearchParams(); + qs.set("id", String(postId)); + try { + const res = await fetch(`${API_BASE}/post?${qs.toString()}`, { + credentials: "include", + }); + if (!res.ok) return null; + const data = await res.json().catch(() => null); + return data && typeof data === "object" ? data : null; + } catch { + return null; + } +} + /** @@ -514,3 +546,100 @@ export async function fetchIsland(islandId) { } */ } + +export async function recordPostView(postId) { + if (!postId) return { ok: false }; + try { + const res = await fetch(`${API_BASE}/post/view`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ post_id: postId }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +export async function togglePostLike(postId, like = true) { + if (!postId) return { ok: false }; + try { + const res = await fetch(`${API_BASE}/post/like`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + credentials: "include", + body: JSON.stringify({ post_id: postId, like }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +export async function recordPostShare(postId) { + if (!postId) return { ok: false }; + try { + const res = await fetch(`${API_BASE}/post/share`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ post_id: postId }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +export async function fetchPostComments(postId, limit = 20) { + if (!postId) return []; + const qs = new URLSearchParams(); + qs.set("post_id", String(postId)); + if (limit) qs.set("limit", String(limit)); + try { + const res = await fetch(`${API_BASE}/post/comments?${qs.toString()}`, { + credentials: "include", + }); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +export async function createPostComment(postId, body) { + if (!postId || !body) return { ok: false }; + try { + const res = await fetch(`${API_BASE}/post/comment`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + credentials: "include", + body: JSON.stringify({ post_id: postId, body }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +export async function fetchPostLikeState(postId) { + if (!postId) return { ok: false }; + const qs = new URLSearchParams(); + qs.set("post_id", String(postId)); + try { + const res = await fetch(`${API_BASE}/post/like-state?${qs.toString()}`, { + credentials: "include", + headers: { ...authHeaders() }, + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index ebccb1f..e2c1fd9 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -157,6 +157,7 @@ export function AuthProvider({ children }) { const [needsEmailVerification, setNeedsEmailVerification] = useState(false); const refreshTimer = useRef(null); + const lastAuthStatusRef = useRef(null); function setAnon(msg = "") { setStatus("anon"); @@ -382,6 +383,22 @@ export function AuthProvider({ children }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + const authenticated = status === "auth"; + const username = user?.username || ""; + const token = tokens?.access_token || ""; + const nextKey = `${authenticated}:${username}:${token ? "t" : "n"}`; + if (lastAuthStatusRef.current === nextKey) return; + lastAuthStatusRef.current = nextKey; + try { + window.dispatchEvent( + new CustomEvent("sociowire:auth-changed", { + detail: { authenticated, username }, + }) + ); + } catch {} + }, [status, user, tokens]); + // Backward-compatible fields expected by TopBar/MapView const value = useMemo(() => { const loading = status === "loading"; diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index 3291fa5..50997bf 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -82,6 +82,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } const [signupInfo, setSignupInfo] = useState(""); const [signupLoading, setSignupLoading] = useState(false); const [showInstallBtn, setShowInstallBtn] = useState(false); + const [authNotice, setAuthNotice] = useState(""); const openModal = (nextMode) => { setMode(nextMode); @@ -93,12 +94,27 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } setShowAuth(false); setLoginPass(""); setSignupError(""); + setAuthNotice(""); }; useEffect(() => { if (authenticated) setShowAuth(false); }, [authenticated]); + useEffect(() => { + const handler = (e) => { + const detail = e?.detail || {}; + const msg = + (detail.message || "").toString().trim() || + "Please sign in or create a free account to continue."; + setAuthNotice(msg); + setMode(detail.mode === "signup" ? "signup" : "login"); + setShowAuth(true); + }; + window.addEventListener("sociowire:auth-required", handler); + return () => window.removeEventListener("sociowire:auth-required", handler); + }, []); + // Check if PWA install should be shown (simple: hide if already PWA) useEffect(() => { const checkInstall = () => { @@ -382,6 +398,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } {signupInfo ?
{signupInfo}
: null} + {authNotice ?
{authNotice}
: null} {mode === "login" && lastError ? (
{lastError} @@ -455,4 +472,4 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } ) : null} ); -} \ No newline at end of file +} diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index e8438c5..ff71f70 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import "maplibre-gl/dist/maplibre-gl.css"; import "../../styles/map.css"; import "../../styles/mapMarkers.css"; @@ -14,11 +14,13 @@ import { import { useMapCore } from "./useMapCore"; import { useUserPosition } from "./useUserPosition"; import { usePostsEngine } from "./usePostsEngine"; +import { openCenteredOverlay } from "./markerManager"; import { useAuth } from "../../auth/AuthContext"; import FilterButtons from "../Filters/FilterButtons"; import TimeFilterButtons from "../Filters/TimeFilterButtons"; import SmartSearchBar from "../Search/SmartSearchBar"; import UserProfile from "../Profile/UserProfile"; +import { fetchPostById } from "../../api/client"; export default function MapView({ theme = "dark", @@ -62,6 +64,34 @@ export default function MapView({ // Profile state (selectedIsland is now passed from App.jsx) const [selectedProfile, setSelectedProfile] = useState(null); + const openedPostRef = useRef(null); + const queryPostRef = useRef(false); + const overlayTimerRef = useRef(null); + const pendingOpenPostIdRef = useRef(null); + const pendingOpenFullRef = useRef(false); + + const openOverlayWhenReady = useCallback( + (post, opts = {}) => { + if (!post?.id) return; + const { fullScreen = false } = opts; + const tryOpen = () => { + const map = mapRef.current; + if (!map) return false; + openCenteredOverlay({ map, post, theme, fullScreen }); + return true; + }; + + if (tryOpen()) return; + if (overlayTimerRef.current) clearInterval(overlayTimerRef.current); + overlayTimerRef.current = setInterval(() => { + if (tryOpen()) { + clearInterval(overlayTimerRef.current); + overlayTimerRef.current = null; + } + }, 220); + }, + [mapRef, theme] + ); const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } = usePostsEngine({ @@ -76,6 +106,7 @@ export default function MapView({ expandedElRef, onAutoWidenTimeFilter: (next) => setTimeFilter(next), onSelectPost, + username, }); const [isCreating, setIsCreating] = useState(false); @@ -168,7 +199,63 @@ export default function MapView({ if (typeof lng === "number" && typeof lat === "number") { map.flyTo({ center: [lng, lat], zoom: 8, speed: 1.4 }); } - }, [selectedPost, mapRef]); + if (selectedPost?.id && openedPostRef.current !== selectedPost.id) { + openedPostRef.current = selectedPost.id; + const wantFull = pendingOpenFullRef.current && pendingOpenPostIdRef.current === selectedPost.id; + openOverlayWhenReady(selectedPost, { fullScreen: wantFull }); + if (wantFull) { + pendingOpenFullRef.current = false; + pendingOpenPostIdRef.current = null; + } + } + }, [selectedPost, mapRef, theme]); + + useEffect(() => { + if (queryPostRef.current) return; + queryPostRef.current = true; + const qs = new URLSearchParams(window.location.search || ""); + let idRaw = qs.get("post_id") || qs.get("id") || qs.get("p"); + if (!idRaw) { + const path = (window.location.pathname || "").trim(); + const m = path.match(/^\/p\/(\d+)/); + if (m && m[1]) { + idRaw = m[1]; + try { + window.history.replaceState(null, "", `/?post_id=${m[1]}`); + } catch {} + } + } + if (!idRaw) return; + const id = Number(idRaw); + if (!Number.isFinite(id) || id <= 0) return; + pendingOpenPostIdRef.current = id; + pendingOpenFullRef.current = true; + fetchPostById(id).then((post) => { + if (!post) return; + onSelectPost?.(post); + openOverlayWhenReady(post, { fullScreen: true }); + pendingOpenPostIdRef.current = null; + pendingOpenFullRef.current = false; + }); + }, [onSelectPost, openOverlayWhenReady]); + + useEffect(() => { + const wantId = pendingOpenPostIdRef.current; + if (!wantId || !Array.isArray(visiblePosts) || !visiblePosts.length) 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; + }, [visiblePosts, onSelectPost, openOverlayWhenReady]); + + useEffect(() => { + return () => { + if (overlayTimerRef.current) clearInterval(overlayTimerRef.current); + }; + }, []); useEffect(() => { const el = stageRef.current; @@ -371,14 +458,31 @@ export default function MapView({ const handleOpenCreate = () => { if (!authenticated) { - alert("You must be logged in to place a wire."); + try { + window.dispatchEvent( + new CustomEvent("sociowire:auth-required", { + detail: { + message: "Please sign in or create a free account to create a post.", + mode: "login", + }, + }) + ); + } catch {} return; } if (needsEmailVerification) { - alert( - "You must verify your email before posting. Use 'Resend email' in the top bar, then login again." - ); + try { + window.dispatchEvent( + new CustomEvent("sociowire:auth-required", { + detail: { + message: + "Please verify your email before creating a post. Check your inbox or resend verification in the top bar.", + mode: "login", + }, + }) + ); + } catch {} return; } diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index bf1f0a3..ba8b759 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -8,6 +8,16 @@ import CardRenderer from "../Cards/CardRenderer"; import { cardTokens } from "../../theme/cardTokens"; import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs"; import { getMarkerColorForCategory } from "./postPriority"; +import { loadAuth } from "../../auth/authStorage"; +import { + recordPostShare, + recordPostView, + togglePostLike, + fetchPostById, + fetchPostComments, + createPostComment, + fetchPostLikeState, +} from "../../api/client"; export function clearAllMarkers(markersRef, expandedElRef) { if (markersRef.current) { @@ -31,6 +41,121 @@ export function clearOcclusion(markersRef) { } } +function formatCount(value) { + if (!Number.isFinite(value)) return "—"; + if (value < 1000) return String(Math.max(0, Math.floor(value))); + if (value < 1000000) return `${(value / 1000).toFixed(1)}k`; + return `${(value / 1000000).toFixed(1)}m`; +} + +function readCount(post, keys) { + for (const key of keys) { + const v = post?.[key]; + if (Number.isFinite(v)) return v; + const n = Number(v); + if (!Number.isNaN(n) && Number.isFinite(n)) return n; + } + return null; +} + +function updateStat(modalWrap, statKey, value) { + const el = modalWrap.querySelector(`[data-stat="${statKey}"] .sw-stat-num`); + if (el) el.textContent = formatCount(value); +} + +function isAuthed() { + try { + const auth = loadAuth(); + return !!(auth && auth.access_token); + } catch { + return false; + } +} + +function requestAuth(message, mode = "login") { + try { + window.dispatchEvent( + new CustomEvent("sociowire:auth-required", { + detail: { message, mode }, + }) + ); + } catch {} +} + +function applyPostDetails(modalWrap, post) { + if (!modalWrap || !post) return; + + const title = (post?.title || post?.Title || "Untitled").toString(); + const summary = (post?.snippet || post?.body || "").toString(); + const img = post?.image_large || post?.image_small || post?.image || post?.media_url || ""; + const url = (post?.url || "").toString().trim(); + + const titleEl = modalWrap.querySelector(".sw-modal-title"); + if (titleEl) titleEl.textContent = title; + + const summaryEl = modalWrap.querySelector(".sw-modal-summary"); + if (summaryEl) summaryEl.textContent = summary; + + const hero = modalWrap.querySelector(".sw-modal-hero"); + if (hero) { + hero.innerHTML = img + ? `` + : `
`; + } + + const badge = modalWrap.querySelector(".sw-modal-badge"); + if (badge) badge.textContent = normCatLabel(post); + + const meta = modalWrap.querySelector(".sw-modal-meta"); + if (meta) { + const author = (post?.author || post?.Author || "").toString().trim(); + const sub = (post?.sub_category || post?.subCategory || "").toString().trim(); + const relTime = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt); + const metaLeft = author ? `by ${author}` : ""; + const metaRight = sub ? sub : ""; + meta.innerHTML = ` + ${metaLeft ? `${escapeHtml(metaLeft)}` : ""} + ${metaRight ? `• ${escapeHtml(metaRight)}` : ""} + • ${escapeHtml(relTime)} + `; + } + + const cta = modalWrap.querySelector(".sw-cta-primary"); + if (cta) { + if (url) { + cta.removeAttribute("disabled"); + cta.setAttribute("data-url", url); + } else { + cta.setAttribute("disabled", "true"); + cta.setAttribute("data-url", ""); + } + } + + const viewCount = readCount(post, ["view_count", "views", "viewCount"]); + const likeCount = readCount(post, ["like_count", "likes", "likeCount"]); + const shareCount = readCount(post, ["share_count", "shares", "shareCount"]); + const commentCount = readCount(post, ["comment_count", "comments", "commentCount"]); + updateStat(modalWrap, "views", viewCount); + updateStat(modalWrap, "likes", likeCount); + updateStat(modalWrap, "shares", shareCount); + updateStat(modalWrap, "comments", commentCount); +} + +function renderComments(container, items) { + if (!container) return; + if (!Array.isArray(items) || items.length === 0) { + container.innerHTML = `
— no comments yet
`; + return; + } + container.innerHTML = items + .map((c) => { + const who = (c?.username || "anon").toString().trim(); + const body = (c?.body || "").toString().trim(); + return `
${escapeHtml(who)}: ${escapeHtml(body)}
`; + }) + .join(""); +} + function mountCard(container, spec, data, theme) { const root = createRoot(container); const tokens = cardTokens?.[theme] || cardTokens.blue; @@ -97,6 +222,7 @@ function closeCenteredOverlay(map) { if (ov.closing) return; if (ov.onKey) window.removeEventListener("keydown", ov.onKey); + if (ov.onAuth) window.removeEventListener("sociowire:auth-changed", ov.onAuth); // animate out try { @@ -118,26 +244,33 @@ function closeCenteredOverlay(map) { } catch {} } -function openCenteredOverlay({ map, post, theme }) { +export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { if (!map) return; closeCenteredOverlay(map); - const data = adaptPostToTemplateData(post); - const cat = normCatLabel(post); - const relTime = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt); - const author = (post?.author || post?.Author || "").toString().trim(); - const sub = (post?.sub_category || post?.subCategory || "").toString().trim(); + let postData = { ...(post || {}) }; + const data = adaptPostToTemplateData(postData); + const cat = normCatLabel(postData); + const relTime = formatRelativeTime(postData?.created_at || postData?.CreatedAt || postData?.createdAt); + const author = (postData?.author || postData?.Author || "").toString().trim(); + const sub = (postData?.sub_category || postData?.subCategory || "").toString().trim(); const metaLeft = author ? `by ${author}` : ""; const metaRight = sub ? sub : ""; const img = data?.image || ""; const url = data?.url || ""; + const postID = postData?.id || postData?.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"]); + const commentCount = readCount(postData, ["comment_count", "comments", "commentCount"]); // Backdrop catches outside click to close const backdrop = document.createElement("div"); try { backdrop.classList.add("sw-centered-backdrop","sw-enter"); } catch {} backdrop.classList.add("sw-centered-backdrop"); backdrop.className = "sw-centered-backdrop"; + if (fullScreen) backdrop.classList.add("sw-modal-fullscreen"); backdrop.style.position = "fixed"; backdrop.style.inset = "0"; backdrop.style.zIndex = "9999999"; @@ -153,6 +286,7 @@ function openCenteredOverlay({ map, post, theme }) { const modalWrap = document.createElement("div"); modalWrap.classList.add("sw-centered-modal"); modalWrap.className = "post-pin--expanded sw-centered-modal"; + if (fullScreen) modalWrap.classList.add("sw-modal-fullscreen"); modalWrap.style.pointerEvents = "auto"; // Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.) @@ -179,17 +313,18 @@ function openCenteredOverlay({ map, post, theme }) { }
-
${escapeHtml(data?.headline || post?.title || "Untitled")}
+
${escapeHtml(data?.headline || postData?.title || "Untitled")}
- ${escapeHtml(data?.summary || post?.snippet || "")} + ${escapeHtml(data?.summary || postData?.snippet || "")}
-
👁 12.4k
-
❤️ 1.3k
-
💬 248
-
📍 ${escapeHtml(post?.city || post?.location_name || "Nearby")}
+
👁 ${formatCount(viewCount)}
+
❤️ ${formatCount(likeCount)}
+
🔁 ${formatCount(shareCount)}
+
💬 ${formatCount(commentCount)}
+
📍 ${escapeHtml(postData?.city || postData?.location_name || "Nearby")}
@@ -199,8 +334,8 @@ function openCenteredOverlay({ map, post, theme }) {
- - + +
@@ -208,10 +343,7 @@ function openCenteredOverlay({ map, post, theme }) {
Live chat / comments
-
— …
-
— what the f***?
-
— nice…
-
— my eyes!!
+
— loading…
@@ -277,6 +409,150 @@ function openCenteredOverlay({ map, post, theme }) { }); } + if (postID > 0 && !modalWrap.__swViewed) { + modalWrap.__swViewed = true; + recordPostView(postID).then((res) => { + const counts = res?.counts; + if (counts) { + updateStat(modalWrap, "views", counts.view_count); + updateStat(modalWrap, "likes", counts.like_count); + updateStat(modalWrap, "shares", counts.share_count); + updateStat(modalWrap, "comments", counts.comment_count); + } + }); + } + + let liked = false; + const likeBtn = modalWrap.querySelector('[data-action="like"]'); + const commentInput = modalWrap.querySelector(".sw-livechat-input"); + const commentBtn = modalWrap.querySelector(".sw-livechat-post"); + + const applyAuthUI = () => { + const authed = isAuthed(); + if (likeBtn) { + likeBtn.disabled = false; + likeBtn.textContent = authed && liked ? "Liked" : "Like"; + } + if (commentInput) { + commentInput.readOnly = !authed; + commentInput.placeholder = authed ? "Write a comment…" : "Sign in to comment"; + } + }; + + if (likeBtn && postID > 0) { + likeBtn.addEventListener("click", async () => { + if (!isAuthed()) { + requestAuth("Please sign in or create a free account to like this post."); + return; + } + likeBtn.disabled = true; + const res = await togglePostLike(postID, !liked); + if (res?.ok) { + liked = !!res.liked; + likeBtn.textContent = liked ? "Liked" : "Like"; + const counts = res?.counts; + if (counts) { + updateStat(modalWrap, "likes", counts.like_count); + updateStat(modalWrap, "shares", counts.share_count); + updateStat(modalWrap, "comments", counts.comment_count); + updateStat(modalWrap, "views", counts.view_count); + } + } + likeBtn.disabled = false; + }); + } + + const shareBtn = modalWrap.querySelector('[data-action="share"]'); + if (shareBtn && postID > 0) { + shareBtn.addEventListener("click", async () => { + const shareUrl = `${window.location.origin}/p/${postID}`; + const shareTitle = postData?.title || postData?.Title || "SocioWire"; + try { + if (navigator.share) { + await navigator.share({ title: shareTitle, url: shareUrl }); + } else if (navigator.clipboard) { + await navigator.clipboard.writeText(shareUrl); + } + } catch {} + const res = await recordPostShare(postID); + const counts = res?.counts; + if (counts) { + updateStat(modalWrap, "views", counts.view_count); + updateStat(modalWrap, "shares", counts.share_count); + } + }); + } + + if (postID > 0) { + fetchPostById(postID).then((fresh) => { + if (!fresh) return; + postData = { ...postData, ...fresh }; + applyPostDetails(modalWrap, postData); + }); + } + + const commentsBody = modalWrap.querySelector(".sw-livechat-body"); + if (commentsBody && postID > 0) { + fetchPostComments(postID, 20).then((items) => renderComments(commentsBody, items)); + } + if (commentBtn && commentInput && postID > 0) { + commentBtn.addEventListener("click", async () => { + if (!isAuthed()) { + requestAuth("Please sign in or create a free account to comment."); + return; + } + const body = (commentInput.value || "").trim(); + if (!body) return; + commentBtn.disabled = true; + const res = await createPostComment(postID, body); + if (res?.ok) { + commentInput.value = ""; + const existing = await fetchPostComments(postID, 20); + renderComments(commentsBody, existing); + const counts = res?.counts; + if (counts) { + updateStat(modalWrap, "comments", counts.comment_count); + updateStat(modalWrap, "likes", counts.like_count); + updateStat(modalWrap, "shares", counts.share_count); + updateStat(modalWrap, "views", counts.view_count); + } + } + commentBtn.disabled = false; + }); + commentInput.addEventListener("focus", () => { + if (!isAuthed()) { + requestAuth("Please sign in or create a free account to comment."); + } + }); + } + + if (postID > 0 && isAuthed()) { + fetchPostLikeState(postID).then((res) => { + if (res?.ok && res.liked) { + liked = true; + if (likeBtn) likeBtn.textContent = "Liked"; + } + }); + } + + applyAuthUI(); + const onAuth = () => { + if (postID > 0 && isAuthed()) { + fetchPostLikeState(postID).then((res) => { + if (res?.ok && res.liked) { + liked = true; + } else { + liked = false; + } + applyAuthUI(); + }); + } else { + liked = false; + applyAuthUI(); + } + }; + window.addEventListener("sociowire:auth-changed", onAuth); + // still mount template full (hidden) so you can switch back later instantly let unmountFull = null; const fullWrap = modalWrap.querySelector(".sw-template-full-wrap"); @@ -286,10 +562,11 @@ function openCenteredOverlay({ map, post, theme }) { } map.__swCenteredOverlay = { - postId: post?.id, + postId: postData?.id, modalWrapRef: modalWrap, backdrop, onKey, + onAuth, unmountFull, unmountMini: null, }; @@ -444,7 +721,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the return; } - openCenteredOverlay({ map, post, theme }); + openCenteredOverlay({ map, post, theme, fullScreen: false }); if (expandedElRef) expandedElRef.current = null; }); } @@ -593,7 +870,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe return; } - openCenteredOverlay({ map, post, theme }); + openCenteredOverlay({ map, post, theme, fullScreen: false }); if (expandedElRef) expandedElRef.current = null; }); diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 4e9d005..143ff88 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -116,10 +116,11 @@ export function usePostsEngine({ expandedElRef, onAutoWidenTimeFilter, onSelectPost, + username, theme = "blue", }) { const allPostsRef = useRef([]); - const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "", timeFilter: "" }); + const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "", timeFilter: "", bounds: null }); const delayedFetchRef = useRef(null); const autoWidenRef = useRef(false); const [mapReady, setMapReady] = useState(false); @@ -523,6 +524,11 @@ export function usePostsEngine({ refreshVisibleAndMarkers(timeFilter); }, [timeFilter, mainFilter, subFilter, searchQuery, refreshVisibleAndMarkers]); + // On view change: re-filter markers/cards to current bounds even without fetch + useEffect(() => { + refreshVisibleAndMarkers(timeFilter); + }, [viewParams, refreshVisibleAndMarkers, timeFilter]); + // Fetch on view change (moveend) useEffect(() => { const map = mapRef.current; @@ -557,6 +563,20 @@ export function usePostsEngine({ const [lng, lat] = resolvedView.center; const radiusKm = resolvedView.radiusKm || 750; const filterKey = `${mainFilter}|${subFilter}`; + let bounds = null; + try { + const b = mapObj.getBounds(); + if (b) { + const sw = b.getSouthWest(); + const ne = b.getNorthEast(); + bounds = { + minLat: sw?.lat, + minLon: sw?.lng, + maxLat: ne?.lat, + maxLon: ne?.lng, + }; + } + } catch {} const last = lastFetchRef.current; const forceFetchAt = mapObj?.__swForceFetchAt || 0; @@ -572,9 +592,20 @@ export function usePostsEngine({ const lastR = last.radiusKm || 0; const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.1; - const minMoveKm = Math.max(5, radiusKm * 0.1); + let boundsChanged = false; + if (last.bounds && bounds) { + const spanLat = Math.max(0.0001, Math.abs(last.bounds.maxLat - last.bounds.minLat)); + const spanLon = Math.max(0.0001, Math.abs(last.bounds.maxLon - last.bounds.minLon)); + boundsChanged = + Math.abs(bounds.minLat - last.bounds.minLat) / spanLat > 0.15 || + Math.abs(bounds.maxLat - last.bounds.maxLat) / spanLat > 0.15 || + Math.abs(bounds.minLon - last.bounds.minLon) / spanLon > 0.15 || + Math.abs(bounds.maxLon - last.bounds.maxLon) / spanLon > 0.15; + } - if (!radiusChanged && distKm < minMoveKm) return; + const minMoveKm = Math.max(2, radiusKm * 0.05); + + if (!radiusChanged && !boundsChanged && distKm < minMoveKm) return; } try { @@ -586,28 +617,13 @@ export function usePostsEngine({ subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : ""; // Use smart feed for personalized, trending posts - let bounds = null; - try { - const b = mapObj.getBounds(); - if (b) { - const sw = b.getSouthWest(); - const ne = b.getNorthEast(); - bounds = { - minLat: sw?.lat, - minLon: sw?.lng, - maxLat: ne?.lat, - maxLon: ne?.lng, - }; - } - } catch {} - let newPosts = await fetchSmartFeed({ lat, lon: lng, radius_km: radiusKm, limit: 80, bounds, - // TODO: Add user_id from auth context when available + username: username || undefined, }); const timeParam = timeFilter && timeFilter !== "PAST" ? timeFilter : undefined; if (!hasPostsInRadius(newPosts, lat, lng, radiusKm)) { @@ -647,7 +663,7 @@ export function usePostsEngine({ } allPostsRef.current = Array.from(byId.values()); - lastFetchRef.current = { center: [...resolvedView.center], radiusKm, filterKey, timeFilter }; + lastFetchRef.current = { center: [...resolvedView.center], radiusKm, filterKey, timeFilter, bounds }; // Smooth update: sync markers/clusters + wall without clearing everything refreshVisibleAndMarkers(timeFilter); @@ -680,7 +696,7 @@ export function usePostsEngine({ delayedFetchRef.current = null; } }; - }, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers]); + }, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers, username]); useEffect(() => { const map = mapRef.current; diff --git a/src/styles/auth-modal.css b/src/styles/auth-modal.css index 27f49ae..1893644 100644 --- a/src/styles/auth-modal.css +++ b/src/styles/auth-modal.css @@ -1,7 +1,7 @@ .auth-modal-backdrop { position: fixed; inset: 0; - z-index: 60; + z-index: 10000020; display: flex; align-items: center; justify-content: center; diff --git a/src/styles/mapMarkers.css b/src/styles/mapMarkers.css index 386d21b..3f9fd77 100644 --- a/src/styles/mapMarkers.css +++ b/src/styles/mapMarkers.css @@ -60,8 +60,16 @@ box-shadow: 0 12px 30px rgba(0,0,0,0.7), 0 0 16px rgba(56,189,248,0.5) !important; padding: 12px 14px; color: #e5e7eb !important; /* texte blanc clair */ + width: min(92vw, 440px); + height: min(86vh, 640px); + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; } + /* Couleur badge par catégorie dans grosse carte */ .sw-centered-modal [data-category="NEWS"] .sw-modal-badge { background: rgba(56,189,248,0.35) !important; @@ -90,6 +98,22 @@ border: 3px solid #000 !important; /* contour plus épais */ box-shadow: 0 12px 30px rgba(0,0,0,0.7), 0 0 16px rgba(56,189,248,0.5) !important; } + +.sw-centered-modal.sw-modal-fullscreen{ + width: 100vw; + height: 100vh; + max-width: 100vw; + max-height: 100vh; + border-radius: 0 !important; + margin: 0 !important; +} +.sw-centered-modal.sw-modal-fullscreen .post-card.sw-expanded-shell{ + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + border-radius: 0 !important; +} .sw-modal-title, .sw-modal-summary, .sw-stat-pill, @@ -242,22 +266,51 @@ .sw-expanded-right{ display:none; } + + .sw-livechat-body{ + max-height: 150px; + overflow-y: auto; + padding-right: 4px; + } + .sw-chat-line{ + font-size: 12px; + line-height: 1.35; + } + .sw-livechat-inputrow{ + flex-direction: column; + align-items: stretch; + gap: 6px; + } + .sw-livechat-input{ + font-size: 13px; + padding: 10px 12px; + } + .sw-livechat-post{ + width: 100%; + font-size: 13px; + padding: 10px 14px; + } } /* Fix full wrap collapse */ .post-pin--expanded .post-card.sw-expanded-shell{ - width: 360px !important; - min-width: 360px !important; - max-width: 360px !important; - height: 520px !important; - max-height: 520px !important; + width: 440px !important; + min-width: 440px !important; + max-width: 440px !important; + height: 640px !important; + max-height: 640px !important; overflow: hidden !important; } +/* Override hidden overflow for centered modal to allow scroll */ +.sw-centered-modal .post-card.sw-expanded-shell{ + overflow-y: auto !important; +} + .sw-template-full-wrap{ display: block !important; - width: 360px !important; - height: 520px !important; + width: 440px !important; + height: 640px !important; } .sw-template-full-wrap > *{ @@ -406,6 +459,8 @@ .sw-modal-hero{ width:100%; height: 180px; + min-height: 180px; + flex: 0 0 auto; border-radius: 16px; overflow:hidden; border: 3px solid #000 !important; /* contour plus épais */ @@ -441,6 +496,7 @@ color:#D7E9FF; opacity:.95; margin-bottom: 8px; + flex: 0 0 auto; } .sw-stat-row{ @@ -494,6 +550,10 @@ @media (max-width: 640px){ .sw-modal-hero{ height: 150px; } .sw-modal-title{ font-size: 20px; line-height: 24px; } + .sw-centered-modal .post-card.sw-expanded-shell{ + width: min(96vw, 420px); + height: min(92vh, 700px); + } } /* SW_CENTERED_MODAL_ANIM */ @@ -512,6 +572,10 @@ transition: opacity 440ms ease; } +.sw-centered-backdrop.sw-modal-fullscreen{ + padding: 0; +} + .sw-centered-modal{ transform: scale(0.94); opacity: 0.0; @@ -573,4 +637,4 @@ opacity: 0; transform: translate3d(0,0,0) scale(0.96); transition: opacity 840ms ease, transform 840ms ease; -} \ No newline at end of file +}