feat: refine create flow and island UI

This commit is contained in:
Your Name 2025-12-28 22:50:32 -05:00
parent 909715c2d7
commit 972ba1afe5
20 changed files with 2036 additions and 288 deletions

View File

@ -51,7 +51,7 @@ async function resolveAssetRefsInPosts(posts) {
if (!Array.isArray(posts) || posts.length === 0) return posts; if (!Array.isArray(posts) || posts.length === 0) return posts;
const ids = []; const ids = [];
for (const p of posts) { for (const p of posts) {
[p?.image_small, p?.image_large, p?.image, p?.media_url].forEach((v) => { [p?.image_small, p?.image_medium, p?.image_large, p?.image, p?.media_url].forEach((v) => {
if (isAssetRef(v)) ids.push(v.replace("asset://", "")); if (isAssetRef(v)) ids.push(v.replace("asset://", ""));
}); });
} }
@ -66,7 +66,7 @@ async function resolveAssetRefsInPosts(posts) {
clone.id = clone.post_id; clone.id = clone.post_id;
} }
} }
["image_small", "image_large", "image", "media_url"].forEach((k) => { ["image_small", "image_medium", "image_large", "image", "media_url"].forEach((k) => {
const v = clone[k]; const v = clone[k];
if (isAssetRef(v)) { if (isAssetRef(v)) {
const id = v.replace("asset://", ""); const id = v.replace("asset://", "");
@ -225,6 +225,33 @@ export async function createPost(payload, token) {
return res.json().catch(() => ({})); return res.json().catch(() => ({}));
} }
/**
* Fetch link preview data for a URL (auth required).
*/
export async function fetchPostPreview(url, token) {
const raw = (url || "").toString().trim();
if (!raw) return null;
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
else Object.assign(headers, authHeaders());
const res = await fetch(`${API_BASE}/post/preview`, {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify({ url: raw }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(text || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json().catch(() => null);
}
/** /**
* Récupère un post par ID (détails + counts) * Récupère un post par ID (détails + counts)
*/ */
@ -246,6 +273,25 @@ export async function fetchPostById(postId) {
} }
} }
export async function fetchPostByUrl(url) {
const raw = (url || "").toString().trim();
if (!raw) return null;
const qs = new URLSearchParams();
qs.set("url", raw);
try {
const res = await fetch(`${API_BASE}/post?${qs.toString()}`, {
credentials: "include",
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
if (!data || typeof data !== "object") return null;
const resolved = await resolveAssetRefsInPosts([data]);
return resolved && resolved[0] ? resolved[0] : data;
} catch {
return null;
}
}
export async function fetchPostTruth(postId) { export async function fetchPostTruth(postId) {
if (!postId) return null; if (!postId) return null;
const qs = new URLSearchParams(); const qs = new URLSearchParams();

View File

@ -138,6 +138,12 @@ async function me(accessToken) {
username: (u && (u.username || u.user || u.preferred_username)) || "", username: (u && (u.username || u.user || u.preferred_username)) || "",
emailVerified, emailVerified,
avatarUrl: u && typeof u.avatar_url === "string" ? u.avatar_url : "", avatarUrl: u && typeof u.avatar_url === "string" ? u.avatar_url : "",
needsUsername:
u && typeof u.needs_username === "boolean"
? u.needs_username
: u && typeof u.needsUsername === "boolean"
? u.needsUsername
: null,
}; };
} }
@ -189,10 +195,12 @@ export function AuthProvider({ children }) {
const [user, setUser] = useState(null); // { username } const [user, setUser] = useState(null); // { username }
const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... } const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... }
const [avatarUrl, setAvatarUrl] = useState(""); const [avatarUrl, setAvatarUrl] = useState("");
const [profileLoaded, setProfileLoaded] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [lastInfo, setLastInfo] = useState(""); const [lastInfo, setLastInfo] = useState("");
const [needsEmailVerification, setNeedsEmailVerification] = useState(false); const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
const [needsUsername, setNeedsUsername] = useState(false);
const refreshTimer = useRef(null); const refreshTimer = useRef(null);
const lastAuthStatusRef = useRef(null); const lastAuthStatusRef = useRef(null);
@ -202,9 +210,11 @@ export function AuthProvider({ children }) {
setUser(null); setUser(null);
setTokens(null); setTokens(null);
setAvatarUrl(""); setAvatarUrl("");
setProfileLoaded(false);
setError(msg || ""); setError(msg || "");
setLastInfo(""); setLastInfo("");
setNeedsEmailVerification(false); setNeedsEmailVerification(false);
setNeedsUsername(false);
clearAuth(); clearAuth();
} }
@ -216,6 +226,7 @@ export function AuthProvider({ children }) {
setUser(u ? { username: u } : null); setUser(u ? { username: u } : null);
setStatus("auth"); setStatus("auth");
setProfileLoaded(false);
setError(""); setError("");
} }
@ -232,6 +243,11 @@ export function AuthProvider({ children }) {
if (mr.username) { if (mr.username) {
setUser({ username: mr.username }); setUser({ username: mr.username });
} }
if (typeof mr.needsUsername === "boolean") {
setNeedsUsername(mr.needsUsername);
} else {
setNeedsUsername(false);
}
if (typeof verified !== "boolean") { if (typeof verified !== "boolean") {
const tv = tokenEmailVerified(at); const tv = tokenEmailVerified(at);
@ -240,11 +256,13 @@ export function AuthProvider({ children }) {
if (typeof verified === "boolean") { if (typeof verified === "boolean") {
setNeedsEmailVerification(!verified); setNeedsEmailVerification(!verified);
setProfileLoaded(true);
return { ok: true, verified }; return { ok: true, verified };
} }
// If cannot determine, do not block UI aggressively // If cannot determine, do not block UI aggressively
setNeedsEmailVerification(false); setNeedsEmailVerification(false);
setProfileLoaded(true);
return { ok: mr.ok, verified: null }; return { ok: mr.ok, verified: null };
} }
@ -334,6 +352,12 @@ export function AuthProvider({ children }) {
setLastInfo(""); setLastInfo("");
const saved = loadAuth(); const saved = loadAuth();
if (!saved?.access_token) { if (!saved?.access_token) {
try {
if (sessionStorage.getItem("sociowire:logout")) {
setAnon("");
return;
}
} catch {}
if (keycloakEnabled()) { if (keycloakEnabled()) {
const ok = await trySilentKeycloakSession(); const ok = await trySilentKeycloakSession();
if (ok) { if (ok) {
@ -404,6 +428,9 @@ export function AuthProvider({ children }) {
refreshTimer.current = null; refreshTimer.current = null;
const redirectUri = window.location.origin + window.location.pathname; const redirectUri = window.location.origin + window.location.pathname;
const doSsoLogout = keycloakEnabled(); const doSsoLogout = keycloakEnabled();
try {
sessionStorage.setItem("sociowire:logout", String(Date.now()));
} catch {}
setAnon(""); setAnon("");
if (!doSsoLogout) return; if (!doSsoLogout) return;
try { try {
@ -434,6 +461,9 @@ export function AuthProvider({ children }) {
async function completeKeycloakLogin() { async function completeKeycloakLogin() {
if (!keycloakEnabled()) return { ok: false, reason: "disabled" }; if (!keycloakEnabled()) return { ok: false, reason: "disabled" };
try {
sessionStorage.removeItem("sociowire:logout");
} catch {}
setStatus("loading"); setStatus("loading");
setError(""); setError("");
@ -511,6 +541,11 @@ export function AuthProvider({ children }) {
async function trySilentKeycloakSession() { async function trySilentKeycloakSession() {
let kc = null; let kc = null;
try {
if (sessionStorage.getItem("sociowire:logout")) {
return false;
}
} catch {}
try { try {
kc = getKeycloak(); kc = getKeycloak();
} catch { } catch {
@ -558,6 +593,9 @@ export function AuthProvider({ children }) {
setAnon("SSO is not configured."); setAnon("SSO is not configured.");
return; return;
} }
try {
sessionStorage.removeItem("sociowire:logout");
} catch {}
const redirectUri = window.location.origin + window.location.pathname; const redirectUri = window.location.origin + window.location.pathname;
try { try {
const kc = getKeycloak(); const kc = getKeycloak();
@ -626,6 +664,8 @@ export function AuthProvider({ children }) {
tokens, tokens,
error, error,
avatarUrl, avatarUrl,
profileLoaded,
needsUsername,
// expected by UI // expected by UI
loading, loading,
@ -636,6 +676,7 @@ export function AuthProvider({ children }) {
lastError: error || "", lastError: error || "",
lastInfo: lastInfo || "", lastInfo: lastInfo || "",
needsEmailVerification: !!needsEmailVerification, needsEmailVerification: !!needsEmailVerification,
needsUsername: !!needsUsername,
resendVerificationEmail, resendVerificationEmail,
loginWithProvider, loginWithProvider,
ssoEnabled: keycloakEnabled(), ssoEnabled: keycloakEnabled(),
@ -648,10 +689,11 @@ export function AuthProvider({ children }) {
setError, setError,
setLastInfo, setLastInfo,
setNeedsEmailVerification, setNeedsEmailVerification,
setNeedsUsername,
clearError: () => setError(""), clearError: () => setError(""),
clearInfo: () => setLastInfo(""), clearInfo: () => setLastInfo(""),
}; };
}, [status, user, tokens, error, lastInfo, needsEmailVerification, avatarUrl]); }, [status, user, tokens, error, lastInfo, needsEmailVerification, needsUsername, avatarUrl, profileLoaded]);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>; return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
} }

View File

@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import '../../styles/island.css'; import '../../styles/island.css';
import { fetchPosts, uploadProfileAvatar } from '../../api/client'; import { fetchPosts, updateProfileUsername, uploadProfileAvatar } from '../../api/client';
import { useAuth } from '../../auth/AuthContext'; import { useAuth } from '../../auth/AuthContext';
import { trackEvent } from "../../utils/analytics"; import { trackEvent } from "../../utils/analytics";
@ -15,7 +15,7 @@ export default function IslandViewer({ island, onClose }) {
const mapRef = useRef(null); const mapRef = useRef(null);
const animationRef = useRef(null); const animationRef = useRef(null);
const fileRef = useRef(null); const fileRef = useRef(null);
const { authenticated, username, setAvatarUrl } = useAuth(); const { authenticated, username, refreshProfile, setAvatarUrl, setNeedsUsername } = useAuth();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(""); const [uploadError, setUploadError] = useState("");
const [uploadInfo, setUploadInfo] = useState(""); const [uploadInfo, setUploadInfo] = useState("");
@ -26,12 +26,18 @@ export default function IslandViewer({ island, onClose }) {
const [activeTab, setActiveTab] = useState("profile"); const [activeTab, setActiveTab] = useState("profile");
const [userPosts, setUserPosts] = useState([]); const [userPosts, setUserPosts] = useState([]);
const [postsLoading, setPostsLoading] = useState(false); const [postsLoading, setPostsLoading] = useState(false);
const [localUsername, setLocalUsername] = useState("");
const [usernameDraft, setUsernameDraft] = useState("");
const [usernameSaving, setUsernameSaving] = useState(false);
const [usernameError, setUsernameError] = useState("");
const [usernameInfo, setUsernameInfo] = useState("");
const islandUsername = localUsername || island?.username || "";
useEffect(() => { useEffect(() => {
if (!island || !containerRef.current) return; if (!island || !containerRef.current) return;
console.log('[IslandViewer] Rendering island with MapLibre:', island.username, island.seed); console.log('[IslandViewer] Rendering island with MapLibre:', islandUsername, island.seed);
trackEvent("island_open", { username: island.username || "" }); trackEvent("island_open", { username: islandUsername || "" });
// Center of island in virtual coordinates // Center of island in virtual coordinates
const centerLng = 0; const centerLng = 0;
@ -112,15 +118,17 @@ export default function IslandViewer({ island, onClose }) {
// Add content items as markers // Add content items as markers
if (island.content && island.content.length > 0) { if (island.content && island.content.length > 0) {
console.log('[IslandViewer] Adding', island.content.length, 'content items'); console.log('[IslandViewer] Adding', island.content.length, 'content items');
island.content.forEach(item => { const usedSlots = new Map();
island.content.forEach((item, idx) => {
const contentEl = createContentElement(item); const contentEl = createContentElement(item);
const pos = resolveContentPosition(item, idx, island.seed, usedSlots);
new maplibregl.Marker({ new maplibregl.Marker({
element: contentEl, element: contentEl,
anchor: 'bottom' anchor: 'bottom'
}) })
.setLngLat([ .setLngLat([
centerLng + (item.position_x - 0.5) * 0.001, centerLng + (pos.x - 0.5) * 0.0012,
centerLat + (item.position_y - 0.5) * 0.001 centerLat + (pos.y - 0.5) * 0.0012
]) ])
.addTo(map); .addTo(map);
}); });
@ -151,10 +159,18 @@ export default function IslandViewer({ island, onClose }) {
}, [island?.avatar_url]); }, [island?.avatar_url]);
useEffect(() => { useEffect(() => {
if (!island?.username) return; setLocalUsername(island?.username || "");
}, [island?.username]);
useEffect(() => {
setUsernameDraft(islandUsername);
}, [islandUsername]);
useEffect(() => {
if (!islandUsername) return;
let cancelled = false; let cancelled = false;
setPostsLoading(true); setPostsLoading(true);
fetchPosts({ username: island.username, limit: 40 }) fetchPosts({ username: islandUsername, limit: 40 })
.then((items) => { .then((items) => {
if (cancelled) return; if (cancelled) return;
setUserPosts(Array.isArray(items) ? items : []); setUserPosts(Array.isArray(items) ? items : []);
@ -174,8 +190,8 @@ export default function IslandViewer({ island, onClose }) {
if (!island) return null; if (!island) return null;
const isSelf = const isSelf =
authenticated && username && island.username && authenticated && username && islandUsername &&
username.toLowerCase() === island.username.toLowerCase(); username.toLowerCase() === islandUsername.toLowerCase();
return ( return (
<div className="island-viewer-modal" onClick={onClose}> <div className="island-viewer-modal" onClick={onClose}>
@ -183,36 +199,72 @@ export default function IslandViewer({ island, onClose }) {
<button className="island-viewer-close" onClick={onClose}>×</button> <button className="island-viewer-close" onClick={onClose}>×</button>
<div className="island-viewer-header"> <div className="island-viewer-header">
<div className="island-header-row"> <div className="island-header-row">
<div className="island-header-avatar"> <div className="island-header-avatar">
{avatarSrc ? ( {avatarSrc ? (
<img src={avatarSrc} alt={island.username} /> <img src={avatarSrc} alt={islandUsername} />
) : ( ) : (
<span>{(island.username || "U")[0].toUpperCase()}</span> <span>{(islandUsername || "U")[0].toUpperCase()}</span>
)} )}
</div>
<div className="island-header-text">
<h2>{island.display_name || `${islandUsername}'s Island`}</h2>
<p>{island.bio || 'Welcome to my island'}</p>
</div>
</div> </div>
<div className="island-header-text">
<h2>{island.display_name || `${island.username}'s Island`}</h2>
<p>{island.bio || 'Welcome to my island'}</p>
</div>
</div>
</div> </div>
{activeTab === "profile" ? ( {activeTab === "profile" ? (
<div className="island-profile-panel"> <div className="island-profile-panel">
<div className="island-profile-avatar"> <div className="island-profile-avatar">
{avatarSrc ? ( {avatarSrc ? (
<img src={avatarSrc} alt={island.username} /> <img src={avatarSrc} alt={islandUsername} />
) : ( ) : (
<div className="island-profile-letter"> <div className="island-profile-letter">
{(island.username || "U")[0].toUpperCase()} {(islandUsername || "U")[0].toUpperCase()}
</div> </div>
)} )}
</div> </div>
<div className="island-profile-meta"> <div className="island-profile-meta">
<div className="island-profile-name">@{island.username}</div> <div className="island-profile-name">@{islandUsername}</div>
{isSelf ? ( {isSelf ? (
<div className="island-profile-actions"> <div className="island-profile-actions">
<div className="island-profile-row">
<label>Username</label>
<div className="island-profile-inline">
<input
className="island-profile-input"
value={usernameDraft}
onChange={(e) => setUsernameDraft(e.target.value)}
placeholder="Choose a username"
disabled={usernameSaving}
/>
<button
type="button"
className="island-profile-btn"
disabled={usernameSaving || !usernameDraft.trim()}
onClick={async () => {
const next = usernameDraft.trim();
if (!next) return;
setUsernameSaving(true);
setUsernameError("");
setUsernameInfo("");
const res = await updateProfileUsername(next);
if (res && res.ok && res.username) {
setLocalUsername(res.username);
if (setNeedsUsername) setNeedsUsername(false);
if (refreshProfile) await refreshProfile();
setUsernameInfo("Username updated.");
} else {
setUsernameError(res?.text || "Update failed.");
}
setUsernameSaving(false);
}}
>
{usernameSaving ? "Saving..." : "Change"}
</button>
</div>
</div>
<div className="island-profile-row"> <div className="island-profile-row">
<label>Photo visibility</label> <label>Photo visibility</label>
<select <select
@ -286,6 +338,8 @@ export default function IslandViewer({ island, onClose }) {
/> />
{uploadError ? <div className="island-profile-error">{uploadError}</div> : null} {uploadError ? <div className="island-profile-error">{uploadError}</div> : null}
{uploadInfo ? <div className="island-profile-info">{uploadInfo}</div> : null} {uploadInfo ? <div className="island-profile-info">{uploadInfo}</div> : null}
{usernameError ? <div className="island-profile-error">{usernameError}</div> : null}
{usernameInfo ? <div className="island-profile-info">{usernameInfo}</div> : null}
</div> </div>
) : null} ) : null}
</div> </div>
@ -388,14 +442,14 @@ function createIslandElement(seed, terrainType = 'tropical') {
el.className = 'island-terrain-marker'; el.className = 'island-terrain-marker';
const terrainColor = getTerrainColorCSS(terrainType); const terrainColor = getTerrainColorCSS(terrainType);
const size = 200 + rng() * 50; // 200-250px const size = 220 + rng() * 50; // 220-270px
el.innerHTML = ` el.innerHTML = `
<div style=" <div style="
width: ${size}px; width: ${size}px;
height: ${size}px; height: ${size}px;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle at 30% 30%, ${terrainColor}dd, ${terrainColor}99, ${terrainColor}66); background: radial-gradient(circle at 30% 30%, ${terrainColor}ee, ${terrainColor}aa, ${terrainColor}77);
box-shadow: box-shadow:
0 0 40px ${terrainColor}88, 0 0 40px ${terrainColor}88,
0 20px 40px rgba(0,0,0,0.4), 0 20px 40px rgba(0,0,0,0.4),
@ -404,6 +458,21 @@ function createIslandElement(seed, terrainType = 'tropical') {
position: relative; position: relative;
animation: islandFloat 4s ease-in-out infinite; animation: islandFloat 4s ease-in-out infinite;
"> ">
<div style="
position: absolute;
inset: 8px;
border-radius: 50%;
border: 6px solid rgba(255, 243, 199, 0.55);
box-shadow: inset 0 0 12px rgba(255, 243, 199, 0.45);
"></div>
<div style="
position: absolute;
top: 12%;
right: 18%;
font-size: 22px;
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.4));
animation: contentFloat 3s ease-in-out infinite;
"></div>
${createIslandTexture(seed, terrainType)} ${createIslandTexture(seed, terrainType)}
</div> </div>
`; `;
@ -559,10 +628,10 @@ function createContentElement(content) {
el.innerHTML = ` el.innerHTML = `
<div style=" <div style="
width: 40px; width: 46px;
height: 60px; height: 64px;
background: linear-gradient(135deg, ${color}ee, ${color}aa); background: linear-gradient(135deg, ${color}ee, ${color}aa);
border-radius: 8px; border-radius: 12px;
box-shadow: box-shadow:
0 4px 12px rgba(0,0,0,0.4), 0 4px 12px rgba(0,0,0,0.4),
0 0 20px ${color}66; 0 0 20px ${color}66;
@ -582,6 +651,28 @@ function createContentElement(content) {
return el; return el;
} }
function resolveContentPosition(item, index, seed, used) {
const rng = seededRandom(seed + index * 7 + 13);
let x = Number.isFinite(item.position_x) ? item.position_x : 0.5;
let y = Number.isFinite(item.position_y) ? item.position_y : 0.5;
if (!Number.isFinite(x)) x = 0.5;
if (!Number.isFinite(y)) y = 0.5;
// If missing or stacked, scatter in a ring.
const rawKey = `${Math.round(x * 1000)}:${Math.round(y * 1000)}`;
const seen = used.get(rawKey) || 0;
used.set(rawKey, seen + 1);
if (seen > 0 || (x === 0.5 && y === 0.5)) {
const angle = rng() * Math.PI * 2;
const radius = 0.06 + rng() * 0.16 + seen * 0.03;
x = 0.5 + Math.cos(angle) * radius;
y = 0.5 + Math.sin(angle) * radius;
}
x = Math.max(0.1, Math.min(0.9, x));
y = Math.max(0.1, Math.min(0.9, y));
return { x, y };
}
/** /**
* Seeded random number generator (deterministic) * Seeded random number generator (deterministic)
*/ */

View File

@ -65,6 +65,8 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
loginWithProvider, loginWithProvider,
ssoEnabled, ssoEnabled,
avatarUrl, avatarUrl,
profileLoaded,
needsUsername,
needsEmailVerification, needsEmailVerification,
lastError, lastError,
lastInfo, lastInfo,
@ -72,12 +74,6 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
clearInfo, clearInfo,
} = useAuth(); } = useAuth();
const displayUsername = (() => {
const base = (username || "user").toString();
if (base.length <= 9) return base;
return base.slice(0, 9) + "..";
})();
const [showAuth, setShowAuth] = useState(false); const [showAuth, setShowAuth] = useState(false);
const [mode, setMode] = useState("login"); const [mode, setMode] = useState("login");
const [showUsernameModal, setShowUsernameModal] = useState(false); const [showUsernameModal, setShowUsernameModal] = useState(false);
@ -86,6 +82,15 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
const [usernameInfo, setUsernameInfo] = useState(""); const [usernameInfo, setUsernameInfo] = useState("");
const [usernameSaving, setUsernameSaving] = useState(false); const [usernameSaving, setUsernameSaving] = useState(false);
const usernamePromptTimer = useRef(null); const usernamePromptTimer = useRef(null);
const invalidSinceRef = useRef(null);
const [stableUsername, setStableUsername] = useState("");
const displayUsername = (() => {
const raw = (stableUsername || username || "user").toString();
if (!stableUsername && raw.includes("@")) return "user";
if (raw.length <= 9) return raw;
return raw.slice(0, 9) + "..";
})();
const [loginUser, setLoginUser] = useState(""); const [loginUser, setLoginUser] = useState("");
const [loginPass, setLoginPass] = useState(""); const [loginPass, setLoginPass] = useState("");
@ -158,7 +163,22 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
}, [authenticated]); }, [authenticated]);
useEffect(() => { useEffect(() => {
const needsUsername = (name) => { if (!authenticated) {
setStableUsername("");
return;
}
const next = (username || "").trim();
if (!next) return;
const looksLikeEmail = next.includes("@");
if (!looksLikeEmail) {
setStableUsername(next);
return;
}
if (stableUsername) return;
}, [authenticated, username, stableUsername]);
useEffect(() => {
const requiresUsername = (name) => {
if (!name) return false; if (!name) return false;
if (name.includes("@")) return true; if (name.includes("@")) return true;
const re = /^[A-Za-z0-9_.]{3,24}$/; const re = /^[A-Za-z0-9_.]{3,24}$/;
@ -173,7 +193,8 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
setShowUsernameModal(false); setShowUsernameModal(false);
return; return;
} }
if (needsUsername(username)) { const shouldPrompt = !!needsUsername || requiresUsername(username);
if (shouldPrompt) {
if (!showUsernameModal) { if (!showUsernameModal) {
const base = (username || "").split("@")[0] || ""; const base = (username || "").split("@")[0] || "";
const cleaned = base.replace(/[^A-Za-z0-9_.]/g, "").slice(0, 24); const cleaned = base.replace(/[^A-Za-z0-9_.]/g, "").slice(0, 24);
@ -182,12 +203,12 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
setUsernameInfo(""); setUsernameInfo("");
usernamePromptTimer.current = setTimeout(() => { usernamePromptTimer.current = setTimeout(() => {
setShowUsernameModal(true); setShowUsernameModal(true);
}, 350); }, 600);
} }
} else if (showUsernameModal) { } else if (showUsernameModal) {
setShowUsernameModal(false); setShowUsernameModal(false);
} }
}, [authenticated, username, showUsernameModal, loading]); }, [authenticated, username, showUsernameModal, loading, needsUsername]);
useEffect(() => { useEffect(() => {
return () => { return () => {

View File

@ -5,7 +5,7 @@ import "../../styles/mapMarkers.css";
import "../../styles/posts.css"; import "../../styles/posts.css";
import "../../styles/filters.css"; import "../../styles/filters.css";
import { createPost } from "../../api/client"; import { createPost, fetchPostPreview } from "../../api/client";
import { import {
FILTER_MAIN_CATEGORIES, FILTER_MAIN_CATEGORIES,
FILTER_CATEGORY_MAP, FILTER_CATEGORY_MAP,
@ -20,7 +20,7 @@ import FilterButtons from "../Filters/FilterButtons";
import TimeFilterButtons from "../Filters/TimeFilterButtons"; import TimeFilterButtons from "../Filters/TimeFilterButtons";
import SmartSearchBar from "../Search/SmartSearchBar"; import SmartSearchBar from "../Search/SmartSearchBar";
import UserProfile from "../Profile/UserProfile"; import UserProfile from "../Profile/UserProfile";
import { fetchPostById } from "../../api/client"; import { fetchPostById, fetchPostByUrl } from "../../api/client";
import { trackEvent } from "../../utils/analytics"; import { trackEvent } from "../../utils/analytics";
export default function MapView({ export default function MapView({
@ -33,6 +33,8 @@ export default function MapView({
onOpenIslands, onOpenIslands,
}) { }) {
const { authenticated, username, token, needsEmailVerification } = useAuth(); const { authenticated, username, token, needsEmailVerification } = useAuth();
const CLUSTERS_ENABLED = true;
const HEATMAP_ENABLED = false;
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } = const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
useMapCore(theme); useMapCore(theme);
@ -49,6 +51,7 @@ export default function MapView({
} }
}); });
const [subFilter, setSubFilter] = useState("All"); const [subFilter, setSubFilter] = useState("All");
const [mainNewsOnly, setMainNewsOnly] = useState(false);
useEffect(() => { useEffect(() => {
try { try {
@ -63,12 +66,15 @@ export default function MapView({
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const skipAutoZoomRef = useRef(false); const skipAutoZoomRef = useRef(false);
const [clusterFocus, setClusterFocus] = useState(null);
const [forceFullPostId, setForceFullPostId] = useState(null);
// Profile state (selectedIsland is now passed from App.jsx) // Profile state (selectedIsland is now passed from App.jsx)
const [selectedProfile, setSelectedProfile] = useState(null); const [selectedProfile, setSelectedProfile] = useState(null);
const openedPostRef = useRef(null); const openedPostRef = useRef(null);
const queryPostRef = useRef(false); const queryPostRef = useRef(false);
const overlayTimerRef = useRef(null); const overlayTimerRef = useRef(null);
const panTimerRef = useRef(null);
const pendingOpenPostIdRef = useRef(null); const pendingOpenPostIdRef = useRef(null);
const pendingOpenFullRef = useRef(false); const pendingOpenFullRef = useRef(false);
const searchFitQueryRef = useRef(""); const searchFitQueryRef = useRef("");
@ -97,6 +103,55 @@ export default function MapView({
[mapRef, theme] [mapRef, theme]
); );
const fitBoundsWhenReady = useCallback(
(bounds, opts = {}) => {
const tryFit = () => {
const map = mapRef.current;
if (!map) return false;
try {
map.fitBounds(bounds, opts);
} catch {}
return true;
};
if (tryFit()) return;
if (panTimerRef.current) clearInterval(panTimerRef.current);
panTimerRef.current = setInterval(() => {
if (tryFit()) {
clearInterval(panTimerRef.current);
panTimerRef.current = null;
}
}, 220);
},
[mapRef]
);
const panToPostWhenReady = useCallback(
(post, zoom = 12) => {
const lat = Number(post?.lat);
const lon = Number(post?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
const tryPan = () => {
const map = mapRef.current;
if (!map) return false;
try {
map.easeTo({ center: [lon, lat], zoom, duration: 700 });
} catch {}
return true;
};
if (tryPan()) return;
if (panTimerRef.current) clearInterval(panTimerRef.current);
panTimerRef.current = setInterval(() => {
if (tryPan()) {
clearInterval(panTimerRef.current);
panTimerRef.current = null;
}
}, 220);
},
[mapRef]
);
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate } = const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate } =
usePostsEngine({ usePostsEngine({
theme, theme,
@ -106,6 +161,11 @@ export default function MapView({
subFilter, subFilter,
timeFilter, timeFilter,
searchQuery, searchQuery,
clusterFocus,
mainNewsOnly,
clustersEnabled: CLUSTERS_ENABLED,
heatmapEnabled: HEATMAP_ENABLED,
forceFullPostId,
markersRef, markersRef,
expandedElRef, expandedElRef,
onAutoWidenTimeFilter: (next) => setTimeFilter(next), onAutoWidenTimeFilter: (next) => setTimeFilter(next),
@ -116,12 +176,18 @@ export default function MapView({
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [draftTitle, setDraftTitle] = useState(""); const [draftTitle, setDraftTitle] = useState("");
const [draftBody, setDraftBody] = useState(""); const [draftBody, setDraftBody] = useState("");
const [draftUrl, setDraftUrl] = useState("");
const [draftSource, setDraftSource] = useState("");
const [draftPublishedAt, setDraftPublishedAt] = useState("");
const [draftCategory, setDraftCategory] = useState("News"); const [draftCategory, setDraftCategory] = useState("News");
const [draftSubCategory, setDraftSubCategory] = useState( const [draftSubCategory, setDraftSubCategory] = useState(
CREATE_CATEGORY_MAP.News[0] CREATE_CATEGORY_MAP.News[0]
); );
const [draftCoords, setDraftCoords] = useState(null); const [draftCoords, setDraftCoords] = useState(null);
const [draftImage, setDraftImage] = useState(""); const [draftImage, setDraftImage] = useState("");
const [draftImageSmall, setDraftImageSmall] = useState("");
const [draftImageMedium, setDraftImageMedium] = useState("");
const [draftImageLarge, setDraftImageLarge] = useState("");
const [draftImageFile, setDraftImageFile] = useState(null); const [draftImageFile, setDraftImageFile] = useState(null);
const [draftImagePreview, setDraftImagePreview] = useState(""); const [draftImagePreview, setDraftImagePreview] = useState("");
const [useCustomImage, setUseCustomImage] = useState(false); const [useCustomImage, setUseCustomImage] = useState(false);
@ -132,12 +198,28 @@ export default function MapView({
const [imageGroupId, setImageGroupId] = useState(""); const [imageGroupId, setImageGroupId] = useState("");
const [imageUsers, setImageUsers] = useState(""); const [imageUsers, setImageUsers] = useState("");
const [uploadingImage, setUploadingImage] = useState(false); const [uploadingImage, setUploadingImage] = useState(false);
const [urlPreviewLoading, setUrlPreviewLoading] = useState(false);
const [urlPreviewError, setUrlPreviewError] = useState("");
const [titleTouched, setTitleTouched] = useState(false);
const [bodyTouched, setBodyTouched] = useState(false);
const [imageTouched, setImageTouched] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState(""); const [saveError, setSaveError] = useState("");
const [createStep, setCreateStep] = useState(0); const [createStep, setCreateStep] = useState(0);
const previewTimerRef = useRef(null);
const lastPreviewUrlRef = useRef("");
const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"]; const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"];
const formatPreviewDate = (value) => {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toLocaleString();
};
const isLikelyUrl = (value) => /^https?:\/\//i.test(String(value || "").trim());
const getSubcatIcon = (main, sub) => { const getSubcatIcon = (main, sub) => {
const M = { const M = {
All: { All: {
@ -235,7 +317,6 @@ export default function MapView({
useEffect(() => { useEffect(() => {
if (queryPostRef.current) return; if (queryPostRef.current) return;
queryPostRef.current = true;
const qs = new URLSearchParams(window.location.search || ""); const qs = new URLSearchParams(window.location.search || "");
let idRaw = qs.get("post_id") || qs.get("id") || qs.get("p"); let idRaw = qs.get("post_id") || qs.get("id") || qs.get("p");
if (!idRaw) { if (!idRaw) {
@ -249,6 +330,7 @@ export default function MapView({
} }
} }
if (!idRaw) return; if (!idRaw) return;
queryPostRef.current = true;
const id = Number(idRaw); const id = Number(idRaw);
if (!Number.isFinite(id) || id <= 0) return; if (!Number.isFinite(id) || id <= 0) return;
pendingOpenPostIdRef.current = id; pendingOpenPostIdRef.current = id;
@ -262,9 +344,193 @@ export default function MapView({
}); });
}, [onSelectPost, openOverlayWhenReady]); }, [onSelectPost, openOverlayWhenReady]);
useEffect(() => {
if (queryPostRef.current) return;
if (!CLUSTERS_ENABLED) return;
const path = (window.location.pathname || "").trim();
const clusterMatch = path.match(/^\/cluster\/[^/]+\/\d{8}/);
if (!clusterMatch) return;
queryPostRef.current = true;
const fullUrl = `${window.location.origin}${clusterMatch[0]}`;
fetchPostByUrl(fullUrl).then((post) => {
if (!post) return;
const items = Array.isArray(post.cluster_items) ? post.cluster_items : [];
const ids = Array.isArray(post.cluster_members) ? post.cluster_members : [];
const points = items
.map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) }))
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
const mainPost = { ...post };
if (!Array.isArray(mainPost.cluster_items) || mainPost.cluster_items.length === 0) {
mainPost.cluster_items = items;
}
setClusterFocus({
key: post.cluster_key || "",
ids,
posts: items.map((it) => ({
id: Number(it.id || it.ID) || 0,
title: it.title || "Untitled",
snippet: it.snippet || "",
category: "NEWS",
sub_category: "World",
lat: Number(it.lat),
lon: Number(it.lon),
url: it.url || "",
source: it.source || "",
image_small: it.image_small || "",
image_medium: it.image_medium || "",
image_large: it.image_large || "",
created_at: it.published_at || "",
published_at: it.published_at || "",
})).concat([mainPost]),
points,
center: { lat: Number(post.lat), lon: Number(post.lon) },
});
onSelectPost?.(mainPost);
panToPostWhenReady(mainPost, 12);
openOverlayWhenReady(mainPost, { fullScreen: true });
});
}, [mapRef, onSelectPost, openOverlayWhenReady, panToPostWhenReady]);
useEffect(() => {
if (!CLUSTERS_ENABLED || !HEATMAP_ENABLED) return;
const handler = (ev) => {
const detail = ev?.detail || {};
const mode = detail.mode || "click";
const skipPan = !!detail.skipPan;
const key = (detail.clusterKey || "").toString();
const members = Array.isArray(detail.members) ? detail.members : [];
const postId = Number(detail.postId || 0);
const ids = members.length ? Array.from(new Set([...members, postId].filter(Boolean))) : (postId ? [postId] : []);
const items = Array.isArray(detail.items) ? detail.items : [];
const mainPost = detail.post && typeof detail.post === "object" ? detail.post : null;
const toExtraPosts = (list, basePost) => {
const out = [];
if (basePost) {
const withItems = { ...basePost };
if (!Array.isArray(withItems.cluster_items) || withItems.cluster_items.length === 0) {
withItems.cluster_items = list;
}
out.push(withItems);
}
for (const it of list) {
if (!it) continue;
const id = Number(it.id || it.ID);
out.push({
id: Number.isFinite(id) ? id : 0,
title: it.title || it.Title || "Untitled",
snippet: it.snippet || "",
category: "NEWS",
sub_category: "World",
lat: Number(it.lat),
lon: Number(it.lon),
url: it.url || "",
source: it.source || "",
image_small: it.image_small || "",
image_medium: it.image_medium || "",
image_large: it.image_large || "",
created_at: it.published_at || "",
published_at: it.published_at || "",
});
}
return out;
};
const applyClusterFocus = (list, basePost, points) => {
setClusterFocus((prev) => {
if (prev && prev.key && key && prev.key === key) {
return mode === "hover" ? prev : null;
}
return {
key,
ids,
posts: toExtraPosts(list, basePost),
points,
center: detail.center || null,
mode,
};
});
};
const map = mapRef.current;
if (!map) return;
try { map.__swForceFetchAt = Date.now(); } catch {}
const points = Array.isArray(detail.points) ? detail.points : [];
const center = detail.center || {};
const ensurePoints = (list) => {
const pts = list
.map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) }))
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
if (Number.isFinite(center.lat) && Number.isFinite(center.lon)) {
pts.push({ lat: center.lat, lon: center.lon });
}
return pts;
};
const usePoints = (pts) => {
if (skipPan) return;
const valid = pts.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
if (valid.length >= 2) {
let minLat = 90, maxLat = -90, minLon = 180, maxLon = -180;
for (const p of valid) {
minLat = Math.min(minLat, p.lat);
maxLat = Math.max(maxLat, p.lat);
minLon = Math.min(minLon, p.lon);
maxLon = Math.max(maxLon, p.lon);
}
fitBoundsWhenReady([[minLon, minLat], [maxLon, maxLat]], { padding: 80, duration: 700 });
return;
}
const lat = Number(center.lat);
const lon = Number(center.lon);
if (Number.isFinite(lat) && Number.isFinite(lon)) {
panToPostWhenReady({ lat, lon }, 12);
}
};
if (!items.length && mainPost?.url) {
fetchPostByUrl(mainPost.url).then((full) => {
if (full && Array.isArray(full.cluster_items) && full.cluster_items.length > 0) {
const fullItems = full.cluster_items;
const pts = ensurePoints(fullItems);
applyClusterFocus(fullItems, { ...mainPost, ...full, cluster_items: fullItems }, pts);
usePoints(pts);
} else {
applyClusterFocus(items, mainPost, points);
usePoints(points);
}
});
return;
}
const pts = ensurePoints(items);
applyClusterFocus(items, mainPost, pts);
usePoints(pts);
};
window.addEventListener("sociowire:cluster-heatmap", handler);
return () => window.removeEventListener("sociowire:cluster-heatmap", handler);
}, [mapRef, fitBoundsWhenReady, panToPostWhenReady, CLUSTERS_ENABLED, HEATMAP_ENABLED]);
useEffect(() => {
if (!CLUSTERS_ENABLED || !HEATMAP_ENABLED) return;
const handler = () => {
setClusterFocus((prev) => (prev?.mode === "hover" ? null : prev));
};
window.addEventListener("sociowire:cluster-heatmap-clear", handler);
return () => window.removeEventListener("sociowire:cluster-heatmap-clear", handler);
}, [CLUSTERS_ENABLED, HEATMAP_ENABLED]);
useEffect(() => { useEffect(() => {
const wantId = pendingOpenPostIdRef.current; const wantId = pendingOpenPostIdRef.current;
if (!wantId || !Array.isArray(visiblePosts) || !visiblePosts.length) return; if (!wantId || !Array.isArray(visiblePosts) || !visiblePosts.length) return;
const map = mapRef.current;
const overlay = map?.__swCenteredOverlay;
if (overlay && overlay.postId === wantId) {
pendingOpenPostIdRef.current = null;
pendingOpenFullRef.current = false;
return;
}
const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId); const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId);
if (!match) return; if (!match) return;
pendingOpenPostIdRef.current = null; pendingOpenPostIdRef.current = null;
@ -277,6 +543,7 @@ export default function MapView({
useEffect(() => { useEffect(() => {
return () => { return () => {
if (overlayTimerRef.current) clearInterval(overlayTimerRef.current); if (overlayTimerRef.current) clearInterval(overlayTimerRef.current);
if (panTimerRef.current) clearInterval(panTimerRef.current);
}; };
}, []); }, []);
@ -346,6 +613,112 @@ export default function MapView({
coords = [item.lon, item.lat]; coords = [item.lon, item.lat];
} }
// If picking a post, open the real post (with likes/comments) after fetch.
const kind = (item?.kind || item?.type || "").toString().toLowerCase();
const raw = item?.raw || item;
const looksLikePost =
kind === "post" ||
kind === "posts" ||
Number(raw?.post_id || 0) > 0 ||
!!raw?.url_hash ||
(!!raw?.url && !kind.includes("profile") && !kind.includes("place"));
if (looksLikePost) {
skipAutoZoomRef.current = true;
setSearchQuery("");
setMainFilter("All");
setSubFilter("All");
setTimeFilter("PAST"); // Show all time periods
const postId = Number(raw?.post_id ?? raw?.id ?? item?.id ?? 0);
const rawUrl = raw?.url || item?.url || "";
if (Number.isFinite(postId) && postId > 0) {
setForceFullPostId(postId);
}
const focusPost = (post) => {
if (!post) return;
const lat = Number(post?.lat);
const lon = Number(post?.lon);
if (Number.isFinite(lat) && Number.isFinite(lon)) {
const center = map.getCenter?.();
const dz = (zoom || 12);
const distKm = center ? Math.sqrt(Math.pow(center.lat - lat, 2) + Math.pow(center.lng - lon, 2)) * 111 : 999;
const curZoom = map.getZoom?.() || dz;
const targetZoom = distKm > 8 ? dz : Math.max(curZoom, Math.min(dz, curZoom + 0.8));
map.easeTo({
center: [lon, lat],
zoom: targetZoom,
duration: 650,
essential: true
});
map.__swForceFetchAt = Date.now();
}
openedPostRef.current = post?.id || null;
onSelectPost?.(post);
openOverlayWhenReady(post, { fullScreen: false });
};
const stubPost = (() => {
const lat = Number(raw?.lat ?? item?.lat);
const lon = Number(raw?.lon ?? item?.lon);
const id = Number(postId || 0);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return {
id: Number.isFinite(id) ? id : 0,
title: raw?.title || item?.title || "Post",
snippet: raw?.snippet || item?.snippet || "",
category: raw?.category || item?.category || "NEWS",
sub_category: raw?.sub_category || item?.sub_category || "",
lat,
lon,
url: raw?.url || item?.url || "",
image_small: raw?.image_small || item?.image_small || "",
image_medium: raw?.image_medium || item?.image_medium || "",
image_large: raw?.image_large || item?.image_large || "",
created_at: raw?.created_at || item?.created_at || "",
published_at: raw?.published_at || item?.published_at || "",
};
})();
const tryOpen = async (attempt = 0) => {
let post = null;
if (Number.isFinite(postId) && postId > 0) {
post = await fetchPostById(postId);
}
if (!post && rawUrl) {
post = await fetchPostByUrl(rawUrl);
}
if (post) {
pendingOpenPostIdRef.current = null;
pendingOpenFullRef.current = false;
focusPost(post);
return;
}
if (attempt < 2) {
setTimeout(() => tryOpen(attempt + 1), 650 + attempt * 400);
return;
}
if (stubPost) {
pendingOpenPostIdRef.current = null;
pendingOpenFullRef.current = false;
focusPost(stubPost);
}
if (coords) {
pendingOpenPostIdRef.current = null;
pendingOpenFullRef.current = false;
map.easeTo({
center: coords,
zoom: zoom || 12,
duration: 650,
essential: true
});
map.__swForceFetchAt = Date.now();
}
};
tryOpen();
return;
}
if (!coords) { if (!coords) {
console.warn('[MapView] No coords for suggestion:', item); console.warn('[MapView] No coords for suggestion:', item);
return; return;
@ -360,61 +733,21 @@ export default function MapView({
}); });
map.__swForceFetchAt = Date.now(); map.__swForceFetchAt = Date.now();
// If picking a post, clear filters and avoid query-locking if (kind === 'city' || kind === 'region' || kind === 'place' || kind === 'poi') {
const kind = (item?.kind || item?.type || "").toString().toLowerCase();
if (kind === 'post') {
skipAutoZoomRef.current = true;
setSearchQuery("");
setMainFilter("All");
setSubFilter("All");
setTimeFilter("PAST"); // Show all time periods
const raw = item.raw || item;
const postId = raw?.post_id ?? raw?.id ?? item?.id ?? 0;
if (raw && typeof raw === "object") {
const lat = typeof raw.lat === "number" ? raw.lat : coords[1];
const lon = typeof raw.lon === "number" ? raw.lon : coords[0];
const builtPost = {
id: raw.post_id ?? raw.id ?? 0,
title: raw.title || item.title || "Post",
snippet: raw.snippet || raw.summary || "",
category: raw.category || "NEWS",
sub_category: raw.sub_category || raw.subCategory || "ALL",
lat,
lon,
url: raw.url || "",
source: raw.source || "",
published_at: raw.published_at || "",
created_at: raw.published_at || "",
url_hash: raw.url_hash || "",
};
handleIncomingPost(builtPost);
if (postId > 0) {
fetchPostById(postId).then((full) => {
if (full) {
openOverlayWhenReady(full, { fullScreen: true });
} else {
openOverlayWhenReady(builtPost, { fullScreen: true });
}
});
} else {
openOverlayWhenReady(builtPost, { fullScreen: true });
}
}
} else if (kind === 'city' || kind === 'region' || kind === 'place' || kind === 'poi') {
// Place picks should move the map without forcing a text filter. // Place picks should move the map without forcing a text filter.
skipAutoZoomRef.current = false; skipAutoZoomRef.current = false;
setSearchQuery(""); setSearchQuery("");
} else { setForceFullPostId(null);
const q = (item.title || item.name || "").toString().trim(); return;
skipAutoZoomRef.current = false; }
setSearchQuery(q);
if (q) { const q = (item.title || item.name || "").toString().trim();
setMainFilter("All"); skipAutoZoomRef.current = false;
setSubFilter("All"); setSearchQuery(q);
setTimeFilter("PAST"); if (q) {
} setMainFilter("All");
setSubFilter("All");
setTimeFilter("PAST");
} }
}; };
@ -422,6 +755,7 @@ export default function MapView({
console.log('[MapView] Search/filter by:', query); console.log('[MapView] Search/filter by:', query);
skipAutoZoomRef.current = false; skipAutoZoomRef.current = false;
setSearchQuery(query); setSearchQuery(query);
if (!query.trim()) setForceFullPostId(null);
// Clear ALL filters to show all search results // Clear ALL filters to show all search results
if (query.trim()) { if (query.trim()) {
@ -563,6 +897,9 @@ export default function MapView({
setCreateStep(0); setCreateStep(0);
setDraftTitle(""); setDraftTitle("");
setDraftBody(""); setDraftBody("");
setDraftUrl("");
setDraftSource("");
setDraftPublishedAt("");
setDraftImage(""); setDraftImage("");
setDraftImageFile(null); setDraftImageFile(null);
setDraftImagePreview(""); setDraftImagePreview("");
@ -573,6 +910,12 @@ export default function MapView({
setImageVisibility("public"); setImageVisibility("public");
setImageGroupId(""); setImageGroupId("");
setImageUsers(""); setImageUsers("");
setUrlPreviewLoading(false);
setUrlPreviewError("");
setTitleTouched(false);
setBodyTouched(false);
setImageTouched(false);
lastPreviewUrlRef.current = "";
const main = mainFilter === "All" ? "News" : mainFilter; const main = mainFilter === "All" ? "News" : mainFilter;
setDraftCategory(main); setDraftCategory(main);
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News; const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
@ -591,6 +934,7 @@ export default function MapView({
const handleImageFileChange = async (e) => { const handleImageFileChange = async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setImageTouched(true);
// Vérifier le type de fichier // Vérifier le type de fichier
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
@ -636,11 +980,24 @@ export default function MapView({
} }
const data = await res.json(); const data = await res.json();
if (data && data.url) { const variants = data && data.variants ? data.variants : null;
setDraftImage(data.url); const pickRef = (v) => {
} else if (data && data.asset_id) { if (!v) return "";
setDraftImage(`asset://${data.asset_id}`); if (v.asset_id) return `asset://${v.asset_id}`;
return v.url || "";
};
const smallRef = variants ? pickRef(variants.small) : "";
const mediumRef = variants ? pickRef(variants.medium) : "";
let largeRef = variants ? pickRef(variants.large) : "";
if (!largeRef) {
if (data && data.url) largeRef = data.url;
else if (data && data.asset_id) largeRef = `asset://${data.asset_id}`;
} }
const fallbackRef = largeRef || mediumRef || smallRef;
setDraftImageSmall(smallRef || fallbackRef);
setDraftImageMedium(mediumRef || fallbackRef);
setDraftImageLarge(largeRef || fallbackRef);
if (fallbackRef) setDraftImage(fallbackRef);
setUploadingImage(false); setUploadingImage(false);
} catch (err) { } catch (err) {
console.error('Image upload error:', err); console.error('Image upload error:', err);
@ -648,9 +1005,64 @@ export default function MapView({
setUploadingImage(false); setUploadingImage(false);
setDraftImageFile(null); setDraftImageFile(null);
setDraftImagePreview(""); setDraftImagePreview("");
setDraftImageSmall("");
setDraftImageMedium("");
setDraftImageLarge("");
} }
}; };
const handleUrlPreview = async (rawOverride) => {
const raw = (rawOverride || draftUrl).trim();
if (!raw || urlPreviewLoading) return;
if (!/^https?:\/\//i.test(raw)) return;
if (raw === lastPreviewUrlRef.current) return;
setUrlPreviewLoading(true);
setUrlPreviewError("");
lastPreviewUrlRef.current = raw;
try {
const data = await fetchPostPreview(raw, token);
if (!data) throw new Error("Preview unavailable");
const title = (data.title || "").trim();
const desc = (data.description || "").trim();
const image = (data.image || "").trim();
if (!titleTouched && title) setDraftTitle(title);
if (!bodyTouched && desc) setDraftBody(desc);
if (data.source) setDraftSource(String(data.source));
if (data.published_at) setDraftPublishedAt(String(data.published_at));
if (!imageTouched && image) {
setUseCustomImage(true);
if (!draftImageFile && !draftImagePreview) {
setDraftImage(image);
setDraftImageSmall(image);
setDraftImageMedium(image);
setDraftImageLarge(image);
}
}
} catch (err) {
setUrlPreviewError(err?.message || "Failed to fetch link preview");
} finally {
setUrlPreviewLoading(false);
}
};
useEffect(() => {
const raw = draftUrl.trim();
if (!raw || !/^https?:\/\//i.test(raw)) return;
if (raw === lastPreviewUrlRef.current) return;
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
previewTimerRef.current = setTimeout(() => {
handleUrlPreview(raw);
}, 650);
return () => {
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
};
}, [draftUrl]);
const handleSubmitPost = async () => { const handleSubmitPost = async () => {
if (isSaving) return; if (isSaving) return;
setSaveError(""); setSaveError("");
@ -722,9 +1134,26 @@ export default function MapView({
.filter(Boolean), .filter(Boolean),
}; };
if (useCustomImage && draftImage.trim()) { if (draftUrl.trim()) {
payload.image_small = draftImage.trim(); payload.url = draftUrl.trim();
payload.image_large = draftImage.trim(); }
if (draftSource.trim()) {
payload.source = draftSource.trim();
}
if (draftPublishedAt.trim()) {
payload.published_at = draftPublishedAt.trim();
}
if (
draftImageSmall ||
draftImageMedium ||
draftImageLarge ||
(useCustomImage && draftImage.trim())
) {
const fallback = draftImage.trim();
payload.image_small = (draftImageSmall || fallback).trim();
payload.image_medium = (draftImageMedium || fallback).trim();
payload.image_large = (draftImageLarge || fallback).trim();
} }
const newPost = await createPost(payload, token); const newPost = await createPost(payload, token);
@ -789,6 +1218,18 @@ export default function MapView({
onPlaceTourWire={handleOpenCreate} onPlaceTourWire={handleOpenCreate}
/> />
<div className="sw-search__side" aria-label="Search actions"> <div className="sw-search__side" aria-label="Search actions">
{CLUSTERS_ENABLED && (
<button
type="button"
className={
"sw-search__iconBtn" + (mainNewsOnly ? " is-active" : "")
}
title="Main news"
onClick={() => setMainNewsOnly((prev) => !prev)}
>
<i className="fa-solid fa-bolt"></i>
</button>
)}
<button <button
type="button" type="button"
className="sw-search__iconBtn" className="sw-search__iconBtn"
@ -928,44 +1369,154 @@ export default function MapView({
<input <input
className="create-input" className="create-input"
placeholder="Short title..." placeholder="Short title or paste a link..."
value={draftTitle} value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)} onChange={(e) => {
const next = e.target.value;
if (isLikelyUrl(next)) {
setDraftTitle(next);
setTitleTouched(false);
setDraftUrl(next.trim());
setUrlPreviewError("");
setDraftSource("");
setDraftPublishedAt("");
handleUrlPreview(next);
return;
}
setDraftTitle(next);
setTitleTouched(true);
}}
/> />
<div className="create-hint">Give it a clear, punchy headline.</div> <div className="create-hint">
Type a title or paste a link well auto-fill from the URL.
</div>
{draftUrl && !draftSource && !draftPublishedAt && (
<div className="create-link-meta">
<span>Link detected · fetching preview...</span>
</div>
)}
{(draftSource || draftPublishedAt) && (
<div className="create-link-meta">
{draftSource ? <span>{draftSource}</span> : null}
{draftPublishedAt ? (
<span>{formatPreviewDate(draftPublishedAt)}</span>
) : null}
</div>
)}
{urlPreviewError && (
<div className="create-link-error">{urlPreviewError}</div>
)}
</div> </div>
)} )}
{createStep === 1 && ( {createStep === 1 && (
<div className="create-step-body"> <div className="create-step-body">
{(draftImagePreview ||
draftImageLarge ||
draftImageMedium ||
draftImageSmall ||
draftImage) && (
<div className="create-link-preview">
<img
src={
draftImagePreview ||
draftImageLarge ||
draftImageMedium ||
draftImageSmall ||
draftImage
}
alt="Link preview"
/>
<div className="create-link-preview-meta">
{draftSource ? <span>{draftSource}</span> : null}
{draftPublishedAt ? (
<span>{formatPreviewDate(draftPublishedAt)}</span>
) : null}
</div>
</div>
)}
<textarea <textarea
className="create-textarea" className="create-textarea"
rows={3} rows={3}
placeholder="Context (optional)..." placeholder="Context (optional)..."
value={draftBody} value={draftBody}
onChange={(e) => setDraftBody(e.target.value)} onChange={(e) => {
setDraftBody(e.target.value);
setBodyTouched(true);
}}
/> />
<div className="create-image-option"> <div className="create-image-choice">
<label className="create-checkbox"> <button
<input type="button"
type="checkbox" className={
checked={useCustomImage} "create-image-choice-btn" +
onChange={(e) => setUseCustomImage(e.target.checked)} (!useCustomImage ? " is-active" : "")
/> }
<span>Use a custom image instead of the category icon</span> onClick={() => {
</label> setUseCustomImage(false);
}}
>
Use category icon
</button>
<button
type="button"
className={
"create-image-choice-btn" +
(useCustomImage ? " is-active" : "")
}
onClick={() => {
setUseCustomImage(true);
}}
>
Add an image
</button>
</div> </div>
<div className="create-muted">
No image? Well use the category icon.
</div>
{!useCustomImage && (
<div className="create-image-placeholder">
<div className="create-review-icon">
<i className={getSubcatIcon(draftCategory, draftSubCategory)} />
</div>
<div>
<div className="create-image-placeholder-title">
Category icon
</div>
<div className="create-image-placeholder-sub">
Clean default if you skip images.
</div>
</div>
</div>
)}
{useCustomImage && ( {useCustomImage && (
<div className="create-image-panel"> <div className="create-image-panel">
<div className="create-image-row"> <div className="create-image-row">
<input <label className="create-file-btn">
type="file" Choose file
accept="image/*" <input
onChange={handleImageFileChange} className="create-file-input"
disabled={uploadingImage} type="file"
/> accept="image/*"
onChange={handleImageFileChange}
disabled={uploadingImage}
/>
</label>
<label className="create-file-btn create-file-btn--camera">
Take photo
<input
className="create-file-input"
type="file"
accept="image/*"
capture="environment"
onChange={handleImageFileChange}
disabled={uploadingImage}
/>
</label>
{uploadingImage && ( {uploadingImage && (
<span className="create-uploading">Uploading...</span> <span className="create-uploading">Uploading...</span>
)} )}
@ -987,20 +1538,24 @@ export default function MapView({
</div> </div>
)} )}
<div className="create-muted"> {!draftUrl.trim() && !draftImageFile && !draftImagePreview ? (
Or paste an image URL: <>
</div> <div className="create-muted">
<input Or paste an image URL:
className="create-input" </div>
placeholder="https://example.com/image.jpg" <input
value={draftImageFile ? "" : draftImage} className="create-input"
onChange={(e) => { placeholder="https://example.com/image.jpg"
setDraftImage(e.target.value); value={draftImage}
setDraftImageFile(null); onChange={(e) => {
setDraftImagePreview(""); setDraftImage(e.target.value);
}} setDraftImageFile(null);
disabled={!!draftImageFile} setDraftImagePreview("");
/> setImageTouched(true);
}}
/>
</>
) : null}
<div className="create-privacy-row"> <div className="create-privacy-row">
<label>Image visibility</label> <label>Image visibility</label>

View File

@ -17,7 +17,10 @@ export default function TemplateMarkerCard({
const spec = mode === "mini" ? template?.mini_spec_json : template?.full_spec_json; const spec = mode === "mini" ? template?.mini_spec_json : template?.full_spec_json;
const data = useMemo(() => { const data = useMemo(() => {
const rawImage = post?.image_large || post?.image_small || post?.image || post?.media_url || ""; const rawImage =
mode === "mini"
? (post?.image_small || post?.image_medium || "")
: (post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || "");
return { return {
headline: post?.title || "Untitled", headline: post?.title || "Untitled",
source: post?.source || (post?.author ? `by ${post.author}` : "") || (post?.sub_category || ""), source: post?.source || (post?.author ? `by ${post.author}` : "") || (post?.sub_category || ""),

View File

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

View File

@ -25,6 +25,7 @@ import { trackEvent } from "../../utils/analytics";
const avatarCache = new Map(); const avatarCache = new Map();
const avatarInflight = new Map(); const avatarInflight = new Map();
const HEATMAP_ENABLED = false;
function applyAvatar(el, username, url) { function applyAvatar(el, username, url) {
if (!el) return; if (!el) return;
@ -43,8 +44,9 @@ function applyAvatar(el, username, url) {
function resolvePostImage(post) { function resolvePostImage(post) {
return normalizeImageUrl( return normalizeImageUrl(
post?.image_small || post?.image_small ||
post?.image || post?.image_medium ||
post?.image_large || post?.image_large ||
post?.image ||
post?.media_url || post?.media_url ||
post?.image_url || post?.image_url ||
"" ""
@ -224,6 +226,7 @@ function applyPostDetails(modalWrap, post) {
const title = (post?.title || post?.Title || "Untitled").toString(); const title = (post?.title || post?.Title || "Untitled").toString();
const summary = (post?.snippet || post?.body || "").toString(); const summary = (post?.snippet || post?.body || "").toString();
const img = heroImageForPost(post); const img = heroImageForPost(post);
const fullImg = fullImageForPost(post);
const url = (post?.url || "").toString().trim(); const url = (post?.url || "").toString().trim();
const titleEl = modalWrap.querySelector(".sw-modal-title"); const titleEl = modalWrap.querySelector(".sw-modal-title");
@ -233,10 +236,13 @@ function applyPostDetails(modalWrap, post) {
if (summaryEl) summaryEl.textContent = summary; if (summaryEl) summaryEl.textContent = summary;
const hero = modalWrap.querySelector(".sw-modal-hero"); const hero = modalWrap.querySelector(".sw-modal-hero");
if (hero) { const clusterHero = modalWrap.querySelector(".sw-cluster-hero-media");
hero.innerHTML = img if (hero || clusterHero) {
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />` const target = hero || clusterHero;
target.innerHTML = img
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImg)}" alt="" loading="lazy" />`
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(post)}"></i><span>${escapeHtml(normCatLabel(post))}</span></div></div>`; : `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(post)}"></i><span>${escapeHtml(normCatLabel(post))}</span></div></div>`;
bindHeroLightbox(modalWrap);
} }
const badge = modalWrap.querySelector(".sw-modal-badge"); const badge = modalWrap.querySelector(".sw-modal-badge");
@ -257,6 +263,7 @@ function applyPostDetails(modalWrap, post) {
`; `;
hydrateAvatars(modalWrap); hydrateAvatars(modalWrap);
} }
setModalTags(modalWrap, post);
const cta = modalWrap.querySelector(".sw-cta-primary"); const cta = modalWrap.querySelector(".sw-cta-primary");
if (cta) { if (cta) {
@ -306,7 +313,11 @@ function renderComments(container, items) {
} }
function mountCard(container, spec, data, theme, post) { function mountCard(container, spec, data, theme, post) {
const root = createRoot(container); let root = container.__swReactRoot;
if (!root) {
root = createRoot(container);
container.__swReactRoot = root;
}
const tokens = cardTokens?.[theme] || cardTokens.blue; const tokens = cardTokens?.[theme] || cardTokens.blue;
const score = truthScore(post || data || {}); const score = truthScore(post || data || {});
@ -374,6 +385,7 @@ function mountCard(container, spec, data, theme, post) {
return () => { return () => {
try { root.unmount(); } catch {} try { root.unmount(); } catch {}
container.__swReactRoot = null;
}; };
} }
@ -385,6 +397,101 @@ function escapeHtml(str) {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function renderTagPills(tags) {
if (!Array.isArray(tags) || tags.length === 0) return "";
const uniq = [];
const seen = new Set();
for (const raw of tags) {
const t = (raw || "").toString().trim().toLowerCase();
if (!t || seen.has(t)) continue;
seen.add(t);
uniq.push(t);
if (uniq.length >= 6) break;
}
if (uniq.length === 0) return "";
return `
<div class="sw-modal-tags">
${uniq.map((t) => `<span class="sw-modal-tag">#${escapeHtml(t)}</span>`).join("")}
</div>
`;
}
function setModalTags(modalWrap, post) {
if (!modalWrap) return;
const tagsHtml = renderTagPills(post?.event_tags || post?.tags);
const existing = modalWrap.querySelector(".sw-modal-tags");
if (tagsHtml) {
if (existing) {
existing.outerHTML = tagsHtml;
} else {
const titleEl = modalWrap.querySelector(".sw-modal-title") || modalWrap.querySelector(".sw-cluster-title");
if (titleEl) titleEl.insertAdjacentHTML("afterend", tagsHtml);
}
} else if (existing) {
existing.remove();
}
}
function bindHeroLightbox(modalWrap) {
if (!modalWrap) return;
const img = modalWrap.querySelector(".sw-modal-hero img, .sw-cluster-hero-media img");
if (!img || img.dataset.zoomBound === "1") return;
const fullSrc = (img.getAttribute("data-fullsrc") || img.getAttribute("src") || "").trim();
if (!fullSrc) return;
img.dataset.zoomBound = "1";
img.style.cursor = "zoom-in";
img.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
openImageLightbox(fullSrc);
});
}
function openImageLightbox(src) {
if (!src) return;
const overlay = document.createElement("div");
overlay.className = "sw-image-lightbox";
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.background = "rgba(8,10,16,0.86)";
overlay.style.zIndex = "10000000";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.padding = "16px";
const img = document.createElement("img");
img.src = src;
img.alt = "";
img.style.maxWidth = "96vw";
img.style.maxHeight = "96vh";
img.style.objectFit = "contain";
img.style.borderRadius = "14px";
img.style.boxShadow = "0 20px 50px rgba(0,0,0,0.45)";
overlay.appendChild(img);
const close = (opts = {}) => {
try { document.removeEventListener("keydown", onKey); } catch {}
try { window.removeEventListener("popstate", onPop); } catch {}
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
if (!opts.skipHistory && overlay.__swPushed) {
overlay.__swPushed = false;
try { history.back(); } catch {}
}
};
const onKey = (e) => {
if (e.key === "Escape") close();
};
const onPop = () => close({ skipHistory: true });
overlay.addEventListener("click", close);
document.addEventListener("keydown", onKey);
window.addEventListener("popstate", onPop);
try {
history.pushState({ sw: "image" }, "");
overlay.__swPushed = true;
} catch {}
document.body.appendChild(overlay);
}
function normCatLabel(post) { function normCatLabel(post) {
const c = (post?.category || "").toString().toUpperCase(); const c = (post?.category || "").toString().toUpperCase();
if (c === "NEWS") return "NEWS"; if (c === "NEWS") return "NEWS";
@ -409,8 +516,71 @@ function categoryIconClass(post) {
} }
} }
function isClusterMainPost(post) {
const clusterPostId = Number(post?.cluster_post_id);
const postId = Number(post?.post_id || post?.id);
if (Number.isFinite(clusterPostId) && Number.isFinite(postId) && clusterPostId > 0 && postId > 0) {
return clusterPostId === postId;
}
const key = (post?.cluster_key || post?.clusterKey || "").toString().trim();
const members = Array.isArray(post?.cluster_members) ? post.cluster_members : [];
if (key && members.length > 0) return true;
const url = (post?.url || "").toString();
return url.includes("/cluster/");
}
function dispatchClusterHeatmap(post, opts = {}) {
if (!post) return;
const postId = Number(post?.id || post?.post_id || 0);
const members = Array.isArray(post?.cluster_members) ? post.cluster_members : [];
const items = Array.isArray(post?.cluster_items) ? post.cluster_items : [];
const points = items
.map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) }))
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
window.dispatchEvent(
new CustomEvent("sociowire:cluster-heatmap", {
detail: {
clusterKey: post?.cluster_key || post?.clusterKey || "",
postId,
members,
points,
items,
post,
center: { lat: Number(post?.lat), lon: Number(post?.lon) },
mode: opts.mode || "click",
skipPan: !!opts.skipPan,
},
})
);
}
function dispatchClusterHeatmapClear() {
window.dispatchEvent(new CustomEvent("sociowire:cluster-heatmap-clear"));
}
function buildClusterItemsHtml(items) {
if (!Array.isArray(items) || items.length === 0) {
return `<div class="sw-cluster-source-empty">No related reports yet.</div>`;
}
const rows = items.slice(0, 6).map((item) => {
const title = escapeHtml(item?.title || "Untitled");
const source = escapeHtml(item?.source || "");
const url = escapeHtml(item?.url || "");
const meta = source ? `<span class="sw-cluster-source">${source}</span>` : "";
const href = url ? ` data-related-url="${url}"` : "";
return `
<div class="sw-cluster-source-item"${href}>
<div class="sw-cluster-source-title">${title}</div>
${meta}
</div>
`;
});
return rows.join("");
}
function heroImageForPost(post) { function heroImageForPost(post) {
const raw = const raw =
post?.image_medium ||
post?.image_large || post?.image_large ||
post?.image_small || post?.image_small ||
post?.image || post?.image ||
@ -422,6 +592,17 @@ function heroImageForPost(post) {
return `/og/category?cat=${encodeURIComponent(cat)}`; return `/og/category?cat=${encodeURIComponent(cat)}`;
} }
function fullImageForPost(post) {
const raw =
post?.image_large ||
post?.image_medium ||
post?.image_small ||
post?.image ||
post?.media_url ||
"";
return normalizeImageUrl(raw);
}
function formatRelativeTime(createdAt) { function formatRelativeTime(createdAt) {
try { try {
if (!createdAt) return "now"; if (!createdAt) return "now";
@ -464,7 +645,7 @@ function truthScore(post) {
if (Number.isFinite(raw)) { if (Number.isFinite(raw)) {
return clamp(Math.round(raw), 0, 100); return clamp(Math.round(raw), 0, 100);
} }
return 50; return 65;
} }
function truthColor(score) { function truthColor(score) {
@ -530,6 +711,12 @@ function closeCenteredOverlay(map, opts = {}) {
ov.historyClosed = true; ov.historyClosed = true;
try { window.history.back(); } catch {} try { window.history.back(); } catch {}
} }
if (map) {
try {
map.__swForceFetchAt = Date.now();
map.fire("moveend");
} catch {}
}
} catch {} } catch {}
} }
@ -550,7 +737,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {} try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {}
let postData = { ...(post || {}) }; let postData = { ...(post || {}) };
const data = adaptPostToTemplateData(postData); const data = adaptPostToTemplateData(postData, { size: "full" });
const cat = normCatLabel(postData); const cat = normCatLabel(postData);
const templateKey = getTemplateKeyForPost(postData); const templateKey = getTemplateKeyForPost(postData);
const relTime = formatRelativeTime(getPostTime(postData)); const relTime = formatRelativeTime(getPostTime(postData));
@ -566,6 +753,13 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
const likeCount = readCount(postData, ["like_count", "likes", "likeCount"]); const likeCount = readCount(postData, ["like_count", "likes", "likeCount"]);
const shareCount = readCount(postData, ["share_count", "shares", "shareCount"]); const shareCount = readCount(postData, ["share_count", "shares", "shareCount"]);
const commentCount = readCount(postData, ["comment_count", "comments", "commentCount"]); const commentCount = readCount(postData, ["comment_count", "comments", "commentCount"]);
const isCluster = isClusterMainPost(postData);
const clusterItems = Array.isArray(postData?.cluster_items) ? postData.cluster_items : [];
const clusterCount = Array.isArray(postData?.cluster_members) ? postData.cluster_members.length : 0;
const tagPills = renderTagPills(postData?.event_tags || postData?.tags);
const heatmapPill = HEATMAP_ENABLED
? `<div class="sw-stat-pill sw-heatmap-pill" data-action="cluster-heatmap">🔥 Heat map</div>`
: "";
trackEvent("post_open", { trackEvent("post_open", {
post_id: postID, post_id: postID,
source: postData?.source || postData?.Source || "", source: postData?.source || postData?.Source || "",
@ -599,84 +793,181 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
modalWrap.style.pointerEvents = "auto"; modalWrap.style.pointerEvents = "auto";
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.) // Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
modalWrap.innerHTML = ` if (isCluster) {
<div class="post-card sw-expanded-shell sw-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}"> modalWrap.innerHTML = `
<div class="sw-modal-toprow"> <div class="post-card sw-expanded-shell sw-modal sw-cluster-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
<div class="sw-modal-left"> <div class="sw-modal-toprow">
<div class="sw-modal-badge">${escapeHtml(cat)}</div> <div class="sw-modal-left">
<div class="sw-modal-meta"> <div class="sw-modal-badge">MAIN NEWS</div>
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""} <div class="sw-modal-meta">
${author ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""} ${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""} ${author ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""} ${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
<span> ${escapeHtml(relTime)}</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-cluster-title">${escapeHtml(postData?.cluster_title || postData?.title || "Untitled")}</div>
${tagPills}
<div class="sw-cluster-body">
<div class="sw-cluster-left">
<div class="sw-cluster-hero">
<div class="sw-truth-rail" data-score="${truth}">
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
<div class="sw-truth-rail-bar">
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
</div>
<div class="sw-truth-rail-score">${truth}</div>
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
</div>
<div class="sw-cluster-hero-media">
${
img
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
}
</div>
</div>
<div class="sw-cluster-actions">
<button class="post-card-btn" type="button" data-action="like">Like</button>
<button class="post-card-btn" type="button" data-action="share">Share</button>
<button class="post-card-btn" type="button">Fix</button>
</div>
</div>
<div class="sw-cluster-right">
<div class="sw-cluster-related-title">Related reports ${clusterCount ? `(${clusterCount})` : ""}</div>
<div class="sw-cluster-related-list">
${buildClusterItemsHtml(clusterItems)}
</div>
<div class="sw-cluster-generated">Generated news update</div>
<div class="sw-cluster-summary">
${escapeHtml(data?.summary || postData?.snippet || "")}
</div>
</div> </div>
</div> </div>
<button class="sw-modal-x" type="button" aria-label="Close"></button> <div class="sw-stat-row">
</div> <div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
<div class="sw-stat-pill" data-stat="likes"> <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
${heatmapPill}
</div>
<div class="sw-modal-hero-row"> <div class="post-card-comments sw-livechat">
<div class="sw-truth-rail" data-score="${truth}"> <div class="sw-livechat-title">Live chat / comments</div>
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button> <div class="sw-livechat-body">
<div class="sw-truth-rail-bar"> <div class="sw-chat-line"> loading</div>
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></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 class="sw-truth-rail-score">${truth}</div>
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
</div> </div>
<div class="sw-modal-hero">
${ </div>
img `;
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />` } else {
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>` modalWrap.innerHTML = `
} <div class="post-card sw-expanded-shell sw-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
<div class="sw-modal-toprow">
<div class="sw-modal-left">
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
<div class="sw-modal-meta">
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
${author ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
<span> ${escapeHtml(relTime)}</span>
</div>
</div>
<button class="sw-modal-x" type="button" aria-label="Close"></button>
</div> </div>
</div>
<div class="sw-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div>
<div class="sw-modal-summary"> <div class="sw-modal-hero-row">
${escapeHtml(data?.summary || postData?.snippet || "")} <div class="sw-truth-rail" data-score="${truth}">
</div> <button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
<div class="sw-truth-rail-bar">
<div class="sw-stat-row"> <div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div> </div>
<div class="sw-stat-pill" data-stat="likes"> <span class="sw-stat-num">${formatCount(likeCount)}</span></div> <div class="sw-truth-rail-score">${truth}</div>
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div> <button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div> </div>
<div class="sw-stat-pill">📍 ${escapeHtml(postData?.city || postData?.location_name || "Nearby")}</div> <div class="sw-modal-hero">
</div> ${
img
<div class="sw-modal-cta-row"> ? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
<button class="sw-cta-primary" type="button" ${url ? "" : "disabled"} data-url="${escapeHtml(url)}"> : `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
Open source }
</button> </div>
<div class="sw-cta-actions">
<button class="post-card-btn" type="button">Contact</button>
<button class="post-card-btn" type="button" data-action="like">Like</button>
<button class="post-card-btn" type="button" data-action="share">Share</button>
<button class="post-card-btn" type="button">Fix</button>
</div> </div>
</div> <div class="sw-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div>
${tagPills}
<div class="post-card-comments sw-livechat"> <div class="sw-modal-summary">
<div class="sw-livechat-title">Live chat / comments</div> ${escapeHtml(data?.summary || postData?.snippet || "")}
<div class="sw-livechat-body">
<div class="sw-chat-line"> loading</div>
</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="sw-stat-row">
`; <div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
<div class="sw-stat-pill" data-stat="likes"> <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
${heatmapPill}
</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" data-action="like">Like</button>
<button class="post-card-btn" type="button" data-action="share">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"> loading</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>
`;
}
bindHeroLightbox(modalWrap);
backdrop.appendChild(modalWrap); backdrop.appendChild(modalWrap);
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
hydrateAvatars(modalWrap); hydrateAvatars(modalWrap);
const relatedLinks = modalWrap.querySelectorAll("[data-related-url]");
relatedLinks.forEach((el) => {
el.addEventListener("click", () => {
const target = el.getAttribute("data-related-url");
if (!target) return;
try {
window.open(target, "_blank", "noopener");
} catch {
window.location.href = target;
}
});
});
requestAnimationFrame(() => { requestAnimationFrame(() => {
try { backdrop.classList.add("sw-enter-active"); } catch {} try { backdrop.classList.add("sw-enter-active"); } catch {}
try { modalWrap.classList.add("sw-enter-active"); } catch {} try { modalWrap.classList.add("sw-enter-active"); } catch {}
@ -864,6 +1155,14 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
}); });
} }
const heatBtn = modalWrap.querySelector('[data-action="cluster-heatmap"]');
if (HEATMAP_ENABLED && heatBtn && isCluster) {
heatBtn.addEventListener("click", () => {
closeCenteredOverlay(map, { force: true });
dispatchClusterHeatmap(postData, { mode: "click" });
});
}
if (postID > 0) { if (postID > 0) {
fetchPostById(postID).then((fresh) => { fetchPostById(postID).then((fresh) => {
if (!fresh) return; if (!fresh) return;
@ -1077,26 +1376,28 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
} }
function renderCompact() { function renderCompact() {
unmountIfAny(); if (!root.__swCompactReady) {
root.className = "post-pin post-pin--compact sw-appear sw-appear-in"; root.className = "post-pin post-pin--compact sw-appear sw-appear-in";
root.style.zIndex = "1"; root.style.zIndex = "1";
// Add connecting line if offset is significant // Add connecting line if offset is significant
// DISABLED: No longer using pixel offsets // DISABLED: No longer using pixel offsets
const hasOffset = false; const hasOffset = false;
const lineHTML = ''; const lineHTML = '';
root.innerHTML = ` root.innerHTML = `
<div class="sw-template-mini-wrap"></div> <div class="sw-template-mini-wrap"></div>
<div class="post-pin-pointer-small"></div> <div class="post-pin-pointer-small"></div>
${lineHTML} ${lineHTML}
`; `;
root.__swCompactReady = true;
}
const wrap = root.querySelector(".sw-template-mini-wrap"); const wrap = root.querySelector(".sw-template-mini-wrap");
if (wrap) { if (wrap) {
const activePost = root.__post || post; const activePost = root.__post || post;
const spec = getTemplateSpecForPost(activePost, "mini"); const spec = getTemplateSpecForPost(activePost, "mini");
const data = adaptPostToTemplateData(activePost); const data = adaptPostToTemplateData(activePost, { size: "mini" });
root.__lastTemplateKey = getTemplateKeyForPost(activePost); root.__lastTemplateKey = getTemplateKeyForPost(activePost);
root.__lastImage = data?.image || ""; root.__lastImage = data?.image || "";
root.__lastTitle = data?.headline || ""; root.__lastTitle = data?.headline || "";
@ -1109,6 +1410,18 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
renderCompact(); renderCompact();
root.__renderCompact = renderCompact; root.__renderCompact = renderCompact;
if (HEATMAP_ENABLED && isClusterMainPost(post)) {
const handleEnter = () => {
dispatchClusterHeatmap(root.__post || post, { mode: "hover", skipPan: true });
};
const handleLeave = () => {
dispatchClusterHeatmapClear();
};
root.addEventListener("mouseenter", handleEnter);
root.addEventListener("mouseleave", handleLeave);
root.__swClusterHover = { handleEnter, handleLeave };
}
const marker = new maplibregl.Marker({ const marker = new maplibregl.Marker({
element: root, element: root,
anchor: "bottom", anchor: "bottom",
@ -1176,9 +1489,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
function renderSimple() { function renderSimple() {
const activePost = root.__post || post; const activePost = root.__post || post;
const color = getMarkerColorForCategory(activePost?.category); const color = getMarkerColorForCategory(activePost?.category);
const img = normalizeImageUrl( const img = normalizeImageUrl(activePost?.image_small || "");
activePost?.image_small || activePost?.image || activePost?.image_large || activePost?.media_url || "",
);
root.__lastTemplateKey = getTemplateKeyForPost(activePost); root.__lastTemplateKey = getTemplateKeyForPost(activePost);
root.__lastImage = img || ""; root.__lastImage = img || "";
root.__lastTitle = (activePost?.title || "").toString(); root.__lastTitle = (activePost?.title || "").toString();
@ -1254,11 +1565,15 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
renderSimple(); renderSimple();
root.__renderSimple = renderSimple; root.__renderSimple = renderSimple;
const isCluster = HEATMAP_ENABLED && isClusterMainPost(post);
const markerEl = root.querySelector(".sw-simple-marker"); const markerEl = root.querySelector(".sw-simple-marker");
const hoverCard = root.querySelector(".sw-simple-hover-card"); const hoverCard = root.querySelector(".sw-simple-hover-card");
// Hover effects // Hover effects
root.addEventListener("mouseenter", () => { root.addEventListener("mouseenter", () => {
if (isCluster) {
dispatchClusterHeatmap(root.__post || post, { mode: "hover", skipPan: true });
}
if (markerEl) { if (markerEl) {
markerEl.style.transform = "scale(1.2)"; markerEl.style.transform = "scale(1.2)";
markerEl.style.boxShadow = "0 4px 16px rgba(0,0,0,0.5)"; markerEl.style.boxShadow = "0 4px 16px rgba(0,0,0,0.5)";
@ -1270,6 +1585,9 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
}); });
root.addEventListener("mouseleave", () => { root.addEventListener("mouseleave", () => {
if (isCluster) {
dispatchClusterHeatmapClear();
}
if (markerEl) { if (markerEl) {
markerEl.style.transform = "scale(1)"; markerEl.style.transform = "scale(1)";
markerEl.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)"; markerEl.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)";

View File

@ -9,6 +9,30 @@
* - events * - events
*/ */
export const TEMPLATE_SPECS = { export const TEMPLATE_SPECS = {
cluster: {
mini_spec: {
size: { w: 270, h: 108 },
background: { type: "gradient", value: "newsBlue" },
radius: 20,
layers: [
{ id: "icon", type: "icon", x: 12, y: 12, w: 72, h: 72, text: "\uf1ea", style: "text.icon", bind: "data.categoryIcon" },
{ id: "imageOverlay", type: "image", x: 12, y: 12, w: 72, h: 72, bind: "data.image", radius: 12 },
{ id: "badge", type: "chip", x: 96, y: 10, text: "MAIN NEWS", style: "chip.news" },
{ id: "title", type: "text", x: 96, y: 34, w: 156, h: 46, bind: "data.headline", style: "text.title", maxLines: 2 },
{ id: "meta", type: "text", x: 96, y: 82, w: 156, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
],
},
full_spec: {
size: { w: 380, h: 280 },
background: { type: "solid", value: "surface" },
radius: 24,
layers: [
{ id: "badge", type: "chip", x: 16, y: 12, text: "MAIN NEWS", style: "chip.news" },
{ id: "title", type: "text", x: 16, y: 52, w: 348, h: 76, bind: "data.headline", style: "text.h1", maxLines: 3 },
{ id: "summary", type: "text", x: 16, y: 136, w: 348, h: 90, bind: "data.summary", style: "text.body", maxLines: 4 },
],
},
},
news: { news: {
mini_spec: { mini_spec: {
size: { w: 240, h: 96 }, size: { w: 240, h: 96 },
@ -185,6 +209,7 @@ function isBreaking(post) {
} }
function normCat(post) { function normCat(post) {
if (isClusterPost(post)) return "cluster";
const c = (post?.category || post?.Category || "").toString().toUpperCase(); const c = (post?.category || post?.Category || "").toString().toUpperCase();
const sub = (post?.sub_category || post?.subCategory || "").toString().toLowerCase(); const sub = (post?.sub_category || post?.subCategory || "").toString().toLowerCase();
@ -260,14 +285,19 @@ function getCategoryIcon(post) {
return categoryIcons[subCategory] || categoryIcons.default; return categoryIcons[subCategory] || categoryIcons.default;
} }
export function adaptPostToTemplateData(post) { export function adaptPostToTemplateData(post, opts = {}) {
const headline = post?.title || "Untitled"; const headline = post?.cluster_title || post?.title || "Untitled";
const author = post?.author ? `by ${post.author}` : ""; const author = post?.author ? `by ${post.author}` : "";
const sub = post?.sub_category ? String(post.sub_category) : ""; const sub = post?.sub_category ? String(post.sub_category) : "";
const source = author || sub || ""; const clusterCount = Array.isArray(post?.cluster_members) ? post.cluster_members.length : 0;
const source = clusterCount > 1 ? `${clusterCount} related reports` : (author || sub || "");
const summary = post?.snippet || post?.body || ""; const summary = post?.snippet || post?.body || "";
const url = post?.url || ""; const url = post?.url || "";
const rawImage = post?.image_large || post?.image_small || post?.image || post?.media_url || ""; const size = (opts.size || "full").toString();
const rawImage =
size === "mini"
? (post?.image_small || post?.image_medium || post?.image || post?.media_url || "")
: (post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || "");
const image = normalizeImageUrl(rawImage) || `/og/category?cat=${encodeURIComponent((post?.category || "NEWS").toString().toUpperCase())}&variant=mini`; const image = normalizeImageUrl(rawImage) || `/og/category?cat=${encodeURIComponent((post?.category || "NEWS").toString().toUpperCase())}&variant=mini`;
const categoryIcon = getCategoryIcon(post); const categoryIcon = getCategoryIcon(post);
return { headline, source, summary, url, image, categoryIcon }; return { headline, source, summary, url, image, categoryIcon };
@ -280,3 +310,13 @@ function normalizeImageUrl(raw) {
if (value.startsWith("http://")) return `https://${value.slice(7)}`; if (value.startsWith("http://")) return `https://${value.slice(7)}`;
return value; return value;
} }
function isClusterPost(post) {
const clusterPostId = Number(post?.cluster_post_id);
const postId = Number(post?.post_id);
if (Number.isFinite(clusterPostId) && Number.isFinite(postId) && clusterPostId > 0 && postId > 0) {
return clusterPostId === postId;
}
const url = (post?.url || "").toString();
return url.includes("/cluster/");
}

View File

@ -115,6 +115,16 @@ export function useMapCore(theme) {
map.on("load", () => { map.on("load", () => {
setViewParams(getViewFromMap(map)); setViewParams(getViewFromMap(map));
// Don't force pitch on load - let it stay as configured // Don't force pitch on load - let it stay as configured
try {
map.__swForceFetchAt = Date.now();
map.fire("moveend");
setTimeout(() => {
try {
map.__swForceFetchAt = Date.now();
map.fire("moveend");
} catch {}
}, 650);
} catch {}
}); });
map.on("moveend", () => { map.on("moveend", () => {

View File

@ -11,6 +11,15 @@ function getId(p) {
return p?.id ?? p?._id ?? null; return p?.id ?? p?._id ?? null;
} }
function normalizePostId(post) {
if (!post || typeof post !== "object") return post;
const pid = Number(post?.post_id ?? post?.postId ?? post?.postID ?? 0);
if (Number.isFinite(pid) && pid > 0 && Number(post?.id) !== pid) {
return { ...post, reco_id: post?.id ?? post?.reco_id, id: pid };
}
return post;
}
function getPostKey(p) { function getPostKey(p) {
const id = getId(p); const id = getId(p);
if (id != null && id !== 0) return `id:${id}`; if (id != null && id !== 0) return `id:${id}`;
@ -46,8 +55,9 @@ function getTemplateKey(post) {
function resolvePostImage(post) { function resolvePostImage(post) {
return ( return (
post?.image_small || post?.image_small ||
post?.image || post?.image_medium ||
post?.image_large || post?.image_large ||
post?.image ||
post?.media_url || post?.media_url ||
post?.image_url || post?.image_url ||
"" ""
@ -131,6 +141,142 @@ function getPostTimeMs(p) {
return d.getTime(); return d.getTime();
} }
function isClusterMainPost(p) {
const clusterPostId = Number(p?.cluster_post_id);
const postId = Number(p?.post_id || p?.id);
if (Number.isFinite(clusterPostId) && Number.isFinite(postId) && clusterPostId > 0 && postId > 0) {
return clusterPostId === postId;
}
const key = (p?.cluster_key || p?.clusterKey || "").toString().trim();
const members = Array.isArray(p?.cluster_members) ? p.cluster_members : [];
if (key && members.length > 0) return true;
const url = (p?.url || "").toString();
return url.includes("/cluster/");
}
function clusterColor(post) {
const c = (post?.category || "NEWS").toString().toUpperCase();
if (c === "EVENT" || c === "EVENTS") return "#a855f7";
if (c === "FRIENDS") return "#22c55e";
if (c === "MARKET") return "#facc15";
return "#38bdf8";
}
function clusterLineColor(post) {
const c = (post?.category || "NEWS").toString().toUpperCase();
if (c === "EVENT" || c === "EVENTS") return "rgba(168,85,247,0.7)";
if (c === "FRIENDS") return "rgba(34,197,94,0.7)";
if (c === "MARKET") return "rgba(250,204,21,0.8)";
return "rgba(56,189,248,0.75)";
}
function convexHull(points) {
if (!Array.isArray(points) || points.length < 3) return points || [];
const pts = [...points].sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]));
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower = [];
for (const p of pts) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
lower.pop();
}
lower.push(p);
}
const upper = [];
for (let i = pts.length - 1; i >= 0; i--) {
const p = pts[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
upper.pop();
}
upper.push(p);
}
upper.pop();
lower.pop();
return lower.concat(upper);
}
function chaikinSmooth(points, iterations = 2) {
let pts = points.slice();
if (pts.length < 4) return pts;
for (let i = 0; i < iterations; i++) {
const next = [];
for (let j = 0; j < pts.length - 1; j++) {
const p0 = pts[j];
const p1 = pts[j + 1];
const q = [0.75 * p0[0] + 0.25 * p1[0], 0.75 * p0[1] + 0.25 * p1[1]];
const r = [0.25 * p0[0] + 0.75 * p1[0], 0.25 * p0[1] + 0.75 * p1[1]];
next.push(q, r);
}
next.push(next[0]);
pts = next;
}
return pts;
}
function circlePolygon(center, radiusKm, steps = 24) {
const [lon, lat] = center;
const latDeg = radiusKm / 111;
const lonDeg = radiusKm / (111 * Math.cos((lat * Math.PI) / 180) || 1);
const coords = [];
for (let i = 0; i <= steps; i++) {
const angle = (Math.PI * 2 * i) / steps;
coords.push([lon + Math.cos(angle) * lonDeg, lat + Math.sin(angle) * latDeg]);
}
return coords;
}
function buildClusterAreaFeatures(posts) {
const features = [];
for (const p of posts || []) {
if (!isClusterMainPost(p)) continue;
const items = Array.isArray(p?.cluster_items) ? p.cluster_items : [];
const pts = items
.map((it) => [Number(it?.lon), Number(it?.lat)])
.filter((v) => Number.isFinite(v[0]) && Number.isFinite(v[1]));
if (pts.length === 0) continue;
let ring = [];
if (pts.length >= 3) {
ring = convexHull(pts);
if (ring.length < 3) {
ring = pts.slice(0, 3);
}
ring = [...ring, ring[0]];
ring = chaikinSmooth(ring, 2);
// Slight outward nudge so the bubble touches all points
const center = ring.slice(0, -1).reduce((acc, p) => [acc[0] + p[0], acc[1] + p[1]], [0, 0]);
const count = Math.max(1, ring.length - 1);
const centerPt = [center[0] / count, center[1] / count];
ring = ring.map((p, idx) => {
if (idx === ring.length - 1) return p;
const dx = p[0] - centerPt[0];
const dy = p[1] - centerPt[1];
return [centerPt[0] + dx * 1.32, centerPt[1] + dy * 1.32];
});
ring[ring.length - 1] = ring[0];
} else {
const centerLon = pts.reduce((s, v) => s + v[0], 0) / pts.length;
const centerLat = pts.reduce((s, v) => s + v[1], 0) / pts.length;
let maxKm = 0.0;
for (const [lon, lat] of pts) {
maxKm = Math.max(maxKm, haversineKm(centerLat, centerLon, lat, lon));
}
const radius = Math.min(80, Math.max(0.4, maxKm * 1.55 || 0.6));
ring = circlePolygon([centerLon, centerLat], radius, 28);
}
features.push({
type: "Feature",
geometry: { type: "Polygon", coordinates: [ring] },
properties: {
id: getId(p) || 0,
color: clusterColor(p),
line: clusterLineColor(p),
},
});
}
return features;
}
const MAX_POOL_POSTS = 75; const MAX_POOL_POSTS = 75;
const MAX_VISIBLE_POSTS = 45; const MAX_VISIBLE_POSTS = 45;
@ -159,6 +305,11 @@ export function usePostsEngine({
subFilter, subFilter,
timeFilter, timeFilter,
searchQuery, searchQuery,
clusterFocus,
mainNewsOnly,
clustersEnabled = true,
heatmapEnabled = true,
forceFullPostId = null,
markersRef, markersRef,
expandedElRef, expandedElRef,
onAutoWidenTimeFilter, onAutoWidenTimeFilter,
@ -174,15 +325,20 @@ export function usePostsEngine({
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
const initialFetchKickRef = useRef(false); const initialFetchKickRef = useRef(false);
const autoWidenRef = useRef(false); const autoWidenRef = useRef(false);
const searchResultsRef = useRef(false);
const retryRef = useRef({ key: "", count: 0, ts: 0 });
const [mapReady, setMapReady] = useState(false); const [mapReady, setMapReady] = useState(false);
const clusterModeRef = useRef(false); const clusterModeRef = useRef(false);
const CLUSTER_THRESHOLD = 140; const CLUSTER_THRESHOLD = 140;
const SIMPLE_MARKER_LIMIT = 40; const SIMPLE_MARKER_LIMIT = 50;
const CLUSTER_SOURCE_ID = "sw-posts"; const CLUSTER_SOURCE_ID = "sw-posts";
const CLUSTER_LAYER_ID = "sw-clusters"; const CLUSTER_LAYER_ID = "sw-clusters";
const CLUSTER_COUNT_ID = "sw-cluster-count"; const CLUSTER_COUNT_ID = "sw-cluster-count";
const UNCLUSTERED_ID = "sw-unclustered"; const UNCLUSTERED_ID = "sw-unclustered";
const CLUSTER_AREA_SOURCE_ID = "sw-cluster-areas";
const CLUSTER_AREA_FILL_ID = "sw-cluster-area-fill";
const CLUSTER_AREA_LINE_ID = "sw-cluster-area-line";
useEffect(() => { useEffect(() => {
if (mapReady) return; if (mapReady) return;
@ -215,7 +371,7 @@ export function usePostsEngine({
}, [mapReady, mapRef]); }, [mapReady, mapRef]);
const priorityOpts = { const priorityOpts = {
maxFullCards: 12, maxFullCards: 15,
clusterRadius: 0.002, clusterRadius: 0.002,
minScoreForCard: 50, minScoreForCard: 50,
}; };
@ -333,6 +489,38 @@ export function usePostsEngine({
} }
}, [onSelectPost]); }, [onSelectPost]);
const ensureClusterAreaLayers = useCallback((map) => {
if (!map) return;
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
if (map.getSource(CLUSTER_AREA_SOURCE_ID)) return;
map.addSource(CLUSTER_AREA_SOURCE_ID, {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: CLUSTER_AREA_FILL_ID,
type: "fill",
source: CLUSTER_AREA_SOURCE_ID,
paint: {
"fill-color": ["get", "color"],
"fill-opacity": 0.42,
},
});
map.addLayer({
id: CLUSTER_AREA_LINE_ID,
type: "line",
source: CLUSTER_AREA_SOURCE_ID,
paint: {
"line-color": ["get", "line"],
"line-width": 2,
"line-opacity": 0.7,
},
});
}, [CLUSTER_AREA_SOURCE_ID, CLUSTER_AREA_FILL_ID, CLUSTER_AREA_LINE_ID]);
const setClusterVisibility = useCallback((map, visible) => { const setClusterVisibility = useCallback((map, visible) => {
if (!map) return; if (!map) return;
const v = visible ? "visible" : "none"; const v = visible ? "visible" : "none";
@ -341,6 +529,14 @@ export function usePostsEngine({
if (map.getLayer(UNCLUSTERED_ID)) map.setLayoutProperty(UNCLUSTERED_ID, "visibility", v); if (map.getLayer(UNCLUSTERED_ID)) map.setLayoutProperty(UNCLUSTERED_ID, "visibility", v);
}, []); }, []);
const updateClusterAreas = useCallback((map, posts) => {
if (!map) return;
const src = map.getSource(CLUSTER_AREA_SOURCE_ID);
if (!src || !src.setData) return;
const features = buildClusterAreaFeatures(posts || []);
src.setData({ type: "FeatureCollection", features });
}, [CLUSTER_AREA_SOURCE_ID]);
const updateClusterData = useCallback((map, posts) => { const updateClusterData = useCallback((map, posts) => {
if (!map) return; if (!map) return;
const src = map.getSource(CLUSTER_SOURCE_ID); const src = map.getSource(CLUSTER_SOURCE_ID);
@ -380,6 +576,10 @@ export function usePostsEngine({
(visible) => { (visible) => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return { fullCardPosts: [], clusterPosts: visible || [] }; if (!map) return { fullCardPosts: [], clusterPosts: visible || [] };
const searchActive = (searchQuery || "").trim().length > 0;
if (searchActive) {
return { fullCardPosts: [], clusterPosts: visible || [] };
}
const center = map.getCenter(); const center = map.getCenter();
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null; const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
const { fullCardPosts, simpleMarkerPosts } = prioritizePosts( const { fullCardPosts, simpleMarkerPosts } = prioritizePosts(
@ -389,7 +589,7 @@ export function usePostsEngine({
); );
return { fullCardPosts, clusterPosts: simpleMarkerPosts }; return { fullCardPosts, clusterPosts: simpleMarkerPosts };
}, },
[mapRef, priorityOpts] [mapRef, priorityOpts, searchQuery]
); );
const syncPriorityMarkers = useCallback( const syncPriorityMarkers = useCallback(
@ -458,17 +658,27 @@ export function usePostsEngine({
map.__swMarkersRef = markersRef; map.__swMarkersRef = markersRef;
// If user is reading an expanded overlay, don't touch markers // If user is reading an expanded overlay, don't touch markers
if (expandedElRef?.current) return; if (expandedElRef?.current || map.__swCenteredOverlay) return;
// Use priority system to decide which posts show full cards vs simple markers // Use priority system to decide which posts show full cards vs simple markers
const center = map.getCenter(); const center = map.getCenter();
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null; const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
const { fullCardPosts, simpleMarkerPosts } = prioritizePosts(visible, viewCenter, { const searchActive = (searchQuery || "").trim().length > 0;
maxFullCards: 12, // Max 12 cartes complètes (réduit de 15) let fullCardPosts = [];
clusterRadius: 0.002, // ~200m clustering radius (augmenté pour plus de groupement) let simpleMarkerPosts = [];
minScoreForCard: 50, // Score minimum pour carte complète (augmenté de 35 à 50) if (searchActive) {
}); fullCardPosts = visible || [];
simpleMarkerPosts = [];
} else {
const prioritized = prioritizePosts(visible, viewCenter, {
maxFullCards: 15, // 10-15 cartes complètes selon le zoom
clusterRadius: 0.002, // ~200m clustering radius (augmenté pour plus de groupement)
minScoreForCard: 50, // Score minimum pour carte complète (augmenté de 35 à 50)
});
fullCardPosts = prioritized.fullCardPosts;
simpleMarkerPosts = prioritized.simpleMarkerPosts;
}
console.log('[usePostsEngine] Prioritized:', { console.log('[usePostsEngine] Prioritized:', {
total: visible.length, total: visible.length,
@ -482,7 +692,9 @@ export function usePostsEngine({
const id = getId(post); const id = getId(post);
if (id != null) { if (id != null) {
// Tag avec le type de marqueur souhaité // Tag avec le type de marqueur souhaité
const isFullCard = fullCardPosts.some(p => getId(p) === id); const isFullCard =
id === forceFullPostId ||
fullCardPosts.some(p => getId(p) === id);
wanted.set(id, { post, type: isFullCard ? 'full' : 'simple' }); wanted.set(id, { post, type: isFullCard ? 'full' : 'simple' });
} }
} }
@ -548,36 +760,58 @@ export function usePostsEngine({
} }
} }
}, },
[mapRef, markersRef, expandedElRef, theme] [mapRef, markersRef, expandedElRef, theme, searchQuery, forceFullPostId]
); );
const computeVisible = useCallback( const computeVisible = useCallback(
(tf) => { (tf) => {
const posts = allPostsRef.current || []; const basePosts = allPostsRef.current || [];
const extraPosts = Array.isArray(clusterFocus?.posts) ? clusterFocus.posts : [];
const posts = extraPosts.length
? Array.from(new Map([...extraPosts, ...basePosts].map((p) => {
const normalized = normalizePostId(p);
return [getPostKey(normalized), normalized];
})).values())
: basePosts;
const catCode = categoryCode(mainFilter); const catCode = categoryCode(mainFilter);
const clusterIDs = clustersEnabled && Array.isArray(clusterFocus?.ids) ? clusterFocus.ids : null;
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
return posts.filter((p) => { return posts.filter((p) => {
if (isClusterFocus) {
const id = Number(getId(p));
const clusterPostId = Number(p?.cluster_post_id);
if (clusterIDs && !clusterIDs.includes(id) && !clusterIDs.includes(clusterPostId)) {
return false;
}
return true;
}
if (clustersEnabled && mainNewsOnly && !isClusterMainPost(p)) return false;
if (catCode) { if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase(); const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false; if (pc !== catCode) return false;
} }
if (!matchesSubFilter(p, subFilter)) return false; if (!matchesSubFilter(p, subFilter)) return false;
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, tf)) return false; if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, tf)) return false;
if (!matchesSearch(p, searchQuery)) return false; if (!searchResultsRef.current && !matchesSearch(p, searchQuery)) return false;
return true; return true;
}); });
}, },
[mainFilter, subFilter, searchQuery] [mainFilter, subFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
); );
const refreshVisibleAndMarkers = useCallback( const refreshVisibleAndMarkers = useCallback(
(tf, force = false) => { (tf, force = false) => {
const vAll = computeVisible(tf); const vAll = computeVisible(tf);
const map = mapRef.current; const map = mapRef.current;
let v = (searchQuery || "").trim() ? vAll : filterByBounds(vAll, map); const isClusterFocus = !!(clustersEnabled && Array.isArray(clusterFocus?.ids) && clusterFocus.ids.length > 0);
if (v.length > MAX_VISIBLE_POSTS) { let v = vAll;
const center = map?.getCenter?.(); if (!isClusterFocus) {
v = prunePosts(v, [center?.lng, center?.lat], MAX_VISIBLE_POSTS); v = filterByBounds(vAll, map);
if (v.length > MAX_VISIBLE_POSTS) {
const center = map?.getCenter?.();
v = prunePosts(v, [center?.lng, center?.lat], MAX_VISIBLE_POSTS);
}
} }
// reduce wall blink: don't update if same ids // reduce wall blink: don't update if same ids
@ -589,7 +823,12 @@ export function usePostsEngine({
// keep status silent (no annoying bubbles) // keep status silent (no annoying bubbles)
setStatus(""); setStatus("");
if (map && v.length >= CLUSTER_THRESHOLD) { if (map && clustersEnabled && heatmapEnabled) {
ensureClusterAreaLayers(map);
updateClusterAreas(map, v);
}
if (clustersEnabled && map && v.length >= CLUSTER_THRESHOLD) {
const { fullCardPosts, clusterPosts } = splitForClusters(v); const { fullCardPosts, clusterPosts } = splitForClusters(v);
const simpleVisible = clusterPosts.slice(0, SIMPLE_MARKER_LIMIT); const simpleVisible = clusterPosts.slice(0, SIMPLE_MARKER_LIMIT);
const clusterRemainder = clusterPosts.slice(SIMPLE_MARKER_LIMIT); const clusterRemainder = clusterPosts.slice(SIMPLE_MARKER_LIMIT);
@ -609,13 +848,22 @@ export function usePostsEngine({
syncMarkers(v); syncMarkers(v);
}, },
[computeVisible, syncMarkers, mapRef, ensureClusterLayers, updateClusterData, setClusterVisibility, splitForClusters, syncPriorityMarkers, viewParams] [computeVisible, syncMarkers, mapRef, ensureClusterLayers, ensureClusterAreaLayers, updateClusterAreas, updateClusterData, setClusterVisibility, splitForClusters, syncPriorityMarkers, viewParams, clustersEnabled, heatmapEnabled, clusterFocus, searchQuery]
); );
// On filter/search changes: recompute + sync (NO clear-all rebuild) // On filter/search changes: recompute + sync (NO clear-all rebuild)
useEffect(() => { useEffect(() => {
refreshVisibleAndMarkers(timeFilter); refreshVisibleAndMarkers(timeFilter);
}, [timeFilter, mainFilter, subFilter, searchQuery, refreshVisibleAndMarkers]); }, [timeFilter, mainFilter, subFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
useEffect(() => {
if (!clustersEnabled) return;
if (!clusterFocus) return;
if (Array.isArray(clusterFocus.posts) && clusterFocus.posts.length > 0) {
allPostsRef.current = clusterFocus.posts;
}
refreshVisibleAndMarkers(timeFilter, true);
}, [clusterFocus, timeFilter, refreshVisibleAndMarkers, clustersEnabled]);
// On view change: re-filter markers/cards to current bounds even without fetch // On view change: re-filter markers/cards to current bounds even without fetch
useEffect(() => { useEffect(() => {
@ -637,7 +885,7 @@ export function usePostsEngine({
if ((searchQuery || "").trim()) return; if ((searchQuery || "").trim()) return;
// If expanded overlay is open: delay fetch (do NOT disturb UI) // If expanded overlay is open: delay fetch (do NOT disturb UI)
if (expandedElRef?.current) { if (expandedElRef?.current || mapObj?.__swCenteredOverlay) {
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current); if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, 1200); delayedFetchRef.current = setTimeout(load, 1200);
return; return;
@ -716,7 +964,7 @@ export function usePostsEngine({
lat, lat,
lon: lng, lon: lng,
radius_km: radiusKm, radius_km: radiusKm,
limit: 45, limit: 50,
zoom, zoom,
bounds, bounds,
username: username || undefined, username: username || undefined,
@ -727,18 +975,20 @@ export function usePostsEngine({
const existing = allPostsRef.current || []; const existing = allPostsRef.current || [];
const byId = new Map(); const byId = new Map();
for (const p of existing) { for (const p of existing) {
const key = getPostKey(p); const normalized = normalizePostId(p);
const key = getPostKey(normalized);
if (key) byId.set(key, p); if (key) byId.set(key, p);
} }
if (Array.isArray(newPosts)) { if (Array.isArray(newPosts)) {
for (const p of newPosts) { for (const p of newPosts) {
const key = getPostKey(p); const normalized = normalizePostId(p);
const key = getPostKey(normalized);
if (!key) continue; if (!key) continue;
const prior = byId.get(key); const prior = byId.get(key);
if (prior) { if (prior) {
byId.set(key, { ...prior, ...p }); byId.set(key, { ...prior, ...normalized });
} else { } else {
byId.set(key, p); byId.set(key, normalized);
} }
} }
} }
@ -776,7 +1026,19 @@ export function usePostsEngine({
setLoadingPosts(false); setLoadingPosts(false);
} catch (err) { } catch (err) {
setLoadingPosts(false);
setLoadError("fetch failed");
const key = `${filterKey}|${Math.round(lat * 100)}|${Math.round(lng * 100)}|${Math.round(radiusKm)}`;
const now = Date.now();
if (retryRef.current.key !== key || now - retryRef.current.ts > 5000) {
retryRef.current = { key, count: 0, ts: now };
}
if (retryRef.current.count < 1) {
retryRef.current.count += 1;
retryRef.current.ts = now;
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
delayedFetchRef.current = setTimeout(load, 650);
}
} }
} }
@ -784,8 +1046,12 @@ export function usePostsEngine({
clearTimeout(moveDebounceRef.current); clearTimeout(moveDebounceRef.current);
moveDebounceRef.current = null; moveDebounceRef.current = null;
} }
initialLoadRef.current = false; if (initialLoadRef.current) {
load(); initialLoadRef.current = false;
load();
} else {
moveDebounceRef.current = setTimeout(load, 300);
}
return () => { return () => {
cancelled = true; cancelled = true;
if (moveDebounceRef.current) { if (moveDebounceRef.current) {
@ -822,7 +1088,9 @@ export function usePostsEngine({
}); });
if (cancelled) return; if (cancelled) return;
const posts = Array.isArray(data?.posts) ? data.posts : []; const posts = Array.isArray(data?.posts) ? data.posts : [];
allPostsRef.current = prunePosts(posts, [lon, lat], MAX_POOL_POSTS); const normalized = posts.map((p) => normalizePostId(p));
allPostsRef.current = prunePosts(normalized, [lon, lat], MAX_POOL_POSTS);
searchResultsRef.current = true;
refreshVisibleAndMarkers(timeFilter); refreshVisibleAndMarkers(timeFilter);
trackEvent("search_filter_apply", { trackEvent("search_filter_apply", {
query_len: q.length, query_len: q.length,
@ -838,10 +1106,16 @@ export function usePostsEngine({
}; };
}, [searchQuery, viewParams, mapRef, refreshVisibleAndMarkers, timeFilter]); }, [searchQuery, viewParams, mapRef, refreshVisibleAndMarkers, timeFilter]);
useEffect(() => {
if ((searchQuery || "").trim()) return;
searchResultsRef.current = false;
}, [searchQuery]);
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
const handleStyle = () => { const handleStyle = () => {
if (!clustersEnabled) return;
if (!clusterModeRef.current) return; if (!clusterModeRef.current) return;
ensureClusterLayers(map); ensureClusterLayers(map);
setClusterVisibility(map, true); setClusterVisibility(map, true);
@ -851,7 +1125,7 @@ export function usePostsEngine({
}; };
map.on("styledata", handleStyle); map.on("styledata", handleStyle);
return () => map.off("styledata", handleStyle); return () => map.off("styledata", handleStyle);
}, [mapRef, ensureClusterLayers, setClusterVisibility, updateClusterData, computeVisible, timeFilter, splitForClusters]); }, [mapRef, ensureClusterLayers, setClusterVisibility, updateClusterData, computeVisible, timeFilter, splitForClusters, clustersEnabled]);
const handleIncomingPost = useCallback( const handleIncomingPost = useCallback(
(p) => { (p) => {
@ -862,7 +1136,8 @@ export function usePostsEngine({
} }
const map = mapRef.current; const map = mapRef.current;
const center = map?.getCenter?.(); const center = map?.getCenter?.();
allPostsRef.current = prunePosts([p, ...allPostsRef.current], [center?.lng, center?.lat], MAX_POOL_POSTS); const normalized = normalizePostId(p);
allPostsRef.current = prunePosts([normalized, ...allPostsRef.current], [center?.lng, center?.lat], MAX_POOL_POSTS);
const catCode = categoryCode(mainFilter); const catCode = categoryCode(mainFilter);
if (catCode) { if (catCode) {
@ -879,13 +1154,13 @@ export function usePostsEngine({
} }
// add marker if missing // add marker if missing
const id = getId(p); const id = getId(normalized);
const have = new Set((markersRef.current || []).map((x) => x.id)); const have = new Set((markersRef.current || []).map((x) => x.id));
if (id != null && !have.has(id)) { if (id != null && !have.has(id)) {
createMarkerForPost(p, mapRef, markersRef, expandedElRef, theme); createMarkerForPost(normalized, mapRef, markersRef, expandedElRef, theme);
} }
setVisiblePosts((current) => [p, ...current]); setVisiblePosts((current) => [normalized, ...current]);
}, },
[mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers] [mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers]
); );
@ -917,7 +1192,7 @@ export function usePostsEngine({
updated.push(p); updated.push(p);
continue; continue;
} }
const next = { ...p, ...(postPatch || {}) }; const next = normalizePostId({ ...p, ...(postPatch || {}) });
if (counts) { if (counts) {
if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count; if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count;
if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count; if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count;

View File

@ -187,7 +187,8 @@ export default function PostCard({ post, selected, onSelect }) {
setPostGroupId((post?.group_id || "").toString()); setPostGroupId((post?.group_id || "").toString());
}, [post?.visibility, post?.group_id]); }, [post?.visibility, post?.group_id]);
const rawImg = post?.image_large || post?.image_small || post?.image || post?.media_url || ""; const rawImg =
post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || "";
const img = normalizeImageUrl(rawImg); const img = normalizeImageUrl(rawImg);
const title = post?.title || "Untitled"; const title = post?.title || "Untitled";
const snippet = post?.snippet || ""; const snippet = post?.snippet || "";

View File

@ -22,7 +22,7 @@ export default function PostCardTemplate({ post, theme = "blue", onOpen }) {
source: post.author ? `by ${post.author}` : (post.sub_category || ""), source: post.author ? `by ${post.author}` : (post.sub_category || ""),
summary: post.snippet || post.body || "", summary: post.snippet || post.body || "",
url: post.url || "", url: post.url || "",
image: post.image || post.media_url || "", image: post.image_medium || post.image_large || post.image_small || post.image || post.media_url || "",
}; };
}, [post]); }, [post]);

View File

@ -79,6 +79,7 @@ export default function SmartSearchBar({
const abortRef = useRef(null); const abortRef = useRef(null);
const skipSearchRef = useRef(false); const skipSearchRef = useRef(false);
const boxRef = useRef(null); const boxRef = useRef(null);
const inputRef = useRef(null);
useEffect(() => { useEffect(() => {
function onDocDown(e) { function onDocDown(e) {
@ -137,13 +138,14 @@ export default function SmartSearchBar({
}, [q]); }, [q]);
const showList = open && (items.length > 0 || loading || err); const showList = open && (items.length > 0 || loading || err);
const clearOnFocusRef = useRef(false);
const pick = (it, reason = "click") => { const pick = (it, reason = "click") => {
if (!it) return; if (!it) return;
const zoom = defaultZoomForKind(it.kind); const zoom = defaultZoomForKind(it.kind);
skipSearchRef.current = true; skipSearchRef.current = true;
setOpen(false); setOpen(false);
setQ(it.title || q); clearOnFocusRef.current = true;
trackEvent("search_pick", { trackEvent("search_pick", {
kind: it.kind || "", kind: it.kind || "",
@ -152,6 +154,9 @@ export default function SmartSearchBar({
title_len: (it.title || "").length, title_len: (it.title || "").length,
}); });
onPick && onPick(it, { zoom, reason }); onPick && onPick(it, { zoom, reason });
if (inputRef.current) {
try { inputRef.current.blur(); } catch {}
}
}; };
const onKeyDown = (e) => { const onKeyDown = (e) => {
@ -171,6 +176,9 @@ export default function SmartSearchBar({
setOpen(false); setOpen(false);
trackEvent("search_submit", { query_len: q.trim().length }); trackEvent("search_submit", { query_len: q.trim().length });
onSearch && onSearch(q.trim()); onSearch && onSearch(q.trim());
if (inputRef.current) {
try { inputRef.current.blur(); } catch {}
}
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
setOpen(false); setOpen(false);
@ -194,6 +202,7 @@ export default function SmartSearchBar({
<div className="sw-search" ref={boxRef}> <div className="sw-search" ref={boxRef}>
<div className="sw-search__bar"> <div className="sw-search__bar">
<input <input
ref={inputRef}
className="sw-search__input" className="sw-search__input"
value={q} value={q}
onChange={(e) => { onChange={(e) => {
@ -205,10 +214,13 @@ export default function SmartSearchBar({
} }
}} }}
onFocus={() => { onFocus={() => {
if (q.trim()) { if (clearOnFocusRef.current) {
clearOnFocusRef.current = false;
setQ(""); setQ("");
setItems([]); setItems([]);
setOpen(true);
onSearch && onSearch(""); onSearch && onSearch("");
return;
} }
setOpen(true); setOpen(true);
// Si pas encore cherché et query vide, charger suggestions populaires // Si pas encore cherché et query vide, charger suggestions populaires
@ -261,12 +273,25 @@ export default function SmartSearchBar({
key={it.id} key={it.id}
className={"sw-search__row sw-search__item" + (idx === active ? " is-active" : "")} className={"sw-search__row sw-search__item" + (idx === active ? " is-active" : "")}
onMouseEnter={() => setActive(idx)} onMouseEnter={() => setActive(idx)}
onClick={() => pick(it, "click")} onMouseDown={(e) => {
e.preventDefault();
pick(it, "click");
}}
onClick={(e) => {
e.preventDefault();
}}
> >
<div className="sw-search__title">{truncateText(it.title || "Untitled", 140)}</div> <div className="sw-search__title">{truncateText(it.title || "Untitled", 140)}</div>
<div className="sw-search__sub"> <div className="sw-search__sub">
{truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)} {truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)}
</div> </div>
{Array.isArray(it.tags) && it.tags.length > 0 && (
<div className="sw-search__tags">
{it.tags.slice(0, 4).map((tag) => (
<span key={tag} className="sw-search__tag">#{tag}</span>
))}
</div>
)}
</button> </button>
))} ))}

View File

@ -42,6 +42,12 @@ function normalizeOne(r) {
const type = (r.type ?? r.kind ?? r.entity ?? "").toString().toLowerCase().trim(); const type = (r.type ?? r.kind ?? r.entity ?? "").toString().toLowerCase().trim();
const kind = type || (coords ? "place" : "item"); const kind = type || (coords ? "place" : "item");
const tags =
Array.isArray(r.event_tags)
? r.event_tags
: Array.isArray(r.tags)
? r.tags
: [];
return { return {
id: (r.id ?? r._id ?? r.key ?? title).toString(), id: (r.id ?? r._id ?? r.key ?? title).toString(),
@ -52,6 +58,7 @@ function normalizeOne(r) {
coords, coords,
lat, lat,
lon, lon,
tags,
raw: r, raw: r,
}; };
} }

View File

@ -16,6 +16,11 @@
.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-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-icon-active .sw-icon-label { color:#bfdbfe; font-weight:600; }
/* Right-side filter stack alignment */
.map-overlay-right .sw-icon-column { align-items:flex-end; }
.map-overlay-right .sw-icon-btn { align-items:flex-end; }
.map-overlay-right .sw-icon-label { text-align:right; }
.sw-bottom-row { .sw-bottom-row {
display:flex; display:flex;
gap:.35rem; gap:.35rem;

View File

@ -53,6 +53,8 @@
position: relative; position: relative;
width: 92%; width: 92%;
max-width: 1300px; max-width: 1300px;
max-height: 92vh;
overflow-y: auto;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%); background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
border-radius: 24px; border-radius: 24px;
padding: 2.5rem; padding: 2.5rem;
@ -64,6 +66,19 @@
border: 1px solid rgba(56, 189, 248, 0.2); border: 1px solid rgba(56, 189, 248, 0.2);
} }
.island-viewer-container::-webkit-scrollbar {
width: 10px;
}
.island-viewer-container::-webkit-scrollbar-thumb {
background: rgba(56, 189, 248, 0.35);
border-radius: 999px;
border: 2px solid rgba(15, 23, 42, 0.9);
}
.island-viewer-container::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.5);
border-radius: 999px;
}
body[data-theme="blue"] .island-viewer-modal{ body[data-theme="blue"] .island-viewer-modal{
background: linear-gradient(135deg, rgba(3, 12, 40, 0.95) 0%, rgba(2, 8, 28, 0.98) 100%); background: linear-gradient(135deg, rgba(3, 12, 40, 0.95) 0%, rgba(2, 8, 28, 0.98) 100%);
} }
@ -204,7 +219,10 @@ body[data-theme="blue"] .island-viewer-container{
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.6); background: linear-gradient(135deg, rgba(30, 58, 138, 0.35), rgba(15, 23, 42, 0.7));
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@ -284,10 +302,18 @@ body[data-theme="blue"] .island-viewer-container{
min-width: 0; min-width: 0;
} }
.island-profile-name { .island-profile-name {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 800; font-weight: 800;
color: #e2e8f0; color: #e2e8f0;
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
padding: 0.2rem 0.65rem;
border-radius: 999px;
background: rgba(2, 6, 23, 0.5);
border: 1px solid rgba(148, 163, 184, 0.2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
} }
.island-profile-actions { .island-profile-actions {
display: flex; display: flex;
@ -311,6 +337,15 @@ body[data-theme="blue"] .island-viewer-container{
font-size: 0.75rem; font-size: 0.75rem;
color: #cbd5f5; color: #cbd5f5;
} }
.island-profile-inline {
flex: 1;
display: flex;
align-items: center;
gap: 0.4rem;
}
.island-profile-inline .island-profile-input {
flex: 1;
}
.island-profile-row select { .island-profile-row select {
flex: 1; flex: 1;
background: rgba(15, 23, 42, 0.6); background: rgba(15, 23, 42, 0.6);

View File

@ -83,7 +83,7 @@
/* Left / right: filtres */ /* Left / right: filtres */
.map-overlay-left{ .map-overlay-left{
left:.7rem; left:.7rem;
top:44%; top:50%;
transform:translateY(-50%); transform:translateY(-50%);
display:flex; display:flex;
flex-direction:column; flex-direction:column;
@ -91,13 +91,14 @@
} }
.map-overlay-right{ .map-overlay-right{
right:.7rem; right:.7rem;
top:44%; top:50%;
transform:translateY(-50%); transform:translateY(-50%);
display:flex; display:flex;
flex-direction:column; flex-direction:column;
gap:.5rem; gap:.5rem;
} }
/* BOTTOM: sub-cats au-dessus du Sociowall */ /* BOTTOM: sub-cats au-dessus du Sociowall */
.map-overlay-bottom{ .map-overlay-bottom{
bottom: calc(3.2rem + var(--sw-below-overlap, 0px) + env(safe-area-inset-bottom) + var(--sw-subcats-nudge, 0px)); bottom: calc(3.2rem + var(--sw-below-overlap, 0px) + env(safe-area-inset-bottom) + var(--sw-subcats-nudge, 0px));
@ -230,6 +231,42 @@
gap:.5rem; gap:.5rem;
margin-bottom:.4rem; margin-bottom:.4rem;
} }
.create-link-row{
display:flex;
align-items:center;
gap:.5rem;
margin-bottom:.35rem;
}
.create-link-input{
flex:1;
margin-bottom:0;
}
.create-link-btn{
border-radius:999px;
border:1px solid rgba(148, 163, 184, 0.4);
background:#0f172a;
color:#e2e8f0;
padding:.35rem .7rem;
font-size:.7rem;
cursor:pointer;
white-space:nowrap;
}
.create-link-btn:disabled{
opacity:.6;
cursor:not-allowed;
}
.create-link-meta{
display:flex;
gap:.6rem;
font-size:.68rem;
color:#94a3b8;
margin-bottom:.35rem;
}
.create-link-error{
font-size:.68rem;
color:#f87171;
margin-bottom:.35rem;
}
.crosshair-label{ .crosshair-label{
font-size:.75rem; font-size:.75rem;
color:#cbd5f5; color:#cbd5f5;
@ -286,14 +323,69 @@
gap:.4rem; gap:.4rem;
margin-top:.35rem; margin-top:.35rem;
} }
.create-image-choice{
display:flex;
gap:.5rem;
align-items:center;
margin:.25rem 0 .35rem;
}
.create-image-choice-btn{
flex:1;
padding:.4rem .6rem;
border-radius:999px;
border:1px solid rgba(148, 163, 184, 0.35);
background:rgba(15, 23, 42, 0.6);
color:#e2e8f0;
font-size:.72rem;
font-weight:700;
cursor:pointer;
}
.create-image-choice-btn.is-active{
border-color: rgba(56, 189, 248, 0.7);
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
}
.create-image-placeholder{
display:flex;
align-items:center;
gap:.6rem;
padding:.6rem;
border-radius:12px;
border:1px dashed rgba(148, 163, 184, 0.4);
background:rgba(15, 23, 42, 0.45);
margin:.35rem 0 .65rem;
}
.create-image-placeholder-title{
font-size:.8rem;
font-weight:800;
color:#e2e8f0;
}
.create-image-placeholder-sub{
font-size:.7rem;
color:#94a3b8;
}
.create-image-row{ .create-image-row{
display:flex; display:flex;
gap:.5rem; gap:.5rem;
align-items:center; align-items:center;
} }
.create-image-row input[type="file"]{ .create-file-input{
flex:1; display:none;
}
.create-file-btn{
display:inline-flex;
align-items:center;
gap:.4rem;
padding:.35rem .7rem;
border-radius:999px;
border:1px solid rgba(148, 163, 184, 0.4);
color:#e2e8f0; color:#e2e8f0;
font-size:.7rem;
background:rgba(15, 23, 42, 0.8);
cursor:pointer;
}
.create-file-btn--camera{
border-color: rgba(56, 189, 248, 0.65);
color:#e0f2fe;
} }
.create-uploading{ .create-uploading{
font-size:.7rem; font-size:.7rem;
@ -319,6 +411,30 @@
border-radius:999px; border-radius:999px;
font-size:.68rem; font-size:.68rem;
} }
.create-link-preview{
display:flex;
align-items:center;
gap:.65rem;
padding:.45rem .6rem;
border-radius:12px;
border:1px solid rgba(148, 163, 184, 0.35);
background:rgba(15, 23, 42, 0.55);
margin-bottom:.6rem;
}
.create-link-preview img{
width:58px;
height:58px;
border-radius:10px;
object-fit:cover;
border:1px solid rgba(148, 163, 184, 0.5);
}
.create-link-preview-meta{
display:flex;
flex-direction:column;
gap:.2rem;
font-size:.7rem;
color:#cbd5f5;
}
.create-muted{ .create-muted{
font-size:.7rem; font-size:.7rem;
color:#94a3b8; color:#94a3b8;
@ -442,7 +558,8 @@ body[data-theme="blue"] .create-post-panel{
body[data-theme="blue"] .create-row select, body[data-theme="blue"] .create-row select,
body[data-theme="blue"] .create-input, body[data-theme="blue"] .create-input,
body[data-theme="blue"] .create-textarea, body[data-theme="blue"] .create-textarea,
body[data-theme="blue"] .create-privacy-row select{ body[data-theme="blue"] .create-privacy-row select,
body[data-theme="blue"] .create-link-btn{
background: #0b1b3b; background: #0b1b3b;
border-color: rgba(96, 165, 250, 0.5); border-color: rgba(96, 165, 250, 0.5);
color:#e2e8f0; color:#e2e8f0;
@ -476,7 +593,8 @@ body[data-theme="light"] .create-post-panel{
body[data-theme="light"] .create-row select, body[data-theme="light"] .create-row select,
body[data-theme="light"] .create-input, body[data-theme="light"] .create-input,
body[data-theme="light"] .create-textarea, body[data-theme="light"] .create-textarea,
body[data-theme="light"] .create-privacy-row select{ body[data-theme="light"] .create-privacy-row select,
body[data-theme="light"] .create-link-btn{
background:#ffffff; background:#ffffff;
border-color: rgba(148, 163, 184, 0.7); border-color: rgba(148, 163, 184, 0.7);
color:#0f172a; color:#0f172a;

View File

@ -607,7 +607,7 @@ body[data-theme="light"] .sw-chat-bubble::before{
} }
.sw-modal-hero{ .sw-modal-hero{
width:100%; width:auto;
height: 180px; height: 180px;
min-height: 180px; min-height: 180px;
flex: 1 1 auto; flex: 1 1 auto;
@ -991,8 +991,15 @@ body[data-theme="light"] .sw-modal-hero-fallback{
align-items: stretch; align-items: stretch;
} }
.sw-truth-rail{ .sw-truth-rail{
width: 12px; width: 22px;
min-width: 12px; min-width: 22px;
height: 140px;
min-height: 140px;
margin: 0 8px 0 0;
align-self: flex-start;
}
.sw-cluster-hero{
grid-template-columns: 28px 1fr;
} }
} }
@ -1080,15 +1087,145 @@ body[data-theme="light"] .sw-modal-x{
.post-pin.sw-appear{ .post-pin.sw-appear{
opacity: 0; opacity: 0;
transform: translate3d(0,0,0) scale(0.96); transition: opacity 640ms ease;
transition: opacity 840ms ease, transform 840ms ease;
} }
.post-pin.sw-appear.sw-appear-in{ .post-pin.sw-appear.sw-appear-in{
opacity: 1; opacity: 1;
transform: translate3d(0,0,0) scale(1);
} }
.post-pin.sw-disappear{ .post-pin.sw-disappear{
opacity: 0; opacity: 0;
transform: translate3d(0,0,0) scale(0.96); transition: opacity 640ms ease;
transition: opacity 840ms ease, transform 840ms ease; }
.post-pin--simple.sw-appear{
opacity: 0;
transition: opacity 520ms ease;
}
.post-pin--simple.sw-appear.sw-appear-in{
opacity: 1;
}
.post-pin--simple.sw-disappear{
opacity: 0;
transition: opacity 520ms ease;
}
/* Cluster main news layout */
.sw-cluster-modal .sw-modal-toprow{
margin-bottom: 6px;
}
.sw-cluster-title{
font-size: 22px;
font-weight: 800;
margin: 4px 0 14px;
color: var(--sw-modal-text, #e5e7eb);
}
.sw-modal-tags{
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: -6px 0 14px;
}
.sw-modal-tag{
font-size: 11px;
text-transform: lowercase;
padding: 3px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.12);
color: var(--sw-modal-text, #e5e7eb);
letter-spacing: 0.2px;
}
.sw-cluster-body{
display: grid;
grid-template-columns: minmax(220px, 44%) minmax(240px, 56%);
gap: 16px;
align-items: start;
}
.sw-cluster-hero{
display: grid;
grid-template-columns: 22px minmax(0,1fr);
grid-auto-flow: column;
gap: 10px;
align-items: stretch;
}
.sw-cluster-hero .sw-truth-rail{
grid-row: 1;
align-self: flex-start;
}
.sw-cluster-hero-media{
border-radius: 18px;
overflow: hidden;
background: radial-gradient(circle at 30% 30%, var(--sw-modal-accent-soft, rgba(56,189,248,0.18)), rgba(3,14,32,0.98));
box-shadow: 0 10px 24px rgba(0,0,0,0.35), 0 0 18px var(--sw-modal-accent-glow, rgba(56,189,248,0.45));
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.sw-cluster-hero-media img{
width: 100%;
height: 100%;
object-fit: cover;
}
.sw-cluster-actions{
margin-top: 10px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.sw-cluster-related-title{
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--sw-modal-meta, #9eb7ff);
margin-bottom: 8px;
}
.sw-cluster-related-list{
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.sw-cluster-source-item{
padding: 8px 10px;
border-radius: 12px;
background: rgba(15,23,42,0.5);
border: 1px solid rgba(148,163,184,0.2);
cursor: pointer;
}
.sw-cluster-source-item:hover{
border-color: var(--sw-modal-accent, rgba(56,189,248,0.6));
box-shadow: 0 0 0 1px var(--sw-modal-accent-soft, rgba(56,189,248,0.2));
}
.sw-cluster-source-title{
font-size: 14px;
font-weight: 600;
color: var(--sw-modal-text, #e5e7eb);
}
.sw-cluster-source{
font-size: 12px;
color: var(--sw-modal-meta, #9eb7ff);
}
.sw-cluster-source-empty{
font-size: 13px;
color: var(--sw-modal-meta, #9eb7ff);
}
.sw-cluster-generated{
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--sw-modal-meta, #9eb7ff);
margin: 6px 0 6px;
}
.sw-cluster-summary{
font-size: 15px;
line-height: 1.5;
color: var(--sw-modal-text, #e5e7eb);
}
@media (max-width: 860px){
.sw-cluster-body{
grid-template-columns: 1fr;
}
.sw-cluster-hero{
grid-template-columns: 28px 1fr;
}
} }

View File

@ -96,6 +96,11 @@
color: var(--sw-text, rgba(255,255,255,0.92)); color: var(--sw-text, rgba(255,255,255,0.92));
} }
.sw-search__iconBtn.is-active {
background: linear-gradient(135deg, rgba(56,189,248,0.9), rgba(34,211,238,0.9));
color: #0b1220;
}
.sw-search__iconBtn:active { .sw-search__iconBtn:active {
transform: translateY(1px); transform: translateY(1px);
} }
@ -144,6 +149,20 @@
color: var(--sw-muted, rgba(255,255,255,0.60)); color: var(--sw-muted, rgba(255,255,255,0.60));
font-size: 12px; font-size: 12px;
} }
.sw-search__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.sw-search__tag {
font-size: 11px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255,255,255,0.12);
color: rgba(255,255,255,0.88);
letter-spacing: 0.2px;
}
.sw-search__muted { .sw-search__muted {
color: var(--sw-muted, rgba(255,255,255,0.60)); color: var(--sw-muted, rgba(255,255,255,0.60));