toit marche
This commit is contained in:
parent
195b5634a1
commit
c9872d6260
|
|
@ -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");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -9,6 +9,11 @@ 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)" },
|
||||
|
|
@ -37,6 +42,11 @@ 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)" },
|
||||
|
|
@ -65,6 +75,11 @@ 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)" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue