toit marche

This commit is contained in:
Your Name 2025-12-19 13:04:18 -05:00
parent 195b5634a1
commit c9872d6260
26 changed files with 4606 additions and 81 deletions

View File

@ -32,10 +32,12 @@ export default function App() {
setTheme(saved);
document.body.setAttribute("data-theme", saved);
} else {
document.body.setAttribute("data-theme", "dark");
setTheme("blue");
document.body.setAttribute("data-theme", "blue");
}
} catch (e) {
document.body.setAttribute("data-theme", "dark");
setTheme("blue");
document.body.setAttribute("data-theme", "blue");
}
}, []);

View File

@ -31,15 +31,29 @@ export default function MapView({
useMapCore(theme);
const [mainFilter, setMainFilter] = useState("All");
const [timeFilter, setTimeFilter] = useState("RECENT");
const TIME_FILTER_KEY = "sociowire:timeFilter";
const [timeFilter, setTimeFilter] = useState(() => {
try {
const v = localStorage.getItem(TIME_FILTER_KEY);
const ok = ["NOW","TODAY","RECENT","PAST"].includes(v);
return ok ? v : "TODAY";
} catch {
return "TODAY";
}
});
const [subFilter, setSubFilter] = useState("All");
useEffect(() => {
try { localStorage.setItem(TIME_FILTER_KEY, timeFilter); } catch {}
}, [timeFilter]);
const stageRef = useRef(null);
const [showSubcats, setShowSubcats] = useState(true);
const userPosition = useUserPosition(mapRef, hasLastView);
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } = usePostsEngine({
theme,
mapRef,
viewParams,
mainFilter,

View File

@ -0,0 +1,423 @@
import React, { useEffect, useRef, useState } from "react";
import "maplibre-gl/dist/maplibre-gl.css";
import "../../styles/mapMarkers.css";
import "../../styles/overlays.css";
import "../../styles/posts.css";
import "../../styles/filters.css";
import { createPost } from "../../api/client";
import { FILTER_MAIN_CATEGORIES, FILTER_CATEGORY_MAP, CREATE_CATEGORY_MAP } from "./mapConfig";
import { useMapCore } from "./useMapCore";
import { useUserPosition } from "./useUserPosition";
import { usePostsEngine } from "./usePostsEngine";
import { useAuth } from "../../auth/AuthContext";
import FilterButtons from "../Filters/FilterButtons";
import TimeFilterButtons from "../Filters/TimeFilterButtons";
export default function MapView({
theme = "dark",
// lift posts to App (so Sociowall is NOT inside the map)
onPostsState,
// selection controlled by App
selectedPost,
onSelectPost,
}) {
const { authenticated, username, token } = useAuth();
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
useMapCore(theme);
const [mainFilter, setMainFilter] = useState("All");
const TIME_FILTER_KEY = "sociowire:timeFilter";
const [timeFilter, setTimeFilter] = useState(() => {
try {
const v = localStorage.getItem(TIME_FILTER_KEY);
const ok = ["NOW","TODAY","RECENT","PAST"].includes(v);
return ok ? v : "TODAY";
} catch {
return "TODAY";
}
});
const [subFilter, setSubFilter] = useState("All");
useEffect(() => {
try { localStorage.setItem(TIME_FILTER_KEY, timeFilter); } catch {}
}, [timeFilter]);
const stageRef = useRef(null);
const [showSubcats, setShowSubcats] = useState(true);
const userPosition = useUserPosition(mapRef, hasLastView);
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } = usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
});
const [isCreating, setIsCreating] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftBody, setDraftBody] = useState("");
const [draftCategory, setDraftCategory] = useState("News");
const [draftSubCategory, setDraftSubCategory] = useState(CREATE_CATEGORY_MAP.News[0]);
const [draftCoords, setDraftCoords] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"];
/* SW_SUBCAT_ICONS:BEGIN */
const getSubcatIcon = (main, sub) => {
const M = {
All: {
All: "fa-solid fa-layer-group",
},
News: {
All: "fa-solid fa-layer-group",
World: "fa-solid fa-globe",
Politics: "fa-solid fa-landmark-flag",
Tech: "fa-solid fa-microchip",
Finance: "fa-solid fa-chart-line",
Local: "fa-solid fa-location-dot",
},
Friends: {
All: "fa-solid fa-layer-group",
Nearby: "fa-solid fa-location-crosshairs",
Chats: "fa-solid fa-comments",
Groups: "fa-solid fa-user-group",
Stories: "fa-solid fa-book-open",
Favs: "fa-solid fa-heart",
},
Events: {
All: "fa-solid fa-layer-group",
Today: "fa-solid fa-calendar-day",
Weekend: "fa-solid fa-calendar-week",
Music: "fa-solid fa-music",
Sports: "fa-solid fa-football",
Local: "fa-solid fa-location-dot",
},
Market: {
All: "fa-solid fa-layer-group",
Actu: "fa-solid fa-newspaper",
Tech: "fa-solid fa-microchip",
Finan: "fa-solid fa-money-bill-trend-up",
Sport: "fa-solid fa-basketball",
Deals: "fa-solid fa-tags",
},
};
return (M[main] && M[main][sub]) || "";
};
/* SW_SUBCAT_ICONS:END */
// keep subFilter valid
useEffect(() => {
if (!bottomCategories.length) return;
if (!subFilter || !bottomCategories.includes(subFilter)) setSubFilter("All");
}, [mainFilter, bottomCategories, subFilter]);
// push posts state up to App (for Sociowall)
useEffect(() => {
if (!onPostsState) return;
onPostsState({
status,
posts: visiblePosts,
loading: loadingPosts,
error: loadError,
});
}, [status, visiblePosts, loadingPosts, loadError, onPostsState]);
// if App selects a post (from Sociowall), fly to it on map
useEffect(() => {
if (!selectedPost) return;
const map = mapRef.current;
if (!map) return;
const lng = selectedPost.lon ?? selectedPost.lng;
const lat = selectedPost.lat ?? selectedPost.latitude;
if (typeof lng === "number" && typeof lat === "number") {
map.flyTo({ center: [lng, lat], zoom: 8, speed: 1.4 });
}
}, [selectedPost, mapRef]);
// Websocket for new posts
useEffect(() => {
const el = stageRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const obs = new IntersectionObserver(
(entries) => {
const e = entries && entries[0];
const ok = !!(e && e.isIntersecting && e.intersectionRatio > 0.15);
setShowSubcats(ok);
},
{ threshold: [0, 0.15, 0.3, 0.6] }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const host =
window.location.port === "5173"
? `${window.location.hostname}:8081`
: window.location.host;
const wsUrl = `${proto}://${host}/ws`;
let ws;
try {
ws = new WebSocket(wsUrl);
} catch {
return;
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "new_post" && msg.post) handleIncomingPost(msg.post);
} catch {}
};
return () => ws && ws.close();
}, [handleIncomingPost]);
const handleFlyToMe = () => {
const map = mapRef.current;
if (!map || !userPosition) return;
map.flyTo({ center: userPosition, zoom: 9, speed: 1.2 });
};
const handleOpenCreate = () => {
if (!authenticated) {
alert("You must be logged in to place a wire.");
return;
}
setIsCreating(true);
setDraftTitle("");
setDraftBody("");
const main = mainFilter === "All" ? "News" : mainFilter;
setDraftCategory(main);
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
setDraftSubCategory(list[0]);
setDraftCoords(null);
};
const handleSearchClick = () => {
alert("🔍 Search feature coming soon!");
};
const handleSubmitPost = async () => {
if (isSaving) return;
setSaveError("");
const map = mapRef.current;
if (!map) return;
if (!authenticated) {
setSaveError("You must be logged in to post.");
return;
}
const authorName = (username || "").trim() || "anon";
let baseLng;
let baseLat;
if (draftCoords && draftCoords.length === 2) {
baseLng = draftCoords[0];
baseLat = draftCoords[1];
} else {
const center = map.getCenter();
baseLng = center.lng;
baseLat = center.lat;
}
// keep marker visually above pointer
const pixelOffsetY = -20;
const screenPoint = map.project([baseLng, baseLat]);
const correctedPoint = { x: screenPoint.x, y: screenPoint.y + pixelOffsetY };
const correctedLngLat = map.unproject([correctedPoint.x, correctedPoint.y]);
const lng = correctedLngLat.lng;
const lat = correctedLngLat.lat;
if (!draftTitle.trim()) {
setSaveError("Title required.");
return;
}
try {
setIsSaving(true);
const newPost = await createPost(
{
title: draftTitle.trim(),
snippet: draftBody.trim() || draftTitle.trim(),
category: draftCategory === "Events" ? "EVENT" : draftCategory.toUpperCase(),
sub_category: draftSubCategory || "ALL",
lat,
lon: lng,
author: authorName,
},
token
);
if (newPost && newPost.id) handleIncomingPost(newPost);
setIsSaving(false);
setIsCreating(false);
} catch (e) {
console.error(e);
setIsSaving(false);
setSaveError("Error creating post.");
}
};
const handleMainFilterSelect = (code) => {
if (!FILTER_MAIN_CATEGORIES.includes(code)) return;
setMainFilter(code);
};
// allow selecting from map later (optional hook)
const handleSelectPost = (post) => {
if (onSelectPost) onSelectPost(post);
const map = mapRef.current;
if (!map) return;
const lng = post.lon ?? post.lng;
const lat = post.lat ?? post.latitude;
if (typeof lng === "number" && typeof lat === "number") {
map.flyTo({ center: [lng, lat], zoom: 8, speed: 1.4 });
}
};
return (
<div className="map-page">
<div className="map-stage" ref={stageRef}>
<div ref={containerRef} className="map-container" />
{status && <div className="map-status">{status}</div>}
<div className="map-overlay map-overlay-top">
<button className="chip-pill" onClick={handleSearchClick}>
Look at
</button>
<button className="chip-pill" onClick={handleOpenCreate}>
Place your wire
</button>
</div>
<div className="map-overlay map-overlay-left">
<TimeFilterButtons active={timeFilter} onSelect={(code) => setTimeFilter(code)} />
</div>
<div className="map-overlay map-overlay-right">
<FilterButtons active={mainFilter} onSelect={handleMainFilterSelect} />
</div>
<div className="map-overlay map-overlay-bottom">
<div className="sw-bottom-row">
{bottomCategories.map((c) => (
<button
key={c}
type="button"
className={"sw-bottom-item" + (subFilter === c ? " sw-bottom-active" : "")}
onClick={() => setSubFilter(c)}
>
<div className="sw-bottom-circle">
{(() => {
const ic = getSubcatIcon(mainFilter, c);
return ic ? <i className={ic} /> : c === "All" ? "All" : c.slice(0, 2);
})()}
</div>
<div className="sw-bottom-label">{c}</div>
</button>
))}
</div>
</div>
<div className="map-overlay map-overlay-myloc">
<button className="chip-pill" disabled={!userPosition} onClick={handleFlyToMe}>
My spot
</button>
</div>
{isCreating && (
<div className="map-overlay map-crosshair">
<div className="crosshair-aim" />
</div>
)}
{isCreating && (
<div className="map-overlay create-post-panel">
<div className="create-post-header">
<span>Create wire</span>
<button className="create-close" onClick={() => setIsCreating(false)}>
</button>
</div>
<span className="crosshair-label">
Move the map, aim with the crosshair your wire will be pinned there.
</span>
<div className="create-row">
<select
value={draftCategory}
onChange={(e) => {
const cat = e.target.value;
setDraftCategory(cat);
setDraftSubCategory((CREATE_CATEGORY_MAP[cat] || CREATE_CATEGORY_MAP.News)[0]);
}}
>
{["News", "Friends", "Events", "Market"].map((cat) => (
<option key={cat}>{cat}</option>
))}
</select>
<select value={draftSubCategory} onChange={(e) => setDraftSubCategory(e.target.value)}>
{(CREATE_CATEGORY_MAP[draftCategory] || CREATE_CATEGORY_MAP.News).map((sub) => (
<option key={sub}>{sub}</option>
))}
</select>
</div>
<input
className="create-input"
placeholder="Short title..."
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
/>
<textarea
className="create-textarea"
rows={2}
placeholder="Context (optional)..."
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
/>
{saveError && <div className="create-error">{saveError}</div>}
<div className="create-actions">
<button className="chip-pill" onClick={handleSubmitPost} disabled={isSaving}>
{isSaving ? "Posting..." : "Post"}
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -24,7 +24,7 @@ export function getViewFromMap(map) {
radiusKm = 750;
}
radiusKm = Math.min(Math.max(radiusKm, 25), 1500);
radiusKm = Math.min(Math.max(radiusKm, 25), 1000);
return {
center: [center.lng, center.lat],

View File

@ -2,6 +2,8 @@ import maplibregl from "maplibre-gl";
import React from "react";
import { createRoot } from "react-dom/client";
const SW_ANIM_MS = 840;
import CardRenderer from "../Cards/CardRenderer";
import { cardTokens } from "../../theme/cardTokens";
import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs";
@ -91,11 +93,25 @@ function closeCenteredOverlay(map) {
try {
const ov = map?.__swCenteredOverlay;
if (!ov) return;
if (ov.closing) return;
if (ov.onKey) window.removeEventListener("keydown", ov.onKey);
// animate out
try {
const mw = ov.modalWrapRef;
if (mw) {
mw.style.opacity = "0";
mw.style.transform = "scale(0.96)";
}
if (ov.backdrop) ov.backdrop.style.opacity = "0";
} catch {}
try { if (ov.unmountFull) ov.unmountFull(); } catch {}
try { if (ov.unmountMini) ov.unmountMini(); } catch {}
try { ov.backdrop?.remove?.(); } catch {}
try {
const b = ov.backdrop;
if (b) setTimeout(() => { try { b.remove(); } catch {} }, 300);
} catch {}
map.__swCenteredOverlay = null;
} catch {}
@ -118,6 +134,9 @@ function openCenteredOverlay({ map, post, theme }) {
// 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";
backdrop.style.position = "fixed";
backdrop.style.inset = "0";
backdrop.style.zIndex = "9999999";
@ -131,7 +150,8 @@ function openCenteredOverlay({ map, post, theme }) {
// Wrapper uses existing styling hooks
const modalWrap = document.createElement("div");
modalWrap.className = "post-pin--expanded";
modalWrap.classList.add("sw-centered-modal");
modalWrap.className = "post-pin--expanded sw-centered-modal";
modalWrap.style.pointerEvents = "auto";
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
@ -205,6 +225,31 @@ function openCenteredOverlay({ map, post, theme }) {
backdrop.appendChild(modalWrap);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
try { backdrop.classList.add("sw-enter-active"); } catch {}
try { modalWrap.classList.add("sw-enter-active"); } catch {}
});
// animate in
backdrop.style.opacity = "0";
modalWrap.style.opacity = "0";
modalWrap.style.transform = "scale(0.96)";
requestAnimationFrame(() => {
backdrop.style.transition = "opacity 260ms ease";
modalWrap.style.transition = "opacity 260ms ease, transform 320ms cubic-bezier(.2,.9,.2,1)";
backdrop.style.opacity = "1";
modalWrap.style.opacity = "1";
modalWrap.style.transform = "scale(1)";
});
// ✅ animate in (fade + scale)
try {
requestAnimationFrame(() => {
backdrop.classList.add("sw-open");
modalWrap.classList.add("sw-open");
});
} catch {}
// outside click closes
backdrop.addEventListener("click", () => closeCenteredOverlay(map));
@ -241,6 +286,7 @@ function openCenteredOverlay({ map, post, theme }) {
map.__swCenteredOverlay = {
postId: post?.id,
modalWrapRef: modalWrap,
backdrop,
onKey,
unmountFull,
@ -271,6 +317,8 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
const lngLat = [lon, lat];
const root = document.createElement("div");
root.classList.add("sw-appear");
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
root.className = "post-pin post-pin--compact";
root.style.zIndex = "1";
@ -283,7 +331,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
function renderCompact() {
unmountIfAny();
root.className = "post-pin post-pin--compact";
root.className = "post-pin post-pin--compact sw-appear sw-appear-in";
root.style.zIndex = "1";
root.innerHTML = `

View File

@ -0,0 +1,366 @@
import maplibregl from "maplibre-gl";
import React from "react";
import { createRoot } from "react-dom/client";
import CardRenderer from "../Cards/CardRenderer";
import { cardTokens } from "../../theme/cardTokens";
import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs";
export function clearAllMarkers(markersRef, expandedElRef) {
if (markersRef.current) {
for (const m of markersRef.current) {
try { if (m?.el && m.el.__swUnmount) m.el.__swUnmount(); } catch {}
try { m.marker.remove(); } catch {}
}
}
markersRef.current = [];
if (expandedElRef) expandedElRef.current = null;
}
export function applyOcclusionForExpanded() {}
export function clearOcclusion(markersRef) {
const list = markersRef.current || [];
for (const item of list) {
const el = item?.el;
if (!el) continue;
el.style.visibility = "visible";
el.style.pointerEvents = "auto";
}
}
function mountCard(container, spec, data, theme) {
const root = createRoot(container);
const tokens = cardTokens?.[theme] || cardTokens.blue;
root.render(
React.createElement(CardRenderer, {
spec,
data,
themeTokens: tokens,
onAction: (action, value) => {
if (action?.type === "open_url" && value) {
try { window.open(value, "_blank"); } catch {}
}
},
className: "",
})
);
return () => {
try { root.unmount(); } catch {}
};
}
function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function normCatLabel(post) {
const c = (post?.category || "").toString().toUpperCase();
if (c === "NEWS") return "NEWS";
if (c === "EVENT" || c === "EVENTS") return "EVENTS";
if (c === "FRIENDS") return "FRIENDS";
if (c === "MARKET") return "MARKET";
return "NEWS";
}
function formatRelativeTime(createdAt) {
try {
if (!createdAt) return "now";
const d = createdAt instanceof Date ? createdAt : new Date(String(createdAt));
if (Number.isNaN(d.getTime())) return "now";
const s = Math.floor((Date.now() - d.getTime()) / 1000);
if (s < 60) return "now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h`;
const days = Math.floor(h / 24);
return `${days}d`;
} catch {
return "now";
}
}
function closeCenteredOverlay(map) {
try {
const ov = map?.__swCenteredOverlay;
if (!ov) return;
if (ov.closing) return;
if (ov.onKey) window.removeEventListener("keydown", ov.onKey);
// animate out
try {
const mw = ov.modalWrapRef;
if (mw) {
mw.style.opacity = "0";
mw.style.transform = "scale(0.96)";
}
if (ov.backdrop) ov.backdrop.style.opacity = "0";
} catch {}
try { if (ov.unmountFull) ov.unmountFull(); } catch {}
try { if (ov.unmountMini) ov.unmountMini(); } catch {}
try {
const b = ov.backdrop;
if (b) setTimeout(() => { try { b.remove(); } catch {} }, 300);
} catch {}
map.__swCenteredOverlay = null;
} catch {}
}
function openCenteredOverlay({ map, post, theme }) {
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();
const metaLeft = author ? `by ${author}` : "";
const metaRight = sub ? sub : "";
const img = data?.image || "";
const url = data?.url || "";
// Backdrop catches outside click to close
const backdrop = document.createElement("div");
backdrop.classList.add("sw-centered-backdrop");
backdrop.className = "sw-centered-backdrop";
backdrop.style.position = "fixed";
backdrop.style.inset = "0";
backdrop.style.zIndex = "9999999";
backdrop.style.background = "rgba(0,0,0,0.08)";
backdrop.style.backdropFilter = "blur(1px)";
backdrop.style.display = "flex";
backdrop.style.alignItems = "center";
backdrop.style.justifyContent = "center";
backdrop.style.padding = "12px";
backdrop.style.pointerEvents = "auto";
// Wrapper uses existing styling hooks
const modalWrap = document.createElement("div");
modalWrap.classList.add("sw-centered-modal");
modalWrap.className = "post-pin--expanded sw-centered-modal";
modalWrap.style.pointerEvents = "auto";
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
modalWrap.innerHTML = `
<div class="post-card sw-expanded-shell sw-modal">
<div class="sw-modal-toprow">
<div class="sw-modal-left">
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
<div class="sw-modal-meta">
${metaLeft ? `<span>${escapeHtml(metaLeft)}</span>` : ""}
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
<span>• ${escapeHtml(relTime)}</span>
</div>
</div>
<button class="sw-modal-x" type="button" aria-label="Close">✕</button>
</div>
<div class="sw-modal-hero">
${
img
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
: `<div class="sw-modal-hero-fallback"></div>`
}
</div>
<div class="sw-modal-title">${escapeHtml(data?.headline || post?.title || "Untitled")}</div>
<div class="sw-modal-summary">
${escapeHtml(data?.summary || post?.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>
<div class="sw-modal-cta-row">
<button class="sw-cta-primary" type="button" ${url ? "" : "disabled"} data-url="${escapeHtml(url)}">
Open source
</button>
<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">Fix</button>
</div>
</div>
<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>
<div class="sw-livechat-inputrow">
<input class="sw-livechat-input" placeholder="Write a comment…" />
<button class="sw-livechat-post" type="button">POST</button>
</div>
</div>
<!-- (Optional) keep template renderer mounted but hidden for future -->
<div class="sw-template-full-wrap" style="display:none;"></div>
</div>
`;
backdrop.appendChild(modalWrap);
document.body.appendChild(backdrop);
// animate in
backdrop.style.opacity = "0";
modalWrap.style.opacity = "0";
modalWrap.style.transform = "scale(0.96)";
requestAnimationFrame(() => {
backdrop.style.transition = "opacity 260ms ease";
modalWrap.style.transition = "opacity 260ms ease, transform 320ms cubic-bezier(.2,.9,.2,1)";
backdrop.style.opacity = "1";
modalWrap.style.opacity = "1";
modalWrap.style.transform = "scale(1)";
});
// ✅ animate in (fade + scale)
try {
requestAnimationFrame(() => {
backdrop.classList.add("sw-open");
modalWrap.classList.add("sw-open");
});
} catch {}
// outside click closes
backdrop.addEventListener("click", () => closeCenteredOverlay(map));
// inside click doesn't close
modalWrap.addEventListener("click", (e) => e.stopPropagation());
// close button
const xbtn = modalWrap.querySelector(".sw-modal-x");
if (xbtn) xbtn.addEventListener("click", () => closeCenteredOverlay(map));
// ESC closes
const onKey = (e) => {
if (e.key === "Escape") closeCenteredOverlay(map);
};
window.addEventListener("keydown", onKey);
// CTA open url
const cta = modalWrap.querySelector(".sw-cta-primary");
if (cta) {
cta.addEventListener("click", () => {
const u = cta.getAttribute("data-url") || "";
if (!u) return;
try { window.open(u, "_blank", "noopener,noreferrer"); } catch {}
});
}
// still mount template full (hidden) so you can switch back later instantly
let unmountFull = null;
const fullWrap = modalWrap.querySelector(".sw-template-full-wrap");
if (fullWrap) {
const spec = getTemplateSpecForPost(post, "full");
unmountFull = mountCard(fullWrap, spec, data, theme);
}
map.__swCenteredOverlay = {
postId: post?.id,
modalWrapRef: modalWrap,
backdrop,
onKey,
unmountFull,
unmountMini: null,
};
}
export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
const map = mapRef.current;
if (!map) return;
const lat =
typeof post.lat === "number"
? post.lat
: typeof post.latitude === "number"
? post.latitude
: null;
const lon =
typeof post.lon === "number"
? post.lon
: typeof post.lng === "number"
? post.lng
: null;
if (lat == null || lon == null) return;
const lngLat = [lon, lat];
const root = document.createElement("div");
root.classList.add("sw-appear");
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
root.className = "post-pin post-pin--compact";
root.style.zIndex = "1";
function unmountIfAny() {
if (root.__swUnmount) {
try { root.__swUnmount(); } catch {}
root.__swUnmount = null;
}
}
function renderCompact() {
unmountIfAny();
root.className = "post-pin post-pin--compact";
root.style.zIndex = "1";
root.innerHTML = `
<div class="sw-template-mini-wrap"></div>
<div class="post-pin-pointer-small"></div>
`;
const wrap = root.querySelector(".sw-template-mini-wrap");
if (wrap) {
const spec = getTemplateSpecForPost(post, "mini");
const data = adaptPostToTemplateData(post);
root.__swUnmount = mountCard(wrap, spec, data, theme);
}
clearOcclusion(markersRef);
}
renderCompact();
root.__renderCompact = renderCompact;
const marker = new maplibregl.Marker({ element: root, anchor: "bottom" })
.setLngLat(lngLat)
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post });
root.addEventListener("click", (ev) => {
ev.stopPropagation();
const existing = map.__swCenteredOverlay;
if (existing && existing.postId === post?.id) {
closeCenteredOverlay(map);
return;
}
openCenteredOverlay({ map, post, theme });
if (expandedElRef) expandedElRef.current = null;
});
}

View File

@ -0,0 +1,366 @@
import maplibregl from "maplibre-gl";
import React from "react";
import { createRoot } from "react-dom/client";
import CardRenderer from "../Cards/CardRenderer";
import { cardTokens } from "../../theme/cardTokens";
import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs";
export function clearAllMarkers(markersRef, expandedElRef) {
if (markersRef.current) {
for (const m of markersRef.current) {
try { if (m?.el && m.el.__swUnmount) m.el.__swUnmount(); } catch {}
try { m.marker.remove(); } catch {}
}
}
markersRef.current = [];
if (expandedElRef) expandedElRef.current = null;
}
export function applyOcclusionForExpanded() {}
export function clearOcclusion(markersRef) {
const list = markersRef.current || [];
for (const item of list) {
const el = item?.el;
if (!el) continue;
el.style.visibility = "visible";
el.style.pointerEvents = "auto";
}
}
function mountCard(container, spec, data, theme) {
const root = createRoot(container);
const tokens = cardTokens?.[theme] || cardTokens.blue;
root.render(
React.createElement(CardRenderer, {
spec,
data,
themeTokens: tokens,
onAction: (action, value) => {
if (action?.type === "open_url" && value) {
try { window.open(value, "_blank"); } catch {}
}
},
className: "",
})
);
return () => {
try { root.unmount(); } catch {}
};
}
function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function normCatLabel(post) {
const c = (post?.category || "").toString().toUpperCase();
if (c === "NEWS") return "NEWS";
if (c === "EVENT" || c === "EVENTS") return "EVENTS";
if (c === "FRIENDS") return "FRIENDS";
if (c === "MARKET") return "MARKET";
return "NEWS";
}
function formatRelativeTime(createdAt) {
try {
if (!createdAt) return "now";
const d = createdAt instanceof Date ? createdAt : new Date(String(createdAt));
if (Number.isNaN(d.getTime())) return "now";
const s = Math.floor((Date.now() - d.getTime()) / 1000);
if (s < 60) return "now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h`;
const days = Math.floor(h / 24);
return `${days}d`;
} catch {
return "now";
}
}
function closeCenteredOverlay(map) {
try {
const ov = map?.__swCenteredOverlay;
if (!ov) return;
if (ov.closing) return;
if (ov.onKey) window.removeEventListener("keydown", ov.onKey);
// animate out
try {
const mw = ov.modalWrapRef;
if (mw) {
mw.style.opacity = "0";
mw.style.transform = "scale(0.96)";
}
if (ov.backdrop) ov.backdrop.style.opacity = "0";
} catch {}
try { if (ov.unmountFull) ov.unmountFull(); } catch {}
try { if (ov.unmountMini) ov.unmountMini(); } catch {}
try {
const b = ov.backdrop;
if (b) setTimeout(() => { try { b.remove(); } catch {} }, 300);
} catch {}
map.__swCenteredOverlay = null;
} catch {}
}
function openCenteredOverlay({ map, post, theme }) {
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();
const metaLeft = author ? `by ${author}` : "";
const metaRight = sub ? sub : "";
const img = data?.image || "";
const url = data?.url || "";
// Backdrop catches outside click to close
const backdrop = document.createElement("div");
backdrop.classList.add("sw-centered-backdrop");
backdrop.className = "sw-centered-backdrop";
backdrop.style.position = "fixed";
backdrop.style.inset = "0";
backdrop.style.zIndex = "9999999";
backdrop.style.background = "rgba(0,0,0,0.08)";
backdrop.style.backdropFilter = "blur(1px)";
backdrop.style.display = "flex";
backdrop.style.alignItems = "center";
backdrop.style.justifyContent = "center";
backdrop.style.padding = "12px";
backdrop.style.pointerEvents = "auto";
// Wrapper uses existing styling hooks
const modalWrap = document.createElement("div");
modalWrap.classList.add("sw-centered-modal");
modalWrap.className = "post-pin--expanded sw-centered-modal";
modalWrap.style.pointerEvents = "auto";
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
modalWrap.innerHTML = `
<div class="post-card sw-expanded-shell sw-modal">
<div class="sw-modal-toprow">
<div class="sw-modal-left">
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
<div class="sw-modal-meta">
${metaLeft ? `<span>${escapeHtml(metaLeft)}</span>` : ""}
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
<span>• ${escapeHtml(relTime)}</span>
</div>
</div>
<button class="sw-modal-x" type="button" aria-label="Close">✕</button>
</div>
<div class="sw-modal-hero">
${
img
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
: `<div class="sw-modal-hero-fallback"></div>`
}
</div>
<div class="sw-modal-title">${escapeHtml(data?.headline || post?.title || "Untitled")}</div>
<div class="sw-modal-summary">
${escapeHtml(data?.summary || post?.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>
<div class="sw-modal-cta-row">
<button class="sw-cta-primary" type="button" ${url ? "" : "disabled"} data-url="${escapeHtml(url)}">
Open source
</button>
<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">Fix</button>
</div>
</div>
<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>
<div class="sw-livechat-inputrow">
<input class="sw-livechat-input" placeholder="Write a comment…" />
<button class="sw-livechat-post" type="button">POST</button>
</div>
</div>
<!-- (Optional) keep template renderer mounted but hidden for future -->
<div class="sw-template-full-wrap" style="display:none;"></div>
</div>
`;
backdrop.appendChild(modalWrap);
document.body.appendChild(backdrop);
// animate in
backdrop.style.opacity = "0";
modalWrap.style.opacity = "0";
modalWrap.style.transform = "scale(0.96)";
requestAnimationFrame(() => {
backdrop.style.transition = "opacity 260ms ease";
modalWrap.style.transition = "opacity 260ms ease, transform 320ms cubic-bezier(.2,.9,.2,1)";
backdrop.style.opacity = "1";
modalWrap.style.opacity = "1";
modalWrap.style.transform = "scale(1)";
});
// ✅ animate in (fade + scale)
try {
requestAnimationFrame(() => {
backdrop.classList.add("sw-open");
modalWrap.classList.add("sw-open");
});
} catch {}
// outside click closes
backdrop.addEventListener("click", () => closeCenteredOverlay(map));
// inside click doesn't close
modalWrap.addEventListener("click", (e) => e.stopPropagation());
// close button
const xbtn = modalWrap.querySelector(".sw-modal-x");
if (xbtn) xbtn.addEventListener("click", () => closeCenteredOverlay(map));
// ESC closes
const onKey = (e) => {
if (e.key === "Escape") closeCenteredOverlay(map);
};
window.addEventListener("keydown", onKey);
// CTA open url
const cta = modalWrap.querySelector(".sw-cta-primary");
if (cta) {
cta.addEventListener("click", () => {
const u = cta.getAttribute("data-url") || "";
if (!u) return;
try { window.open(u, "_blank", "noopener,noreferrer"); } catch {}
});
}
// still mount template full (hidden) so you can switch back later instantly
let unmountFull = null;
const fullWrap = modalWrap.querySelector(".sw-template-full-wrap");
if (fullWrap) {
const spec = getTemplateSpecForPost(post, "full");
unmountFull = mountCard(fullWrap, spec, data, theme);
}
map.__swCenteredOverlay = {
postId: post?.id,
modalWrapRef: modalWrap,
backdrop,
onKey,
unmountFull,
unmountMini: null,
};
}
export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
const map = mapRef.current;
if (!map) return;
const lat =
typeof post.lat === "number"
? post.lat
: typeof post.latitude === "number"
? post.latitude
: null;
const lon =
typeof post.lon === "number"
? post.lon
: typeof post.lng === "number"
? post.lng
: null;
if (lat == null || lon == null) return;
const lngLat = [lon, lat];
const root = document.createElement("div");
root.classList.add("sw-appear");
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
root.className = "post-pin post-pin--compact";
root.style.zIndex = "1";
function unmountIfAny() {
if (root.__swUnmount) {
try { root.__swUnmount(); } catch {}
root.__swUnmount = null;
}
}
function renderCompact() {
unmountIfAny();
root.className = "post-pin post-pin--compact sw-appear sw-appear-in";
root.style.zIndex = "1";
root.innerHTML = `
<div class="sw-template-mini-wrap"></div>
<div class="post-pin-pointer-small"></div>
`;
const wrap = root.querySelector(".sw-template-mini-wrap");
if (wrap) {
const spec = getTemplateSpecForPost(post, "mini");
const data = adaptPostToTemplateData(post);
root.__swUnmount = mountCard(wrap, spec, data, theme);
}
clearOcclusion(markersRef);
}
renderCompact();
root.__renderCompact = renderCompact;
const marker = new maplibregl.Marker({ element: root, anchor: "bottom" })
.setLngLat(lngLat)
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post });
root.addEventListener("click", (ev) => {
ev.stopPropagation();
const existing = map.__swCenteredOverlay;
if (existing && existing.postId === post?.id) {
closeCenteredOverlay(map);
return;
}
openCenteredOverlay({ map, post, theme });
if (expandedElRef) expandedElRef.current = null;
});
}

View File

@ -1,8 +1,12 @@
/**
* Frontend-only template specs.
* Pointers stay as-is:
* - compact triangle: .post-pin-pointer-small
* - expanded triangle: .post-card-pointer
* Variants:
* - news
* - breaking (news urgent)
* - finance (news finance)
* - market
* - friends
* - events
*/
export const TEMPLATE_SPECS = {
news: {
@ -13,11 +17,9 @@ export const TEMPLATE_SPECS = {
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "NEWS", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 }
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
// ✅ FULL: remove useless empty top (no hero image block), keep SAME overall size
full_spec: {
size: { w: 360, h: 260 },
background: { type: "solid", value: "surface" },
@ -25,18 +27,161 @@ export const TEMPLATE_SPECS = {
layers: [
{ id: "badge", type: "chip", x: 16, y: 12, text: "NEWS", style: "chip.news" },
{ id: "title", type: "text", x: 16, y: 52, w: 328, h: 72, bind: "data.headline", style: "text.h1", maxLines: 3 },
{ id: "summary", type: "text", x: 16, y: 132, w: 328, h: 78, bind: "data.summary", style: "text.body", maxLines: 4 }
{ id: "summary", type: "text", x: 16, y: 132, w: 328, h: 78, bind: "data.summary", style: "text.body", maxLines: 4 },
],
},
},
breaking: {
mini_spec: {
size: { w: 240, h: 96 },
background: { type: "gradient", value: "breakingRed" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "BREAKING", style: "chip.danger" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
size: { w: 360, h: 260 },
background: { type: "gradient", value: "breakingRed" },
radius: 22,
layers: [
{ id: "badge", type: "chip", x: 16, y: 12, text: "BREAKING", style: "chip.danger" },
{ id: "title", type: "text", x: 16, y: 52, w: 328, h: 72, bind: "data.headline", style: "text.h1", maxLines: 3 },
{ id: "summary", type: "text", x: 16, y: 132, w: 328, h: 78, bind: "data.summary", style: "text.body", maxLines: 4 },
],
},
},
finance: {
mini_spec: {
size: { w: 240, h: 96 },
background: { type: "gradient", value: "financeTeal" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "FINANCE", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
size: { w: 360, h: 260 },
background: { type: "gradient", value: "financeTeal" },
radius: 22,
layers: [
{ id: "badge", type: "chip", x: 16, y: 12, text: "FINANCE", style: "chip.news" },
{ id: "title", type: "text", x: 16, y: 52, w: 328, h: 72, bind: "data.headline", style: "text.h1", maxLines: 3 },
{ id: "summary", type: "text", x: 16, y: 132, w: 328, h: 78, bind: "data.summary", style: "text.body", maxLines: 4 },
],
},
},
market: {
mini_spec: {
size: { w: 240, h: 96 },
background: { type: "gradient", value: "marketGold" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "MARKET", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
size: { w: 360, h: 260 },
background: { type: "gradient", value: "marketGold" },
radius: 22,
layers: [
{ id: "badge", type: "chip", x: 16, y: 12, text: "MARKET", style: "chip.news" },
{ id: "title", type: "text", x: 16, y: 52, w: 328, h: 72, bind: "data.headline", style: "text.h1", maxLines: 3 },
{ id: "summary", type: "text", x: 16, y: 132, w: 328, h: 78, bind: "data.summary", style: "text.body", maxLines: 4 },
],
},
},
friends: {
mini_spec: {
size: { w: 240, h: 96 },
background: { type: "gradient", value: "friendsGreen" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "FRIENDS", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
size: { w: 360, h: 260 },
background: { type: "gradient", value: "friendsGreen" },
radius: 22,
layers: [
{ id: "badge", type: "chip", x: 16, y: 12, text: "FRIENDS", style: "chip.news" },
{ id: "title", type: "text", x: 16, y: 52, w: 328, h: 72, bind: "data.headline", style: "text.h1", maxLines: 3 },
{ id: "summary", type: "text", x: 16, y: 132, w: 328, h: 78, bind: "data.summary", style: "text.body", maxLines: 4 },
],
},
},
events: {
mini_spec: {
size: { w: 240, h: 96 },
background: { type: "gradient", value: "eventsPurple" },
radius: 18,
layers: [
{ id: "badge", type: "chip", x: 12, y: 10, text: "EVENTS", style: "chip.news" },
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
size: { w: 360, h: 260 },
background: { type: "gradient", value: "eventsPurple" },
radius: 22,
layers: [
{ id: "badge", type: "chip", x: 16, y: 12, text: "EVENTS", style: "chip.news" },
{ id: "title", type: "text", x: 16, y: 52, w: 328, h: 72, bind: "data.headline", style: "text.h1", maxLines: 3 },
{ id: "summary", type: "text", x: 16, y: 132, w: 328, h: 78, bind: "data.summary", style: "text.body", maxLines: 4 },
],
},
},
};
function parseDateAny(raw) {
if (!raw) return null;
if (raw instanceof Date) return raw;
const s = String(raw).trim();
if (!s) return null;
// handle "YYYY-MM-DD HH:mm:ss"
const fixed = s.includes(" ") && !s.includes("T") ? s.replace(" ", "T") : s;
const d = new Date(fixed);
return Number.isNaN(d.getTime()) ? null : d;
}
function isBreaking(post) {
if (post?.breaking === true || post?.is_breaking === true) return true;
const t = (post?.title || "").toString().toLowerCase();
if (t.includes("breaking") || t.includes("urgent") || t.includes("alerte")) return true;
const d = parseDateAny(post?.created_at || post?.CreatedAt || post?.createdAt);
if (!d) return false;
const mins = (Date.now() - d.getTime()) / 60000;
return mins >= 0 && mins <= 45;
}
function normCat(post) {
const c = (post?.category || "").toString().toUpperCase();
if (c === "NEWS") return "news";
if (c === "EVENT") return "news";
if (c === "FRIENDS") return "news";
if (c === "MARKET") return "news";
const c = (post?.category || post?.Category || "").toString().toUpperCase();
const sub = (post?.sub_category || post?.subCategory || "").toString().toLowerCase();
if (c === "MARKET") return "market";
if (c === "FRIENDS") return "friends";
if (c === "EVENT" || c === "EVENTS") return "events";
if (c === "NEWS" && (sub.includes("finan") || sub.includes("finance"))) return "finance";
if (c === "NEWS" && isBreaking(post)) return "breaking";
return "news";
}

View File

@ -97,6 +97,9 @@ export function useMapCore(theme) {
map.addControl(new maplibregl.NavigationControl(), "top-right");
mapRef.current = map;
// ✅ Ensure viewParams is set immediately so posts load without moving the map
try { setViewParams(getViewFromMap(map)); } catch {}
const reOcclude = () => {
const el = expandedElRef.current;
if (el) applyOcclusionForExpanded(markersRef, el);

View File

@ -4,6 +4,21 @@ import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo";
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
function getId(p) {
return p?.id ?? p?._id ?? null;
}
function sameIdList(a, b) {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (getId(a[i]) !== getId(b[i])) return false;
}
return true;
}
export function usePostsEngine({
mapRef,
viewParams,
@ -12,51 +27,109 @@ export function usePostsEngine({
timeFilter,
markersRef,
expandedElRef,
theme = "blue",
}) {
const allPostsRef = useRef([]);
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "" });
const delayedFetchRef = useRef(null);
const [status, setStatus] = useState("Loading posts...");
const [status, setStatus] = useState("");
const [visiblePosts, setVisiblePosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState("");
const rebuildMarkersForFilters = useCallback(
(tf) => {
const syncMarkers = useCallback(
(visible) => {
const map = mapRef.current;
if (!map) return;
// ✅ If expanded popup is open, NEVER rebuild markers (rebuild would close it)
// If user is reading an expanded overlay, don't touch markers
if (expandedElRef?.current) return;
clearAllMarkers(markersRef, expandedElRef);
const wanted = new Map();
for (const post of visible) {
const id = getId(post);
if (id != null) wanted.set(id, post);
}
const cur = markersRef.current || [];
const next = [];
// remove markers not wanted (smooth fade-out)
for (const item of cur) {
const id = item?.id;
if (id == null || !wanted.has(id)) {
try {
const el = item?.el;
if (el) {
el.classList.add("sw-disappear");
el.style.pointerEvents = "none";
}
} catch {}
// let CSS transition play, then remove from map
setTimeout(() => {
try { if (item?.el && item.el.__swUnmount) item.el.__swUnmount(); } catch {}
try { item?.marker?.remove?.(); } catch {}
}, 840);
} else {
next.push(item);
}
}
markersRef.current = next;
const have = new Set(next.map((x) => x.id));
// add missing markers
for (const post of visible) {
const id = getId(post);
if (id == null || have.has(id)) continue;
createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
}
},
[mapRef, markersRef, expandedElRef, theme]
);
const computeVisible = useCallback(
(tf) => {
const posts = allPostsRef.current || [];
const catCode = categoryCode(mainFilter);
const visible = posts.filter((p) => {
return posts.filter((p) => {
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false;
}
if (!matchesSubFilter(p, subFilter)) return false;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false;
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, tf)) return false;
return true;
});
visible.forEach((post) => createMarkerForPost(post, mapRef, markersRef, expandedElRef));
setVisiblePosts(visible);
setStatus(visible.length ? "" : "No posts found.");
},
[mapRef, markersRef, expandedElRef, mainFilter, subFilter]
[mainFilter, subFilter]
);
useEffect(() => {
rebuildMarkersForFilters(timeFilter);
}, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]);
const refreshVisibleAndMarkers = useCallback(
(tf) => {
const v = computeVisible(tf);
// reduce wall blink: don't update if same ids
setVisiblePosts((prev) => (sameIdList(prev, v) ? prev : v));
// no annoying "No posts found" bubble
setStatus("");
syncMarkers(v);
},
[computeVisible, syncMarkers]
);
// On filter changes: recompute + sync (NO clear-all rebuild)
useEffect(() => {
refreshVisibleAndMarkers(timeFilter);
}, [timeFilter, mainFilter, subFilter, refreshVisibleAndMarkers]);
// Fetch on view change (moveend)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
@ -67,18 +140,18 @@ export function usePostsEngine({
async function load() {
const mapObj = mapRef.current;
// ✅ If expanded popup is open: delay fetch (do NOT clear/rebuild while user reads)
// If expanded overlay is open: delay fetch (do NOT disturb UI)
if (expandedElRef?.current) {
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, 2500);
delayedFetchRef.current = setTimeout(load, 1200);
return;
}
// Also respect ignore window (during auto-pan)
// Respect ignore window if any
try {
const until = mapObj?.__swIgnoreFetchUntil || 0;
if (until && Date.now() < until) {
const wait = Math.min(4000, until - Date.now());
const wait = Math.min(2500, until - Date.now());
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, wait + 25);
return;
@ -93,18 +166,17 @@ export function usePostsEngine({
if (last.center && last.filterKey === filterKey) {
const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, viewParams.center[1], lng);
const distKm = haversineKm(lastLat, lastLng, lat, lng);
const lastR = last.radiusKm || 0;
const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25;
const minMoveKm = Math.max(50, radiusKm * 0.25);
const minMoveKm = Math.max(40, radiusKm * 0.22);
if (!radiusChanged && distKm < minMoveKm) return;
}
try {
setStatus("Loading posts...");
setLoadingPosts(true);
setLoadError("");
@ -126,47 +198,27 @@ export function usePostsEngine({
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
if (p && typeof p.id !== "undefined") byId.set(p.id, p);
const id = getId(p);
if (id != null) byId.set(id, p);
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) byId.set(p.id, p);
const id = getId(p);
if (id != null && !byId.has(id)) byId.set(id, p);
}
}
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = { center: [...viewParams.center], radiusKm, filterKey };
// If expanded popup is open, don't rebuild markers (keeps it open)
try {
if (expandedElRef?.current) {
const posts = allPostsRef.current || [];
const catCode2 = categoryCode(mainFilter);
// Smooth update: sync markers + wall without clearing everything
refreshVisibleAndMarkers(timeFilter);
const visible2 = posts.filter((p) => {
if (catCode2) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode2) return false;
}
if (!matchesSubFilter(p, subFilter)) return false;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) return false;
return true;
});
setVisiblePosts(visible2);
setStatus(visible2.length ? "" : "No posts found.");
setLoadingPosts(false);
return;
}
} catch {}
rebuildMarkersForFilters(timeFilter);
setLoadingPosts(false);
} catch (err) {
console.error("Erreur chargement posts:", err);
if (!cancelled) {
setStatus("No posts found.");
setLoadError("");
setLoadError("Error loading posts.");
setLoadingPosts(false);
}
}
@ -180,7 +232,7 @@ export function usePostsEngine({
delayedFetchRef.current = null;
}
};
}, [viewParams, mainFilter, subFilter, mapRef, rebuildMarkersForFilters, timeFilter, expandedElRef]);
}, [viewParams, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers]);
const handleIncomingPost = useCallback(
(p) => {
@ -192,12 +244,21 @@ export function usePostsEngine({
if (pc !== catCode) return;
}
if (!matchesSubFilter(p, subFilter)) return;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) return;
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, timeFilter)) return;
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
setVisiblePosts((current) => [...current, p]);
// add marker if missing
const id = getId(p);
const have = new Set((markersRef.current || []).map((x) => x.id));
if (id != null && !have.has(id)) {
createMarkerForPost(p, mapRef, markersRef, expandedElRef, theme);
}
setVisiblePosts((current) => {
const next = [...current, p];
return next;
});
},
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef]
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef, theme]
);
return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost };

View File

@ -0,0 +1,204 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPosts } from "../../api/client";
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo";
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
export function usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
}) {
const allPostsRef = useRef([]);
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "" });
const delayedFetchRef = useRef(null);
const [status, setStatus] = useState("Loading posts...");
const [visiblePosts, setVisiblePosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState("");
const rebuildMarkersForFilters = useCallback(
(tf) => {
const map = mapRef.current;
if (!map) return;
// ✅ If expanded popup is open, NEVER rebuild markers (rebuild would close it)
if (expandedElRef?.current) return;
clearAllMarkers(markersRef, expandedElRef);
const posts = allPostsRef.current || [];
const catCode = categoryCode(mainFilter);
const visible = posts.filter((p) => {
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false;
}
if (!matchesSubFilter(p, subFilter)) return false;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false;
return true;
});
visible.forEach((post) => createMarkerForPost(post, mapRef, markersRef, expandedElRef));
setVisiblePosts(visible);
setStatus(visible.length ? "" : "No posts found.");
},
[mapRef, markersRef, expandedElRef, mainFilter, subFilter]
);
useEffect(() => {
rebuildMarkersForFilters(timeFilter);
}, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!viewParams.center) return;
let cancelled = false;
async function load() {
const mapObj = mapRef.current;
// ✅ If expanded popup is open: delay fetch (do NOT clear/rebuild while user reads)
if (expandedElRef?.current) {
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, 2500);
return;
}
// Also respect ignore window (during auto-pan)
try {
const until = mapObj?.__swIgnoreFetchUntil || 0;
if (until && Date.now() < until) {
const wait = Math.min(4000, until - Date.now());
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, wait + 25);
return;
}
} catch {}
const [lng, lat] = viewParams.center;
const radiusKm = viewParams.radiusKm || 750;
const filterKey = `${mainFilter}|${subFilter}`;
const last = lastFetchRef.current;
if (last.center && last.filterKey === filterKey) {
const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, viewParams.center[1], lng);
const lastR = last.radiusKm || 0;
const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25;
const minMoveKm = Math.max(50, radiusKm * 0.25);
if (!radiusChanged && distKm < minMoveKm) return;
}
try {
setStatus("Loading posts...");
setLoadingPosts(true);
setLoadError("");
const catCode = categoryCode(mainFilter);
const subCatParam =
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
const newPosts = await fetchPosts({
category: catCode,
subCategory: subCatParam,
time: "",
lat,
lon: lng,
radiusKm,
});
if (cancelled) return;
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
if (p && typeof p.id !== "undefined") byId.set(p.id, p);
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) byId.set(p.id, p);
}
}
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = { center: [...viewParams.center], radiusKm, filterKey };
// If expanded popup is open, don't rebuild markers (keeps it open)
try {
if (expandedElRef?.current) {
const posts = allPostsRef.current || [];
const catCode2 = categoryCode(mainFilter);
const visible2 = posts.filter((p) => {
if (catCode2) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode2) return false;
}
if (!matchesSubFilter(p, subFilter)) return false;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) return false;
return true;
});
setVisiblePosts(visible2);
setStatus(visible2.length ? "" : "No posts found.");
setLoadingPosts(false);
return;
}
} catch {}
rebuildMarkersForFilters(timeFilter);
setLoadingPosts(false);
} catch (err) {
console.error("Erreur chargement posts:", err);
if (!cancelled) {
setStatus("No posts found.");
setLoadError("");
setLoadingPosts(false);
}
}
}
load();
return () => {
cancelled = true;
if (delayedFetchRef.current) {
try { clearTimeout(delayedFetchRef.current); } catch {}
delayedFetchRef.current = null;
}
};
}, [viewParams, mainFilter, subFilter, mapRef, rebuildMarkersForFilters, timeFilter, expandedElRef]);
const handleIncomingPost = useCallback(
(p) => {
allPostsRef.current = [...allPostsRef.current, p];
const catCode = categoryCode(mainFilter);
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return;
}
if (!matchesSubFilter(p, subFilter)) return;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) return;
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
setVisiblePosts((current) => [...current, p]);
},
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef]
);
return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost };
}

