feat: refine create flow and island UI
This commit is contained in:
parent
909715c2d7
commit
972ba1afe5
|
|
@ -51,7 +51,7 @@ async function resolveAssetRefsInPosts(posts) {
|
|||
if (!Array.isArray(posts) || posts.length === 0) return posts;
|
||||
const ids = [];
|
||||
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://", ""));
|
||||
});
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ async function resolveAssetRefsInPosts(posts) {
|
|||
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];
|
||||
if (isAssetRef(v)) {
|
||||
const id = v.replace("asset://", "");
|
||||
|
|
@ -225,6 +225,33 @@ export async function createPost(payload, token) {
|
|||
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)
|
||||
*/
|
||||
|
|
@ -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) {
|
||||
if (!postId) return null;
|
||||
const qs = new URLSearchParams();
|
||||
|
|
|
|||
|
|
@ -138,6 +138,12 @@ async function me(accessToken) {
|
|||
username: (u && (u.username || u.user || u.preferred_username)) || "",
|
||||
emailVerified,
|
||||
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 [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... }
|
||||
const [avatarUrl, setAvatarUrl] = useState("");
|
||||
const [profileLoaded, setProfileLoaded] = useState(false);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [lastInfo, setLastInfo] = useState("");
|
||||
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
|
||||
const [needsUsername, setNeedsUsername] = useState(false);
|
||||
|
||||
const refreshTimer = useRef(null);
|
||||
const lastAuthStatusRef = useRef(null);
|
||||
|
|
@ -202,9 +210,11 @@ export function AuthProvider({ children }) {
|
|||
setUser(null);
|
||||
setTokens(null);
|
||||
setAvatarUrl("");
|
||||
setProfileLoaded(false);
|
||||
setError(msg || "");
|
||||
setLastInfo("");
|
||||
setNeedsEmailVerification(false);
|
||||
setNeedsUsername(false);
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +226,7 @@ export function AuthProvider({ children }) {
|
|||
setUser(u ? { username: u } : null);
|
||||
|
||||
setStatus("auth");
|
||||
setProfileLoaded(false);
|
||||
setError("");
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +243,11 @@ export function AuthProvider({ children }) {
|
|||
if (mr.username) {
|
||||
setUser({ username: mr.username });
|
||||
}
|
||||
if (typeof mr.needsUsername === "boolean") {
|
||||
setNeedsUsername(mr.needsUsername);
|
||||
} else {
|
||||
setNeedsUsername(false);
|
||||
}
|
||||
|
||||
if (typeof verified !== "boolean") {
|
||||
const tv = tokenEmailVerified(at);
|
||||
|
|
@ -240,11 +256,13 @@ export function AuthProvider({ children }) {
|
|||
|
||||
if (typeof verified === "boolean") {
|
||||
setNeedsEmailVerification(!verified);
|
||||
setProfileLoaded(true);
|
||||
return { ok: true, verified };
|
||||
}
|
||||
|
||||
// If cannot determine, do not block UI aggressively
|
||||
setNeedsEmailVerification(false);
|
||||
setProfileLoaded(true);
|
||||
return { ok: mr.ok, verified: null };
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +352,12 @@ export function AuthProvider({ children }) {
|
|||
setLastInfo("");
|
||||
const saved = loadAuth();
|
||||
if (!saved?.access_token) {
|
||||
try {
|
||||
if (sessionStorage.getItem("sociowire:logout")) {
|
||||
setAnon("");
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
if (keycloakEnabled()) {
|
||||
const ok = await trySilentKeycloakSession();
|
||||
if (ok) {
|
||||
|
|
@ -404,6 +428,9 @@ export function AuthProvider({ children }) {
|
|||
refreshTimer.current = null;
|
||||
const redirectUri = window.location.origin + window.location.pathname;
|
||||
const doSsoLogout = keycloakEnabled();
|
||||
try {
|
||||
sessionStorage.setItem("sociowire:logout", String(Date.now()));
|
||||
} catch {}
|
||||
setAnon("");
|
||||
if (!doSsoLogout) return;
|
||||
try {
|
||||
|
|
@ -434,6 +461,9 @@ export function AuthProvider({ children }) {
|
|||
|
||||
async function completeKeycloakLogin() {
|
||||
if (!keycloakEnabled()) return { ok: false, reason: "disabled" };
|
||||
try {
|
||||
sessionStorage.removeItem("sociowire:logout");
|
||||
} catch {}
|
||||
|
||||
setStatus("loading");
|
||||
setError("");
|
||||
|
|
@ -511,6 +541,11 @@ export function AuthProvider({ children }) {
|
|||
|
||||
async function trySilentKeycloakSession() {
|
||||
let kc = null;
|
||||
try {
|
||||
if (sessionStorage.getItem("sociowire:logout")) {
|
||||
return false;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
kc = getKeycloak();
|
||||
} catch {
|
||||
|
|
@ -558,6 +593,9 @@ export function AuthProvider({ children }) {
|
|||
setAnon("SSO is not configured.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.removeItem("sociowire:logout");
|
||||
} catch {}
|
||||
const redirectUri = window.location.origin + window.location.pathname;
|
||||
try {
|
||||
const kc = getKeycloak();
|
||||
|
|
@ -626,6 +664,8 @@ export function AuthProvider({ children }) {
|
|||
tokens,
|
||||
error,
|
||||
avatarUrl,
|
||||
profileLoaded,
|
||||
needsUsername,
|
||||
|
||||
// expected by UI
|
||||
loading,
|
||||
|
|
@ -636,6 +676,7 @@ export function AuthProvider({ children }) {
|
|||
lastError: error || "",
|
||||
lastInfo: lastInfo || "",
|
||||
needsEmailVerification: !!needsEmailVerification,
|
||||
needsUsername: !!needsUsername,
|
||||
resendVerificationEmail,
|
||||
loginWithProvider,
|
||||
ssoEnabled: keycloakEnabled(),
|
||||
|
|
@ -648,10 +689,11 @@ export function AuthProvider({ children }) {
|
|||
setError,
|
||||
setLastInfo,
|
||||
setNeedsEmailVerification,
|
||||
setNeedsUsername,
|
||||
clearError: () => setError(""),
|
||||
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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import '../../styles/island.css';
|
||||
import { fetchPosts, uploadProfileAvatar } from '../../api/client';
|
||||
import { fetchPosts, updateProfileUsername, uploadProfileAvatar } from '../../api/client';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import { trackEvent } from "../../utils/analytics";
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ export default function IslandViewer({ island, onClose }) {
|
|||
const mapRef = useRef(null);
|
||||
const animationRef = useRef(null);
|
||||
const fileRef = useRef(null);
|
||||
const { authenticated, username, setAvatarUrl } = useAuth();
|
||||
const { authenticated, username, refreshProfile, setAvatarUrl, setNeedsUsername } = useAuth();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [uploadInfo, setUploadInfo] = useState("");
|
||||
|
|
@ -26,12 +26,18 @@ export default function IslandViewer({ island, onClose }) {
|
|||
const [activeTab, setActiveTab] = useState("profile");
|
||||
const [userPosts, setUserPosts] = useState([]);
|
||||
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(() => {
|
||||
if (!island || !containerRef.current) return;
|
||||
|
||||
console.log('[IslandViewer] Rendering island with MapLibre:', island.username, island.seed);
|
||||
trackEvent("island_open", { username: island.username || "" });
|
||||
console.log('[IslandViewer] Rendering island with MapLibre:', islandUsername, island.seed);
|
||||
trackEvent("island_open", { username: islandUsername || "" });
|
||||
|
||||
// Center of island in virtual coordinates
|
||||
const centerLng = 0;
|
||||
|
|
@ -112,15 +118,17 @@ export default function IslandViewer({ island, onClose }) {
|
|||
// Add content items as markers
|
||||
if (island.content && island.content.length > 0) {
|
||||
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 pos = resolveContentPosition(item, idx, island.seed, usedSlots);
|
||||
new maplibregl.Marker({
|
||||
element: contentEl,
|
||||
anchor: 'bottom'
|
||||
})
|
||||
.setLngLat([
|
||||
centerLng + (item.position_x - 0.5) * 0.001,
|
||||
centerLat + (item.position_y - 0.5) * 0.001
|
||||
centerLng + (pos.x - 0.5) * 0.0012,
|
||||
centerLat + (pos.y - 0.5) * 0.0012
|
||||
])
|
||||
.addTo(map);
|
||||
});
|
||||
|
|
@ -151,10 +159,18 @@ export default function IslandViewer({ island, onClose }) {
|
|||
}, [island?.avatar_url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!island?.username) return;
|
||||
setLocalUsername(island?.username || "");
|
||||
}, [island?.username]);
|
||||
|
||||
useEffect(() => {
|
||||
setUsernameDraft(islandUsername);
|
||||
}, [islandUsername]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandUsername) return;
|
||||
let cancelled = false;
|
||||
setPostsLoading(true);
|
||||
fetchPosts({ username: island.username, limit: 40 })
|
||||
fetchPosts({ username: islandUsername, limit: 40 })
|
||||
.then((items) => {
|
||||
if (cancelled) return;
|
||||
setUserPosts(Array.isArray(items) ? items : []);
|
||||
|
|
@ -174,8 +190,8 @@ export default function IslandViewer({ island, onClose }) {
|
|||
|
||||
if (!island) return null;
|
||||
const isSelf =
|
||||
authenticated && username && island.username &&
|
||||
username.toLowerCase() === island.username.toLowerCase();
|
||||
authenticated && username && islandUsername &&
|
||||
username.toLowerCase() === islandUsername.toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="island-viewer-modal" onClick={onClose}>
|
||||
|
|
@ -186,13 +202,13 @@ export default function IslandViewer({ island, onClose }) {
|
|||
<div className="island-header-row">
|
||||
<div className="island-header-avatar">
|
||||
{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 || `${island.username}'s Island`}</h2>
|
||||
<h2>{island.display_name || `${islandUsername}'s Island`}</h2>
|
||||
<p>{island.bio || 'Welcome to my island'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -202,17 +218,53 @@ export default function IslandViewer({ island, onClose }) {
|
|||
<div className="island-profile-panel">
|
||||
<div className="island-profile-avatar">
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt={island.username} />
|
||||
<img src={avatarSrc} alt={islandUsername} />
|
||||
) : (
|
||||
<div className="island-profile-letter">
|
||||
{(island.username || "U")[0].toUpperCase()}
|
||||
{(islandUsername || "U")[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="island-profile-meta">
|
||||
<div className="island-profile-name">@{island.username}</div>
|
||||
<div className="island-profile-name">@{islandUsername}</div>
|
||||
{isSelf ? (
|
||||
<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">
|
||||
<label>Photo visibility</label>
|
||||
<select
|
||||
|
|
@ -286,6 +338,8 @@ export default function IslandViewer({ island, onClose }) {
|
|||
/>
|
||||
{uploadError ? <div className="island-profile-error">{uploadError}</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>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -388,14 +442,14 @@ function createIslandElement(seed, terrainType = 'tropical') {
|
|||
el.className = 'island-terrain-marker';
|
||||
|
||||
const terrainColor = getTerrainColorCSS(terrainType);
|
||||
const size = 200 + rng() * 50; // 200-250px
|
||||
const size = 220 + rng() * 50; // 220-270px
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
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:
|
||||
0 0 40px ${terrainColor}88,
|
||||
0 20px 40px rgba(0,0,0,0.4),
|
||||
|
|
@ -404,6 +458,21 @@ function createIslandElement(seed, terrainType = 'tropical') {
|
|||
position: relative;
|
||||
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)}
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -559,10 +628,10 @@ function createContentElement(content) {
|
|||
|
||||
el.innerHTML = `
|
||||
<div style="
|
||||
width: 40px;
|
||||
height: 60px;
|
||||
width: 46px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, ${color}ee, ${color}aa);
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0,0,0,0.4),
|
||||
0 0 20px ${color}66;
|
||||
|
|
@ -582,6 +651,28 @@ function createContentElement(content) {
|
|||
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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
loginWithProvider,
|
||||
ssoEnabled,
|
||||
avatarUrl,
|
||||
profileLoaded,
|
||||
needsUsername,
|
||||
needsEmailVerification,
|
||||
lastError,
|
||||
lastInfo,
|
||||
|
|
@ -72,12 +74,6 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
clearInfo,
|
||||
} = 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 [mode, setMode] = useState("login");
|
||||
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
||||
|
|
@ -86,6 +82,15 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
const [usernameInfo, setUsernameInfo] = useState("");
|
||||
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||
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 [loginPass, setLoginPass] = useState("");
|
||||
|
|
@ -158,7 +163,22 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
}, [authenticated]);
|
||||
|
||||
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.includes("@")) return true;
|
||||
const re = /^[A-Za-z0-9_.]{3,24}$/;
|
||||
|
|
@ -173,7 +193,8 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
setShowUsernameModal(false);
|
||||
return;
|
||||
}
|
||||
if (needsUsername(username)) {
|
||||
const shouldPrompt = !!needsUsername || requiresUsername(username);
|
||||
if (shouldPrompt) {
|
||||
if (!showUsernameModal) {
|
||||
const base = (username || "").split("@")[0] || "";
|
||||
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("");
|
||||
usernamePromptTimer.current = setTimeout(() => {
|
||||
setShowUsernameModal(true);
|
||||
}, 350);
|
||||
}, 600);
|
||||
}
|
||||
} else if (showUsernameModal) {
|
||||
setShowUsernameModal(false);
|
||||
}
|
||||
}, [authenticated, username, showUsernameModal, loading]);
|
||||
}, [authenticated, username, showUsernameModal, loading, needsUsername]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import "../../styles/mapMarkers.css";
|
|||
import "../../styles/posts.css";
|
||||
import "../../styles/filters.css";
|
||||
|
||||
import { createPost } from "../../api/client";
|
||||
import { createPost, fetchPostPreview } from "../../api/client";
|
||||
import {
|
||||
FILTER_MAIN_CATEGORIES,
|
||||
FILTER_CATEGORY_MAP,
|
||||
|
|
@ -20,7 +20,7 @@ import FilterButtons from "../Filters/FilterButtons";
|
|||
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
||||
import SmartSearchBar from "../Search/SmartSearchBar";
|
||||
import UserProfile from "../Profile/UserProfile";
|
||||
import { fetchPostById } from "../../api/client";
|
||||
import { fetchPostById, fetchPostByUrl } from "../../api/client";
|
||||
import { trackEvent } from "../../utils/analytics";
|
||||
|
||||
export default function MapView({
|
||||
|
|
@ -33,6 +33,8 @@ export default function MapView({
|
|||
onOpenIslands,
|
||||
}) {
|
||||
const { authenticated, username, token, needsEmailVerification } = useAuth();
|
||||
const CLUSTERS_ENABLED = true;
|
||||
const HEATMAP_ENABLED = false;
|
||||
|
||||
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
|
||||
useMapCore(theme);
|
||||
|
|
@ -49,6 +51,7 @@ export default function MapView({
|
|||
}
|
||||
});
|
||||
const [subFilter, setSubFilter] = useState("All");
|
||||
const [mainNewsOnly, setMainNewsOnly] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
@ -63,12 +66,15 @@ export default function MapView({
|
|||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const skipAutoZoomRef = useRef(false);
|
||||
const [clusterFocus, setClusterFocus] = useState(null);
|
||||
const [forceFullPostId, setForceFullPostId] = useState(null);
|
||||
|
||||
// Profile state (selectedIsland is now passed from App.jsx)
|
||||
const [selectedProfile, setSelectedProfile] = useState(null);
|
||||
const openedPostRef = useRef(null);
|
||||
const queryPostRef = useRef(false);
|
||||
const overlayTimerRef = useRef(null);
|
||||
const panTimerRef = useRef(null);
|
||||
const pendingOpenPostIdRef = useRef(null);
|
||||
const pendingOpenFullRef = useRef(false);
|
||||
const searchFitQueryRef = useRef("");
|
||||
|
|
@ -97,6 +103,55 @@ export default function MapView({
|
|||
[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 } =
|
||||
usePostsEngine({
|
||||
theme,
|
||||
|
|
@ -106,6 +161,11 @@ export default function MapView({
|
|||
subFilter,
|
||||
timeFilter,
|
||||
searchQuery,
|
||||
clusterFocus,
|
||||
mainNewsOnly,
|
||||
clustersEnabled: CLUSTERS_ENABLED,
|
||||
heatmapEnabled: HEATMAP_ENABLED,
|
||||
forceFullPostId,
|
||||
markersRef,
|
||||
expandedElRef,
|
||||
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
|
||||
|
|
@ -116,12 +176,18 @@ export default function MapView({
|
|||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [draftBody, setDraftBody] = useState("");
|
||||
const [draftUrl, setDraftUrl] = useState("");
|
||||
const [draftSource, setDraftSource] = useState("");
|
||||
const [draftPublishedAt, setDraftPublishedAt] = useState("");
|
||||
const [draftCategory, setDraftCategory] = useState("News");
|
||||
const [draftSubCategory, setDraftSubCategory] = useState(
|
||||
CREATE_CATEGORY_MAP.News[0]
|
||||
);
|
||||
const [draftCoords, setDraftCoords] = useState(null);
|
||||
const [draftImage, setDraftImage] = useState("");
|
||||
const [draftImageSmall, setDraftImageSmall] = useState("");
|
||||
const [draftImageMedium, setDraftImageMedium] = useState("");
|
||||
const [draftImageLarge, setDraftImageLarge] = useState("");
|
||||
const [draftImageFile, setDraftImageFile] = useState(null);
|
||||
const [draftImagePreview, setDraftImagePreview] = useState("");
|
||||
const [useCustomImage, setUseCustomImage] = useState(false);
|
||||
|
|
@ -132,12 +198,28 @@ export default function MapView({
|
|||
const [imageGroupId, setImageGroupId] = useState("");
|
||||
const [imageUsers, setImageUsers] = useState("");
|
||||
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 [saveError, setSaveError] = useState("");
|
||||
const [createStep, setCreateStep] = useState(0);
|
||||
const previewTimerRef = useRef(null);
|
||||
const lastPreviewUrlRef = useRef("");
|
||||
|
||||
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 M = {
|
||||
All: {
|
||||
|
|
@ -235,7 +317,6 @@ export default function MapView({
|
|||
|
||||
useEffect(() => {
|
||||
if (queryPostRef.current) return;
|
||||
queryPostRef.current = true;
|
||||
const qs = new URLSearchParams(window.location.search || "");
|
||||
let idRaw = qs.get("post_id") || qs.get("id") || qs.get("p");
|
||||
if (!idRaw) {
|
||||
|
|
@ -249,6 +330,7 @@ export default function MapView({
|
|||
}
|
||||
}
|
||||
if (!idRaw) return;
|
||||
queryPostRef.current = true;
|
||||
const id = Number(idRaw);
|
||||
if (!Number.isFinite(id) || id <= 0) return;
|
||||
pendingOpenPostIdRef.current = id;
|
||||
|
|
@ -262,9 +344,193 @@ export default function MapView({
|
|||
});
|
||||
}, [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(() => {
|
||||
const wantId = pendingOpenPostIdRef.current;
|
||||
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);
|
||||
if (!match) return;
|
||||
pendingOpenPostIdRef.current = null;
|
||||
|
|
@ -277,6 +543,7 @@ export default function MapView({
|
|||
useEffect(() => {
|
||||
return () => {
|
||||
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];
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.warn('[MapView] No coords for suggestion:', item);
|
||||
return;
|
||||
|
|
@ -360,53 +733,14 @@ export default function MapView({
|
|||
});
|
||||
map.__swForceFetchAt = Date.now();
|
||||
|
||||
// If picking a post, clear filters and avoid query-locking
|
||||
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') {
|
||||
if (kind === 'city' || kind === 'region' || kind === 'place' || kind === 'poi') {
|
||||
// Place picks should move the map without forcing a text filter.
|
||||
skipAutoZoomRef.current = false;
|
||||
setSearchQuery("");
|
||||
} else {
|
||||
setForceFullPostId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const q = (item.title || item.name || "").toString().trim();
|
||||
skipAutoZoomRef.current = false;
|
||||
setSearchQuery(q);
|
||||
|
|
@ -415,13 +749,13 @@ export default function MapView({
|
|||
setSubFilter("All");
|
||||
setTimeFilter("PAST");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (query) => {
|
||||
console.log('[MapView] Search/filter by:', query);
|
||||
skipAutoZoomRef.current = false;
|
||||
setSearchQuery(query);
|
||||
if (!query.trim()) setForceFullPostId(null);
|
||||
|
||||
// Clear ALL filters to show all search results
|
||||
if (query.trim()) {
|
||||
|
|
@ -563,6 +897,9 @@ export default function MapView({
|
|||
setCreateStep(0);
|
||||
setDraftTitle("");
|
||||
setDraftBody("");
|
||||
setDraftUrl("");
|
||||
setDraftSource("");
|
||||
setDraftPublishedAt("");
|
||||
setDraftImage("");
|
||||
setDraftImageFile(null);
|
||||
setDraftImagePreview("");
|
||||
|
|
@ -573,6 +910,12 @@ export default function MapView({
|
|||
setImageVisibility("public");
|
||||
setImageGroupId("");
|
||||
setImageUsers("");
|
||||
setUrlPreviewLoading(false);
|
||||
setUrlPreviewError("");
|
||||
setTitleTouched(false);
|
||||
setBodyTouched(false);
|
||||
setImageTouched(false);
|
||||
lastPreviewUrlRef.current = "";
|
||||
const main = mainFilter === "All" ? "News" : mainFilter;
|
||||
setDraftCategory(main);
|
||||
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
|
||||
|
|
@ -591,6 +934,7 @@ export default function MapView({
|
|||
const handleImageFileChange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImageTouched(true);
|
||||
|
||||
// Vérifier le type de fichier
|
||||
if (!file.type.startsWith('image/')) {
|
||||
|
|
@ -636,11 +980,24 @@ export default function MapView({
|
|||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data && data.url) {
|
||||
setDraftImage(data.url);
|
||||
} else if (data && data.asset_id) {
|
||||
setDraftImage(`asset://${data.asset_id}`);
|
||||
const variants = data && data.variants ? data.variants : null;
|
||||
const pickRef = (v) => {
|
||||
if (!v) return "";
|
||||
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);
|
||||
} catch (err) {
|
||||
console.error('Image upload error:', err);
|
||||
|
|
@ -648,9 +1005,64 @@ export default function MapView({
|
|||
setUploadingImage(false);
|
||||
setDraftImageFile(null);
|
||||
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 () => {
|
||||
if (isSaving) return;
|
||||
setSaveError("");
|
||||
|
|
@ -722,9 +1134,26 @@ export default function MapView({
|
|||
.filter(Boolean),
|
||||
};
|
||||
|
||||
if (useCustomImage && draftImage.trim()) {
|
||||
payload.image_small = draftImage.trim();
|
||||
payload.image_large = draftImage.trim();
|
||||
if (draftUrl.trim()) {
|
||||
payload.url = draftUrl.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);
|
||||
|
|
@ -789,6 +1218,18 @@ export default function MapView({
|
|||
onPlaceTourWire={handleOpenCreate}
|
||||
/>
|
||||
<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
|
||||
type="button"
|
||||
className="sw-search__iconBtn"
|
||||
|
|
@ -928,44 +1369,154 @@ export default function MapView({
|
|||
|
||||
<input
|
||||
className="create-input"
|
||||
placeholder="Short title..."
|
||||
placeholder="Short title or paste a link..."
|
||||
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 — we’ll 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>
|
||||
)}
|
||||
|
||||
{createStep === 1 && (
|
||||
<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
|
||||
className="create-textarea"
|
||||
rows={3}
|
||||
placeholder="Context (optional)..."
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDraftBody(e.target.value);
|
||||
setBodyTouched(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="create-image-option">
|
||||
<label className="create-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCustomImage}
|
||||
onChange={(e) => setUseCustomImage(e.target.checked)}
|
||||
/>
|
||||
<span>Use a custom image instead of the category icon</span>
|
||||
</label>
|
||||
<div className="create-image-choice">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"create-image-choice-btn" +
|
||||
(!useCustomImage ? " is-active" : "")
|
||||
}
|
||||
onClick={() => {
|
||||
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 className="create-muted">
|
||||
No image? We’ll 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 && (
|
||||
<div className="create-image-panel">
|
||||
<div className="create-image-row">
|
||||
<label className="create-file-btn">
|
||||
Choose file
|
||||
<input
|
||||
className="create-file-input"
|
||||
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 && (
|
||||
<span className="create-uploading">Uploading...</span>
|
||||
)}
|
||||
|
|
@ -987,20 +1538,24 @@ export default function MapView({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!draftUrl.trim() && !draftImageFile && !draftImagePreview ? (
|
||||
<>
|
||||
<div className="create-muted">
|
||||
Or paste an image URL:
|
||||
</div>
|
||||
<input
|
||||
className="create-input"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
value={draftImageFile ? "" : draftImage}
|
||||
value={draftImage}
|
||||
onChange={(e) => {
|
||||
setDraftImage(e.target.value);
|
||||
setDraftImageFile(null);
|
||||
setDraftImagePreview("");
|
||||
setImageTouched(true);
|
||||
}}
|
||||
disabled={!!draftImageFile}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="create-privacy-row">
|
||||
<label>Image visibility</label>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ export default function TemplateMarkerCard({
|
|||
const spec = mode === "mini" ? template?.mini_spec_json : template?.full_spec_json;
|
||||
|
||||
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 {
|
||||
headline: post?.title || "Untitled",
|
||||
source: post?.source || (post?.author ? `by ${post.author}` : "") || (post?.sub_category || ""),
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function getViewFromMap(map) {
|
|||
radiusKm = 750;
|
||||
}
|
||||
|
||||
radiusKm = Math.min(Math.max(radiusKm, 25), 1000);
|
||||
radiusKm = Math.min(Math.max(radiusKm * 1.2, 25), 1000);
|
||||
|
||||
return {
|
||||
center: [center.lng, center.lat],
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { trackEvent } from "../../utils/analytics";
|
|||
|
||||
const avatarCache = new Map();
|
||||
const avatarInflight = new Map();
|
||||
const HEATMAP_ENABLED = false;
|
||||
|
||||
function applyAvatar(el, username, url) {
|
||||
if (!el) return;
|
||||
|
|
@ -43,8 +44,9 @@ function applyAvatar(el, username, url) {
|
|||
function resolvePostImage(post) {
|
||||
return normalizeImageUrl(
|
||||
post?.image_small ||
|
||||
post?.image ||
|
||||
post?.image_medium ||
|
||||
post?.image_large ||
|
||||
post?.image ||
|
||||
post?.media_url ||
|
||||
post?.image_url ||
|
||||
""
|
||||
|
|
@ -224,6 +226,7 @@ function applyPostDetails(modalWrap, post) {
|
|||
const title = (post?.title || post?.Title || "Untitled").toString();
|
||||
const summary = (post?.snippet || post?.body || "").toString();
|
||||
const img = heroImageForPost(post);
|
||||
const fullImg = fullImageForPost(post);
|
||||
const url = (post?.url || "").toString().trim();
|
||||
|
||||
const titleEl = modalWrap.querySelector(".sw-modal-title");
|
||||
|
|
@ -233,10 +236,13 @@ function applyPostDetails(modalWrap, post) {
|
|||
if (summaryEl) summaryEl.textContent = summary;
|
||||
|
||||
const hero = modalWrap.querySelector(".sw-modal-hero");
|
||||
if (hero) {
|
||||
hero.innerHTML = img
|
||||
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
|
||||
const clusterHero = modalWrap.querySelector(".sw-cluster-hero-media");
|
||||
if (hero || clusterHero) {
|
||||
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>`;
|
||||
bindHeroLightbox(modalWrap);
|
||||
}
|
||||
|
||||
const badge = modalWrap.querySelector(".sw-modal-badge");
|
||||
|
|
@ -257,6 +263,7 @@ function applyPostDetails(modalWrap, post) {
|
|||
`;
|
||||
hydrateAvatars(modalWrap);
|
||||
}
|
||||
setModalTags(modalWrap, post);
|
||||
|
||||
const cta = modalWrap.querySelector(".sw-cta-primary");
|
||||
if (cta) {
|
||||
|
|
@ -306,7 +313,11 @@ function renderComments(container, items) {
|
|||
}
|
||||
|
||||
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 score = truthScore(post || data || {});
|
||||
|
||||
|
|
@ -374,6 +385,7 @@ function mountCard(container, spec, data, theme, post) {
|
|||
|
||||
return () => {
|
||||
try { root.unmount(); } catch {}
|
||||
container.__swReactRoot = null;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -385,6 +397,101 @@ function escapeHtml(str) {
|
|||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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) {
|
||||
const c = (post?.category || "").toString().toUpperCase();
|
||||
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) {
|
||||
const raw =
|
||||
post?.image_medium ||
|
||||
post?.image_large ||
|
||||
post?.image_small ||
|
||||
post?.image ||
|
||||
|
|
@ -422,6 +592,17 @@ function heroImageForPost(post) {
|
|||
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) {
|
||||
try {
|
||||
if (!createdAt) return "now";
|
||||
|
|
@ -464,7 +645,7 @@ function truthScore(post) {
|
|||
if (Number.isFinite(raw)) {
|
||||
return clamp(Math.round(raw), 0, 100);
|
||||
}
|
||||
return 50;
|
||||
return 65;
|
||||
}
|
||||
|
||||
function truthColor(score) {
|
||||
|
|
@ -530,6 +711,12 @@ function closeCenteredOverlay(map, opts = {}) {
|
|||
ov.historyClosed = true;
|
||||
try { window.history.back(); } catch {}
|
||||
}
|
||||
if (map) {
|
||||
try {
|
||||
map.__swForceFetchAt = Date.now();
|
||||
map.fire("moveend");
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
@ -550,7 +737,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {}
|
||||
|
||||
let postData = { ...(post || {}) };
|
||||
const data = adaptPostToTemplateData(postData);
|
||||
const data = adaptPostToTemplateData(postData, { size: "full" });
|
||||
const cat = normCatLabel(postData);
|
||||
const templateKey = getTemplateKeyForPost(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 shareCount = readCount(postData, ["share_count", "shares", "shareCount"]);
|
||||
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", {
|
||||
post_id: postID,
|
||||
source: postData?.source || postData?.Source || "",
|
||||
|
|
@ -599,6 +793,87 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
modalWrap.style.pointerEvents = "auto";
|
||||
|
||||
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
|
||||
if (isCluster) {
|
||||
modalWrap.innerHTML = `
|
||||
<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-toprow">
|
||||
<div class="sw-modal-left">
|
||||
<div class="sw-modal-badge">MAIN NEWS</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 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 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="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>
|
||||
`;
|
||||
} else {
|
||||
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">
|
||||
|
|
@ -628,12 +903,13 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
<div class="sw-modal-hero">
|
||||
${
|
||||
img
|
||||
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
|
||||
? `<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-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div>
|
||||
${tagPills}
|
||||
|
||||
<div class="sw-modal-summary">
|
||||
${escapeHtml(data?.summary || postData?.snippet || "")}
|
||||
|
|
@ -644,7 +920,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
<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>
|
||||
<div class="sw-stat-pill">📍 ${escapeHtml(postData?.city || postData?.location_name || "Nearby")}</div>
|
||||
${heatmapPill}
|
||||
</div>
|
||||
|
||||
<div class="sw-modal-cta-row">
|
||||
|
|
@ -673,10 +949,25 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindHeroLightbox(modalWrap);
|
||||
|
||||
backdrop.appendChild(modalWrap);
|
||||
document.body.appendChild(backdrop);
|
||||
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(() => {
|
||||
try { backdrop.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) {
|
||||
fetchPostById(postID).then((fresh) => {
|
||||
if (!fresh) return;
|
||||
|
|
@ -1077,7 +1376,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
|||
}
|
||||
|
||||
function renderCompact() {
|
||||
unmountIfAny();
|
||||
if (!root.__swCompactReady) {
|
||||
root.className = "post-pin post-pin--compact sw-appear sw-appear-in";
|
||||
root.style.zIndex = "1";
|
||||
|
||||
|
|
@ -1091,12 +1390,14 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
|||
<div class="post-pin-pointer-small"></div>
|
||||
${lineHTML}
|
||||
`;
|
||||
root.__swCompactReady = true;
|
||||
}
|
||||
|
||||
const wrap = root.querySelector(".sw-template-mini-wrap");
|
||||
if (wrap) {
|
||||
const activePost = root.__post || post;
|
||||
const spec = getTemplateSpecForPost(activePost, "mini");
|
||||
const data = adaptPostToTemplateData(activePost);
|
||||
const data = adaptPostToTemplateData(activePost, { size: "mini" });
|
||||
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
||||
root.__lastImage = data?.image || "";
|
||||
root.__lastTitle = data?.headline || "";
|
||||
|
|
@ -1109,6 +1410,18 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
|||
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({
|
||||
element: root,
|
||||
anchor: "bottom",
|
||||
|
|
@ -1176,9 +1489,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
|||
function renderSimple() {
|
||||
const activePost = root.__post || post;
|
||||
const color = getMarkerColorForCategory(activePost?.category);
|
||||
const img = normalizeImageUrl(
|
||||
activePost?.image_small || activePost?.image || activePost?.image_large || activePost?.media_url || "",
|
||||
);
|
||||
const img = normalizeImageUrl(activePost?.image_small || "");
|
||||
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
||||
root.__lastImage = img || "";
|
||||
root.__lastTitle = (activePost?.title || "").toString();
|
||||
|
|
@ -1254,11 +1565,15 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
|||
renderSimple();
|
||||
root.__renderSimple = renderSimple;
|
||||
|
||||
const isCluster = HEATMAP_ENABLED && isClusterMainPost(post);
|
||||
const markerEl = root.querySelector(".sw-simple-marker");
|
||||
const hoverCard = root.querySelector(".sw-simple-hover-card");
|
||||
|
||||
// Hover effects
|
||||
root.addEventListener("mouseenter", () => {
|
||||
if (isCluster) {
|
||||
dispatchClusterHeatmap(root.__post || post, { mode: "hover", skipPan: true });
|
||||
}
|
||||
if (markerEl) {
|
||||
markerEl.style.transform = "scale(1.2)";
|
||||
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", () => {
|
||||
if (isCluster) {
|
||||
dispatchClusterHeatmapClear();
|
||||
}
|
||||
if (markerEl) {
|
||||
markerEl.style.transform = "scale(1)";
|
||||
markerEl.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)";
|
||||
|
|
|
|||
|
|
@ -9,6 +9,30 @@
|
|||
* - events
|
||||
*/
|
||||
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: {
|
||||
mini_spec: {
|
||||
size: { w: 240, h: 96 },
|
||||
|
|
@ -185,6 +209,7 @@ function isBreaking(post) {
|
|||
}
|
||||
|
||||
function normCat(post) {
|
||||
if (isClusterPost(post)) return "cluster";
|
||||
const c = (post?.category || post?.Category || "").toString().toUpperCase();
|
||||
const sub = (post?.sub_category || post?.subCategory || "").toString().toLowerCase();
|
||||
|
||||
|
|
@ -260,14 +285,19 @@ function getCategoryIcon(post) {
|
|||
return categoryIcons[subCategory] || categoryIcons.default;
|
||||
}
|
||||
|
||||
export function adaptPostToTemplateData(post) {
|
||||
const headline = post?.title || "Untitled";
|
||||
export function adaptPostToTemplateData(post, opts = {}) {
|
||||
const headline = post?.cluster_title || post?.title || "Untitled";
|
||||
const author = post?.author ? `by ${post.author}` : "";
|
||||
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 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 categoryIcon = getCategoryIcon(post);
|
||||
return { headline, source, summary, url, image, categoryIcon };
|
||||
|
|
@ -280,3 +310,13 @@ function normalizeImageUrl(raw) {
|
|||
if (value.startsWith("http://")) return `https://${value.slice(7)}`;
|
||||
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/");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,16 @@ export function useMapCore(theme) {
|
|||
map.on("load", () => {
|
||||
setViewParams(getViewFromMap(map));
|
||||
// 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", () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ function getId(p) {
|
|||
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) {
|
||||
const id = getId(p);
|
||||
if (id != null && id !== 0) return `id:${id}`;
|
||||
|
|
@ -46,8 +55,9 @@ function getTemplateKey(post) {
|
|||
function resolvePostImage(post) {
|
||||
return (
|
||||
post?.image_small ||
|
||||
post?.image ||
|
||||
post?.image_medium ||
|
||||
post?.image_large ||
|
||||
post?.image ||
|
||||
post?.media_url ||
|
||||
post?.image_url ||
|
||||
""
|
||||
|
|
@ -131,6 +141,142 @@ function getPostTimeMs(p) {
|
|||
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_VISIBLE_POSTS = 45;
|
||||
|
||||
|
|
@ -159,6 +305,11 @@ export function usePostsEngine({
|
|||
subFilter,
|
||||
timeFilter,
|
||||
searchQuery,
|
||||
clusterFocus,
|
||||
mainNewsOnly,
|
||||
clustersEnabled = true,
|
||||
heatmapEnabled = true,
|
||||
forceFullPostId = null,
|
||||
markersRef,
|
||||
expandedElRef,
|
||||
onAutoWidenTimeFilter,
|
||||
|
|
@ -174,15 +325,20 @@ export function usePostsEngine({
|
|||
const initialLoadRef = useRef(true);
|
||||
const initialFetchKickRef = useRef(false);
|
||||
const autoWidenRef = useRef(false);
|
||||
const searchResultsRef = useRef(false);
|
||||
const retryRef = useRef({ key: "", count: 0, ts: 0 });
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const clusterModeRef = useRef(false);
|
||||
|
||||
const CLUSTER_THRESHOLD = 140;
|
||||
const SIMPLE_MARKER_LIMIT = 40;
|
||||
const SIMPLE_MARKER_LIMIT = 50;
|
||||
const CLUSTER_SOURCE_ID = "sw-posts";
|
||||
const CLUSTER_LAYER_ID = "sw-clusters";
|
||||
const CLUSTER_COUNT_ID = "sw-cluster-count";
|
||||
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(() => {
|
||||
if (mapReady) return;
|
||||
|
|
@ -215,7 +371,7 @@ export function usePostsEngine({
|
|||
}, [mapReady, mapRef]);
|
||||
|
||||
const priorityOpts = {
|
||||
maxFullCards: 12,
|
||||
maxFullCards: 15,
|
||||
clusterRadius: 0.002,
|
||||
minScoreForCard: 50,
|
||||
};
|
||||
|
|
@ -333,6 +489,38 @@ export function usePostsEngine({
|
|||
}
|
||||
}, [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) => {
|
||||
if (!map) return;
|
||||
const v = visible ? "visible" : "none";
|
||||
|
|
@ -341,6 +529,14 @@ export function usePostsEngine({
|
|||
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) => {
|
||||
if (!map) return;
|
||||
const src = map.getSource(CLUSTER_SOURCE_ID);
|
||||
|
|
@ -380,6 +576,10 @@ export function usePostsEngine({
|
|||
(visible) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return { fullCardPosts: [], clusterPosts: visible || [] };
|
||||
const searchActive = (searchQuery || "").trim().length > 0;
|
||||
if (searchActive) {
|
||||
return { fullCardPosts: [], clusterPosts: visible || [] };
|
||||
}
|
||||
const center = map.getCenter();
|
||||
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
|
||||
const { fullCardPosts, simpleMarkerPosts } = prioritizePosts(
|
||||
|
|
@ -389,7 +589,7 @@ export function usePostsEngine({
|
|||
);
|
||||
return { fullCardPosts, clusterPosts: simpleMarkerPosts };
|
||||
},
|
||||
[mapRef, priorityOpts]
|
||||
[mapRef, priorityOpts, searchQuery]
|
||||
);
|
||||
|
||||
const syncPriorityMarkers = useCallback(
|
||||
|
|
@ -458,17 +658,27 @@ export function usePostsEngine({
|
|||
map.__swMarkersRef = markersRef;
|
||||
|
||||
// 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
|
||||
const center = map.getCenter();
|
||||
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
|
||||
|
||||
const { fullCardPosts, simpleMarkerPosts } = prioritizePosts(visible, viewCenter, {
|
||||
maxFullCards: 12, // Max 12 cartes complètes (réduit de 15)
|
||||
const searchActive = (searchQuery || "").trim().length > 0;
|
||||
let fullCardPosts = [];
|
||||
let simpleMarkerPosts = [];
|
||||
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:', {
|
||||
total: visible.length,
|
||||
|
|
@ -482,7 +692,9 @@ export function usePostsEngine({
|
|||
const id = getId(post);
|
||||
if (id != null) {
|
||||
// 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' });
|
||||
}
|
||||
}
|
||||
|
|
@ -548,37 +760,59 @@ export function usePostsEngine({
|
|||
}
|
||||
}
|
||||
},
|
||||
[mapRef, markersRef, expandedElRef, theme]
|
||||
[mapRef, markersRef, expandedElRef, theme, searchQuery, forceFullPostId]
|
||||
);
|
||||
|
||||
const computeVisible = useCallback(
|
||||
(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 clusterIDs = clustersEnabled && Array.isArray(clusterFocus?.ids) ? clusterFocus.ids : null;
|
||||
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
|
||||
|
||||
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) {
|
||||
const pc = (p.category || p.Category || "").toString().toUpperCase();
|
||||
if (pc !== catCode) return false;
|
||||
}
|
||||
if (!matchesSubFilter(p, subFilter)) return false;
|
||||
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, tf)) return false;
|
||||
if (!matchesSearch(p, searchQuery)) return false;
|
||||
if (!searchResultsRef.current && !matchesSearch(p, searchQuery)) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
[mainFilter, subFilter, searchQuery]
|
||||
[mainFilter, subFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
|
||||
);
|
||||
|
||||
const refreshVisibleAndMarkers = useCallback(
|
||||
(tf, force = false) => {
|
||||
const vAll = computeVisible(tf);
|
||||
const map = mapRef.current;
|
||||
let v = (searchQuery || "").trim() ? vAll : filterByBounds(vAll, map);
|
||||
const isClusterFocus = !!(clustersEnabled && Array.isArray(clusterFocus?.ids) && clusterFocus.ids.length > 0);
|
||||
let v = vAll;
|
||||
if (!isClusterFocus) {
|
||||
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
|
||||
setVisiblePosts((prev) => {
|
||||
|
|
@ -589,7 +823,12 @@ export function usePostsEngine({
|
|||
// keep status silent (no annoying bubbles)
|
||||
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 simpleVisible = clusterPosts.slice(0, SIMPLE_MARKER_LIMIT);
|
||||
const clusterRemainder = clusterPosts.slice(SIMPLE_MARKER_LIMIT);
|
||||
|
|
@ -609,13 +848,22 @@ export function usePostsEngine({
|
|||
|
||||
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)
|
||||
useEffect(() => {
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -637,7 +885,7 @@ export function usePostsEngine({
|
|||
if ((searchQuery || "").trim()) return;
|
||||
|
||||
// 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);
|
||||
delayedFetchRef.current = setTimeout(load, 1200);
|
||||
return;
|
||||
|
|
@ -716,7 +964,7 @@ export function usePostsEngine({
|
|||
lat,
|
||||
lon: lng,
|
||||
radius_km: radiusKm,
|
||||
limit: 45,
|
||||
limit: 50,
|
||||
zoom,
|
||||
bounds,
|
||||
username: username || undefined,
|
||||
|
|
@ -727,18 +975,20 @@ export function usePostsEngine({
|
|||
const existing = allPostsRef.current || [];
|
||||
const byId = new Map();
|
||||
for (const p of existing) {
|
||||
const key = getPostKey(p);
|
||||
const normalized = normalizePostId(p);
|
||||
const key = getPostKey(normalized);
|
||||
if (key) byId.set(key, p);
|
||||
}
|
||||
if (Array.isArray(newPosts)) {
|
||||
for (const p of newPosts) {
|
||||
const key = getPostKey(p);
|
||||
const normalized = normalizePostId(p);
|
||||
const key = getPostKey(normalized);
|
||||
if (!key) continue;
|
||||
const prior = byId.get(key);
|
||||
if (prior) {
|
||||
byId.set(key, { ...prior, ...p });
|
||||
byId.set(key, { ...prior, ...normalized });
|
||||
} else {
|
||||
byId.set(key, p);
|
||||
byId.set(key, normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -776,7 +1026,19 @@ export function usePostsEngine({
|
|||
|
||||
setLoadingPosts(false);
|
||||
} 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);
|
||||
moveDebounceRef.current = null;
|
||||
}
|
||||
if (initialLoadRef.current) {
|
||||
initialLoadRef.current = false;
|
||||
load();
|
||||
} else {
|
||||
moveDebounceRef.current = setTimeout(load, 300);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (moveDebounceRef.current) {
|
||||
|
|
@ -822,7 +1088,9 @@ export function usePostsEngine({
|
|||
});
|
||||
if (cancelled) return;
|
||||
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);
|
||||
trackEvent("search_filter_apply", {
|
||||
query_len: q.length,
|
||||
|
|
@ -838,10 +1106,16 @@ export function usePostsEngine({
|
|||
};
|
||||
}, [searchQuery, viewParams, mapRef, refreshVisibleAndMarkers, timeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((searchQuery || "").trim()) return;
|
||||
searchResultsRef.current = false;
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const handleStyle = () => {
|
||||
if (!clustersEnabled) return;
|
||||
if (!clusterModeRef.current) return;
|
||||
ensureClusterLayers(map);
|
||||
setClusterVisibility(map, true);
|
||||
|
|
@ -851,7 +1125,7 @@ export function usePostsEngine({
|
|||
};
|
||||
map.on("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(
|
||||
(p) => {
|
||||
|
|
@ -862,7 +1136,8 @@ export function usePostsEngine({
|
|||
}
|
||||
const map = mapRef.current;
|
||||
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);
|
||||
if (catCode) {
|
||||
|
|
@ -879,13 +1154,13 @@ export function usePostsEngine({
|
|||
}
|
||||
|
||||
// add marker if missing
|
||||
const id = getId(p);
|
||||
const id = getId(normalized);
|
||||
const have = new Set((markersRef.current || []).map((x) => x.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]
|
||||
);
|
||||
|
|
@ -917,7 +1192,7 @@ export function usePostsEngine({
|
|||
updated.push(p);
|
||||
continue;
|
||||
}
|
||||
const next = { ...p, ...(postPatch || {}) };
|
||||
const next = normalizePostId({ ...p, ...(postPatch || {}) });
|
||||
if (counts) {
|
||||
if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count;
|
||||
if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count;
|
||||
|
|
|
|||
|
|
@ -187,7 +187,8 @@ export default function PostCard({ post, selected, onSelect }) {
|
|||
setPostGroupId((post?.group_id || "").toString());
|
||||
}, [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 title = post?.title || "Untitled";
|
||||
const snippet = post?.snippet || "";
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default function PostCardTemplate({ post, theme = "blue", onOpen }) {
|
|||
source: post.author ? `by ${post.author}` : (post.sub_category || ""),
|
||||
summary: post.snippet || post.body || "",
|
||||
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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export default function SmartSearchBar({
|
|||
const abortRef = useRef(null);
|
||||
const skipSearchRef = useRef(false);
|
||||
const boxRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onDocDown(e) {
|
||||
|
|
@ -137,13 +138,14 @@ export default function SmartSearchBar({
|
|||
}, [q]);
|
||||
|
||||
const showList = open && (items.length > 0 || loading || err);
|
||||
const clearOnFocusRef = useRef(false);
|
||||
|
||||
const pick = (it, reason = "click") => {
|
||||
if (!it) return;
|
||||
const zoom = defaultZoomForKind(it.kind);
|
||||
skipSearchRef.current = true;
|
||||
setOpen(false);
|
||||
setQ(it.title || q);
|
||||
clearOnFocusRef.current = true;
|
||||
|
||||
trackEvent("search_pick", {
|
||||
kind: it.kind || "",
|
||||
|
|
@ -152,6 +154,9 @@ export default function SmartSearchBar({
|
|||
title_len: (it.title || "").length,
|
||||
});
|
||||
onPick && onPick(it, { zoom, reason });
|
||||
if (inputRef.current) {
|
||||
try { inputRef.current.blur(); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
|
|
@ -171,6 +176,9 @@ export default function SmartSearchBar({
|
|||
setOpen(false);
|
||||
trackEvent("search_submit", { query_len: q.trim().length });
|
||||
onSearch && onSearch(q.trim());
|
||||
if (inputRef.current) {
|
||||
try { inputRef.current.blur(); } catch {}
|
||||
}
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
|
|
@ -194,6 +202,7 @@ export default function SmartSearchBar({
|
|||
<div className="sw-search" ref={boxRef}>
|
||||
<div className="sw-search__bar">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sw-search__input"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
|
|
@ -205,10 +214,13 @@ export default function SmartSearchBar({
|
|||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (q.trim()) {
|
||||
if (clearOnFocusRef.current) {
|
||||
clearOnFocusRef.current = false;
|
||||
setQ("");
|
||||
setItems([]);
|
||||
setOpen(true);
|
||||
onSearch && onSearch("");
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
// Si pas encore cherché et query vide, charger suggestions populaires
|
||||
|
|
@ -261,12 +273,25 @@ export default function SmartSearchBar({
|
|||
key={it.id}
|
||||
className={"sw-search__row sw-search__item" + (idx === active ? " is-active" : "")}
|
||||
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__sub">
|
||||
{truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)}
|
||||
</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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ function normalizeOne(r) {
|
|||
|
||||
const type = (r.type ?? r.kind ?? r.entity ?? "").toString().toLowerCase().trim();
|
||||
const kind = type || (coords ? "place" : "item");
|
||||
const tags =
|
||||
Array.isArray(r.event_tags)
|
||||
? r.event_tags
|
||||
: Array.isArray(r.tags)
|
||||
? r.tags
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: (r.id ?? r._id ?? r.key ?? title).toString(),
|
||||
|
|
@ -52,6 +58,7 @@ function normalizeOne(r) {
|
|||
coords,
|
||||
lat,
|
||||
lon,
|
||||
tags,
|
||||
raw: r,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-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 {
|
||||
display:flex;
|
||||
gap:.35rem;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@
|
|||
position: relative;
|
||||
width: 92%;
|
||||
max-width: 1300px;
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
|
||||
border-radius: 24px;
|
||||
padding: 2.5rem;
|
||||
|
|
@ -64,6 +66,19 @@
|
|||
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{
|
||||
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;
|
||||
border-radius: 14px;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -284,10 +302,18 @@ body[data-theme="blue"] .island-viewer-container{
|
|||
min-width: 0;
|
||||
}
|
||||
.island-profile-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
color: #e2e8f0;
|
||||
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 {
|
||||
display: flex;
|
||||
|
|
@ -311,6 +337,15 @@ body[data-theme="blue"] .island-viewer-container{
|
|||
font-size: 0.75rem;
|
||||
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 {
|
||||
flex: 1;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
/* Left / right: filtres */
|
||||
.map-overlay-left{
|
||||
left:.7rem;
|
||||
top:44%;
|
||||
top:50%;
|
||||
transform:translateY(-50%);
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
|
|
@ -91,13 +91,14 @@
|
|||
}
|
||||
.map-overlay-right{
|
||||
right:.7rem;
|
||||
top:44%;
|
||||
top:50%;
|
||||
transform:translateY(-50%);
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* BOTTOM: sub-cats au-dessus du Sociowall */
|
||||
.map-overlay-bottom{
|
||||
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;
|
||||
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{
|
||||
font-size:.75rem;
|
||||
color:#cbd5f5;
|
||||
|
|
@ -286,14 +323,69 @@
|
|||
gap:.4rem;
|
||||
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{
|
||||
display:flex;
|
||||
gap:.5rem;
|
||||
align-items:center;
|
||||
}
|
||||
.create-image-row input[type="file"]{
|
||||
flex:1;
|
||||
.create-file-input{
|
||||
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;
|
||||
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{
|
||||
font-size:.7rem;
|
||||
|
|
@ -319,6 +411,30 @@
|
|||
border-radius:999px;
|
||||
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{
|
||||
font-size:.7rem;
|
||||
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-input,
|
||||
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;
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
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-input,
|
||||
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;
|
||||
border-color: rgba(148, 163, 184, 0.7);
|
||||
color:#0f172a;
|
||||
|
|
|
|||
|
|
@ -607,7 +607,7 @@ body[data-theme="light"] .sw-chat-bubble::before{
|
|||
}
|
||||
|
||||
.sw-modal-hero{
|
||||
width:100%;
|
||||
width:auto;
|
||||
height: 180px;
|
||||
min-height: 180px;
|
||||
flex: 1 1 auto;
|
||||
|
|
@ -991,8 +991,15 @@ body[data-theme="light"] .sw-modal-hero-fallback{
|
|||
align-items: stretch;
|
||||
}
|
||||
.sw-truth-rail{
|
||||
width: 12px;
|
||||
min-width: 12px;
|
||||
width: 22px;
|
||||
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{
|
||||
opacity: 0;
|
||||
transform: translate3d(0,0,0) scale(0.96);
|
||||
transition: opacity 840ms ease, transform 840ms ease;
|
||||
transition: opacity 640ms ease;
|
||||
}
|
||||
.post-pin.sw-appear.sw-appear-in{
|
||||
opacity: 1;
|
||||
transform: translate3d(0,0,0) scale(1);
|
||||
}
|
||||
.post-pin.sw-disappear{
|
||||
opacity: 0;
|
||||
transform: translate3d(0,0,0) scale(0.96);
|
||||
transition: opacity 840ms ease, transform 840ms ease;
|
||||
transition: opacity 640ms 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,11 @@
|
|||
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 {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
|
@ -144,6 +149,20 @@
|
|||
color: var(--sw-muted, rgba(255,255,255,0.60));
|
||||
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 {
|
||||
color: var(--sw-muted, rgba(255,255,255,0.60));
|
||||
|
|
|
|||
Loading…
Reference in New Issue