Improve post modal auth flow and share handling

This commit is contained in:
Your Name 2025-12-24 23:02:11 -05:00
parent 40bc03d90d
commit ffeaee6e88
8 changed files with 682 additions and 58 deletions

View File

@ -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 };
}
}

View File

@ -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";

View File

@ -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 }
</div>
{signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
{authNotice ? <div className="auth-notice auth-notice-warn">{authNotice}</div> : null}
{mode === "login" && lastError ? (
<div className={"auth-notice " + (needsEmailVerification ? "auth-notice-warn" : "auth-notice-error")}>
{lastError}
@ -455,4 +472,4 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
) : null}
</>
);
}
}

View File

@ -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;
}

View File

@ -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
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
: `<div class="sw-modal-hero-fallback"></div>`;
}
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 ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
<span> ${escapeHtml(relTime)}</span>
`;
}
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 = `<div class="sw-chat-line">— no comments yet</div>`;
return;
}
container.innerHTML = items
.map((c) => {
const who = (c?.username || "anon").toString().trim();
const body = (c?.body || "").toString().trim();
return `<div class="sw-chat-line">— <strong>${escapeHtml(who)}</strong>: ${escapeHtml(body)}</div>`;
})
.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 }) {
}
</div>
<div class="sw-modal-title">${escapeHtml(data?.headline || post?.title || "Untitled")}</div>
<div class="sw-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div>
<div class="sw-modal-summary">
${escapeHtml(data?.summary || post?.snippet || "")}
${escapeHtml(data?.summary || postData?.snippet || "")}
</div>
<div class="sw-stat-row">
<div class="sw-stat-pill">👁 12.4k</div>
<div class="sw-stat-pill"> 1.3k</div>
<div class="sw-stat-pill">💬 248</div>
<div class="sw-stat-pill">📍 ${escapeHtml(post?.city || post?.location_name || "Nearby")}</div>
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
<div class="sw-stat-pill" data-stat="likes"> <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
<div class="sw-stat-pill">📍 ${escapeHtml(postData?.city || postData?.location_name || "Nearby")}</div>
</div>
<div class="sw-modal-cta-row">
@ -199,8 +334,8 @@ function openCenteredOverlay({ map, post, theme }) {
<div class="sw-cta-actions">
<button class="post-card-btn" type="button">Contact</button>
<button class="post-card-btn" type="button">Like</button>
<button class="post-card-btn" type="button">Share</button>
<button class="post-card-btn" type="button" data-action="like">Like</button>
<button class="post-card-btn" type="button" data-action="share">Share</button>
<button class="post-card-btn" type="button">Fix</button>
</div>
</div>
@ -208,10 +343,7 @@ function openCenteredOverlay({ map, post, theme }) {
<div class="post-card-comments sw-livechat">
<div class="sw-livechat-title">Live chat / comments</div>
<div class="sw-livechat-body">
<div class="sw-chat-line"> </div>
<div class="sw-chat-line"> what the f***?</div>
<div class="sw-chat-line"> nice</div>
<div class="sw-chat-line"> my eyes!!</div>
<div class="sw-chat-line"> loading</div>
</div>
<div class="sw-livechat-inputrow">
<input class="sw-livechat-input" placeholder="Write a comment…" />
@ -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;
});

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}