View File

@ -0,0 +1,253 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPosts } from "../../api/client";
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo";
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
function getId(p) {
return p?.id ?? p?._id ?? null;
}
function sameIdList(a, b) {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (getId(a[i]) !== getId(b[i])) return false;
}
return true;
}
export function usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
theme = "blue",
}) {
const allPostsRef = useRef([]);
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "" });
const delayedFetchRef = useRef(null);
const [status, setStatus] = useState("");
const [visiblePosts, setVisiblePosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState("");
const syncMarkers = useCallback(
(visible) => {
const map = mapRef.current;
if (!map) return;
// If user is reading an expanded overlay, don't touch markers
if (expandedElRef?.current) return;
const wanted = new Map();
for (const post of visible) {
const id = getId(post);
if (id != null) wanted.set(id, post);
}
const cur = markersRef.current || [];
const next = [];
// remove markers not wanted
for (const item of cur) {
const id = item?.id;
if (id == null || !wanted.has(id)) {
try { if (item?.el && item.el.__swUnmount) item.el.__swUnmount(); } catch {}
try { item?.marker?.remove?.(); } catch {}
} else {
next.push(item);
}
}
markersRef.current = next;
const have = new Set(next.map((x) => x.id));
// add missing markers
for (const post of visible) {
const id = getId(post);
if (id == null || have.has(id)) continue;
createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
}
},
[mapRef, markersRef, expandedElRef, theme]
);
const computeVisible = useCallback(
(tf) => {
const posts = allPostsRef.current || [];
const catCode = categoryCode(mainFilter);
return posts.filter((p) => {
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false;
}
if (!matchesSubFilter(p, subFilter)) return false;
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, tf)) return false;
return true;
});
},
[mainFilter, subFilter]
);
const refreshVisibleAndMarkers = useCallback(
(tf) => {
const v = computeVisible(tf);
// reduce wall blink: don't update if same ids
setVisiblePosts((prev) => (sameIdList(prev, v) ? prev : v));
// no annoying "No posts found" bubble
setStatus("");
syncMarkers(v);
},
[computeVisible, syncMarkers]
);
// On filter changes: recompute + sync (NO clear-all rebuild)
useEffect(() => {
refreshVisibleAndMarkers(timeFilter);
}, [timeFilter, mainFilter, subFilter, refreshVisibleAndMarkers]);
// Fetch on view change (moveend)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!viewParams.center) return;
let cancelled = false;
async function load() {
const mapObj = mapRef.current;
// If expanded overlay is open: delay fetch (do NOT disturb UI)
if (expandedElRef?.current) {
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, 1200);
return;
}
// Respect ignore window if any
try {
const until = mapObj?.__swIgnoreFetchUntil || 0;
if (until && Date.now() < until) {
const wait = Math.min(2500, until - Date.now());
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, wait + 25);
return;
}
} catch {}
const [lng, lat] = viewParams.center;
const radiusKm = viewParams.radiusKm || 750;
const filterKey = `${mainFilter}|${subFilter}`;
const last = lastFetchRef.current;
if (last.center && last.filterKey === filterKey) {
const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, lat, lng);
const lastR = last.radiusKm || 0;
const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25;
const minMoveKm = Math.max(40, radiusKm * 0.22);
if (!radiusChanged && distKm < minMoveKm) return;
}
try {
setLoadingPosts(true);
setLoadError("");
const catCode = categoryCode(mainFilter);
const subCatParam =
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
const newPosts = await fetchPosts({
category: catCode,
subCategory: subCatParam,
time: "",
lat,
lon: lng,
radiusKm,
});
if (cancelled) return;
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
const id = getId(p);
if (id != null) byId.set(id, p);
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
const id = getId(p);
if (id != null && !byId.has(id)) byId.set(id, p);
}
}
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = { center: [...viewParams.center], radiusKm, filterKey };
// Smooth update: sync markers + wall without clearing everything
refreshVisibleAndMarkers(timeFilter);
setLoadingPosts(false);
} catch (err) {
console.error("Erreur chargement posts:", err);
if (!cancelled) {
setLoadError("Error loading posts.");
setLoadingPosts(false);
}
}
}
load();
return () => {
cancelled = true;
if (delayedFetchRef.current) {
try { clearTimeout(delayedFetchRef.current); } catch {}
delayedFetchRef.current = null;
}
};
}, [viewParams, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers]);
const handleIncomingPost = useCallback(
(p) => {
allPostsRef.current = [...allPostsRef.current, p];
const catCode = categoryCode(mainFilter);
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return;
}
if (!matchesSubFilter(p, subFilter)) return;
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, timeFilter)) return;
// add marker if missing
const id = getId(p);
const have = new Set((markersRef.current || []).map((x) => x.id));
if (id != null && !have.has(id)) {
createMarkerForPost(p, mapRef, markersRef, expandedElRef, theme);
}
setVisiblePosts((current) => {
const next = [...current, p];
return next;
});
},
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef, theme]
);
return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost };
}

