This commit is contained in:
Your Name 2025-12-19 15:00:43 -05:00
parent b302d4e7ab
commit db4ce5599d
30 changed files with 0 additions and 7748 deletions

View File

@ -1,226 +0,0 @@
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
const AuthContext = createContext(null);
// Backend API (same origin)
const API_BASE = "/api";
const TOKEN_KEY = "sociowire:access_token";
const REFRESH_KEY = "sociowire:refresh_token";
const USERNAME_KEY = "sociowire:username";
function decodeJwtPayload(token) {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
} catch {
return null;
}
}
function willExpireSoon(token, withinSeconds = 90) {
const p = decodeJwtPayload(token);
if (!p || !p.exp) return true;
const now = Math.floor(Date.now() / 1000);
return p.exp - now <= withinSeconds;
}
async function backendRefresh(refreshToken) {
const res = await fetch(`${API_BASE}/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ refresh_token: refreshToken }),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`${res.status} ${text}`);
}
const data = JSON.parse(text);
if (!data.access_token) throw new Error("No access_token in refresh response");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
};
}
export function AuthProvider({ children }) {
const [booting, setBooting] = useState(true);
const [loading, setLoading] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [username, setUsername] = useState("");
const [token, setToken] = useState(null);
const [lastError, setLastError] = useState("");
const usernameRef = useRef("");
function persistAuth({ accessToken, refreshToken, user }) {
setToken(accessToken);
setAuthenticated(true);
setUsername(user);
usernameRef.current = user;
try {
localStorage.setItem(TOKEN_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
localStorage.setItem(USERNAME_KEY, user);
} catch {}
}
function clearAuth(msg = "") {
setAuthenticated(false);
setToken(null);
setUsername("");
usernameRef.current = "";
setLastError(msg);
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USERNAME_KEY);
} catch {}
}
async function trySilentRefreshIfNeeded() {
try {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedRefresh = localStorage.getItem(REFRESH_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (!storedToken || !storedRefresh) return false;
if (!willExpireSoon(storedToken, 30)) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
return true;
}
const { accessToken, refreshToken } = await backendRefresh(storedRefresh);
persistAuth({ accessToken, refreshToken, user: storedUser });
return true;
} catch (e) {
console.warn("[AUTH] silent refresh failed:", e);
clearAuth("Session expired. Please login again.");
return false;
}
}
useEffect(() => {
(async () => {
try {
const ok = await trySilentRefreshIfNeeded();
if (!ok) {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (storedToken) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
}
}
} finally {
setBooting(false);
}
})();
}, []);
useEffect(() => {
if (!authenticated) return;
const interval = setInterval(async () => {
try {
const t = localStorage.getItem(TOKEN_KEY);
const rt = localStorage.getItem(REFRESH_KEY);
if (!t || !rt) return;
if (!willExpireSoon(t, 90)) return;
const { accessToken, refreshToken } = await backendRefresh(rt);
const u = localStorage.getItem(USERNAME_KEY) || usernameRef.current || "";
persistAuth({ accessToken, refreshToken, user: u });
} catch (e) {
console.warn("[AUTH] auto refresh failed:", e);
clearAuth("Session expired. Please login again.");
}
}, 60_000);
return () => clearInterval(interval);
}, [authenticated]);
async function login(user, pass) {
setLastError("");
const u = (user || "").trim();
if (!u || !pass) {
setLastError("Username and password required.");
return;
}
try {
setLoading(true);
const res = await fetch(`${API_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username: u, password: pass }),
});
const text = await res.text();
if (!res.ok) {
console.error("[AUTH] /api/login failed", res.status, text);
setLastError(`${res.status} login failed`);
return;
}
const data = JSON.parse(text);
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
if (!accessToken) {
setLastError("No access token in response.");
return;
}
persistAuth({ accessToken, refreshToken, user: u });
setLastError("");
} catch (e) {
console.error("[AUTH] network error", e);
setLastError("Network error during login.");
clearAuth("Network error during login.");
} finally {
setLoading(false);
}
}
function logout() {
clearAuth("");
}
return (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
login,
logout,
lastError,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View File

@ -1,47 +0,0 @@
export function parseAuthError(text = "", status = 0) {
// Keycloak token error example:
// {"error":"invalid_grant","error_description":"Account is not fully set up"}
const raw = String(text || "");
let err = "";
let desc = "";
try {
const j = JSON.parse(raw);
err = String(j.error || "");
desc = String(j.error_description || j.errorMessage || "");
} catch {
// raw is not JSON
}
const combined = (err + " " + desc + " " + raw).toLowerCase();
// Email verification / required action
if (
combined.includes("not fully set up") ||
combined.includes("verify") ||
combined.includes("verification") ||
combined.includes("required action") ||
(combined.includes("email") && combined.includes("verified"))
) {
return {
code: "email_verification_required",
message:
"Tu dois confirmer ton email avant de te connecter. Vérifie ta boîte mail (et le spam), puis réessaie.",
};
}
// Bad credentials
if (
combined.includes("invalid user credentials") ||
(combined.includes("invalid_grant") && !combined.includes("not fully set up"))
) {
return { code: "invalid_credentials", message: "Mauvais username ou password." };
}
// Fallback
if (desc) return { code: "login_failed", message: desc };
if (err) return { code: "login_failed", message: err };
if (raw && raw.length < 220) return { code: "login_failed", message: raw };
return { code: "login_failed", message: status ? `Login failed (${status}).` : "Login failed." };
}

View File

@ -1,312 +0,0 @@
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
import { parseAuthError } from "./authErrors";
const AuthContext = createContext(null);
// Backend API (same origin)
const API_BASE = "/api";
const TOKEN_KEY = "sociowire:access_token";
const REFRESH_KEY = "sociowire:refresh_token";
const USERNAME_KEY = "sociowire:username";
function decodeJwtPayload(token) {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
} catch {
return null;
}
}
function willExpireSoon(token, withinSeconds = 90) {
const p = decodeJwtPayload(token);
if (!p || !p.exp) return true;
const now = Math.floor(Date.now() / 1000);
return p.exp - now <= withinSeconds;
}
async function backendRefresh(refreshToken) {
const res = await fetch(`${API_BASE}/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ refresh_token: refreshToken }),
});
const text = await res.text().catch(() => "");
if (!res.ok) {
throw new Error(`${res.status} ${text}`);
}
const data = JSON.parse(text || "{}");
if (!data.access_token) throw new Error("No access_token in refresh response");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
};
}
async function backendWhoami(accessToken) {
const res = await fetch(`${API_BASE}/whoami`, {
method: "GET",
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
credentials: "include",
});
const text = await res.text().catch(() => "");
if (!res.ok) return { ok: false, status: res.status, text };
try {
const j = JSON.parse(text || "{}");
return { ok: true, status: res.status, json: j, text };
} catch {
return { ok: true, status: res.status, json: {}, text };
}
}
async function backendResendVerify(payload) {
const res = await fetch(`${API_BASE}/resend-verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
const text = await res.text().catch(() => "");
return { ok: res.ok, status: res.status, text };
}
export function AuthProvider({ children }) {
const [booting, setBooting] = useState(true);
const [loading, setLoading] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [username, setUsername] = useState("");
const [token, setToken] = useState(null);
const [lastError, setLastError] = useState("");
const [lastErrorCode, setLastErrorCode] = useState("");
const usernameRef = useRef("");
function persistAuth({ accessToken, refreshToken, user }) {
setToken(accessToken);
setAuthenticated(true);
setUsername(user || "");
usernameRef.current = user || "";
try {
localStorage.setItem(TOKEN_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
if (user) localStorage.setItem(USERNAME_KEY, user);
} catch {}
}
function clearAuth(msg = "", code = "") {
setAuthenticated(false);
setToken(null);
setUsername("");
usernameRef.current = "";
setLastError(msg);
setLastErrorCode(code);
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USERNAME_KEY);
} catch {}
}
async function trySilentRefreshIfNeeded() {
try {
const storedToken = localStorage.getItem(TOKEN_KEY) || "";
const storedRefresh = localStorage.getItem(REFRESH_KEY) || "";
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (!storedToken) return false;
// If token still valid, confirm with whoami (prevents stale local token)
if (!willExpireSoon(storedToken, 30)) {
const me = await backendWhoami(storedToken);
if (me.ok && me.json?.authenticated) {
setToken(storedToken);
setUsername(me.json.username || storedUser);
usernameRef.current = me.json.username || storedUser;
setAuthenticated(true);
return true;
}
}
// Expired or rejected -> refresh
if (!storedRefresh) return false;
const { accessToken, refreshToken } = await backendRefresh(storedRefresh);
const me = await backendWhoami(accessToken);
const finalUser = (me.ok && me.json?.authenticated && (me.json.username || storedUser)) || storedUser;
persistAuth({ accessToken, refreshToken, user: finalUser });
return true;
} catch (e) {
console.warn("[AUTH] silent refresh failed:", e);
clearAuth("Session expired. Please login again.", "session_expired");
return false;
}
}
useEffect(() => {
(async () => {
try {
await trySilentRefreshIfNeeded();
} finally {
setBooting(false);
}
})();
}, []);
useEffect(() => {
if (!authenticated) return;
const interval = setInterval(async () => {
try {
const t = localStorage.getItem(TOKEN_KEY) || "";
const rt = localStorage.getItem(REFRESH_KEY) || "";
if (!t || !rt) return;
if (!willExpireSoon(t, 90)) return;
const { accessToken, refreshToken } = await backendRefresh(rt);
const me = await backendWhoami(accessToken);
const u =
(me.ok && me.json?.authenticated && (me.json.username || "")) ||
localStorage.getItem(USERNAME_KEY) ||
usernameRef.current ||
"";
persistAuth({ accessToken, refreshToken, user: u });
} catch (e) {
console.warn("[AUTH] auto refresh failed:", e);
clearAuth("Session expired. Please login again.", "session_expired");
}
}, 60_000);
return () => clearInterval(interval);
}, [authenticated]);
async function login(user, pass) {
setLastError("");
setLastErrorCode("");
const u = (user || "").trim();
if (!u || !pass) {
setLastError("Username and password required.");
setLastErrorCode("missing_fields");
return false;
}
try {
setLoading(true);
const res = await fetch(`${API_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username: u, password: pass }),
});
const text = await res.text().catch(() => "");
if (!res.ok) {
const nice = parseAuthError(text, res.status);
setLastError(nice.message);
setLastErrorCode(nice.code);
return false;
}
const data = JSON.parse(text || "{}");
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
if (!accessToken) {
setLastError("No access token in response.");
setLastErrorCode("token_missing");
return false;
}
// confirm + fetch real username
const me = await backendWhoami(accessToken);
const finalUser =
(me.ok && me.json?.authenticated && (me.json.username || "")) || u;
persistAuth({ accessToken, refreshToken, user: finalUser });
setLastError("");
setLastErrorCode("");
return true;
} catch (e) {
console.error("[AUTH] network error", e);
clearAuth("Network error during login.", "network_error");
return false;
} finally {
setLoading(false);
}
}
async function resendVerifyEmail(usernameOrEmail) {
const v = String(usernameOrEmail || "").trim();
if (!v) {
setLastError("Entre ton username ou email, puis clique renvoyer.");
setLastErrorCode("missing_fields");
return false;
}
try {
const res = await backendResendVerify({ value: v });
if (!res.ok) {
setLastError(`Resend failed (${res.status})`);
setLastErrorCode("resend_failed");
return false;
}
setLastError("Email de confirmation renvoyé. Vérifie ta boîte mail (et le spam).");
setLastErrorCode("verify_sent");
return true;
} catch (e) {
console.error(e);
setLastError("Network error while resending email.");
setLastErrorCode("network_error");
return false;
}
}
function logout() {
clearAuth("", "");
}
return (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
login,
logout,
lastError,
lastErrorCode,
needsEmailVerification: lastErrorCode === "email_verification_required",
resendVerifyEmail,
clearAuthError: () => {
setLastError("");
setLastErrorCode("");
},
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View File

@ -1,47 +0,0 @@
export function parseAuthError(text = "", status = 0) {
// Keycloak token error example:
// {"error":"invalid_grant","error_description":"Account is not fully set up"}
const raw = String(text || "");
let err = "";
let desc = "";
try {
const j = JSON.parse(raw);
err = String(j.error || "");
desc = String(j.error_description || j.errorMessage || "");
} catch {
// raw is not JSON
}
const combined = (err + " " + desc + " " + raw).toLowerCase();
// Email verification / required action
if (
combined.includes("not fully set up") ||
combined.includes("verify") ||
combined.includes("verification") ||
combined.includes("required action") ||
(combined.includes("email") && combined.includes("verified"))
) {
return {
code: "email_verification_required",
message:
"Tu dois confirmer ton email avant de te connecter. Vérifie ta boîte mail (et le spam), puis réessaie.",
};
}
// Bad credentials
if (
combined.includes("invalid user credentials") ||
(combined.includes("invalid_grant") && !combined.includes("not fully set up"))
) {
return { code: "invalid_credentials", message: "Mauvais username ou password." };
}
// Fallback
if (desc) return { code: "login_failed", message: desc };
if (err) return { code: "login_failed", message: err };
if (raw && raw.length < 220) return { code: "login_failed", message: raw };
return { code: "login_failed", message: status ? `Login failed (${status}).` : "Login failed." };
}

View File

@ -1,224 +0,0 @@
import React, { useState, useEffect } from "react";
import { useAuth } from "../../auth/AuthContext";
import { registerUser } from "../../api/client";
const THEME_LABELS = {
dark: "Dark",
blue: "Blue",
light: "Light",
};
export default function TopBar({ theme = "dark", onChangeTheme }) {
const { loading, authenticated, username, login, logout, lastError } = useAuth();
const [showAuth, setShowAuth] = useState(false);
const [mode, setMode] = useState("login");
const [loginUser, setLoginUser] = useState("");
const [loginPass, setLoginPass] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [regUser, setRegUser] = useState("");
const [regEmail, setRegEmail] = useState("");
const [regPass, setRegPass] = useState("");
const [signupError, setSignupError] = useState("");
const [signupLoading, setSignupLoading] = useState(false);
let authLabel = "AUTH: anon";
if (loading) authLabel = "AUTH: loading…";
else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
const openModal = (nextMode) => {
setMode(nextMode);
setShowAuth(true);
setSignupError("");
};
const closeModal = () => {
setShowAuth(false);
setLoginPass("");
setSignupError("");
};
useEffect(() => {
if (authenticated) setShowAuth(false);
}, [authenticated]);
const handleLoginSubmit = async (e) => {
e.preventDefault();
await login(loginUser, loginPass);
};
const handleSignupSubmit = async (e) => {
e.preventDefault();
setSignupError("");
if (
!firstName.trim() ||
!lastName.trim() ||
!regUser.trim() ||
!regEmail.trim() ||
!regPass
) {
setSignupError("First name, last name, username, email and password are required.");
return;
}
try {
setSignupLoading(true);
await registerUser({
username: regUser.trim(),
email: regEmail.trim(),
password: regPass,
firstName: firstName.trim(),
lastName: lastName.trim(),
});
await login(regUser.trim(), regPass);
} catch (err) {
console.error(err);
setSignupError(err.message || "Registration error.");
} finally {
setSignupLoading(false);
}
};
return (
<>
<header className="topbar">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<div className="logo-circle">SW</div>
<div className="title-block">
<div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</div>
<div style={{ fontSize: "0.65rem", opacity: 0.7 }}>
{authLabel}
{lastError && (
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
({lastError})
</span>
)}
</div>
</div>
</div>
<div className="topbar-right">
<div className="theme-switch">
{["dark", "blue", "light"].map((t) => (
<button
key={t}
type="button"
className={"theme-dot" + (theme === t ? " theme-dot-active" : "")}
title={THEME_LABELS[t]}
onClick={() => onChangeTheme && onChangeTheme(t)}
>
{THEME_LABELS[t][0]}
</button>
))}
</div>
{authenticated ? (
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ fontSize: "0.8rem" }}>{username}</span>
<button className="btn-primary" onClick={logout}>
Logout
</button>
</div>
) : (
<div style={{ display: "flex", gap: "0.3rem" }}>
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
Login
</button>
<button type="button" className="btn-secondary" onClick={() => openModal("signup")}>
Sign up
</button>
</div>
)}
</div>
</header>
{showAuth && (
<div className="auth-modal-backdrop" onClick={closeModal}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<div className="auth-modal-header">
<button
type="button"
className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")}
onClick={() => setMode("login")}
>
Login
</button>
<button
type="button"
className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")}
onClick={() => setMode("signup")}
>
Sign up
</button>
<button type="button" className="auth-close" onClick={closeModal}>
</button>
</div>
{mode === "login" ? (
<form className="auth-form" onSubmit={handleLoginSubmit}>
<label>
Username
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
autoComplete="current-password"
/>
</label>
<button type="submit" className="btn-primary btn-full" disabled={loading}>
{loading ? "..." : "Sign in"}
</button>
{lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}
</form>
) : (
<form className="auth-form" onSubmit={handleSignupSubmit}>
<div className="auth-row">
<label>
First name
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</label>
<label>
Last name
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</label>
</div>
<label>
Username
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
</label>
<label>
Email
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
</label>
<label>
Password
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
</label>
{signupError && <div className="auth-error">{signupError}</div>}
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
{signupLoading ? "..." : "Create account"}
</button>
{lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}
</form>
)}
</div>
</div>
)}
</>
);
}

View File

@ -1,246 +0,0 @@
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
const AuthContext = createContext(null);
// Keycloak config
const KC_BASE = "https://web.sociowire.com:8443";
const KC_REALM = "sociowire";
const KC_CLIENT_ID = "sociowire-frontend";
const TOKEN_KEY = "sociowire:access_token";
const REFRESH_KEY = "sociowire:refresh_token";
const USERNAME_KEY = "sociowire:username";
// --- helpers ---
function decodeJwtPayload(token) {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
} catch {
return null;
}
}
function willExpireSoon(token, withinSeconds = 90) {
const p = decodeJwtPayload(token);
if (!p || !p.exp) return true;
const now = Math.floor(Date.now() / 1000);
return p.exp - now <= withinSeconds;
}
async function refreshWithKeycloak(refreshToken) {
const params = new URLSearchParams();
params.set("grant_type", "refresh_token");
params.set("client_id", KC_CLIENT_ID);
params.set("refresh_token", refreshToken);
const res = await fetch(`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const text = await res.text();
if (!res.ok) {
let msg = text;
try {
const j = JSON.parse(text);
msg = j.error_description || j.error || text;
} catch {}
throw new Error(`${res.status} ${msg}`);
}
const data = JSON.parse(text);
if (!data.access_token) throw new Error("No access_token in refresh response");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
};
}
export function AuthProvider({ children }) {
const [booting, setBooting] = useState(true);
const [loading, setLoading] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [username, setUsername] = useState("");
const [token, setToken] = useState(null);
const [lastError, setLastError] = useState("");
const usernameRef = useRef("");
function persistAuth({ accessToken, refreshToken, user }) {
setToken(accessToken);
setAuthenticated(true);
setUsername(user);
usernameRef.current = user;
try {
localStorage.setItem(TOKEN_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
localStorage.setItem(USERNAME_KEY, user);
} catch {}
}
function clearAuth(msg = "") {
setAuthenticated(false);
setToken(null);
setUsername("");
usernameRef.current = "";
setLastError(msg);
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USERNAME_KEY);
} catch {}
}
async function trySilentRefreshIfNeeded() {
try {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedRefresh = localStorage.getItem(REFRESH_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (!storedToken || !storedRefresh) return false;
if (!willExpireSoon(storedToken, 30)) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
return true;
}
const { accessToken, refreshToken } = await refreshWithKeycloak(storedRefresh);
persistAuth({ accessToken, refreshToken, user: storedUser });
return true;
} catch (e) {
console.warn("[AUTH] silent refresh failed:", e);
clearAuth("Session expired. Please login again.");
return false;
}
}
useEffect(() => {
(async () => {
try {
const ok = await trySilentRefreshIfNeeded();
if (!ok) {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (storedToken) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
}
}
} finally {
setBooting(false);
}
})();
}, []);
useEffect(() => {
if (!authenticated) return;
const interval = setInterval(async () => {
try {
const t = localStorage.getItem(TOKEN_KEY);
const rt = localStorage.getItem(REFRESH_KEY);
if (!t || !rt) return;
if (!willExpireSoon(t, 90)) return;
const { accessToken, refreshToken } = await refreshWithKeycloak(rt);
const u = localStorage.getItem(USERNAME_KEY) || usernameRef.current || "";
persistAuth({ accessToken, refreshToken, user: u });
} catch (e) {
console.warn("[AUTH] auto refresh failed:", e);
clearAuth("Session expired. Please login again.");
}
}, 60_000);
return () => clearInterval(interval);
}, [authenticated]);
async function login(user, pass) {
setLastError("");
const u = (user || "").trim();
if (!u || !pass) {
setLastError("Username and password required.");
return;
}
const params = new URLSearchParams();
params.set("grant_type", "password");
params.set("client_id", KC_CLIENT_ID);
params.set("username", u);
params.set("password", pass);
try {
setLoading(true);
const res = await fetch(`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const text = await res.text();
if (!res.ok) {
let msg = "Invalid credentials or auth error.";
try {
const j = JSON.parse(text);
msg = j.error_description || j.error || msg;
} catch {}
clearAuth(`${res.status} ${msg}`);
return;
}
const data = JSON.parse(text);
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
if (!accessToken) {
clearAuth("No access token in response.");
return;
}
persistAuth({ accessToken, refreshToken, user: u });
setLastError("");
} catch (e) {
console.error("[AUTH] network error", e);
clearAuth("Network error during login.");
} finally {
setLoading(false);
}
}
function logout() {
clearAuth("");
}
return (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
login,
logout,
lastError,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View File

@ -1,234 +0,0 @@
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
const AuthContext = createContext(null);
// Keycloak config
const TOKEN_KEY = "sociowire:access_token";
const REFRESH_KEY = "sociowire:refresh_token";
const USERNAME_KEY = "sociowire:username";
// --- helpers ---
function decodeJwtPayload(token) {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
} catch {
return null;
}
}
function willExpireSoon(token, withinSeconds = 90) {
const p = decodeJwtPayload(token);
if (!p || !p.exp) return true;
const now = Math.floor(Date.now() / 1000);
return p.exp - now <= withinSeconds;
}
async function refreshViaBackend(refreshToken) {
const res = await fetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ refresh_token: refreshToken }),
});
const text = await res.text();
if (!res.ok) {
let msg = text;
try {
const j = JSON.parse(text);
msg = j.error_description || j.error || text;
} catch {}
throw new Error(` `);
}
const data = JSON.parse(text);
if (!data.access_token) throw new Error("No access_token in refresh response");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
};
}
export function AuthProvider({ children }) {
const [booting, setBooting] = useState(true);
const [loading, setLoading] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [username, setUsername] = useState("");
const [token, setToken] = useState(null);
const [lastError, setLastError] = useState("");
const usernameRef = useRef("");
function persistAuth({ accessToken, refreshToken, user }) {
setToken(accessToken);
setAuthenticated(true);
setUsername(user);
usernameRef.current = user;
try {
localStorage.setItem(TOKEN_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
localStorage.setItem(USERNAME_KEY, user);
} catch {}
}
function clearAuth(msg = "") {
setAuthenticated(false);
setToken(null);
setUsername("");
usernameRef.current = "";
setLastError(msg);
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USERNAME_KEY);
} catch {}
}
async function trySilentRefreshIfNeeded() {
try {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedRefresh = localStorage.getItem(REFRESH_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (!storedToken || !storedRefresh) return false;
if (!willExpireSoon(storedToken, 30)) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
return true;
}
const { accessToken, refreshToken } = await refreshViaBackend(storedRefresh);
persistAuth({ accessToken, refreshToken, user: storedUser });
return true;
} catch (e) {
console.warn("[AUTH] silent refresh failed:", e);
clearAuth("Session expired. Please login again.");
return false;
}
}
useEffect(() => {
(async () => {
try {
const ok = await trySilentRefreshIfNeeded();
if (!ok) {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (storedToken) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
}
}
} finally {
setBooting(false);
}
})();
}, []);
useEffect(() => {
if (!authenticated) return;
const interval = setInterval(async () => {
try {
const t = localStorage.getItem(TOKEN_KEY);
const rt = localStorage.getItem(REFRESH_KEY);
if (!t || !rt) return;
if (!willExpireSoon(t, 90)) return;
const { accessToken, refreshToken } = await refreshViaBackend(rt);
const u = localStorage.getItem(USERNAME_KEY) || usernameRef.current || "";
persistAuth({ accessToken, refreshToken, user: u });
} catch (e) {
console.warn("[AUTH] auto refresh failed:", e);
clearAuth("Session expired. Please login again.");
}
}, 60_000);
return () => clearInterval(interval);
}, [authenticated]);
async function login(user, pass) {
setLastError("");
const u = (user || "").trim();
if (!u || !pass) {
setLastError("Username and password required.");
return;
}
try {
setLoading(true);
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username: u, password: pass }),
});
const text = await res.text();
if (!res.ok) {
let msg = "Invalid credentials or auth error.";
try {
const j = JSON.parse(text);
msg = j.error_description || j.error || msg;
} catch {}
clearAuth(` `);
return;
}
const data = JSON.parse(text);
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
if (!accessToken) {
clearAuth("No access token in response.");
return;
}
persistAuth({ accessToken, refreshToken, user: u });
setLastError("");
} catch (e) {
console.error("[AUTH] network error", e);
clearAuth("Network error during login.");
} finally {
setLoading(false);
}
}
function logout() {
clearAuth("");
}
return (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
login,
logout,
lastError,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View File

@ -1,234 +0,0 @@
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
const AuthContext = createContext(null);
// Keycloak config
const TOKEN_KEY = "sociowire:access_token";
const REFRESH_KEY = "sociowire:refresh_token";
const USERNAME_KEY = "sociowire:username";
// --- helpers ---
function decodeJwtPayload(token) {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
} catch {
return null;
}
}
function willExpireSoon(token, withinSeconds = 90) {
const p = decodeJwtPayload(token);
if (!p || !p.exp) return true;
const now = Math.floor(Date.now() / 1000);
return p.exp - now <= withinSeconds;
}
async function refreshViaBackend(refreshToken) {
const res = await fetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ refresh_token: refreshToken }),
});
const text = await res.text();
if (!res.ok) {
let msg = text;
try {
const j = JSON.parse(text);
msg = j.error_description || j.error || text;
} catch {}
throw new Error(` `);
}
const data = JSON.parse(text);
if (!data.access_token) throw new Error("No access_token in refresh response");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
};
}
export function AuthProvider({ children }) {
const [booting, setBooting] = useState(true);
const [loading, setLoading] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [username, setUsername] = useState("");
const [token, setToken] = useState(null);
const [lastError, setLastError] = useState("");
const usernameRef = useRef("");
function persistAuth({ accessToken, refreshToken, user }) {
setToken(accessToken);
setAuthenticated(true);
setUsername(user);
usernameRef.current = user;
try {
localStorage.setItem(TOKEN_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
localStorage.setItem(USERNAME_KEY, user);
} catch {}
}
function clearAuth(msg = "") {
setAuthenticated(false);
setToken(null);
setUsername("");
usernameRef.current = "";
setLastError(msg);
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USERNAME_KEY);
} catch {}
}
async function trySilentRefreshIfNeeded() {
try {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedRefresh = localStorage.getItem(REFRESH_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (!storedToken || !storedRefresh) return false;
if (!willExpireSoon(storedToken, 30)) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
return true;
}
const { accessToken, refreshToken } = await refreshViaBackend(storedRefresh);
persistAuth({ accessToken, refreshToken, user: storedUser });
return true;
} catch (e) {
console.warn("[AUTH] silent refresh failed:", e);
clearAuth("Session expired. Please login again.");
return false;
}
}
useEffect(() => {
(async () => {
try {
const ok = await trySilentRefreshIfNeeded();
if (!ok) {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (storedToken) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
}
}
} finally {
setBooting(false);
}
})();
}, []);
useEffect(() => {
if (!authenticated) return;
const interval = setInterval(async () => {
try {
const t = localStorage.getItem(TOKEN_KEY);
const rt = localStorage.getItem(REFRESH_KEY);
if (!t || !rt) return;
if (!willExpireSoon(t, 90)) return;
const { accessToken, refreshToken } = await refreshViaBackend(rt);
const u = localStorage.getItem(USERNAME_KEY) || usernameRef.current || "";
persistAuth({ accessToken, refreshToken, user: u });
} catch (e) {
console.warn("[AUTH] auto refresh failed:", e);
clearAuth("Session expired. Please login again.");
}
}, 60_000);
return () => clearInterval(interval);
}, [authenticated]);
async function login(user, pass) {
setLastError("");
const u = (user || "").trim();
if (!u || !pass) {
setLastError("Username and password required.");
return;
}
try {
setLoading(true);
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username: u, password: pass }),
});
const text = await res.text();
if (!res.ok) {
let msg = "Invalid credentials or auth error.";
try {
const j = JSON.parse(text);
msg = j.error_description || j.error || msg;
} catch {}
clearAuth(` `);
return;
}
const data = JSON.parse(text);
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
if (!accessToken) {
clearAuth("No access token in response.");
return;
}
persistAuth({ accessToken, refreshToken, user: u });
setLastError("");
} catch (e) {
console.error("[AUTH] network error", e);
clearAuth("Network error during login.");
} finally {
setLoading(false);
}
}
function logout() {
clearAuth("");
}
return (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
login,
logout,
lastError,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View File

@ -1,234 +0,0 @@
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
const AuthContext = createContext(null);
// Keycloak config
const TOKEN_KEY = "sociowire:access_token";
const REFRESH_KEY = "sociowire:refresh_token";
const USERNAME_KEY = "sociowire:username";
// --- helpers ---
function decodeJwtPayload(token) {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
} catch {
return null;
}
}
function willExpireSoon(token, withinSeconds = 90) {
const p = decodeJwtPayload(token);
if (!p || !p.exp) return true;
const now = Math.floor(Date.now() / 1000);
return p.exp - now <= withinSeconds;
}
async function refreshViaBackend(refreshToken) {
const res = await fetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ refresh_token: refreshToken }),
});
const text = await res.text();
if (!res.ok) {
let msg = text;
try {
const j = JSON.parse(text);
msg = j.error_description || j.error || text;
} catch {}
throw new Error(` `);
}
const data = JSON.parse(text);
if (!data.access_token) throw new Error("No access_token in refresh response");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
};
}
export function AuthProvider({ children }) {
const [booting, setBooting] = useState(true);
const [loading, setLoading] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [username, setUsername] = useState("");
const [token, setToken] = useState(null);
const [lastError, setLastError] = useState("");
const usernameRef = useRef("");
function persistAuth({ accessToken, refreshToken, user }) {
setToken(accessToken);
setAuthenticated(true);
setUsername(user);
usernameRef.current = user;
try {
localStorage.setItem(TOKEN_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
localStorage.setItem(USERNAME_KEY, user);
} catch {}
}
function clearAuth(msg = "") {
setAuthenticated(false);
setToken(null);
setUsername("");
usernameRef.current = "";
setLastError(msg);
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USERNAME_KEY);
} catch {}
}
async function trySilentRefreshIfNeeded() {
try {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedRefresh = localStorage.getItem(REFRESH_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (!storedToken || !storedRefresh) return false;
if (!willExpireSoon(storedToken, 30)) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
return true;
}
const { accessToken, refreshToken } = await refreshViaBackend(storedRefresh);
persistAuth({ accessToken, refreshToken, user: storedUser });
return true;
} catch (e) {
console.warn("[AUTH] silent refresh failed:", e);
clearAuth("Session expired. Please login again.");
return false;
}
}
useEffect(() => {
(async () => {
try {
const ok = await trySilentRefreshIfNeeded();
if (!ok) {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (storedToken) {
setToken(storedToken);
setUsername(storedUser);
usernameRef.current = storedUser;
setAuthenticated(true);
}
}
} finally {
setBooting(false);
}
})();
}, []);
useEffect(() => {
if (!authenticated) return;
const interval = setInterval(async () => {
try {
const t = localStorage.getItem(TOKEN_KEY);
const rt = localStorage.getItem(REFRESH_KEY);
if (!t || !rt) return;
if (!willExpireSoon(t, 90)) return;
const { accessToken, refreshToken } = await refreshViaBackend(rt);
const u = localStorage.getItem(USERNAME_KEY) || usernameRef.current || "";
persistAuth({ accessToken, refreshToken, user: u });
} catch (e) {
console.warn("[AUTH] auto refresh failed:", e);
clearAuth("Session expired. Please login again.");
}
}, 60_000);
return () => clearInterval(interval);
}, [authenticated]);
async function login(user, pass) {
setLastError("");
const u = (user || "").trim();
if (!u || !pass) {
setLastError("Username and password required.");
return;
}
try {
setLoading(true);
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username: u, password: pass }),
});
const text = await res.text();
if (!res.ok) {
let msg = "Invalid credentials or auth error.";
try {
const j = JSON.parse(text);
msg = j.error_description || j.error || msg;
} catch {}
clearAuth(` `);
return;
}
const data = JSON.parse(text);
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
if (!accessToken) {
clearAuth("No access token in response.");
return;
}
persistAuth({ accessToken, refreshToken, user: u });
setLastError("");
} catch (e) {
console.error("[AUTH] network error", e);
clearAuth("Network error during login.");
} finally {
setLoading(false);
}
}
function logout() {
clearAuth("");
}
return (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
login,
logout,
lastError,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View File

@ -1,343 +0,0 @@
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
const AuthContext = createContext(null);
const API_BASE = "/api";
const TOKEN_KEY = "sociowire:access_token";
const REFRESH_KEY = "sociowire:refresh_token";
const USERNAME_KEY = "sociowire:username";
function decodeJwtPayload(token) {
try {
const parts = String(token || "").split(".");
if (parts.length < 2) return null;
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
} catch {
return null;
}
}
function willExpireSoon(token, withinSeconds = 90) {
const p = decodeJwtPayload(token);
if (!p || !p.exp) return true;
const now = Math.floor(Date.now() / 1000);
return p.exp - now <= withinSeconds;
}
async function apiRefresh(refreshToken) {
const res = await fetch(`${API_BASE}/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ refresh_token: refreshToken }),
});
const text = await res.text().catch(() => "");
if (!res.ok) throw new Error(`${res.status} ${text}`);
const data = JSON.parse(text || "{}");
if (!data.access_token) throw new Error("No access_token in refresh response");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
};
}
async function apiMe(accessToken) {
const res = await fetch(`${API_BASE}/me`, {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
credentials: "include",
});
const text = await res.text().catch(() => "");
if (!res.ok) throw new Error(`${res.status} ${text || "me failed"}`);
const data = JSON.parse(text || "{}");
return data;
}
async function apiResendVerify(accessToken) {
const res = await fetch(`${API_BASE}/resend-verification`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
credentials: "include",
});
const text = await res.text().catch(() => "");
if (!res.ok) throw new Error(`${res.status} ${text || "resend failed"}`);
return text ? JSON.parse(text) : { ok: true };
}
export function AuthProvider({ children }) {
const [booting, setBooting] = useState(true);
const [loading, setLoading] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [username, setUsername] = useState("");
const [token, setToken] = useState(null);
const [emailVerified, setEmailVerified] = useState(true);
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
const [lastError, setLastError] = useState("");
const [lastInfo, setLastInfo] = useState("");
const usernameRef = useRef("");
function persistAuth({ accessToken, refreshToken, user }) {
setToken(accessToken);
setAuthenticated(true);
setUsername(user || "");
usernameRef.current = user || "";
try {
localStorage.setItem(TOKEN_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
localStorage.setItem(USERNAME_KEY, user || "");
} catch {}
}
function clearAuth(msg = "") {
setAuthenticated(false);
setToken(null);
setUsername("");
usernameRef.current = "";
setEmailVerified(true);
setNeedsEmailVerification(false);
setLastError(msg || "");
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USERNAME_KEY);
} catch {}
}
async function syncWithBackend(accessToken, fallbackUser = "") {
// Confirme que le backend accepte vraiment le token
const me = await apiMe(accessToken);
const u =
me.username ||
me.preferred_username ||
me.user ||
fallbackUser ||
localStorage.getItem(USERNAME_KEY) ||
"";
const verified = me.email_verified !== false; // default true si absent
setEmailVerified(verified);
setNeedsEmailVerification(!verified);
setAuthenticated(true);
setToken(accessToken);
setUsername(u);
usernameRef.current = u;
// garde username en storage pour UI
try { localStorage.setItem(USERNAME_KEY, u); } catch {}
if (!verified) {
setLastInfo("Check your inbox and verify your email to unlock posting.");
} else {
setLastInfo("");
}
return { me, verified };
}
async function bootRestore() {
const storedToken = (() => {
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
})();
const storedRefresh = (() => {
try { return localStorage.getItem(REFRESH_KEY); } catch { return null; }
})();
const storedUser = (() => {
try { return localStorage.getItem(USERNAME_KEY) || ""; } catch { return ""; }
})();
if (!storedToken) return;
// 1) essaie /me direct
try {
await syncWithBackend(storedToken, storedUser);
return;
} catch (e) {
// 2) si /me refuse, tente refresh si possible
const msg = String(e?.message || "");
const is401 = msg.startsWith("401") || msg.includes(" 401 ");
if (!is401 || !storedRefresh) {
clearAuth("Session expired. Please login again.");
return;
}
}
try {
const { accessToken, refreshToken } = await apiRefresh(storedRefresh);
persistAuth({ accessToken, refreshToken, user: storedUser });
await syncWithBackend(accessToken, storedUser);
} catch (e) {
console.warn("[AUTH] restore refresh failed:", e);
clearAuth("Session expired. Please login again.");
}
}
useEffect(() => {
(async () => {
try {
await bootRestore();
} finally {
setBooting(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto refresh (et resync backend)
useEffect(() => {
if (!authenticated) return;
const interval = setInterval(async () => {
try {
const t = (() => { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } })();
const rt = (() => { try { return localStorage.getItem(REFRESH_KEY); } catch { return null; } })();
if (!t || !rt) return;
if (!willExpireSoon(t, 90)) {
// même si pas proche dexpirer, on peut vérifier /me 1 fois de temps en temps
return;
}
const { accessToken, refreshToken } = await apiRefresh(rt);
const u = (() => {
try { return localStorage.getItem(USERNAME_KEY) || usernameRef.current || ""; } catch { return usernameRef.current || ""; }
})();
persistAuth({ accessToken, refreshToken, user: u });
await syncWithBackend(accessToken, u);
} catch (e) {
console.warn("[AUTH] auto refresh failed:", e);
clearAuth("Session expired. Please login again.");
}
}, 60_000);
return () => clearInterval(interval);
}, [authenticated]);
async function login(user, pass) {
setLastError("");
setLastInfo("");
const u = (user || "").trim();
if (!u || !pass) {
setLastError("Username and password required.");
return;
}
try {
setLoading(true);
const res = await fetch(`${API_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username: u, password: pass }),
});
const text = await res.text().catch(() => "");
if (!res.ok) {
// backend peut renvoyer un JSON {error:"email_not_verified"}
try {
const obj = JSON.parse(text || "{}");
if (res.status === 403 && obj?.error === "email_not_verified") {
setLastError("Email not verified. Check your inbox (spam) and verify, then login again.");
return;
}
} catch {}
setLastError(`${res.status} login failed`);
return;
}
const data = JSON.parse(text || "{}");
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
if (!accessToken) {
setLastError("No access token in response.");
return;
}
persistAuth({ accessToken, refreshToken, user: u });
// IMPORTANT: resync with backend right after login
try {
await syncWithBackend(accessToken, u);
} catch (e) {
console.warn("[AUTH] /me failed after login:", e);
clearAuth("Login desynced. Please login again.");
return;
}
setLastError("");
} catch (e) {
console.error("[AUTH] network error", e);
clearAuth("Network error during login.");
setLastError("Network error during login.");
} finally {
setLoading(false);
}
}
function logout() {
clearAuth("");
}
async function resendVerificationEmail() {
setLastError("");
setLastInfo("");
const t = token || (() => { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } })();
if (!t) {
setLastError("Not logged in.");
return;
}
try {
await apiResendVerify(t);
setLastInfo("Verification email sent. Check inbox/spam.");
} catch (e) {
console.error(e);
setLastError("Could not resend verification email.");
}
}
return (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
emailVerified,
needsEmailVerification,
login,
logout,
resendVerificationEmail,
lastError,
lastInfo,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View File

@ -1,222 +0,0 @@
import React, { useState, useEffect } from "react";
import { useAuth } from "../../auth/AuthContext";
import { registerUser } from "../../api/client";
const THEME_LABELS = {
dark: "Dark",
blue: "Blue",
light: "Light",
};
export default function TopBar({ theme = "dark", onChangeTheme }) {
const { loading, authenticated, username, login, logout, lastError } = useAuth();
const [showAuth, setShowAuth] = useState(false);
const [mode, setMode] = useState("login");
const [loginUser, setLoginUser] = useState("");
const [loginPass, setLoginPass] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [regUser, setRegUser] = useState("");
const [regEmail, setRegEmail] = useState("");
const [regPass, setRegPass] = useState("");
const [signupError, setSignupError] = useState("");
const [signupLoading, setSignupLoading] = useState(false);
let authLabel = "AUTH: anon";
if (loading) authLabel = "AUTH: loading…";
else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
const openModal = (nextMode) => {
setMode(nextMode);
setShowAuth(true);
setSignupError("");
};
const closeModal = () => {
setShowAuth(false);
setLoginPass("");
setSignupError("");
};
useEffect(() => {
if (authenticated) setShowAuth(false);
}, [authenticated]);
const handleLoginSubmit = async (e) => {
e.preventDefault();
await login(loginUser, loginPass);
};
const handleSignupSubmit = async (e) => {
e.preventDefault();
setSignupError("");
if (
!firstName.trim() ||
!lastName.trim() ||
!regUser.trim() ||
!regEmail.trim() ||
!regPass
) {
setSignupError("First name, last name, username, email and password are required.");
return;
}
try {
setSignupLoading(true);
await registerUser({
username: regUser.trim(),
email: regEmail.trim(),
password: regPass,
firstName: firstName.trim(),
lastName: lastName.trim(),
});
await login(regUser.trim(), regPass);
} catch (err) {
console.error(err);
setSignupError(err.message || "Registration error.");
} finally {
setSignupLoading(false);
}
};
return (
<>
<header className="topbar">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<div className="logo-circle">SW</div>
<div className="title-block">
<div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</div>
<div style={{ fontSize: "0.65rem", opacity: 0.7 }}>
{authLabel}
{lastError && (
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
({lastError})
</span>
)}
</div>
</div>
</div>
<div className="topbar-right">
<div className="theme-switch">
{["dark", "blue", "light"].map((t) => (
<button
key={t}
type="button"
className={"theme-dot" + (theme === t ? " theme-dot-active" : "")}
title={THEME_LABELS[t]}
onClick={() => onChangeTheme && onChangeTheme(t)}
>
{THEME_LABELS[t][0]}
</button>
))}
</div>
{authenticated ? (
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ fontSize: "0.8rem" }}>{username}</span>
<button className="btn-primary" onClick={logout}>
Logout
</button>
</div>
) : (
<div style={{ display: "flex", gap: "0.3rem" }}>
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
Login
</button>
<button type="button" className="btn-secondary" onClick={() => openModal("signup")}>
Sign up
</button>
</div>
)}
</div>
</header>
{showAuth && (
<div className="auth-modal-backdrop" onClick={closeModal}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<div className="auth-modal-header">
<button
type="button"
className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")}
onClick={() => setMode("login")}
>
Login
</button>
<button
type="button"
className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")}
onClick={() => setMode("signup")}
>
Sign up
</button>
<button type="button" className="auth-close" onClick={closeModal}>
</button>
</div>
{mode === "login" ? (
<form className="auth-form" onSubmit={handleLoginSubmit}>
<label>
Username
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
autoComplete="current-password"
/>
</label>
<button type="submit" className="btn-primary btn-full" disabled={loading}>
{loading ? "..." : "Sign in"}
</button>
</form>
) : (
<form className="auth-form" onSubmit={handleSignupSubmit}>
<div className="auth-row">
<label>
First name
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</label>
<label>
Last name
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</label>
</div>
<label>
Username
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
</label>
<label>
Email
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
</label>
<label>
Password
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
</label>
{signupError && <div className="auth-error">{signupError}</div>}
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
{signupLoading ? "..." : "Create account"}
</button>
</form>
)}
</div>
</div>
)}
</>
);
}

View File

@ -1,223 +0,0 @@
import React, { useState, useEffect } from "react";
import { useAuth } from "../../auth/AuthContext";
import { registerUser } from "../../api/client";
const THEME_LABELS = {
dark: "Dark",
blue: "Blue",
light: "Light",
};
export default function TopBar({ theme = "dark", onChangeTheme }) {
const { loading, authenticated, username, login, logout, lastError } = useAuth();
const [showAuth, setShowAuth] = useState(false);
const [mode, setMode] = useState("login");
const [loginUser, setLoginUser] = useState("");
const [loginPass, setLoginPass] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [regUser, setRegUser] = useState("");
const [regEmail, setRegEmail] = useState("");
const [regPass, setRegPass] = useState("");
const [signupError, setSignupError] = useState("");
const [signupLoading, setSignupLoading] = useState(false);
let authLabel = "AUTH: anon";
if (loading) authLabel = "AUTH: loading…";
else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
const openModal = (nextMode) => {
setMode(nextMode);
setShowAuth(true);
setSignupError("");
};
const closeModal = () => {
setShowAuth(false);
setLoginPass("");
setSignupError("");
};
useEffect(() => {
if (authenticated) setShowAuth(false);
}, [authenticated]);
const handleLoginSubmit = async (e) => {
e.preventDefault();
await login(loginUser, loginPass);
};
const handleSignupSubmit = async (e) => {
e.preventDefault();
setSignupError("");
if (
!firstName.trim() ||
!lastName.trim() ||
!regUser.trim() ||
!regEmail.trim() ||
!regPass
) {
setSignupError("First name, last name, username, email and password are required.");
return;
}
try {
setSignupLoading(true);
await registerUser({
username: regUser.trim(),
email: regEmail.trim(),
password: regPass,
firstName: firstName.trim(),
lastName: lastName.trim(),
});
await login(regUser.trim(), regPass);
} catch (err) {
console.error(err);
setSignupError(err.message || "Registration error.");
} finally {
setSignupLoading(false);
}
};
return (
<>
<header className="topbar">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<div className="logo-circle">SW</div>
<div className="title-block">
<div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</div>
<div style={{ fontSize: "0.65rem", opacity: 0.7 }}>
{authLabel}
{lastError && (
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
({lastError})
</span>
)}
</div>
</div>
</div>
<div className="topbar-right">
<div className="theme-switch">
{["dark", "blue", "light"].map((t) => (
<button
key={t}
type="button"
className={"theme-dot" + (theme === t ? " theme-dot-active" : "")}
title={THEME_LABELS[t]}
onClick={() => onChangeTheme && onChangeTheme(t)}
>
{THEME_LABELS[t][0]}
</button>
))}
</div>
{authenticated ? (
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ fontSize: "0.8rem" }}>{username}</span>
<button className="btn-primary" onClick={logout}>
Logout
</button>
</div>
) : (
<div style={{ display: "flex", gap: "0.3rem" }}>
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
Login
</button>
<button type="button" className="btn-secondary" onClick={() => openModal("signup")}>
Sign up
</button>
</div>
)}
</div>
</header>
{showAuth && (
<div className="auth-modal-backdrop" onClick={closeModal}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<div className="auth-modal-header">
<button
type="button"
className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")}
onClick={() => setMode("login")}
>
Login
</button>
<button
type="button"
className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")}
onClick={() => setMode("signup")}
>
Sign up
</button>
<button type="button" className="auth-close" onClick={closeModal}>
</button>
</div>
{mode === "login" ? (
<form className="auth-form" onSubmit={handleLoginSubmit}>
<label>
Username
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
autoComplete="current-password"
/>
</label>
<button type="submit" className="btn-primary btn-full" disabled={loading}>
{loading ? "..." : "Sign in"}
</button>
{lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}
</form>
) : (
<form className="auth-form" onSubmit={handleSignupSubmit}>
<div className="auth-row">
<label>
First name
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</label>
<label>
Last name
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</label>
</div>
<label>
Username
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
</label>
<label>
Email
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
</label>
<label>
Password
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
</label>
{signupError && <div className="auth-error">{signupError}</div>}
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
{signupLoading ? "..." : "Create account"}
</button>
</form>
)}
</div>
</div>
)}
</>
);
}

View File

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

View File

@ -1,296 +0,0 @@
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;
}
function rectsOverlap(a, b) {
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
}
export function applyOcclusionForExpanded(markersRef, expandedEl) {
if (!expandedEl) return;
const expandedCard = expandedEl.querySelector(".post-card");
if (!expandedCard) return;
const PAD_X = 26;
const PAD_TOP = 20;
const PAD_BOTTOM = 140;
const raw = expandedCard.getBoundingClientRect();
const expandedRect = {
left: raw.left - PAD_X,
right: raw.right + PAD_X,
top: raw.top - PAD_TOP,
bottom: raw.bottom + PAD_BOTTOM,
};
const list = markersRef.current || [];
for (const item of list) {
const el = item?.el;
if (!el || el === expandedEl) continue;
const r = el.getBoundingClientRect();
const isBelow = r.top >= raw.top;
if (!isBelow) {
el.style.visibility = "visible";
el.style.pointerEvents = "auto";
continue;
}
const hide = rectsOverlap(expandedRect, r);
el.style.visibility = hide ? "hidden" : "visible";
el.style.pointerEvents = hide ? "none" : "auto";
}
}
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 {}
};
}
// Put clicked coordinate at ~6/7 of map height (=> 1/7 from bottom)
function panMapSoPointHitsTarget(map, lng, lat) {
if (!map) return;
// ✅ ignore map click-close while we animate
map.__swIgnoreMapClickUntil = Date.now() + 2200;
const c = map.getContainer();
const w = c.clientWidth || 0;
const h = c.clientHeight || 0;
if (!w || !h) return;
const mobile = (typeof window !== "undefined" && window.innerWidth <= 640);
const targetX = w * 0.50;
const targetY = mobile ? h * (6 / 7) : h * 0.80;
const p = map.project([lng, lat]);
const dx = p.x - targetX;
const dy = p.y - targetY;
const centerPx = { x: w / 2, y: h / 2 };
const newCenter = map.unproject([centerPx.x + dx, centerPx.y + dy]);
try {
map.easeTo({
center: newCenter,
duration: 420,
easing: (t) => t * (2 - t),
});
} catch {}
}
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) {
console.warn("post sans coordonnée:", post);
return;
}
const root = document.createElement("div");
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);
}
function renderExpanded() {
unmountIfAny();
root.className = "post-pin post-pin--expanded";
root.style.zIndex = "999999";
const headline = escapeHtml(post?.title || "Untitled");
root.innerHTML = `
<div class="post-card sw-expanded-shell">
<div class="sw-expanded-top">
<div class="sw-expanded-left">
<div class="sw-template-full-wrap"></div>
</div>
<div class="sw-expanded-right">
<div class="sw-watch-title">Watching</div>
<div class="sw-watch-list">
<div class="sw-watch-item"> Julia <div class="sw-watch-msg"> I see it</div></div>
<div class="sw-watch-item"> Kim / CNN<div class="sw-watch-msg"> Blah blah blah</div></div>
<div class="sw-watch-item"> Yan <div class="sw-watch-msg"> Watch this!!</div></div>
</div>
<button class="sw-add-feed-btn" type="button">ADD TO FEED</button>
</div>
</div>
<div class="sw-news-generated">
<div class="sw-news-title">${headline}</div>
<div class="sw-news-sub">The news here (generated)</div>
</div>
<div class="post-card-footer sw-actions-row">
<button class="post-card-btn">Contact</button>
<button class="post-card-btn">Like</button>
<button class="post-card-btn">Share</button>
<button class="post-card-btn">Fix</button>
</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>
</div>
<div class="post-card-pointer"></div>
`;
const wrap = root.querySelector(".sw-template-full-wrap");
if (wrap) {
const spec = getTemplateSpecForPost(post, "full");
const data = adaptPostToTemplateData(post);
root.__swUnmount = mountCard(wrap, spec, data, theme);
}
requestAnimationFrame(() => {
panMapSoPointHitsTarget(mapRef.current, lon, lat);
applyOcclusionForExpanded(markersRef, root);
});
}
renderCompact();
root.__renderCompact = renderCompact;
const marker = new maplibregl.Marker({ element: root, anchor: "bottom" })
.setLngLat([lon, lat])
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post });
root.addEventListener("click", (ev) => {
ev.stopPropagation();
// ✅ ignore the map click-close that often fires after marker click on mobile
const m = mapRef.current;
if (m) m.__swIgnoreMapClickUntil = Date.now() + 2000;
if (expandedElRef.current && expandedElRef.current !== root) {
if (expandedElRef.current.__renderCompact) expandedElRef.current.__renderCompact();
expandedElRef.current = null;
}
const isExpanded = root.classList.contains("post-pin--expanded");
if (isExpanded) {
renderCompact();
expandedElRef.current = null;
} else {
do {
eval { my = q{}; };
} while(0);
do {
try { const map = mapRef.current; if (map) { map.__swIgnoreCloseUntil = Date.now() + 2000; map.__swIgnoreMapClickUntil = Date.now() + 2000; } } catch {}
} while(0);
renderExpanded();
expandedElRef.current = root;
}
});
}
function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@ -1,192 +0,0 @@
import maplibregl from "maplibre-gl";
export function clearAllMarkers(markersRef, expandedElRef) {
if (markersRef.current) {
for (const m of markersRef.current) {
try {
m.marker.remove();
} catch {}
}
}
markersRef.current = [];
if (expandedElRef) expandedElRef.current = null;
}
function rectsOverlap(a, b) {
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
}
export function applyOcclusionForExpanded(markersRef, expandedEl) {
if (!expandedEl) return;
const expandedCard = expandedEl.querySelector(".post-card");
if (!expandedCard) return;
const PAD_X = 26;
const PAD_TOP = 20;
const PAD_BOTTOM = 140;
const raw = expandedCard.getBoundingClientRect();
const expandedRect = {
left: raw.left - PAD_X,
right: raw.right + PAD_X,
top: raw.top - PAD_TOP,
bottom: raw.bottom + PAD_BOTTOM,
};
const list = markersRef.current || [];
for (const item of list) {
const el = item?.el;
if (!el || el === expandedEl) continue;
const r = el.getBoundingClientRect();
const isBelow = r.top >= raw.top;
if (!isBelow) {
el.style.visibility = "visible";
el.style.pointerEvents = "auto";
continue;
}
const hide = rectsOverlap(expandedRect, r);
el.style.visibility = hide ? "hidden" : "visible";
el.style.pointerEvents = hide ? "none" : "auto";
}
}
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";
}
}
export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) {
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) {
console.warn("post sans coordonnée:", post);
return;
}
const title = (post.title || "").trim() || "Untitled";
const body = (post.snippet || "").trim() || (post.body || "").trim() || title;
const author = (post.author || "").trim() || "unknown";
const created = (post.created_at || "").toString().slice(0, 19);
const category = (post.category || "").toUpperCase() || "";
const subCat = (post.sub_category || "").toString();
const root = document.createElement("div");
root.className = "post-pin post-pin--compact";
root.style.zIndex = "1";
function renderCompact() {
root.className = "post-pin post-pin--compact";
root.style.zIndex = "1";
root.innerHTML = `
<div class="post-pin-bubble">
<div class="post-pin-dot"></div>
<div class="post-pin-text">${escapeHtml(title)}</div>
</div>
<div class="post-pin-pointer-small"></div>
`;
clearOcclusion(markersRef);
}
function renderExpanded() {
root.className = "post-pin post-pin--expanded";
root.style.zIndex = "999999";
root.innerHTML = `
<div class="post-card">
<div class="post-card-header">
<div class="post-card-cat">
${escapeHtml(category || "NEWS")}
${subCat ? " · " + escapeHtml(subCat) : ""}
</div>
<div class="post-card-time">${escapeHtml(created || "")}</div>
</div>
<div class="post-card-main">
<div class="post-card-media">
<div class="post-card-media-label">Image / Vidéo</div>
</div>
<div class="post-card-info">
<div class="post-card-title">${escapeHtml(title)}</div>
<div class="post-card-meta">
by ${escapeHtml(author)} · lat ${lat.toFixed(3)} · lon ${lon.toFixed(3)}
</div>
<div class="post-card-body">${escapeHtml(body)}</div>
</div>
</div>
<div class="post-card-footer">
<button class="post-card-btn">Live</button>
<button class="post-card-btn">Like</button>
<button class="post-card-btn">Share</button>
<button class="post-card-btn">Fix</button>
</div>
<div class="post-card-comments">Live chat / comments (placeholder)</div>
</div>
<div class="post-card-pointer"></div>
`;
requestAnimationFrame(() => {
applyOcclusionForExpanded(markersRef, root);
});
}
renderCompact();
root.__renderCompact = renderCompact;
const marker = new maplibregl.Marker({ element: root, anchor: "bottom" })
.setLngLat([lon, lat])
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post });
root.addEventListener("click", (ev) => {
ev.stopPropagation();
if (expandedElRef.current && expandedElRef.current !== root) {
if (expandedElRef.current.__renderCompact) expandedElRef.current.__renderCompact();
expandedElRef.current = null;
}
const isExpanded = root.classList.contains("post-pin--expanded");
if (isExpanded) {
renderCompact();
expandedElRef.current = null;
} else {
renderExpanded();
expandedElRef.current = root;
}
});
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@ -1,220 +0,0 @@
import maplibregl from "maplibre-gl";
import React from "react";
import { createRoot } from "react-dom/client";
import TemplateMarkerCard from "./TemplateMarkerCard";
import { getDefaultTemplateCached } from "../../api/templates";
export function clearAllMarkers(markersRef, expandedElRef) {
if (markersRef.current) {
for (const m of markersRef.current) {
try {
// unmount react if present
if (m?.el && m.el.__reactRoot) {
try { m.el.__reactRoot.unmount(); } catch {}
m.el.__reactRoot = null;
}
m.marker.remove();
} catch {}
}
}
markersRef.current = [];
if (expandedElRef) expandedElRef.current = null;
}
function rectsOverlap(a, b) {
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
}
export function applyOcclusionForExpanded(markersRef, expandedEl) {
if (!expandedEl) return;
// our expanded DOM contains .post-card
const expandedCard = expandedEl.querySelector(".post-card");
if (!expandedCard) return;
const PAD_X = 26;
const PAD_TOP = 20;
const PAD_BOTTOM = 140;
const raw = expandedCard.getBoundingClientRect();
const expandedRect = {
left: raw.left - PAD_X,
right: raw.right + PAD_X,
top: raw.top - PAD_TOP,
bottom: raw.bottom + PAD_BOTTOM,
};
const list = markersRef.current || [];
for (const item of list) {
const el = item?.el;
if (!el || el === expandedEl) continue;
const r = el.getBoundingClientRect();
const isBelow = r.top >= raw.top;
if (!isBelow) {
el.style.visibility = "visible";
el.style.pointerEvents = "auto";
continue;
}
const hide = rectsOverlap(expandedRect, r);
el.style.visibility = hide ? "hidden" : "visible";
el.style.pointerEvents = hide ? "none" : "auto";
}
}
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 escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function currentTheme() {
try {
const t = document?.body?.getAttribute("data-theme") || "dark";
if (t === "dark" || t === "blue" || t === "light") return t;
} catch {}
return "dark";
}
function ensureReactRoot(el) {
if (el.__reactRoot) return el.__reactRoot;
el.__reactRoot = createRoot(el);
return el.__reactRoot;
}
export function createMarkerForPost(post, mapRef, markersRef, expandedElRef) {
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) {
console.warn("post sans coordonnée:", post);
return;
}
const title = (post.title || "").trim() || "Untitled";
const root = document.createElement("div");
root.className = "post-pin post-pin--compact";
root.style.zIndex = "1";
const mount = document.createElement("div");
mount.className = "post-react-mount";
root.appendChild(mount);
function renderCompact() {
root.className = "post-pin post-pin--compact";
root.style.zIndex = "1";
// compact stays HTML (fast + matches your CSS)
root.innerHTML = `
<div class="post-pin-bubble">
<div class="post-pin-dot"></div>
<div class="post-pin-text">${escapeHtml(title)}</div>
</div>
<div class="post-pin-pointer-small"></div>
`;
// compact uses no React; clean old root if any
if (root.__reactRoot) {
try { root.__reactRoot.unmount(); } catch {}
root.__reactRoot = null;
}
clearOcclusion(markersRef);
}
async function renderExpanded() {
root.className = "post-pin post-pin--expanded";
root.style.zIndex = "999999";
// Create expanded shell that matches existing occlusion CSS selectors
root.innerHTML = `
<div class="post-card">
<div class="post-card-body" style="font-size:11px; opacity:.9;">Loading template…</div>
</div>
<div class="post-card-pointer"></div>
`;
// Render React inside .post-card (replace loading)
const cardEl = root.querySelector(".post-card");
if (!cardEl) return;
const theme = currentTheme();
try {
const tpl = await getDefaultTemplateCached("news"); // default type for now
const rr = ensureReactRoot(cardEl);
rr.render(
<TemplateMarkerCard
post={post}
template={tpl}
theme={theme}
mode="full"
/>
);
} catch (e) {
// fallback if template fetch fails
cardEl.innerHTML = `
<div style="font-weight:800; font-size:13px; margin-bottom:4px;">${escapeHtml(title)}</div>
<div style="font-size:11px; opacity:.9;">(template load failed)</div>
`;
}
requestAnimationFrame(() => {
applyOcclusionForExpanded(markersRef, root);
});
}
renderCompact();
root.__renderCompact = renderCompact;
const marker = new maplibregl.Marker({ element: root, anchor: "bottom" })
.setLngLat([lon, lat])
.addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post });
root.addEventListener("click", (ev) => {
ev.stopPropagation();
if (expandedElRef.current && expandedElRef.current !== root) {
if (expandedElRef.current.__renderCompact) expandedElRef.current.__renderCompact();
expandedElRef.current = null;
}
const isExpanded = root.classList.contains("post-pin--expanded");
if (isExpanded) {
renderCompact();
expandedElRef.current = null;
} else {
renderExpanded();
expandedElRef.current = root;
}
});
}

View File

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

View File

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

View File

@ -1,166 +0,0 @@
import { useEffect, useRef, useState } from "react";
import maplibregl from "maplibre-gl";
import { LAST_VIEW_KEY } from "./mapConfig";
import { getViewFromMap } from "./mapGeo";
import { clearAllMarkers, applyOcclusionForExpanded } from "./markerManager";
// renvoie le style MapLibre en fonction du thème
function getBaseStyle(theme) {
const t = theme || "dark";
let tiles;
switch (t) {
case "light":
tiles = ["https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"];
break;
case "blue":
tiles = ["https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png"];
break;
case "dark":
default:
tiles = ["https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"];
break;
}
return {
version: 8,
sources: {
"carto-base": {
type: "raster",
tiles,
tileSize: 256,
attribution: "© OpenStreetMap contributors © CARTO",
},
},
layers: [
{
id: "carto-base-layer",
type: "raster",
source: "carto-base",
},
],
};
}
export function useMapCore(theme) {
const containerRef = useRef(null);
const mapRef = useRef(null);
const markersRef = useRef([]);
const expandedElRef = useRef(null);
// ✅ default plus large
const [viewParams, setViewParams] = useState({
center: null,
radiusKm: 750,
});
const [hasLastView, setHasLastView] = useState(false);
function closeExpandedIfAny() {
const el = expandedElRef.current;
if (el && el.__setExpanded) {
el.__setExpanded(false);
}
expandedElRef.current = null;
}
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: getBaseStyle(theme || "dark"),
center: [-95, 40],
zoom: 3.5,
pitch: 0,
bearing: 0,
antialias: true,
});
// Reprise dernière vue
let hadLastView = false;
try {
const raw = localStorage.getItem(LAST_VIEW_KEY);
if (raw) {
const v = JSON.parse(raw);
if (
typeof v.lat === "number" &&
typeof v.lon === "number" &&
typeof v.zoom === "number"
) {
map.setCenter([v.lon, v.lat]);
map.setZoom(v.zoom);
hadLastView = true;
}
}
} catch {}
setHasLastView(hadLastView);
map.addControl(new maplibregl.NavigationControl(), "top-right");
mapRef.current = map;
// If a post is expanded, re-apply occlusion on zoom/move (only hides overlapping ones)
const reOcclude = () => {
const el = expandedElRef.current;
if (el) applyOcclusionForExpanded(markersRef, el);
};
map.on("move", reOcclude);
map.on("zoom", reOcclude);
// Close expanded post when clicking on the map (outside markers)
map.on("click", () => {
const el = expandedElRef.current;
if (el && el.__renderCompact) {
el.__renderCompact();
expandedElRef.current = null;
}
});
map.on("load", () => {
const vp = getViewFromMap(map);
setViewParams(vp);
});
map.on("moveend", () => {
const vp = getViewFromMap(map);
setViewParams(vp);
try {
const center = vp.center;
localStorage.setItem(
LAST_VIEW_KEY,
JSON.stringify({
lat: center[1],
lon: center[0],
zoom: map.getZoom(),
})
);
} catch {}
});
// IMPORTANT: click/drag sur la map => fermer le gros post
map.on("click", () => closeExpandedIfAny());
map.on("dragstart", () => closeExpandedIfAny());
return () => {
clearAllMarkers(markersRef, expandedElRef);
map.remove();
mapRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
map.setStyle(getBaseStyle(theme || "dark"));
}, [theme]);
return {
containerRef,
mapRef,
markersRef,
expandedElRef,
viewParams,
hasLastView,
};
}

View File

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

View File

@ -1,210 +0,0 @@
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";
/**
* Gère :
* - posts en mémoire (allPostsRef)
* - filtres (cat / sous-cat / temps) appliqués sur la map + Sociowall
* - fetch par zone (backend)
*/
export function usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
}) {
const allPostsRef = useRef([]);
const lastFetchRef = useRef({
center: null,
radiusKm: null,
filterKey: "",
});
const [status, setStatus] = useState("Loading posts...");
const [visiblePosts, setVisiblePosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState("");
// Applique tous les filtres sur ce qu'on a déjà
const rebuildMarkersForFilters = useCallback(
(tf) => {
const map = mapRef.current;
if (!map) return;
clearAllMarkers(markersRef, expandedElRef);
const posts = allPostsRef.current || [];
const catCode = categoryCode(mainFilter);
const visible = posts.filter((p) => {
// catégorie
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false;
}
// sous-catégorie
if (!matchesSubFilter(p, subFilter)) return false;
// temps
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]
);
// Changement de cat / sous-cat / temps → filtre instantané
useEffect(() => {
rebuildMarkersForFilters(timeFilter);
}, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]);
// Fetch backend quand la vue bouge assez (OU quand le zoom change beaucoup le radius)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!viewParams.center) return;
let cancelled = false;
async function load() {
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);
// ✅ refetch si le radius change pas mal (zoom in/out)
const lastR = last.radiusKm || 0;
const radiusChanged =
!lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25; // 25%
// move threshold moins agressif
const minMoveKm = Math.max(50, radiusKm * 0.25);
// Si ni zoom ni move significatif => skip
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,
};
// Après réponse du backend → on ré-applique les filtres
rebuildMarkersForFilters(timeFilter);
setLoadingPosts(false);
} catch (err) {
console.error("Erreur chargement posts:", err);
if (!cancelled) {
setStatus("Error loading posts.");
setLoadError("Error loading posts.");
setLoadingPosts(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, [
viewParams,
mainFilter,
subFilter,
mapRef,
rebuildMarkersForFilters,
timeFilter,
]);
// Nouveau post reçu via WebSocket
const handleIncomingPost = useCallback(
(p) => {
// ajoute au cache global
allPostsRef.current = [...allPostsRef.current, p];
// si ça matche les filtres courants → on l'affiche
const catCode = categoryCode(mainFilter);
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return;
}
if (!matchesSubFilter(p, subFilter)) return;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) {
return;
}
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
setVisiblePosts((current) => [...current, p]);
},
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef]
);
return {
status,
visiblePosts,
loadingPosts,
loadError,
handleIncomingPost,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,61 +0,0 @@
.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; }
}