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 }) {