View File

@ -0,0 +1,264 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPosts } from "../../api/client";
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo";
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
function getId(p) {
return p?.id ?? p?._id ?? null;
}
function sameIdList(a, b) {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (getId(a[i]) !== getId(b[i])) return false;
}
return true;
}
export function usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
theme = "blue",
}) {
const allPostsRef = useRef([]);
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "" });
const delayedFetchRef = useRef(null);
const [status, setStatus] = useState("");
const [visiblePosts, setVisiblePosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState("");
const syncMarkers = useCallback(
(visible) => {
const map = mapRef.current;
if (!map) return;
// If user is reading an expanded overlay, don't touch markers
if (expandedElRef?.current) return;
const wanted = new Map();
for (const post of visible) {
const id = getId(post);
if (id != null) wanted.set(id, post);
}
const cur = markersRef.current || [];
const next = [];
// remove markers not wanted (smooth fade-out)
for (const item of cur) {
const id = item?.id;
if (id == null || !wanted.has(id)) {
try {
const el = item?.el;
if (el) {
el.classList.add("sw-disappear");
el.style.pointerEvents = "none";
}
} catch {}
// let CSS transition play, then remove from map
setTimeout(() => {
try { if (item?.el && item.el.__swUnmount) item.el.__swUnmount(); } catch {}
try { item?.marker?.remove?.(); } catch {}
}, 280);
} else {
next.push(item);
}
}
markersRef.current = next;
const have = new Set(next.map((x) => x.id));
// add missing markers
for (const post of visible) {
const id = getId(post);
if (id == null || have.has(id)) continue;
createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
}
},
[mapRef, markersRef, expandedElRef, theme]
);
const computeVisible = useCallback(
(tf) => {
const posts = allPostsRef.current || [];
const catCode = categoryCode(mainFilter);
return posts.filter((p) => {
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false;
}
if (!matchesSubFilter(p, subFilter)) return false;
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, tf)) return false;
return true;
});
},
[mainFilter, subFilter]
);
const refreshVisibleAndMarkers = useCallback(
(tf) => {
const v = computeVisible(tf);
// reduce wall blink: don't update if same ids
setVisiblePosts((prev) => (sameIdList(prev, v) ? prev : v));
// no annoying "No posts found" bubble
setStatus("");
syncMarkers(v);
},
[computeVisible, syncMarkers]
);
// On filter changes: recompute + sync (NO clear-all rebuild)
useEffect(() => {
refreshVisibleAndMarkers(timeFilter);
}, [timeFilter, mainFilter, subFilter, refreshVisibleAndMarkers]);
// Fetch on view change (moveend)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!viewParams.center) return;
let cancelled = false;
async function load() {
const mapObj = mapRef.current;
// If expanded overlay is open: delay fetch (do NOT disturb UI)
if (expandedElRef?.current) {
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, 1200);
return;
}
// Respect ignore window if any
try {
const until = mapObj?.__swIgnoreFetchUntil || 0;
if (until && Date.now() < until) {
const wait = Math.min(2500, until - Date.now());
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, wait + 25);
return;
}
} catch {}
const [lng, lat] = viewParams.center;
const radiusKm = viewParams.radiusKm || 750;
const filterKey = `${mainFilter}|${subFilter}`;
const last = lastFetchRef.current;
if (last.center && last.filterKey === filterKey) {
const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, lat, lng);
const lastR = last.radiusKm || 0;
const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25;
const minMoveKm = Math.max(40, radiusKm * 0.22);
if (!radiusChanged && distKm < minMoveKm) return;
}
try {
setLoadingPosts(true);
setLoadError("");
const catCode = categoryCode(mainFilter);
const subCatParam =
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
const newPosts = await fetchPosts({
category: catCode,
subCategory: subCatParam,
time: "",
lat,
lon: lng,
radiusKm,
});
if (cancelled) return;
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
const id = getId(p);
if (id != null) byId.set(id, p);
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
const id = getId(p);
if (id != null && !byId.has(id)) byId.set(id, p);
}
}
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = { center: [...viewParams.center], radiusKm, filterKey };
// Smooth update: sync markers + wall without clearing everything
refreshVisibleAndMarkers(timeFilter);
setLoadingPosts(false);
} catch (err) {
console.error("Erreur chargement posts:", err);
if (!cancelled) {
setLoadError("Error loading posts.");
setLoadingPosts(false);
}
}
}
load();
return () => {
cancelled = true;
if (delayedFetchRef.current) {
try { clearTimeout(delayedFetchRef.current); } catch {}
delayedFetchRef.current = null;
}
};
}, [viewParams, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers]);
const handleIncomingPost = useCallback(
(p) => {
allPostsRef.current = [...allPostsRef.current, p];
const catCode = categoryCode(mainFilter);
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return;
}
if (!matchesSubFilter(p, subFilter)) return;
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, timeFilter)) return;
// add marker if missing
const id = getId(p);
const have = new Set((markersRef.current || []).map((x) => x.id));
if (id != null && !have.has(id)) {
createMarkerForPost(p, mapRef, markersRef, expandedElRef, theme);
}
setVisiblePosts((current) => {
const next = [...current, p];
return next;
});
},
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef, theme]
);
return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost };
}

