Improve post modal auth flow and share handling
This commit is contained in:
parent
40bc03d90d
commit
ffeaee6e88
|
|
@ -2,8 +2,21 @@
|
||||||
* SocioWire frontend API client
|
* SocioWire frontend API client
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { loadAuth } from "../auth/authStorage";
|
||||||
|
|
||||||
const API_BASE = "/api";
|
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.
|
* Récupère la liste des posts, avec filtres optionnels.
|
||||||
*/
|
*/
|
||||||
|
|
@ -129,6 +142,25 @@ export async function createPost(payload, token) {
|
||||||
return res.json().catch(() => ({}));
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ export function AuthProvider({ children }) {
|
||||||
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
|
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
|
||||||
|
|
||||||
const refreshTimer = useRef(null);
|
const refreshTimer = useRef(null);
|
||||||
|
const lastAuthStatusRef = useRef(null);
|
||||||
|
|
||||||
function setAnon(msg = "") {
|
function setAnon(msg = "") {
|
||||||
setStatus("anon");
|
setStatus("anon");
|
||||||
|
|
@ -382,6 +383,22 @@ export function AuthProvider({ children }) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// Backward-compatible fields expected by TopBar/MapView
|
||||||
const value = useMemo(() => {
|
const value = useMemo(() => {
|
||||||
const loading = status === "loading";
|
const loading = status === "loading";
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
const [signupInfo, setSignupInfo] = useState("");
|
const [signupInfo, setSignupInfo] = useState("");
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
const [signupLoading, setSignupLoading] = useState(false);
|
||||||
const [showInstallBtn, setShowInstallBtn] = useState(false);
|
const [showInstallBtn, setShowInstallBtn] = useState(false);
|
||||||
|
const [authNotice, setAuthNotice] = useState("");
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
const openModal = (nextMode) => {
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
|
|
@ -93,12 +94,27 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
setShowAuth(false);
|
setShowAuth(false);
|
||||||
setLoginPass("");
|
setLoginPass("");
|
||||||
setSignupError("");
|
setSignupError("");
|
||||||
|
setAuthNotice("");
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authenticated) setShowAuth(false);
|
if (authenticated) setShowAuth(false);
|
||||||
}, [authenticated]);
|
}, [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)
|
// Check if PWA install should be shown (simple: hide if already PWA)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkInstall = () => {
|
const checkInstall = () => {
|
||||||
|
|
@ -382,6 +398,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
|
{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 ? (
|
{mode === "login" && lastError ? (
|
||||||
<div className={"auth-notice " + (needsEmailVerification ? "auth-notice-warn" : "auth-notice-error")}>
|
<div className={"auth-notice " + (needsEmailVerification ? "auth-notice-warn" : "auth-notice-error")}>
|
||||||
{lastError}
|
{lastError}
|
||||||
|
|
@ -455,4 +472,4 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 "maplibre-gl/dist/maplibre-gl.css";
|
||||||
import "../../styles/map.css";
|
import "../../styles/map.css";
|
||||||
import "../../styles/mapMarkers.css";
|
import "../../styles/mapMarkers.css";
|
||||||
|
|
@ -14,11 +14,13 @@ import {
|
||||||
import { useMapCore } from "./useMapCore";
|
import { useMapCore } from "./useMapCore";
|
||||||
import { useUserPosition } from "./useUserPosition";
|
import { useUserPosition } from "./useUserPosition";
|
||||||
import { usePostsEngine } from "./usePostsEngine";
|
import { usePostsEngine } from "./usePostsEngine";
|
||||||
|
import { openCenteredOverlay } from "./markerManager";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import FilterButtons from "../Filters/FilterButtons";
|
import FilterButtons from "../Filters/FilterButtons";
|
||||||
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
||||||
import SmartSearchBar from "../Search/SmartSearchBar";
|
import SmartSearchBar from "../Search/SmartSearchBar";
|
||||||
import UserProfile from "../Profile/UserProfile";
|
import UserProfile from "../Profile/UserProfile";
|
||||||
|
import { fetchPostById } from "../../api/client";
|
||||||
|
|
||||||
export default function MapView({
|
export default function MapView({
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
|
|
@ -62,6 +64,34 @@ export default function MapView({
|
||||||
|
|
||||||
// Profile state (selectedIsland is now passed from App.jsx)
|
// Profile state (selectedIsland is now passed from App.jsx)
|
||||||
const [selectedProfile, setSelectedProfile] = useState(null);
|
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 } =
|
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } =
|
||||||
usePostsEngine({
|
usePostsEngine({
|
||||||
|
|
@ -76,6 +106,7 @@ export default function MapView({
|
||||||
expandedElRef,
|
expandedElRef,
|
||||||
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
|
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
|
||||||
onSelectPost,
|
onSelectPost,
|
||||||
|
username,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
@ -168,7 +199,63 @@ export default function MapView({
|
||||||
if (typeof lng === "number" && typeof lat === "number") {
|
if (typeof lng === "number" && typeof lat === "number") {
|
||||||
map.flyTo({ center: [lng, lat], zoom: 8, speed: 1.4 });
|
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(() => {
|
useEffect(() => {
|
||||||
const el = stageRef.current;
|
const el = stageRef.current;
|
||||||
|
|
@ -371,14 +458,31 @@ export default function MapView({
|
||||||
|
|
||||||
const handleOpenCreate = () => {
|
const handleOpenCreate = () => {
|
||||||
if (!authenticated) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsEmailVerification) {
|
if (needsEmailVerification) {
|
||||||
alert(
|
try {
|
||||||
"You must verify your email before posting. Use 'Resend email' in the top bar, then login again."
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ import CardRenderer from "../Cards/CardRenderer";
|
||||||
import { cardTokens } from "../../theme/cardTokens";
|
import { cardTokens } from "../../theme/cardTokens";
|
||||||
import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs";
|
import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs";
|
||||||
import { getMarkerColorForCategory } from "./postPriority";
|
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) {
|
export function clearAllMarkers(markersRef, expandedElRef) {
|
||||||
if (markersRef.current) {
|
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) {
|
function mountCard(container, spec, data, theme) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
const tokens = cardTokens?.[theme] || cardTokens.blue;
|
const tokens = cardTokens?.[theme] || cardTokens.blue;
|
||||||
|
|
@ -97,6 +222,7 @@ function closeCenteredOverlay(map) {
|
||||||
if (ov.closing) return;
|
if (ov.closing) return;
|
||||||
|
|
||||||
if (ov.onKey) window.removeEventListener("keydown", ov.onKey);
|
if (ov.onKey) window.removeEventListener("keydown", ov.onKey);
|
||||||
|
if (ov.onAuth) window.removeEventListener("sociowire:auth-changed", ov.onAuth);
|
||||||
|
|
||||||
// animate out
|
// animate out
|
||||||
try {
|
try {
|
||||||
|
|
@ -118,26 +244,33 @@ function closeCenteredOverlay(map) {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCenteredOverlay({ map, post, theme }) {
|
export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
closeCenteredOverlay(map);
|
closeCenteredOverlay(map);
|
||||||
|
|
||||||
const data = adaptPostToTemplateData(post);
|
let postData = { ...(post || {}) };
|
||||||
const cat = normCatLabel(post);
|
const data = adaptPostToTemplateData(postData);
|
||||||
const relTime = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt);
|
const cat = normCatLabel(postData);
|
||||||
const author = (post?.author || post?.Author || "").toString().trim();
|
const relTime = formatRelativeTime(postData?.created_at || postData?.CreatedAt || postData?.createdAt);
|
||||||
const sub = (post?.sub_category || post?.subCategory || "").toString().trim();
|
const author = (postData?.author || postData?.Author || "").toString().trim();
|
||||||
|
const sub = (postData?.sub_category || postData?.subCategory || "").toString().trim();
|
||||||
const metaLeft = author ? `by ${author}` : "";
|
const metaLeft = author ? `by ${author}` : "";
|
||||||
const metaRight = sub ? sub : "";
|
const metaRight = sub ? sub : "";
|
||||||
const img = data?.image || "";
|
const img = data?.image || "";
|
||||||
const url = data?.url || "";
|
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
|
// Backdrop catches outside click to close
|
||||||
const backdrop = document.createElement("div");
|
const backdrop = document.createElement("div");
|
||||||
try { backdrop.classList.add("sw-centered-backdrop","sw-enter"); } catch {}
|
try { backdrop.classList.add("sw-centered-backdrop","sw-enter"); } catch {}
|
||||||
backdrop.classList.add("sw-centered-backdrop");
|
backdrop.classList.add("sw-centered-backdrop");
|
||||||
backdrop.className = "sw-centered-backdrop";
|
backdrop.className = "sw-centered-backdrop";
|
||||||
|
if (fullScreen) backdrop.classList.add("sw-modal-fullscreen");
|
||||||
backdrop.style.position = "fixed";
|
backdrop.style.position = "fixed";
|
||||||
backdrop.style.inset = "0";
|
backdrop.style.inset = "0";
|
||||||
backdrop.style.zIndex = "9999999";
|
backdrop.style.zIndex = "9999999";
|
||||||
|
|
@ -153,6 +286,7 @@ function openCenteredOverlay({ map, post, theme }) {
|
||||||
const modalWrap = document.createElement("div");
|
const modalWrap = document.createElement("div");
|
||||||
modalWrap.classList.add("sw-centered-modal");
|
modalWrap.classList.add("sw-centered-modal");
|
||||||
modalWrap.className = "post-pin--expanded sw-centered-modal";
|
modalWrap.className = "post-pin--expanded sw-centered-modal";
|
||||||
|
if (fullScreen) modalWrap.classList.add("sw-modal-fullscreen");
|
||||||
modalWrap.style.pointerEvents = "auto";
|
modalWrap.style.pointerEvents = "auto";
|
||||||
|
|
||||||
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
|
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
|
||||||
|
|
@ -179,17 +313,18 @@ function openCenteredOverlay({ map, post, theme }) {
|
||||||
}
|
}
|
||||||
</div>
|
</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">
|
<div class="sw-modal-summary">
|
||||||
${escapeHtml(data?.summary || post?.snippet || "")}
|
${escapeHtml(data?.summary || postData?.snippet || "")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sw-stat-row">
|
<div class="sw-stat-row">
|
||||||
<div class="sw-stat-pill">👁 12.4k</div>
|
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
|
||||||
<div class="sw-stat-pill">❤️ 1.3k</div>
|
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
|
||||||
<div class="sw-stat-pill">💬 248</div>
|
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
|
||||||
<div class="sw-stat-pill">📍 ${escapeHtml(post?.city || post?.location_name || "Nearby")}</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>
|
||||||
|
|
||||||
<div class="sw-modal-cta-row">
|
<div class="sw-modal-cta-row">
|
||||||
|
|
@ -199,8 +334,8 @@ function openCenteredOverlay({ map, post, theme }) {
|
||||||
|
|
||||||
<div class="sw-cta-actions">
|
<div class="sw-cta-actions">
|
||||||
<button class="post-card-btn" type="button">Contact</button>
|
<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" data-action="like">Like</button>
|
||||||
<button class="post-card-btn" type="button">Share</button>
|
<button class="post-card-btn" type="button" data-action="share">Share</button>
|
||||||
<button class="post-card-btn" type="button">Fix</button>
|
<button class="post-card-btn" type="button">Fix</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -208,10 +343,7 @@ function openCenteredOverlay({ map, post, theme }) {
|
||||||
<div class="post-card-comments sw-livechat">
|
<div class="post-card-comments sw-livechat">
|
||||||
<div class="sw-livechat-title">Live chat / comments</div>
|
<div class="sw-livechat-title">Live chat / comments</div>
|
||||||
<div class="sw-livechat-body">
|
<div class="sw-livechat-body">
|
||||||
<div class="sw-chat-line">— …</div>
|
<div class="sw-chat-line">— loading…</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>
|
</div>
|
||||||
<div class="sw-livechat-inputrow">
|
<div class="sw-livechat-inputrow">
|
||||||
<input class="sw-livechat-input" placeholder="Write a comment…" />
|
<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
|
// still mount template full (hidden) so you can switch back later instantly
|
||||||
let unmountFull = null;
|
let unmountFull = null;
|
||||||
const fullWrap = modalWrap.querySelector(".sw-template-full-wrap");
|
const fullWrap = modalWrap.querySelector(".sw-template-full-wrap");
|
||||||
|
|
@ -286,10 +562,11 @@ function openCenteredOverlay({ map, post, theme }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
map.__swCenteredOverlay = {
|
map.__swCenteredOverlay = {
|
||||||
postId: post?.id,
|
postId: postData?.id,
|
||||||
modalWrapRef: modalWrap,
|
modalWrapRef: modalWrap,
|
||||||
backdrop,
|
backdrop,
|
||||||
onKey,
|
onKey,
|
||||||
|
onAuth,
|
||||||
unmountFull,
|
unmountFull,
|
||||||
unmountMini: null,
|
unmountMini: null,
|
||||||
};
|
};
|
||||||
|
|
@ -444,7 +721,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openCenteredOverlay({ map, post, theme });
|
openCenteredOverlay({ map, post, theme, fullScreen: false });
|
||||||
if (expandedElRef) expandedElRef.current = null;
|
if (expandedElRef) expandedElRef.current = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -593,7 +870,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openCenteredOverlay({ map, post, theme });
|
openCenteredOverlay({ map, post, theme, fullScreen: false });
|
||||||
if (expandedElRef) expandedElRef.current = null;
|
if (expandedElRef) expandedElRef.current = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,11 @@ export function usePostsEngine({
|
||||||
expandedElRef,
|
expandedElRef,
|
||||||
onAutoWidenTimeFilter,
|
onAutoWidenTimeFilter,
|
||||||
onSelectPost,
|
onSelectPost,
|
||||||
|
username,
|
||||||
theme = "blue",
|
theme = "blue",
|
||||||
}) {
|
}) {
|
||||||
const allPostsRef = useRef([]);
|
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 delayedFetchRef = useRef(null);
|
||||||
const autoWidenRef = useRef(false);
|
const autoWidenRef = useRef(false);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
@ -523,6 +524,11 @@ export function usePostsEngine({
|
||||||
refreshVisibleAndMarkers(timeFilter);
|
refreshVisibleAndMarkers(timeFilter);
|
||||||
}, [timeFilter, mainFilter, subFilter, searchQuery, refreshVisibleAndMarkers]);
|
}, [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)
|
// Fetch on view change (moveend)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|
@ -557,6 +563,20 @@ export function usePostsEngine({
|
||||||
const [lng, lat] = resolvedView.center;
|
const [lng, lat] = resolvedView.center;
|
||||||
const radiusKm = resolvedView.radiusKm || 750;
|
const radiusKm = resolvedView.radiusKm || 750;
|
||||||
const filterKey = `${mainFilter}|${subFilter}`;
|
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 last = lastFetchRef.current;
|
||||||
const forceFetchAt = mapObj?.__swForceFetchAt || 0;
|
const forceFetchAt = mapObj?.__swForceFetchAt || 0;
|
||||||
|
|
@ -572,9 +592,20 @@ export function usePostsEngine({
|
||||||
const lastR = last.radiusKm || 0;
|
const lastR = last.radiusKm || 0;
|
||||||
const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.1;
|
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 {
|
try {
|
||||||
|
|
@ -586,28 +617,13 @@ export function usePostsEngine({
|
||||||
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
|
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
|
||||||
|
|
||||||
// Use smart feed for personalized, trending posts
|
// 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({
|
let newPosts = await fetchSmartFeed({
|
||||||
lat,
|
lat,
|
||||||
lon: lng,
|
lon: lng,
|
||||||
radius_km: radiusKm,
|
radius_km: radiusKm,
|
||||||
limit: 80,
|
limit: 80,
|
||||||
bounds,
|
bounds,
|
||||||
// TODO: Add user_id from auth context when available
|
username: username || undefined,
|
||||||
});
|
});
|
||||||
const timeParam = timeFilter && timeFilter !== "PAST" ? timeFilter : undefined;
|
const timeParam = timeFilter && timeFilter !== "PAST" ? timeFilter : undefined;
|
||||||
if (!hasPostsInRadius(newPosts, lat, lng, radiusKm)) {
|
if (!hasPostsInRadius(newPosts, lat, lng, radiusKm)) {
|
||||||
|
|
@ -647,7 +663,7 @@ export function usePostsEngine({
|
||||||
}
|
}
|
||||||
|
|
||||||
allPostsRef.current = Array.from(byId.values());
|
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
|
// Smooth update: sync markers/clusters + wall without clearing everything
|
||||||
refreshVisibleAndMarkers(timeFilter);
|
refreshVisibleAndMarkers(timeFilter);
|
||||||
|
|
@ -680,7 +696,7 @@ export function usePostsEngine({
|
||||||
delayedFetchRef.current = null;
|
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(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
.auth-modal-backdrop {
|
.auth-modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 60;
|
z-index: 10000020;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
box-shadow: 0 12px 30px rgba(0,0,0,0.7), 0 0 16px rgba(56,189,248,0.5) !important;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
color: #e5e7eb !important; /* texte blanc clair */
|
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 */
|
/* Couleur badge par catégorie dans grosse carte */
|
||||||
.sw-centered-modal [data-category="NEWS"] .sw-modal-badge {
|
.sw-centered-modal [data-category="NEWS"] .sw-modal-badge {
|
||||||
background: rgba(56,189,248,0.35) !important;
|
background: rgba(56,189,248,0.35) !important;
|
||||||
|
|
@ -90,6 +98,22 @@
|
||||||
border: 3px solid #000 !important; /* contour plus épais */
|
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;
|
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-title,
|
||||||
.sw-modal-summary,
|
.sw-modal-summary,
|
||||||
.sw-stat-pill,
|
.sw-stat-pill,
|
||||||
|
|
@ -242,22 +266,51 @@
|
||||||
.sw-expanded-right{
|
.sw-expanded-right{
|
||||||
display:none;
|
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 */
|
/* Fix full wrap collapse */
|
||||||
.post-pin--expanded .post-card.sw-expanded-shell{
|
.post-pin--expanded .post-card.sw-expanded-shell{
|
||||||
width: 360px !important;
|
width: 440px !important;
|
||||||
min-width: 360px !important;
|
min-width: 440px !important;
|
||||||
max-width: 360px !important;
|
max-width: 440px !important;
|
||||||
height: 520px !important;
|
height: 640px !important;
|
||||||
max-height: 520px !important;
|
max-height: 640px !important;
|
||||||
overflow: hidden !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{
|
.sw-template-full-wrap{
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 360px !important;
|
width: 440px !important;
|
||||||
height: 520px !important;
|
height: 640px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-template-full-wrap > *{
|
.sw-template-full-wrap > *{
|
||||||
|
|
@ -406,6 +459,8 @@
|
||||||
.sw-modal-hero{
|
.sw-modal-hero{
|
||||||
width:100%;
|
width:100%;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
|
min-height: 180px;
|
||||||
|
flex: 0 0 auto;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
border: 3px solid #000 !important; /* contour plus épais */
|
border: 3px solid #000 !important; /* contour plus épais */
|
||||||
|
|
@ -441,6 +496,7 @@
|
||||||
color:#D7E9FF;
|
color:#D7E9FF;
|
||||||
opacity:.95;
|
opacity:.95;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-stat-row{
|
.sw-stat-row{
|
||||||
|
|
@ -494,6 +550,10 @@
|
||||||
@media (max-width: 640px){
|
@media (max-width: 640px){
|
||||||
.sw-modal-hero{ height: 150px; }
|
.sw-modal-hero{ height: 150px; }
|
||||||
.sw-modal-title{ font-size: 20px; line-height: 24px; }
|
.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 */
|
/* SW_CENTERED_MODAL_ANIM */
|
||||||
|
|
@ -512,6 +572,10 @@
|
||||||
transition: opacity 440ms ease;
|
transition: opacity 440ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sw-centered-backdrop.sw-modal-fullscreen{
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sw-centered-modal{
|
.sw-centered-modal{
|
||||||
transform: scale(0.94);
|
transform: scale(0.94);
|
||||||
opacity: 0.0;
|
opacity: 0.0;
|
||||||
|
|
@ -573,4 +637,4 @@
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate3d(0,0,0) scale(0.96);
|
transform: translate3d(0,0,0) scale(0.96);
|
||||||
transition: opacity 840ms ease, transform 840ms ease;
|
transition: opacity 840ms ease, transform 840ms ease;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue