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