View File

@ -35,7 +35,6 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
</div>
</div>
{loading && <p className="status-text">Chargement...</p>}
{error && <p className="status-text status-error">{error}</p>}
{filtered.map((p) => (

View File

@ -0,0 +1,53 @@
import React, { useState, useMemo } from "react";
import PostCard from "./PostCard";
function getKmFromPost(post) {
return post.km ?? post.distance_km ?? post.distanceKm ?? post.dist_km ?? null;
}
export default function PostList({ posts, loading, error, selectedPost, onSelectPost }) {
const [kmFilter, setKmFilter] = useState(1000);
const filtered = useMemo(
() =>
posts.filter((p) => {
const km = getKmFromPost(p);
if (km == null) return true;
return km <= kmFilter;
}),
[posts, kmFilter]
);
return (
<div className="post-list">
<div className="post-list-header">
<div className="km-filter">
<label>
Rayon : <span>{kmFilter} km</span>
</label>
<input
type="range"
min={0}
max={1000}
value={kmFilter}
onChange={(e) => setKmFilter(Number(e.target.value))}
/>
</div>
</div>
{loading && <p className="status-text">Chargement...</p>}
{error && <p className="status-text status-error">{error}</p>}
{filtered.map((p) => (
<PostCard
key={p.id ?? p._id}
post={p}
selected={selectedPost && (selectedPost.id ?? selectedPost._id) === (p.id ?? p._id)}
onSelect={onSelectPost}
/>
))}
{!loading && filtered.length === 0 && <p className="status-text">Aucun post dans ce rayon.</p>}
</div>
);
}

View File

@ -6,7 +6,7 @@
border: 1.5px solid rgba(148, 163, 184, 0.7);
display:flex; align-items:center; justify-content:center;
box-shadow:0 3px 10px rgba(0,0,0,.75);
transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease, background .12s ease;
transition: transform 0.36s ease, box-shadow 0.36s ease, border-color 0.36s ease, background 0.36s ease;
}
.sw-icon-circle i { font-size:22px; }
.sw-icon-label { font-size:.7rem; line-height:1; text-shadow:0 1px 2px rgba(0,0,0,.9); }

139
src/styles/filters.css.bak3 Normal file
View File

@ -0,0 +1,139 @@
.sw-icon-column { display:flex; flex-direction:column; gap:.55rem; align-items:center; }
.sw-icon-btn { background:none; border:none; padding:0; cursor:pointer; display:flex; flex-direction:column; align-items:center; gap:.15rem; color:#e5e7eb; }
.sw-icon-circle {
width:54px; height:54px; border-radius:50%;
background: rgba(15, 23, 42, 0.86);
border: 1.5px solid rgba(148, 163, 184, 0.7);
display:flex; align-items:center; justify-content:center;
box-shadow:0 3px 10px rgba(0,0,0,.75);
transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease, background .12s ease;
}
.sw-icon-circle i { font-size:22px; }
.sw-icon-label { font-size:.7rem; line-height:1; text-shadow:0 1px 2px rgba(0,0,0,.9); }
.sw-icon-btn:hover .sw-icon-circle { transform: translateY(-1px); box-shadow:0 5px 14px rgba(0,0,0,.85); }
.sw-icon-btn:active .sw-icon-circle { transform: scale(.96); box-shadow:0 2px 8px rgba(0,0,0,.9); }
.sw-icon-active .sw-icon-circle { background: rgba(37, 99, 235, 0.82); border-color:#60a5fa; box-shadow:0 0 14px rgba(56,189,248,.95); }
.sw-icon-active .sw-icon-label { color:#bfdbfe; font-weight:600; }
.sw-bottom-row {
display:flex; gap:.35rem; padding:.2rem .6rem;
background: rgba(15, 23, 42, 0.94);
border-radius: 999px;
box-shadow: 0 4px 16px rgba(0,0,0,.85);
}
.sw-bottom-item { border:none; background:none; padding:0; cursor:pointer; display:flex; flex-direction:column; align-items:center; gap:.12rem; min-width:54px; }
.sw-bottom-circle {
width:40px; height:40px; border-radius:50%;
background: rgba(15, 23, 42, 0.86);
border: 1.5px solid rgba(148, 163, 184, 0.6);
display:flex; align-items:center; justify-content:center;
font-size:.78rem;
box-shadow:0 3px 10px rgba(0,0,0,.75);
}
/* SW_SUBCAT_ICON_CSS */
.sw-bottom-circle i{
font-size: 18px;
line-height: 1;
}
/* SW_SUBCAT_ICON_CSS:END */
.sw-bottom-label { font-size:.65rem; color:#e5e7eb; text-shadow:0 1px 2px rgba(0,0,0,.9); }
.sw-bottom-active .sw-bottom-circle { background: rgba(37, 99, 235, 0.82); border-color:#60a5fa; }
.sw-bottom-active .sw-bottom-label { color:#bfdbfe; font-weight:600; }
/* Floating subcategory dock (always above Sociowall, not coverable) */
.sw-subcats-float{
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(14px + var(--sw-wall-overlap, 0px));
z-index: 300;
pointer-events: none;
}
.sw-subcats-float .sw-bottom-row{
pointer-events: auto;
max-width: min(980px, 96vw);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.sw-subcats-float .sw-bottom-row::-webkit-scrollbar{ height: 0; }
/* SW_FILTER_ICON_VISIBILITY:BEGIN */
/* Make subcategory icons inherit color (so they show) */
.sw-bottom-item{ color:#e5e7eb; }
.sw-bottom-circle{ color:#e5e7eb; }
.sw-bottom-circle i{
color: currentColor;
font-size: 20px; /* closer to right-side icons feel */
line-height: 1;
opacity: .96;
filter: drop-shadow(0 1px 2px rgba(0,0,0,.65));
}
/* Active state: brighter icon/text like category buttons */
.sw-bottom-active .sw-bottom-circle{
color:#dbeafe;
box-shadow:0 0 14px rgba(56,189,248,.60);
border-color: rgba(96,165,250,.95);
}
.sw-bottom-active .sw-bottom-label{
color:#bfdbfe;
font-weight:700;
}
/* ---------- LIGHT THEME FIXES (readable) ---------- */
body[data-theme="light"] .sw-bottom-row{
background: rgba(255,255,255,0.95);
border: 1px solid rgba(148,163,184,0.85);
box-shadow: 0 6px 18px rgba(0,0,0,.12);
}
body[data-theme="light"] .sw-bottom-item{ color:#0B1220; }
body[data-theme="light"] .sw-bottom-circle{
background: rgba(255,255,255,0.92);
border-color: rgba(148,163,184,0.85);
box-shadow: 0 3px 10px rgba(0,0,0,.10);
color:#0B1220;
}
body[data-theme="light"] .sw-bottom-label{
color:#0B1220;
text-shadow:none;
}
body[data-theme="light"] .sw-bottom-active .sw-bottom-circle{
background: rgba(30,107,255,0.92);
border-color: rgba(30,107,255,0.95);
color:#ffffff;
box-shadow:0 0 14px rgba(30,107,255,.35);
}
body[data-theme="light"] .sw-bottom-active .sw-bottom-label{
color:#0B1220;
font-weight:800;
}
/* Also fix right-side category buttons in light theme */
body[data-theme="light"] .sw-icon-btn{ color:#0B1220; }
body[data-theme="light"] .sw-icon-circle{
background: rgba(255,255,255,0.92);
border-color: rgba(148,163,184,0.85);
box-shadow: 0 3px 10px rgba(0,0,0,.10);
}
body[data-theme="light"] .sw-icon-label{
color:#0B1220;
text-shadow:none;
}
body[data-theme="light"] .sw-icon-active .sw-icon-circle{
background: rgba(30,107,255,0.92);
border-color: rgba(30,107,255,0.95);
box-shadow:0 0 14px rgba(30,107,255,.35);
}
body[data-theme="light"] .sw-icon-active .sw-icon-label{
color:#0B1220;
font-weight:800;
}
/* SW_FILTER_ICON_VISIBILITY:END */

View File

@ -507,3 +507,144 @@
.sw-modal-hero{ height: 150px; }
.sw-modal-title{ font-size: 20px; line-height: 24px; }
}
/* =========================================
SW_CENTERED_MODAL_ANIM
- fade + scale on open/close (slow)
========================================= */
.sw-centered-backdrop{
position: fixed;
inset: 0;
z-index: 9999999;
background: rgba(0,0,0,0.08);
backdrop-filter: blur(1px);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
pointer-events: auto;
opacity: 0;
transition: opacity 440ms ease;
}
.sw-centered-modal{
transform: scale(0.94);
opacity: 0.0;
transition: transform 440ms ease, opacity 440ms ease;
will-change: transform, opacity;
}
.sw-centered-backdrop.sw-open{
opacity: 1;
}
.sw-centered-modal.sw-open{
transform: scale(1);
opacity: 1.0;
}
/* closing state (optional hook) */
.sw-centered-backdrop.sw-closing{
opacity: 0;
}
/* =========================================
SW_MINI_SMOOTH_MOVE
- stop "sautillage/blink sec" while panning/zooming
- MapLibre animates marker transforms every frame.
- Any CSS transition/filter on marker subtree can cause shimmer/jitter on mobile.
========================================= */
.post-pin{
will-change: transform;
transform: translate3d(0,0,0);
}
/* NO transitions on marker subtree (prevents bounce/lag while map moves) */
.post-pin, .post-pin *{
transition: none !important;
}
/* Make the mini template wrap GPU-friendly */
.sw-template-mini-wrap{
backface-visibility: hidden;
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.62);
transform-origin: bottom center;
}
/* Mobile scale keeps same behavior, but still GPU */
@media (max-width: 640px){
.sw-template-mini-wrap{
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.52);
}
}
/* Drop-shadow filters on triangles can shimmer on Android أثناء الحركة */
.post-pin--compact .post-pin-pointer-small{
filter: none;
}
@media (prefers-reduced-motion: reduce){
.sw-centered-backdrop, .sw-centered-modal{
transition: none !important;
}
}
/* SW_MARKER_SMOOTH_APPEAR:BEGIN */
.post-pin.sw-appear{
opacity: 0;
transform: translate3d(0,0,0) scale(0.96);
transition: opacity 840ms ease, transform 840ms ease;
}
.post-pin.sw-appear.sw-appear-in{
opacity: 1;
transform: translate3d(0,0,0) scale(1);
}
.post-pin.sw-disappear{
opacity: 0;
transform: translate3d(0,0,0) scale(0.96);
transition: opacity 840ms ease, transform 840ms ease;
}
/* SW_MARKER_SMOOTH_APPEAR:END */
/* SW_CENTER_MODAL_ANIM:BEGIN */
.sw-centered-backdrop{
opacity: 0;
transition: opacity 840ms ease;
}
.sw-centered-backdrop.sw-enter-active{ opacity: 1; }
.sw-centered-backdrop.sw-leave{ opacity: 0; }
.sw-centered-modal{
opacity: 0;
transform: translate3d(0,0,0) scale(0.92);
transition: opacity 840ms ease, transform 840ms cubic-bezier(.2,.9,.2,1);
will-change: opacity, transform;
}
.sw-centered-modal.sw-enter-active{
opacity: 1;
transform: translate3d(0,0,0) scale(1);
}
.sw-centered-modal.sw-leave{
opacity: 0;
transform: translate3d(0,0,0) scale(0.94);
}
/* SW_CENTER_MODAL_ANIM:END */
/* SW_MODAL_FADE:BEGIN */
.sw-centered-backdrop{ opacity:0; transition: opacity 840ms ease; }
.sw-centered-backdrop.sw-enter-active{ opacity:1; }
.sw-centered-backdrop.sw-leave{ opacity:0; }
.sw-centered-modal{
opacity:0;
transform: translate3d(0,0,0) scale(0.92);
transition: opacity 840ms ease, transform 840ms cubic-bezier(.2,.9,.2,1);
will-change: opacity, transform;
}
.sw-centered-modal.sw-enter-active{ opacity:1; transform: translate3d(0,0,0) scale(1); }
.sw-centered-modal.sw-leave{ opacity:0; transform: translate3d(0,0,0) scale(0.94); }
/* SW_MODAL_FADE:END */

View File

@ -0,0 +1,594 @@
.post-pin {
position: relative;
width: 0;
height: 0;
pointer-events: none;
transform: translate3d(0, 0, 0);
}
.post-pin * { pointer-events: auto; box-sizing: border-box; }
.post-pin--compact .post-pin-bubble {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, calc(-100% - 12px));
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: rgba(6, 40, 80, 0.95);
border: 1px solid rgba(80, 180, 255, 0.9);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
max-width: 180px;
}
.post-pin-dot { width:10px; height:10px; border-radius:999px; background:#ff8a00; margin-right:6px; flex-shrink:0; }
.post-pin-text { color:#e8f5ff; font-size:11px; font-weight:500; max-width:160px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.post-pin--compact .post-pin-pointer-small {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -100%);
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 9px solid rgba(6, 40, 80, 0.95);
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
/* ===== Template mini wrapper (keeps pointer unchanged) ===== */
.sw-template-mini-wrap{
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, calc(-100% - 12px)) scale(0.62);
transform-origin: bottom center;
will-change: transform;
}
@media (max-width: 640px){
.sw-template-mini-wrap{
transform: translate(-50%, calc(-100% - 12px)) scale(0.52);
}
}
/* =========================================================
NEW: FIXED EXPANDED OVERLAY
- Card is fixed near bottom (consistent)
- Pointer follows marker (consistent)
- No huge map recenter jumps
========================================================= */
.sw-expanded-overlay-root{
position:absolute;
inset:0;
z-index: 999999;
pointer-events:none;
}
/* Card position: "dans le bas" (approx bottom zone), consistent on rotate */
.sw-expanded-overlay-card{
position:absolute;
left: 50%;
bottom: calc(12px + env(safe-area-inset-bottom) + var(--sw-below-overlap, 0px));
transform: translateX(-50%);
width: min(92vw, 420px);
pointer-events: auto;
}
/* Make card fit mobile nicely */
.sw-expanded-overlay-card .post-card{
width: 100%;
max-height: min(58vh, 640px);
overflow:auto;
-webkit-overflow-scrolling: touch;
background: rgba(8, 20, 40, 0.97);
border-radius: 14px;
padding: 10px 12px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.85);
border: 1px solid rgba(90, 190, 255, 0.9);
color: #e8f5ff;
}
/* Pointer on the marker position */
.sw-overlay-pointer{
position:absolute;
width:0; height:0;
border-left: 10px solid transparent;
border-right:10px solid transparent;
border-top: 11px solid rgba(8, 20, 40, 0.97);
filter: drop-shadow(0 2px 4px rgba(0,0,0,.8));
transform: translate(-50%, -100%);
left: 0;
top: 0;
}
/* Simple line from marker to card */
.sw-overlay-line{
position:absolute;
height: 2px;
background: rgba(56,189,248,0.35);
transform-origin: 0 50%;
left: 0;
top: 0;
width: 0;
filter: drop-shadow(0 1px 2px rgba(0,0,0,.6));
}
/* ===== Expanded layout pieces (same as before) ===== */
.sw-expanded-shell{ padding: 10px 12px; }
.sw-expanded-top{
display:flex;
gap:10px;
align-items:flex-start;
}
.sw-expanded-left{ flex: 1; }
.sw-expanded-right{
width: 150px;
min-width: 140px;
display:flex;
flex-direction:column;
gap:8px;
padding-top: 4px;
}
.sw-watch-title{
font-size: 11px;
font-weight: 800;
color:#bfdbfe;
text-transform: uppercase;
letter-spacing: .06em;
}
.sw-watch-list{ display:flex; flex-direction:column; gap:6px; }
.sw-watch-item{
font-size: 11px;
color:#e8f5ff;
background: rgba(3, 14, 32, 0.75);
border: 1px solid rgba(90, 190, 255, 0.35);
border-radius: 10px;
padding: 6px 8px;
}
.sw-watch-msg{
opacity:.85;
margin-top:2px;
font-size: 10px;
color:#c7e3ff;
}
.sw-add-feed-btn{
margin-top: 2px;
border: 1px solid rgba(148,163,184,.7);
border-radius: 10px;
padding: 8px 10px;
background: rgba(5, 35, 70, 0.85);
color:#e8f5ff;
font-weight: 900;
font-size: 11px;
cursor:pointer;
}
.sw-news-generated{
margin-top: 10px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(3, 14, 32, 0.75);
border: 1px solid rgba(90, 190, 255, 0.35);
}
.sw-news-title{
font-size: 12px;
font-weight: 900;
color:#e8f5ff;
margin-bottom: 2px;
}
.sw-news-sub{ font-size: 10px; color:#9eb7ff; }
.sw-actions-row{ margin-top: 8px; }
.sw-livechat{ margin-top: 8px; }
.sw-livechat-title{
font-weight: 800;
letter-spacing:.04em;
margin-bottom: 6px;
}
.sw-livechat-body{
display:flex;
flex-direction:column;
gap:3px;
margin-bottom: 8px;
opacity:.95;
}
.sw-chat-line{ font-size: 10px; color:#c7e3ff; }
.sw-livechat-inputrow{ display:flex; gap:8px; align-items:center; }
.sw-livechat-input{
flex:1;
border-radius: 999px;
border: 1px solid rgba(90, 190, 255, 0.35);
background: rgba(3, 14, 32, 0.75);
color:#e8f5ff;
padding: 8px 10px;
font-size: 11px;
outline: none;
}
.sw-livechat-post{
border:none;
border-radius: 10px;
padding: 8px 12px;
background: rgba(56,189,248,0.22);
color:#e8f5ff;
font-weight: 900;
font-size: 11px;
cursor:pointer;
}
/* Mobile: keep overlay readable */
@media (max-width: 640px){
.sw-expanded-overlay-card{
width: min(94vw, 360px);
}
.sw-expanded-top{
flex-direction: column;
}
.sw-expanded-right{
display:none;
}
}
/* SW_FIX_FULL_WRAP_COLLAPSE:BEGIN */
/* Fix: expanded template wrapper collapsing into a thin line */
.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;
overflow: hidden !important; /* no useless scroll */
}
.sw-template-full-wrap{
display: block !important;
width: 360px !important;
height: 520px !important;
}
.sw-template-full-wrap > *{
display: block !important;
}
/* SW_FIX_FULL_WRAP_COLLAPSE:END */
/* ===== Expanded header (mock) ===== */
.sw-expanded-badge{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 6px 10px;
border-radius: 999px;
font-weight: 900;
font-size: 12px;
background: rgba(56,189,248,0.18);
color: #D7F3FF;
width: fit-content;
}
.sw-expanded-title{
margin-top: 10px;
font-size: 26px;
font-weight: 900;
line-height: 1.05;
color: #F0F7FF;
}
.sw-expanded-snippet{
margin-top: 8px;
font-size: 14px;
font-weight: 700;
color: #B5C7E6;
}
/* =========================================================
SW_EXPANDED_POLISH (visual only)
- make expanded look like the mini gradients / modern UI
========================================================= */
.post-pin--expanded .post-card{
background: linear-gradient(135deg, rgba(56,189,248,0.16), rgba(7,18,37,0.98) 55%, rgba(2,6,23,0.98));
border: 1px solid rgba(96,165,250,0.75);
box-shadow: 0 18px 60px rgba(0,0,0,0.55), 0 0 24px rgba(56,189,248,0.22);
}
.sw-expanded-badge{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 7px 12px;
border-radius: 999px;
font-weight: 900;
font-size: 12px;
letter-spacing: .04em;
color: #d7f3ff;
background: rgba(56,189,248,0.16);
border: 1px solid rgba(56,189,248,0.35);
margin-bottom: 10px;
}
.sw-expanded-title{
font-size: 28px;
font-weight: 950;
line-height: 1.05;
color: #f0f7ff;
margin-bottom: 8px;
}
.sw-expanded-meta{
font-size: 12px;
font-weight: 700;
color: rgba(181,199,230,0.95);
margin-bottom: 10px;
}
.sw-expanded-snippet{
font-size: 14px;
line-height: 1.35;
color: rgba(215,233,255,0.95);
background: rgba(3,14,32,0.45);
border: 1px solid rgba(90,190,255,0.22);
border-radius: 14px;
padding: 10px 12px;
}
.post-card-btn{
background: rgba(56,189,248,0.14);
border: 1px solid rgba(56,189,248,0.35);
color: #e8f5ff;
font-weight: 900;
padding: 6px 12px;
}
.post-card-btn:active{
transform: scale(0.98);
}
/* ===== CENTER MODAL CONTENT (pretty + consistent) ===== */
.sw-modal{
padding: 12px 14px;
}
.sw-modal-toprow{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
margin-bottom: 10px;
}
.sw-modal-badge{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 900;
letter-spacing: .06em;
background: rgba(56,189,248,0.16);
border: 1px solid rgba(90, 190, 255, 0.28);
color:#D7F3FF;
text-transform: uppercase;
}
.sw-modal-meta{
margin-top: 6px;
font-size: 11px;
color:#9eb7ff;
opacity:.95;
display:flex;
gap:6px;
flex-wrap:wrap;
}
.sw-modal-x{
border:none;
border-radius:999px;
width:34px;
height:34px;
background: rgba(3,14,32,0.55);
color:#e8f5ff;
font-weight:900;
cursor:pointer;
border:1px solid rgba(90,190,255,0.25);
flex-shrink:0;
}
.sw-modal-hero{
width:100%;
height: 180px;
border-radius: 16px;
overflow:hidden;
border: 1px solid rgba(90, 190, 255, 0.22);
background: radial-gradient(circle at 30% 30%, rgba(56,189,248,0.18), rgba(3,14,32,0.85));
box-shadow: 0 10px 24px rgba(0,0,0,0.35);
margin-bottom: 10px;
}
.sw-modal-hero img{
width:100%;
height:100%;
object-fit: cover;
display:block;
}
.sw-modal-hero-fallback{
width:100%;
height:100%;
background: radial-gradient(circle at 35% 35%, rgba(56,189,248,0.22), rgba(3,14,32,0.85));
}
.sw-modal-title{
font-size: 22px;
font-weight: 900;
line-height: 26px;
color:#F0F7FF;
margin: 6px 0 8px;
}
.sw-modal-summary{
font-size: 13px;
line-height: 17px;
color:#D7E9FF;
opacity:.95;
margin-bottom: 8px;
}
.sw-stat-row{
display:flex;
gap:8px;
flex-wrap:wrap;
margin: 6px 0 10px;
}
.sw-stat-pill{
font-size: 11px;
color:#c7e3ff;
background: rgba(3, 14, 32, 0.58);
border: 1px solid rgba(90, 190, 255, 0.22);
padding: 6px 10px;
border-radius: 999px;
}
.sw-modal-cta-row{
display:flex;
flex-direction:column;
gap:10px;
margin-bottom: 10px;
}
.sw-cta-primary{
width:100%;
border:none;
border-radius: 14px;
padding: 14px 12px;
font-weight: 900;
font-size: 14px;
color:#e8f5ff;
cursor:pointer;
background: rgba(56,189,248,0.22);
box-shadow: 0 10px 18px rgba(0,0,0,0.35);
}
.sw-cta-primary:disabled{
opacity:.55;
cursor:default;
}
.sw-cta-actions{
display:flex;
gap:8px;
flex-wrap:wrap;
}
/* mobile tighten */
@media (max-width: 640px){
.sw-modal-hero{ height: 150px; }
.sw-modal-title{ font-size: 20px; line-height: 24px; }
}
/* =========================================
SW_CENTERED_MODAL_ANIM
- fade + scale on open/close (slow)
========================================= */
.sw-centered-backdrop{
position: fixed;
inset: 0;
z-index: 9999999;
background: rgba(0,0,0,0.08);
backdrop-filter: blur(1px);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
pointer-events: auto;
opacity: 0;
transition: opacity 440ms ease;
}
.sw-centered-modal{
transform: scale(0.94);
opacity: 0.0;
transition: transform 440ms ease, opacity 440ms ease;
will-change: transform, opacity;
}
.sw-centered-backdrop.sw-open{
opacity: 1;
}
.sw-centered-modal.sw-open{
transform: scale(1);
opacity: 1.0;
}
/* closing state (optional hook) */
.sw-centered-backdrop.sw-closing{
opacity: 0;
}
/* =========================================
SW_MINI_SMOOTH_MOVE
- stop "sautillage/blink sec" while panning/zooming
- MapLibre animates marker transforms every frame.
- Any CSS transition/filter on marker subtree can cause shimmer/jitter on mobile.
========================================= */
.post-pin{
will-change: transform;
transform: translate3d(0,0,0);
}
/* NO transitions on marker subtree (prevents bounce/lag while map moves) */
.post-pin, .post-pin *{
transition: none !important;
}
/* Make the mini template wrap GPU-friendly */
.sw-template-mini-wrap{
backface-visibility: hidden;
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.62);
transform-origin: bottom center;
}
/* Mobile scale keeps same behavior, but still GPU */
@media (max-width: 640px){
.sw-template-mini-wrap{
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.52);
}
}
/* Drop-shadow filters on triangles can shimmer on Android أثناء الحركة */
.post-pin--compact .post-pin-pointer-small{
filter: none;
}
@media (prefers-reduced-motion: reduce){
.sw-centered-backdrop, .sw-centered-modal{
transition: none !important;
}
}

View File

@ -0,0 +1,604 @@
.post-pin {
position: relative;
width: 0;
height: 0;
pointer-events: none;
transform: translate3d(0, 0, 0);
}
.post-pin * { pointer-events: auto; box-sizing: border-box; }
.post-pin--compact .post-pin-bubble {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, calc(-100% - 12px));
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: rgba(6, 40, 80, 0.95);
border: 1px solid rgba(80, 180, 255, 0.9);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
max-width: 180px;
}
.post-pin-dot { width:10px; height:10px; border-radius:999px; background:#ff8a00; margin-right:6px; flex-shrink:0; }
.post-pin-text { color:#e8f5ff; font-size:11px; font-weight:500; max-width:160px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.post-pin--compact .post-pin-pointer-small {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -100%);
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 9px solid rgba(6, 40, 80, 0.95);
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
/* ===== Template mini wrapper (keeps pointer unchanged) ===== */
.sw-template-mini-wrap{
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, calc(-100% - 12px)) scale(0.62);
transform-origin: bottom center;
will-change: transform;
}
@media (max-width: 640px){
.sw-template-mini-wrap{
transform: translate(-50%, calc(-100% - 12px)) scale(0.52);
}
}
/* =========================================================
NEW: FIXED EXPANDED OVERLAY
- Card is fixed near bottom (consistent)
- Pointer follows marker (consistent)
- No huge map recenter jumps
========================================================= */
.sw-expanded-overlay-root{
position:absolute;
inset:0;
z-index: 999999;
pointer-events:none;
}
/* Card position: "dans le bas" (approx bottom zone), consistent on rotate */
.sw-expanded-overlay-card{
position:absolute;
left: 50%;
bottom: calc(12px + env(safe-area-inset-bottom) + var(--sw-below-overlap, 0px));
transform: translateX(-50%);
width: min(92vw, 420px);
pointer-events: auto;
}
/* Make card fit mobile nicely */
.sw-expanded-overlay-card .post-card{
width: 100%;
max-height: min(58vh, 640px);
overflow:auto;
-webkit-overflow-scrolling: touch;
background: rgba(8, 20, 40, 0.97);
border-radius: 14px;
padding: 10px 12px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.85);
border: 1px solid rgba(90, 190, 255, 0.9);
color: #e8f5ff;
}
/* Pointer on the marker position */
.sw-overlay-pointer{
position:absolute;
width:0; height:0;
border-left: 10px solid transparent;
border-right:10px solid transparent;
border-top: 11px solid rgba(8, 20, 40, 0.97);
filter: drop-shadow(0 2px 4px rgba(0,0,0,.8));
transform: translate(-50%, -100%);
left: 0;
top: 0;
}
/* Simple line from marker to card */
.sw-overlay-line{
position:absolute;
height: 2px;
background: rgba(56,189,248,0.35);
transform-origin: 0 50%;
left: 0;
top: 0;
width: 0;
filter: drop-shadow(0 1px 2px rgba(0,0,0,.6));
}
/* ===== Expanded layout pieces (same as before) ===== */
.sw-expanded-shell{ padding: 10px 12px; }
.sw-expanded-top{
display:flex;
gap:10px;
align-items:flex-start;
}
.sw-expanded-left{ flex: 1; }
.sw-expanded-right{
width: 150px;
min-width: 140px;
display:flex;
flex-direction:column;
gap:8px;
padding-top: 4px;
}
.sw-watch-title{
font-size: 11px;
font-weight: 800;
color:#bfdbfe;
text-transform: uppercase;
letter-spacing: .06em;
}
.sw-watch-list{ display:flex; flex-direction:column; gap:6px; }
.sw-watch-item{
font-size: 11px;
color:#e8f5ff;
background: rgba(3, 14, 32, 0.75);
border: 1px solid rgba(90, 190, 255, 0.35);
border-radius: 10px;
padding: 6px 8px;
}
.sw-watch-msg{
opacity:.85;
margin-top:2px;
font-size: 10px;
color:#c7e3ff;
}
.sw-add-feed-btn{
margin-top: 2px;
border: 1px solid rgba(148,163,184,.7);
border-radius: 10px;
padding: 8px 10px;
background: rgba(5, 35, 70, 0.85);
color:#e8f5ff;
font-weight: 900;
font-size: 11px;
cursor:pointer;
}
.sw-news-generated{
margin-top: 10px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(3, 14, 32, 0.75);
border: 1px solid rgba(90, 190, 255, 0.35);
}
.sw-news-title{
font-size: 12px;
font-weight: 900;
color:#e8f5ff;
margin-bottom: 2px;
}
.sw-news-sub{ font-size: 10px; color:#9eb7ff; }
.sw-actions-row{ margin-top: 8px; }
.sw-livechat{ margin-top: 8px; }
.sw-livechat-title{
font-weight: 800;
letter-spacing:.04em;
margin-bottom: 6px;
}
.sw-livechat-body{
display:flex;
flex-direction:column;
gap:3px;
margin-bottom: 8px;
opacity:.95;
}
.sw-chat-line{ font-size: 10px; color:#c7e3ff; }
.sw-livechat-inputrow{ display:flex; gap:8px; align-items:center; }
.sw-livechat-input{
flex:1;
border-radius: 999px;
border: 1px solid rgba(90, 190, 255, 0.35);
background: rgba(3, 14, 32, 0.75);
color:#e8f5ff;
padding: 8px 10px;
font-size: 11px;
outline: none;
}
.sw-livechat-post{
border:none;
border-radius: 10px;
padding: 8px 12px;
background: rgba(56,189,248,0.22);
color:#e8f5ff;
font-weight: 900;
font-size: 11px;
cursor:pointer;
}
/* Mobile: keep overlay readable */
@media (max-width: 640px){
.sw-expanded-overlay-card{
width: min(94vw, 360px);
}
.sw-expanded-top{
flex-direction: column;
}
.sw-expanded-right{
display:none;
}
}
/* SW_FIX_FULL_WRAP_COLLAPSE:BEGIN */
/* Fix: expanded template wrapper collapsing into a thin line */
.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;
overflow: hidden !important; /* no useless scroll */
}
.sw-template-full-wrap{
display: block !important;
width: 360px !important;
height: 520px !important;
}
.sw-template-full-wrap > *{
display: block !important;
}
/* SW_FIX_FULL_WRAP_COLLAPSE:END */
/* ===== Expanded header (mock) ===== */
.sw-expanded-badge{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 6px 10px;
border-radius: 999px;
font-weight: 900;
font-size: 12px;
background: rgba(56,189,248,0.18);
color: #D7F3FF;
width: fit-content;
}
.sw-expanded-title{
margin-top: 10px;
font-size: 26px;
font-weight: 900;
line-height: 1.05;
color: #F0F7FF;
}
.sw-expanded-snippet{
margin-top: 8px;
font-size: 14px;
font-weight: 700;
color: #B5C7E6;
}
/* =========================================================
SW_EXPANDED_POLISH (visual only)
- make expanded look like the mini gradients / modern UI
========================================================= */
.post-pin--expanded .post-card{
background: linear-gradient(135deg, rgba(56,189,248,0.16), rgba(7,18,37,0.98) 55%, rgba(2,6,23,0.98));
border: 1px solid rgba(96,165,250,0.75);
box-shadow: 0 18px 60px rgba(0,0,0,0.55), 0 0 24px rgba(56,189,248,0.22);
}
.sw-expanded-badge{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 7px 12px;
border-radius: 999px;
font-weight: 900;
font-size: 12px;
letter-spacing: .04em;
color: #d7f3ff;
background: rgba(56,189,248,0.16);
border: 1px solid rgba(56,189,248,0.35);
margin-bottom: 10px;
}
.sw-expanded-title{
font-size: 28px;
font-weight: 950;
line-height: 1.05;
color: #f0f7ff;
margin-bottom: 8px;
}
.sw-expanded-meta{
font-size: 12px;
font-weight: 700;
color: rgba(181,199,230,0.95);
margin-bottom: 10px;
}
.sw-expanded-snippet{
font-size: 14px;
line-height: 1.35;
color: rgba(215,233,255,0.95);
background: rgba(3,14,32,0.45);
border: 1px solid rgba(90,190,255,0.22);
border-radius: 14px;
padding: 10px 12px;
}
.post-card-btn{
background: rgba(56,189,248,0.14);
border: 1px solid rgba(56,189,248,0.35);
color: #e8f5ff;
font-weight: 900;
padding: 6px 12px;
}
.post-card-btn:active{
transform: scale(0.98);
}
/* ===== CENTER MODAL CONTENT (pretty + consistent) ===== */
.sw-modal{
padding: 12px 14px;
}
.sw-modal-toprow{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
margin-bottom: 10px;
}
.sw-modal-badge{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 900;
letter-spacing: .06em;
background: rgba(56,189,248,0.16);
border: 1px solid rgba(90, 190, 255, 0.28);
color:#D7F3FF;
text-transform: uppercase;
}
.sw-modal-meta{
margin-top: 6px;
font-size: 11px;
color:#9eb7ff;
opacity:.95;
display:flex;
gap:6px;
flex-wrap:wrap;
}
.sw-modal-x{
border:none;
border-radius:999px;
width:34px;
height:34px;
background: rgba(3,14,32,0.55);
color:#e8f5ff;
font-weight:900;
cursor:pointer;
border:1px solid rgba(90,190,255,0.25);
flex-shrink:0;
}
.sw-modal-hero{
width:100%;
height: 180px;
border-radius: 16px;
overflow:hidden;
border: 1px solid rgba(90, 190, 255, 0.22);
background: radial-gradient(circle at 30% 30%, rgba(56,189,248,0.18), rgba(3,14,32,0.85));
box-shadow: 0 10px 24px rgba(0,0,0,0.35);
margin-bottom: 10px;
}
.sw-modal-hero img{
width:100%;
height:100%;
object-fit: cover;
display:block;
}
.sw-modal-hero-fallback{
width:100%;
height:100%;
background: radial-gradient(circle at 35% 35%, rgba(56,189,248,0.22), rgba(3,14,32,0.85));
}
.sw-modal-title{
font-size: 22px;
font-weight: 900;
line-height: 26px;
color:#F0F7FF;
margin: 6px 0 8px;
}
.sw-modal-summary{
font-size: 13px;
line-height: 17px;
color:#D7E9FF;
opacity:.95;
margin-bottom: 8px;
}
.sw-stat-row{
display:flex;
gap:8px;
flex-wrap:wrap;
margin: 6px 0 10px;
}
.sw-stat-pill{
font-size: 11px;
color:#c7e3ff;
background: rgba(3, 14, 32, 0.58);
border: 1px solid rgba(90, 190, 255, 0.22);
padding: 6px 10px;
border-radius: 999px;
}
.sw-modal-cta-row{
display:flex;
flex-direction:column;
gap:10px;
margin-bottom: 10px;
}
.sw-cta-primary{
width:100%;
border:none;
border-radius: 14px;
padding: 14px 12px;
font-weight: 900;
font-size: 14px;
color:#e8f5ff;
cursor:pointer;
background: rgba(56,189,248,0.22);
box-shadow: 0 10px 18px rgba(0,0,0,0.35);
}
.sw-cta-primary:disabled{
opacity:.55;
cursor:default;
}
.sw-cta-actions{
display:flex;
gap:8px;
flex-wrap:wrap;
}
/* mobile tighten */
@media (max-width: 640px){
.sw-modal-hero{ height: 150px; }
.sw-modal-title{ font-size: 20px; line-height: 24px; }
}
/* =========================================
SW_CENTERED_MODAL_ANIM
- fade + scale on open/close (slow)
========================================= */
.sw-centered-backdrop{
position: fixed;
inset: 0;
z-index: 9999999;
background: rgba(0,0,0,0.08);
backdrop-filter: blur(1px);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
pointer-events: auto;
opacity: 0;
transition: opacity 440ms ease;
}
.sw-centered-modal{
transform: scale(0.94);
opacity: 0.0;
transition: transform 440ms ease, opacity 440ms ease;
will-change: transform, opacity;
}
.sw-centered-backdrop.sw-open{
opacity: 1;
}
.sw-centered-modal.sw-open{
transform: scale(1);
opacity: 1.0;
}
/* closing state (optional hook) */
.sw-centered-backdrop.sw-closing{
opacity: 0;
}
/* =========================================
SW_MINI_SMOOTH_MOVE
- stop "sautillage/blink sec" while panning/zooming
- MapLibre animates marker transforms every frame.
- Any CSS transition/filter on marker subtree can cause shimmer/jitter on mobile.
========================================= */
.post-pin{
will-change: transform;
transform: translate3d(0,0,0);
}
/* NO transitions on marker subtree (prevents bounce/lag while map moves) */
.post-pin, .post-pin *{
transition: none !important;
}
/* Make the mini template wrap GPU-friendly */
.sw-template-mini-wrap{
backface-visibility: hidden;
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.62);
transform-origin: bottom center;
}
/* Mobile scale keeps same behavior, but still GPU */
@media (max-width: 640px){
.sw-template-mini-wrap{
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.52);
}
}
/* Drop-shadow filters on triangles can shimmer on Android أثناء الحركة */
.post-pin--compact .post-pin-pointer-small{
filter: none;
}
@media (prefers-reduced-motion: reduce){
.sw-centered-backdrop, .sw-centered-modal{
transition: none !important;
}
}
/* SW_MARKER_SMOOTH_APPEAR:BEGIN */
.post-pin.sw-appear{
opacity: 0;
transition: opacity 280ms ease;
}
.post-pin.sw-appear.sw-appear-in{
opacity: 1;
}
/* SW_MARKER_SMOOTH_APPEAR:END */

View File

@ -0,0 +1,608 @@
.post-pin {
position: relative;
width: 0;
height: 0;
pointer-events: none;
transform: translate3d(0, 0, 0);
}
.post-pin * { pointer-events: auto; box-sizing: border-box; }
.post-pin--compact .post-pin-bubble {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, calc(-100% - 12px));
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: rgba(6, 40, 80, 0.95);
border: 1px solid rgba(80, 180, 255, 0.9);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
max-width: 180px;
}
.post-pin-dot { width:10px; height:10px; border-radius:999px; background:#ff8a00; margin-right:6px; flex-shrink:0; }
.post-pin-text { color:#e8f5ff; font-size:11px; font-weight:500; max-width:160px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.post-pin--compact .post-pin-pointer-small {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -100%);
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 9px solid rgba(6, 40, 80, 0.95);
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
/* ===== Template mini wrapper (keeps pointer unchanged) ===== */
.sw-template-mini-wrap{
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, calc(-100% - 12px)) scale(0.62);
transform-origin: bottom center;
will-change: transform;
}
@media (max-width: 640px){
.sw-template-mini-wrap{
transform: translate(-50%, calc(-100% - 12px)) scale(0.52);
}
}
/* =========================================================
NEW: FIXED EXPANDED OVERLAY
- Card is fixed near bottom (consistent)
- Pointer follows marker (consistent)
- No huge map recenter jumps
========================================================= */
.sw-expanded-overlay-root{
position:absolute;
inset:0;
z-index: 999999;
pointer-events:none;
}
/* Card position: "dans le bas" (approx bottom zone), consistent on rotate */
.sw-expanded-overlay-card{
position:absolute;
left: 50%;
bottom: calc(12px + env(safe-area-inset-bottom) + var(--sw-below-overlap, 0px));
transform: translateX(-50%);
width: min(92vw, 420px);
pointer-events: auto;
}
/* Make card fit mobile nicely */
.sw-expanded-overlay-card .post-card{
width: 100%;
max-height: min(58vh, 640px);
overflow:auto;
-webkit-overflow-scrolling: touch;
background: rgba(8, 20, 40, 0.97);
border-radius: 14px;
padding: 10px 12px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.85);
border: 1px solid rgba(90, 190, 255, 0.9);
color: #e8f5ff;
}
/* Pointer on the marker position */
.sw-overlay-pointer{
position:absolute;
width:0; height:0;
border-left: 10px solid transparent;
border-right:10px solid transparent;
border-top: 11px solid rgba(8, 20, 40, 0.97);
filter: drop-shadow(0 2px 4px rgba(0,0,0,.8));
transform: translate(-50%, -100%);
left: 0;
top: 0;
}
/* Simple line from marker to card */
.sw-overlay-line{
position:absolute;
height: 2px;
background: rgba(56,189,248,0.35);
transform-origin: 0 50%;
left: 0;
top: 0;
width: 0;
filter: drop-shadow(0 1px 2px rgba(0,0,0,.6));
}
/* ===== Expanded layout pieces (same as before) ===== */
.sw-expanded-shell{ padding: 10px 12px; }
.sw-expanded-top{
display:flex;
gap:10px;
align-items:flex-start;
}
.sw-expanded-left{ flex: 1; }
.sw-expanded-right{
width: 150px;
min-width: 140px;
display:flex;
flex-direction:column;
gap:8px;
padding-top: 4px;
}
.sw-watch-title{
font-size: 11px;
font-weight: 800;
color:#bfdbfe;
text-transform: uppercase;
letter-spacing: .06em;
}
.sw-watch-list{ display:flex; flex-direction:column; gap:6px; }
.sw-watch-item{
font-size: 11px;
color:#e8f5ff;
background: rgba(3, 14, 32, 0.75);
border: 1px solid rgba(90, 190, 255, 0.35);
border-radius: 10px;
padding: 6px 8px;
}
.sw-watch-msg{
opacity:.85;
margin-top:2px;
font-size: 10px;
color:#c7e3ff;
}
.sw-add-feed-btn{
margin-top: 2px;
border: 1px solid rgba(148,163,184,.7);
border-radius: 10px;
padding: 8px 10px;
background: rgba(5, 35, 70, 0.85);
color:#e8f5ff;
font-weight: 900;
font-size: 11px;
cursor:pointer;
}
.sw-news-generated{
margin-top: 10px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(3, 14, 32, 0.75);
border: 1px solid rgba(90, 190, 255, 0.35);
}
.sw-news-title{
font-size: 12px;
font-weight: 900;
color:#e8f5ff;
margin-bottom: 2px;
}
.sw-news-sub{ font-size: 10px; color:#9eb7ff; }
.sw-actions-row{ margin-top: 8px; }
.sw-livechat{ margin-top: 8px; }
.sw-livechat-title{
font-weight: 800;
letter-spacing:.04em;
margin-bottom: 6px;
}
.sw-livechat-body{
display:flex;
flex-direction:column;
gap:3px;
margin-bottom: 8px;
opacity:.95;
}
.sw-chat-line{ font-size: 10px; color:#c7e3ff; }
.sw-livechat-inputrow{ display:flex; gap:8px; align-items:center; }
.sw-livechat-input{
flex:1;
border-radius: 999px;
border: 1px solid rgba(90, 190, 255, 0.35);
background: rgba(3, 14, 32, 0.75);
color:#e8f5ff;
padding: 8px 10px;
font-size: 11px;
outline: none;
}
.sw-livechat-post{
border:none;
border-radius: 10px;
padding: 8px 12px;
background: rgba(56,189,248,0.22);
color:#e8f5ff;
font-weight: 900;
font-size: 11px;
cursor:pointer;
}
/* Mobile: keep overlay readable */
@media (max-width: 640px){
.sw-expanded-overlay-card{
width: min(94vw, 360px);
}
.sw-expanded-top{
flex-direction: column;
}
.sw-expanded-right{
display:none;
}
}
/* SW_FIX_FULL_WRAP_COLLAPSE:BEGIN */
/* Fix: expanded template wrapper collapsing into a thin line */
.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;
overflow: hidden !important; /* no useless scroll */
}
.sw-template-full-wrap{
display: block !important;
width: 360px !important;
height: 520px !important;
}
.sw-template-full-wrap > *{
display: block !important;
}
/* SW_FIX_FULL_WRAP_COLLAPSE:END */
/* ===== Expanded header (mock) ===== */
.sw-expanded-badge{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 6px 10px;
border-radius: 999px;
font-weight: 900;
font-size: 12px;
background: rgba(56,189,248,0.18);
color: #D7F3FF;
width: fit-content;
}
.sw-expanded-title{
margin-top: 10px;
font-size: 26px;
font-weight: 900;
line-height: 1.05;
color: #F0F7FF;
}
.sw-expanded-snippet{
margin-top: 8px;
font-size: 14px;
font-weight: 700;
color: #B5C7E6;
}
/* =========================================================
SW_EXPANDED_POLISH (visual only)
- make expanded look like the mini gradients / modern UI
========================================================= */
.post-pin--expanded .post-card{
background: linear-gradient(135deg, rgba(56,189,248,0.16), rgba(7,18,37,0.98) 55%, rgba(2,6,23,0.98));
border: 1px solid rgba(96,165,250,0.75);
box-shadow: 0 18px 60px rgba(0,0,0,0.55), 0 0 24px rgba(56,189,248,0.22);
}
.sw-expanded-badge{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 7px 12px;
border-radius: 999px;
font-weight: 900;
font-size: 12px;
letter-spacing: .04em;
color: #d7f3ff;
background: rgba(56,189,248,0.16);
border: 1px solid rgba(56,189,248,0.35);
margin-bottom: 10px;
}
.sw-expanded-title{
font-size: 28px;
font-weight: 950;
line-height: 1.05;
color: #f0f7ff;
margin-bottom: 8px;
}
.sw-expanded-meta{
font-size: 12px;
font-weight: 700;
color: rgba(181,199,230,0.95);
margin-bottom: 10px;
}
.sw-expanded-snippet{
font-size: 14px;
line-height: 1.35;
color: rgba(215,233,255,0.95);
background: rgba(3,14,32,0.45);
border: 1px solid rgba(90,190,255,0.22);
border-radius: 14px;
padding: 10px 12px;
}
.post-card-btn{
background: rgba(56,189,248,0.14);
border: 1px solid rgba(56,189,248,0.35);
color: #e8f5ff;
font-weight: 900;
padding: 6px 12px;
}
.post-card-btn:active{
transform: scale(0.98);
}
/* ===== CENTER MODAL CONTENT (pretty + consistent) ===== */
.sw-modal{
padding: 12px 14px;
}
.sw-modal-toprow{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
margin-bottom: 10px;
}
.sw-modal-badge{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 900;
letter-spacing: .06em;
background: rgba(56,189,248,0.16);
border: 1px solid rgba(90, 190, 255, 0.28);
color:#D7F3FF;
text-transform: uppercase;
}
.sw-modal-meta{
margin-top: 6px;
font-size: 11px;
color:#9eb7ff;
opacity:.95;
display:flex;
gap:6px;
flex-wrap:wrap;
}
.sw-modal-x{
border:none;
border-radius:999px;
width:34px;
height:34px;
background: rgba(3,14,32,0.55);
color:#e8f5ff;
font-weight:900;
cursor:pointer;
border:1px solid rgba(90,190,255,0.25);
flex-shrink:0;
}
.sw-modal-hero{
width:100%;
height: 180px;
border-radius: 16px;
overflow:hidden;
border: 1px solid rgba(90, 190, 255, 0.22);
background: radial-gradient(circle at 30% 30%, rgba(56,189,248,0.18), rgba(3,14,32,0.85));
box-shadow: 0 10px 24px rgba(0,0,0,0.35);
margin-bottom: 10px;
}
.sw-modal-hero img{
width:100%;
height:100%;
object-fit: cover;
display:block;
}
.sw-modal-hero-fallback{
width:100%;
height:100%;
background: radial-gradient(circle at 35% 35%, rgba(56,189,248,0.22), rgba(3,14,32,0.85));
}
.sw-modal-title{
font-size: 22px;
font-weight: 900;
line-height: 26px;
color:#F0F7FF;
margin: 6px 0 8px;
}
.sw-modal-summary{
font-size: 13px;
line-height: 17px;
color:#D7E9FF;
opacity:.95;
margin-bottom: 8px;
}
.sw-stat-row{
display:flex;
gap:8px;
flex-wrap:wrap;
margin: 6px 0 10px;
}
.sw-stat-pill{
font-size: 11px;
color:#c7e3ff;
background: rgba(3, 14, 32, 0.58);
border: 1px solid rgba(90, 190, 255, 0.22);
padding: 6px 10px;
border-radius: 999px;
}
.sw-modal-cta-row{
display:flex;
flex-direction:column;
gap:10px;
margin-bottom: 10px;
}
.sw-cta-primary{
width:100%;
border:none;
border-radius: 14px;
padding: 14px 12px;
font-weight: 900;
font-size: 14px;
color:#e8f5ff;
cursor:pointer;
background: rgba(56,189,248,0.22);
box-shadow: 0 10px 18px rgba(0,0,0,0.35);
}
.sw-cta-primary:disabled{
opacity:.55;
cursor:default;
}
.sw-cta-actions{
display:flex;
gap:8px;
flex-wrap:wrap;
}
/* mobile tighten */
@media (max-width: 640px){
.sw-modal-hero{ height: 150px; }
.sw-modal-title{ font-size: 20px; line-height: 24px; }
}
/* =========================================
SW_CENTERED_MODAL_ANIM
- fade + scale on open/close (slow)
========================================= */
.sw-centered-backdrop{
position: fixed;
inset: 0;
z-index: 9999999;
background: rgba(0,0,0,0.08);
backdrop-filter: blur(1px);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
pointer-events: auto;
opacity: 0;
transition: opacity 440ms ease;
}
.sw-centered-modal{
transform: scale(0.94);
opacity: 0.0;
transition: transform 440ms ease, opacity 440ms ease;
will-change: transform, opacity;
}
.sw-centered-backdrop.sw-open{
opacity: 1;
}
.sw-centered-modal.sw-open{
transform: scale(1);
opacity: 1.0;
}
/* closing state (optional hook) */
.sw-centered-backdrop.sw-closing{
opacity: 0;
}
/* =========================================
SW_MINI_SMOOTH_MOVE
- stop "sautillage/blink sec" while panning/zooming
- MapLibre animates marker transforms every frame.
- Any CSS transition/filter on marker subtree can cause shimmer/jitter on mobile.
========================================= */
.post-pin{
will-change: transform;
transform: translate3d(0,0,0);
}
/* NO transitions on marker subtree (prevents bounce/lag while map moves) */
.post-pin, .post-pin *{
transition: none !important;
}
/* Make the mini template wrap GPU-friendly */
.sw-template-mini-wrap{
backface-visibility: hidden;
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.62);
transform-origin: bottom center;
}
/* Mobile scale keeps same behavior, but still GPU */
@media (max-width: 640px){
.sw-template-mini-wrap{
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.52);
}
}
/* Drop-shadow filters on triangles can shimmer on Android أثناء الحركة */
.post-pin--compact .post-pin-pointer-small{
filter: none;
}
@media (prefers-reduced-motion: reduce){
.sw-centered-backdrop, .sw-centered-modal{
transition: none !important;
}
}
/* SW_MARKER_SMOOTH_APPEAR:BEGIN */
.post-pin.sw-appear{
opacity: 0;
transition: opacity 280ms ease;
}
.post-pin.sw-appear.sw-appear-in{
opacity: 1;
}
.post-pin.sw-disappear{
opacity: 0;
transition: opacity 280ms ease;
}
/* SW_MARKER_SMOOTH_APPEAR:END */

View File

@ -135,3 +135,28 @@
}
/* SW_LAYOUT_RATIO_FIX:END */
/* SW_DESKTOP_WIDE_LAYOUT:BEGIN */
/* Big browser: Sociowall 75% / Chat 25% + centered container */
@media (min-width: 900px){
.below-stage{
max-width: 1320px;
margin-left: auto;
margin-right: auto;
padding-left: 18px;
padding-right: 18px;
}
.below-row{ gap: 0.9rem; }
.sociowall-panel{ flex: 0 0 74%; }
.chat-panel{
flex: 0 0 26%;
width: auto;
max-width: none;
min-width: 260px;
}
.post-list{ max-height: 50vh; }
.chat-users{ max-height: 50vh; }
}
/* SW_DESKTOP_WIDE_LAYOUT:END */

137
src/styles/posts.css.bak Normal file
View File

@ -0,0 +1,137 @@
/* Sociowall + Chat BELOW the map */
.below-row{
display:flex;
gap:.6rem;
align-items:stretch;
min-width:0;
flex-wrap: nowrap; /* ✅ keep side-by-side */
}
/* Small top nudge down (as requested) */
.sociowall-panel,
.chat-panel{
max-width: 32vw;
min-width: 160px;
flex: 1 1 0; /* ✅ 1/4 */
margin-top: 5px; /* ✅ descend 5px */
}
.sociowall-panel{
flex: 3 1 0; /* ✅ 3/4 */
min-width:0; /* IMPORTANT: prevents pushing chat */
min-height: 160px;
background: rgba(15, 23, 42, 0.96);
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.9);
box-shadow: 0 10px 28px rgba(0,0,0,.9), 0 0 18px rgba(56,189,248,.45);
padding: .45rem .55rem;
display:flex;
flex-direction:column;
}
.sociowall-header{
font-size: .78rem;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color:#bfdbfe;
margin-bottom:.25rem;
}
/* list scroll inside wall */
.post-list{
flex:1;
overflow-y:auto;
padding-right:.15rem;
max-height: 44vh;
}
/* prevent long content from widening layout */
.post-card{
overflow:hidden;
min-width:0;
border-radius:10px;
border:1px solid rgba(55, 65, 81, 0.9);
background: rgba(15, 23, 42, 0.92);
padding:.3rem .45rem;
margin-bottom:.22rem;
cursor:pointer;
}
/* SCALE the fixed template so it never forces width */
.post-card .sw-wall-mini{
--sw-wall-scale: 0.86;
transform: scale(var(--sw-wall-scale));
transform-origin: left top;
width: calc(100% / var(--sw-wall-scale));
}
.post-list-header{ display:flex; align-items:center; gap:.5rem; margin-bottom:.25rem; font-size:.7rem; color:#cbd5f5; }
.km-filter input[type="range"]{ width:160px; }
.status-text{ font-size:.72rem; opacity:.85; margin:.15rem 0; }
.status-error{ color:#fecaca; }
/* ✅ Chat fixed column that can shrink a bit on narrow screens */
.chat-panel{
flex: 0 0 190px;
width: 190px;
min-width: 160px; /* allow shrink in portrait */
max-width: min(220px, 42vw);
min-height: 160px;
background: rgba(15, 23, 42, 0.96);
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.9);
box-shadow: 0 10px 28px rgba(0,0,0,.9), 0 0 18px rgba(56,189,248,.45);
padding:.4rem .45rem;
display:flex;
flex-direction:column;
overflow:hidden;
}
.chat-header{
font-size:.78rem;
font-weight:700;
letter-spacing:.06em;
text-transform:uppercase;
color:#bfdbfe;
margin-bottom:.2rem;
}
.chat-users{
list-style:none;
padding:0; margin:0;
font-size:.76rem;
overflow:auto;
-webkit-overflow-scrolling: touch;
max-height: 44vh;
}
.chat-users li{ display:flex; align-items:center; gap:.3rem; padding:.18rem .1rem; }
.chat-users li::before{ content:"👤"; font-size:.72rem; opacity:.9; }
/* Stack only ultra-ultra-small */
@media (max-width: 340px){
.below-row{ flex-direction: column; }
.chat-panel{ width:100%; min-width:0; max-width:none; flex: 1 1 auto; }
.post-list{ max-height: 46vh; }
}
/* SW_LAYOUT_RATIO_FIX:BEGIN */
.below-row{ flex-wrap: nowrap !important; }
.sociowall-panel{
flex: 3 1 0 !important; /* ✅ 3/4 */
width: auto !important;
max-width: none !important;
min-width: 0 !important;
}
.chat-panel{
flex: 1 1 0 !important; /* ✅ 1/4 */
width: auto !important;
max-width: none !important;
min-width: 140px !important;
}
/* SW_LAYOUT_RATIO_FIX:END */

View File

@ -29,7 +29,7 @@
border:1px solid rgba(148,163,184,.85);
background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem;
display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform .12s ease;
box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform 0.36s ease;
}
.theme-dot:hover{ transform:scale(1.06); }
.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); }

View File

@ -0,0 +1,61 @@
.topbar{
position: sticky; top:0; z-index:100;
width:100%;
padding: .55rem .9rem;
display:flex; align-items:center; justify-content:space-between; gap:.6rem;
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
overflow:hidden;
}
.logo-circle{
width:40px; height:40px; border-radius:50%; flex-shrink:0;
background-image:url("/icons/logo-master.png");
background-size:cover; background-position:center; background-repeat:no-repeat;
box-shadow: 0 0 10px rgba(37,99,235,.8), 0 0 18px rgba(56,189,248,.6);
color:transparent;
}
.title-block{ display:flex; flex-direction:column; line-height:1.05; min-width:0; }
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; }
.theme-switch{ display:flex; gap:.28rem; }
.theme-dot{
width:22px; height:22px; border-radius:999px;
border:1px solid rgba(148,163,184,.85);
background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem;
display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform .12s ease;
}
.theme-dot:hover{ transform:scale(1.06); }
.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); }
.btn-primary, .btn-secondary{
padding:.32rem .85rem; font-size:.78rem; border-radius:999px; font-weight:700; cursor:pointer;
white-space:nowrap; line-height:1;
}
.btn-primary{
border:1px solid #22c55e;
background:radial-gradient(circle at 30% 30%,#22c55e,#16a34a);
color:#f9fafb;
box-shadow:0 4px 10px rgba(22,163,74,.5),0 0 8px rgba(22,163,74,.6);
}
.btn-primary:disabled{ opacity:.6; cursor:default; }
.btn-secondary{
border:1px solid #bfdbfe;
background:rgba(15,23,42,.25);
color:#e0f2fe;
box-shadow:0 3px 10px rgba(15,23,42,.6);
}
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
@media (max-width:640px){
.topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
.main-title{ font-size:1rem; }
.sub-title{ font-size:.7rem; }
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
}

View File

@ -9,7 +9,12 @@ export const cardTokens = {
},
gradients: {
newsBlue: "linear-gradient(135deg, rgba(78,161,255,0.35), rgba(16,18,24,1))",
},
breakingRed: "linear-gradient(135deg, rgba(255,77,77,0.28), rgba(16,18,24,1))",
marketGold: "linear-gradient(135deg, rgba(250,204,21,0.22), rgba(16,18,24,1))",
friendsGreen: "linear-gradient(135deg, rgba(34,197,94,0.22), rgba(16,18,24,1))",
eventsPurple: "linear-gradient(135deg, rgba(168,85,247,0.22), rgba(16,18,24,1))",
financeTeal: "linear-gradient(135deg, rgba(45,212,191,0.22), rgba(16,18,24,1))",
},
radius: { card: 18 },
shadow: { card: "0 10px 30px rgba(0,0,0,0.35)" },
text: {
@ -37,7 +42,12 @@ export const cardTokens = {
},
gradients: {
newsBlue: "linear-gradient(135deg, rgba(56,189,248,0.35), rgba(7,18,37,1))",
},
breakingRed: "linear-gradient(135deg, rgba(255,77,77,0.28), rgba(7,18,37,1))",
marketGold: "linear-gradient(135deg, rgba(250,204,21,0.22), rgba(7,18,37,1))",
friendsGreen: "linear-gradient(135deg, rgba(34,197,94,0.22), rgba(7,18,37,1))",
eventsPurple: "linear-gradient(135deg, rgba(168,85,247,0.22), rgba(7,18,37,1))",
financeTeal: "linear-gradient(135deg, rgba(45,212,191,0.22), rgba(7,18,37,1))",
},
radius: { card: 18 },
shadow: { card: "0 10px 30px rgba(0,0,0,0.40)" },
text: {
@ -65,7 +75,12 @@ export const cardTokens = {
},
gradients: {
newsBlue: "linear-gradient(135deg, rgba(30,107,255,0.20), rgba(255,255,255,1))",
},
breakingRed: "linear-gradient(135deg, rgba(209,0,31,0.16), rgba(255,255,255,1))",
marketGold: "linear-gradient(135deg, rgba(250,204,21,0.18), rgba(255,255,255,1))",
friendsGreen: "linear-gradient(135deg, rgba(34,197,94,0.16), rgba(255,255,255,1))",
eventsPurple: "linear-gradient(135deg, rgba(168,85,247,0.16), rgba(255,255,255,1))",
financeTeal: "linear-gradient(135deg, rgba(45,212,191,0.16), rgba(255,255,255,1))",
},
radius: { card: 18 },
shadow: { card: "0 10px 24px rgba(0,0,0,0.12)" },
text: {