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;
|
if (!Array.isArray(posts) || posts.length === 0) return posts;
|
||||||
const ids = [];
|
const ids = [];
|
||||||
for (const p of posts) {
|
for (const p of posts) {
|
||||||
[p?.image_small, p?.image_large, p?.image, p?.media_url].forEach((v) => {
|
[p?.image_small, p?.image_medium, p?.image_large, p?.image, p?.media_url].forEach((v) => {
|
||||||
if (isAssetRef(v)) ids.push(v.replace("asset://", ""));
|
if (isAssetRef(v)) ids.push(v.replace("asset://", ""));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +66,7 @@ async function resolveAssetRefsInPosts(posts) {
|
||||||
clone.id = clone.post_id;
|
clone.id = clone.post_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
["image_small", "image_large", "image", "media_url"].forEach((k) => {
|
["image_small", "image_medium", "image_large", "image", "media_url"].forEach((k) => {
|
||||||
const v = clone[k];
|
const v = clone[k];
|
||||||
if (isAssetRef(v)) {
|
if (isAssetRef(v)) {
|
||||||
const id = v.replace("asset://", "");
|
const id = v.replace("asset://", "");
|
||||||
|
|
@ -225,6 +225,33 @@ export async function createPost(payload, token) {
|
||||||
return res.json().catch(() => ({}));
|
return res.json().catch(() => ({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch link preview data for a URL (auth required).
|
||||||
|
*/
|
||||||
|
export async function fetchPostPreview(url, token) {
|
||||||
|
const raw = (url || "").toString().trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
else Object.assign(headers, authHeaders());
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/post/preview`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ url: raw }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
const err = new Error(text || `HTTP ${res.status}`);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère un post par ID (détails + counts)
|
* Récupère un post par ID (détails + counts)
|
||||||
*/
|
*/
|
||||||
|
|
@ -246,6 +273,25 @@ export async function fetchPostById(postId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPostByUrl(url) {
|
||||||
|
const raw = (url || "").toString().trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set("url", raw);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/post?${qs.toString()}`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!data || typeof data !== "object") return null;
|
||||||
|
const resolved = await resolveAssetRefsInPosts([data]);
|
||||||
|
return resolved && resolved[0] ? resolved[0] : data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPostTruth(postId) {
|
export async function fetchPostTruth(postId) {
|
||||||
if (!postId) return null;
|
if (!postId) return null;
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,12 @@ async function me(accessToken) {
|
||||||
username: (u && (u.username || u.user || u.preferred_username)) || "",
|
username: (u && (u.username || u.user || u.preferred_username)) || "",
|
||||||
emailVerified,
|
emailVerified,
|
||||||
avatarUrl: u && typeof u.avatar_url === "string" ? u.avatar_url : "",
|
avatarUrl: u && typeof u.avatar_url === "string" ? u.avatar_url : "",
|
||||||
|
needsUsername:
|
||||||
|
u && typeof u.needs_username === "boolean"
|
||||||
|
? u.needs_username
|
||||||
|
: u && typeof u.needsUsername === "boolean"
|
||||||
|
? u.needsUsername
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,10 +195,12 @@ export function AuthProvider({ children }) {
|
||||||
const [user, setUser] = useState(null); // { username }
|
const [user, setUser] = useState(null); // { username }
|
||||||
const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... }
|
const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... }
|
||||||
const [avatarUrl, setAvatarUrl] = useState("");
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
|
const [profileLoaded, setProfileLoaded] = useState(false);
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [lastInfo, setLastInfo] = useState("");
|
const [lastInfo, setLastInfo] = useState("");
|
||||||
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
|
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
|
||||||
|
const [needsUsername, setNeedsUsername] = useState(false);
|
||||||
|
|
||||||
const refreshTimer = useRef(null);
|
const refreshTimer = useRef(null);
|
||||||
const lastAuthStatusRef = useRef(null);
|
const lastAuthStatusRef = useRef(null);
|
||||||
|
|
@ -202,9 +210,11 @@ export function AuthProvider({ children }) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setTokens(null);
|
setTokens(null);
|
||||||
setAvatarUrl("");
|
setAvatarUrl("");
|
||||||
|
setProfileLoaded(false);
|
||||||
setError(msg || "");
|
setError(msg || "");
|
||||||
setLastInfo("");
|
setLastInfo("");
|
||||||
setNeedsEmailVerification(false);
|
setNeedsEmailVerification(false);
|
||||||
|
setNeedsUsername(false);
|
||||||
clearAuth();
|
clearAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,6 +226,7 @@ export function AuthProvider({ children }) {
|
||||||
setUser(u ? { username: u } : null);
|
setUser(u ? { username: u } : null);
|
||||||
|
|
||||||
setStatus("auth");
|
setStatus("auth");
|
||||||
|
setProfileLoaded(false);
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,6 +243,11 @@ export function AuthProvider({ children }) {
|
||||||
if (mr.username) {
|
if (mr.username) {
|
||||||
setUser({ username: mr.username });
|
setUser({ username: mr.username });
|
||||||
}
|
}
|
||||||
|
if (typeof mr.needsUsername === "boolean") {
|
||||||
|
setNeedsUsername(mr.needsUsername);
|
||||||
|
} else {
|
||||||
|
setNeedsUsername(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof verified !== "boolean") {
|
if (typeof verified !== "boolean") {
|
||||||
const tv = tokenEmailVerified(at);
|
const tv = tokenEmailVerified(at);
|
||||||
|
|
@ -240,11 +256,13 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
if (typeof verified === "boolean") {
|
if (typeof verified === "boolean") {
|
||||||
setNeedsEmailVerification(!verified);
|
setNeedsEmailVerification(!verified);
|
||||||
|
setProfileLoaded(true);
|
||||||
return { ok: true, verified };
|
return { ok: true, verified };
|
||||||
}
|
}
|
||||||
|
|
||||||
// If cannot determine, do not block UI aggressively
|
// If cannot determine, do not block UI aggressively
|
||||||
setNeedsEmailVerification(false);
|
setNeedsEmailVerification(false);
|
||||||
|
setProfileLoaded(true);
|
||||||
return { ok: mr.ok, verified: null };
|
return { ok: mr.ok, verified: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,6 +352,12 @@ export function AuthProvider({ children }) {
|
||||||
setLastInfo("");
|
setLastInfo("");
|
||||||
const saved = loadAuth();
|
const saved = loadAuth();
|
||||||
if (!saved?.access_token) {
|
if (!saved?.access_token) {
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem("sociowire:logout")) {
|
||||||
|
setAnon("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
if (keycloakEnabled()) {
|
if (keycloakEnabled()) {
|
||||||
const ok = await trySilentKeycloakSession();
|
const ok = await trySilentKeycloakSession();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|
@ -404,6 +428,9 @@ export function AuthProvider({ children }) {
|
||||||
refreshTimer.current = null;
|
refreshTimer.current = null;
|
||||||
const redirectUri = window.location.origin + window.location.pathname;
|
const redirectUri = window.location.origin + window.location.pathname;
|
||||||
const doSsoLogout = keycloakEnabled();
|
const doSsoLogout = keycloakEnabled();
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("sociowire:logout", String(Date.now()));
|
||||||
|
} catch {}
|
||||||
setAnon("");
|
setAnon("");
|
||||||
if (!doSsoLogout) return;
|
if (!doSsoLogout) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -434,6 +461,9 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
async function completeKeycloakLogin() {
|
async function completeKeycloakLogin() {
|
||||||
if (!keycloakEnabled()) return { ok: false, reason: "disabled" };
|
if (!keycloakEnabled()) return { ok: false, reason: "disabled" };
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem("sociowire:logout");
|
||||||
|
} catch {}
|
||||||
|
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setError("");
|
setError("");
|
||||||
|
|
@ -511,6 +541,11 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
async function trySilentKeycloakSession() {
|
async function trySilentKeycloakSession() {
|
||||||
let kc = null;
|
let kc = null;
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem("sociowire:logout")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
try {
|
try {
|
||||||
kc = getKeycloak();
|
kc = getKeycloak();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -558,6 +593,9 @@ export function AuthProvider({ children }) {
|
||||||
setAnon("SSO is not configured.");
|
setAnon("SSO is not configured.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem("sociowire:logout");
|
||||||
|
} catch {}
|
||||||
const redirectUri = window.location.origin + window.location.pathname;
|
const redirectUri = window.location.origin + window.location.pathname;
|
||||||
try {
|
try {
|
||||||
const kc = getKeycloak();
|
const kc = getKeycloak();
|
||||||
|
|
@ -626,6 +664,8 @@ export function AuthProvider({ children }) {
|
||||||
tokens,
|
tokens,
|
||||||
error,
|
error,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
profileLoaded,
|
||||||
|
needsUsername,
|
||||||
|
|
||||||
// expected by UI
|
// expected by UI
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -636,6 +676,7 @@ export function AuthProvider({ children }) {
|
||||||
lastError: error || "",
|
lastError: error || "",
|
||||||
lastInfo: lastInfo || "",
|
lastInfo: lastInfo || "",
|
||||||
needsEmailVerification: !!needsEmailVerification,
|
needsEmailVerification: !!needsEmailVerification,
|
||||||
|
needsUsername: !!needsUsername,
|
||||||
resendVerificationEmail,
|
resendVerificationEmail,
|
||||||
loginWithProvider,
|
loginWithProvider,
|
||||||
ssoEnabled: keycloakEnabled(),
|
ssoEnabled: keycloakEnabled(),
|
||||||
|
|
@ -648,10 +689,11 @@ export function AuthProvider({ children }) {
|
||||||
setError,
|
setError,
|
||||||
setLastInfo,
|
setLastInfo,
|
||||||
setNeedsEmailVerification,
|
setNeedsEmailVerification,
|
||||||
|
setNeedsUsername,
|
||||||
clearError: () => setError(""),
|
clearError: () => setError(""),
|
||||||
clearInfo: () => setLastInfo(""),
|
clearInfo: () => setLastInfo(""),
|
||||||
};
|
};
|
||||||
}, [status, user, tokens, error, lastInfo, needsEmailVerification, avatarUrl]);
|
}, [status, user, tokens, error, lastInfo, needsEmailVerification, needsUsername, avatarUrl, profileLoaded]);
|
||||||
|
|
||||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import '../../styles/island.css';
|
import '../../styles/island.css';
|
||||||
import { fetchPosts, uploadProfileAvatar } from '../../api/client';
|
import { fetchPosts, updateProfileUsername, uploadProfileAvatar } from '../../api/client';
|
||||||
import { useAuth } from '../../auth/AuthContext';
|
import { useAuth } from '../../auth/AuthContext';
|
||||||
import { trackEvent } from "../../utils/analytics";
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const animationRef = useRef(null);
|
const animationRef = useRef(null);
|
||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
const { authenticated, username, setAvatarUrl } = useAuth();
|
const { authenticated, username, refreshProfile, setAvatarUrl, setNeedsUsername } = useAuth();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState("");
|
const [uploadError, setUploadError] = useState("");
|
||||||
const [uploadInfo, setUploadInfo] = useState("");
|
const [uploadInfo, setUploadInfo] = useState("");
|
||||||
|
|
@ -26,12 +26,18 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
const [activeTab, setActiveTab] = useState("profile");
|
const [activeTab, setActiveTab] = useState("profile");
|
||||||
const [userPosts, setUserPosts] = useState([]);
|
const [userPosts, setUserPosts] = useState([]);
|
||||||
const [postsLoading, setPostsLoading] = useState(false);
|
const [postsLoading, setPostsLoading] = useState(false);
|
||||||
|
const [localUsername, setLocalUsername] = useState("");
|
||||||
|
const [usernameDraft, setUsernameDraft] = useState("");
|
||||||
|
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||||
|
const [usernameError, setUsernameError] = useState("");
|
||||||
|
const [usernameInfo, setUsernameInfo] = useState("");
|
||||||
|
const islandUsername = localUsername || island?.username || "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!island || !containerRef.current) return;
|
if (!island || !containerRef.current) return;
|
||||||
|
|
||||||
console.log('[IslandViewer] Rendering island with MapLibre:', island.username, island.seed);
|
console.log('[IslandViewer] Rendering island with MapLibre:', islandUsername, island.seed);
|
||||||
trackEvent("island_open", { username: island.username || "" });
|
trackEvent("island_open", { username: islandUsername || "" });
|
||||||
|
|
||||||
// Center of island in virtual coordinates
|
// Center of island in virtual coordinates
|
||||||
const centerLng = 0;
|
const centerLng = 0;
|
||||||
|
|
@ -112,15 +118,17 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
// Add content items as markers
|
// Add content items as markers
|
||||||
if (island.content && island.content.length > 0) {
|
if (island.content && island.content.length > 0) {
|
||||||
console.log('[IslandViewer] Adding', island.content.length, 'content items');
|
console.log('[IslandViewer] Adding', island.content.length, 'content items');
|
||||||
island.content.forEach(item => {
|
const usedSlots = new Map();
|
||||||
|
island.content.forEach((item, idx) => {
|
||||||
const contentEl = createContentElement(item);
|
const contentEl = createContentElement(item);
|
||||||
|
const pos = resolveContentPosition(item, idx, island.seed, usedSlots);
|
||||||
new maplibregl.Marker({
|
new maplibregl.Marker({
|
||||||
element: contentEl,
|
element: contentEl,
|
||||||
anchor: 'bottom'
|
anchor: 'bottom'
|
||||||
})
|
})
|
||||||
.setLngLat([
|
.setLngLat([
|
||||||
centerLng + (item.position_x - 0.5) * 0.001,
|
centerLng + (pos.x - 0.5) * 0.0012,
|
||||||
centerLat + (item.position_y - 0.5) * 0.001
|
centerLat + (pos.y - 0.5) * 0.0012
|
||||||
])
|
])
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
});
|
});
|
||||||
|
|
@ -151,10 +159,18 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
}, [island?.avatar_url]);
|
}, [island?.avatar_url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!island?.username) return;
|
setLocalUsername(island?.username || "");
|
||||||
|
}, [island?.username]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUsernameDraft(islandUsername);
|
||||||
|
}, [islandUsername]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!islandUsername) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setPostsLoading(true);
|
setPostsLoading(true);
|
||||||
fetchPosts({ username: island.username, limit: 40 })
|
fetchPosts({ username: islandUsername, limit: 40 })
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setUserPosts(Array.isArray(items) ? items : []);
|
setUserPosts(Array.isArray(items) ? items : []);
|
||||||
|
|
@ -174,8 +190,8 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
|
|
||||||
if (!island) return null;
|
if (!island) return null;
|
||||||
const isSelf =
|
const isSelf =
|
||||||
authenticated && username && island.username &&
|
authenticated && username && islandUsername &&
|
||||||
username.toLowerCase() === island.username.toLowerCase();
|
username.toLowerCase() === islandUsername.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="island-viewer-modal" onClick={onClose}>
|
<div className="island-viewer-modal" onClick={onClose}>
|
||||||
|
|
@ -183,36 +199,72 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
<button className="island-viewer-close" onClick={onClose}>×</button>
|
<button className="island-viewer-close" onClick={onClose}>×</button>
|
||||||
|
|
||||||
<div className="island-viewer-header">
|
<div className="island-viewer-header">
|
||||||
<div className="island-header-row">
|
<div className="island-header-row">
|
||||||
<div className="island-header-avatar">
|
<div className="island-header-avatar">
|
||||||
{avatarSrc ? (
|
{avatarSrc ? (
|
||||||
<img src={avatarSrc} alt={island.username} />
|
<img src={avatarSrc} alt={islandUsername} />
|
||||||
) : (
|
) : (
|
||||||
<span>{(island.username || "U")[0].toUpperCase()}</span>
|
<span>{(islandUsername || "U")[0].toUpperCase()}</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="island-header-text">
|
||||||
|
<h2>{island.display_name || `${islandUsername}'s Island`}</h2>
|
||||||
|
<p>{island.bio || 'Welcome to my island'}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="island-header-text">
|
|
||||||
<h2>{island.display_name || `${island.username}'s Island`}</h2>
|
|
||||||
<p>{island.bio || 'Welcome to my island'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === "profile" ? (
|
{activeTab === "profile" ? (
|
||||||
<div className="island-profile-panel">
|
<div className="island-profile-panel">
|
||||||
<div className="island-profile-avatar">
|
<div className="island-profile-avatar">
|
||||||
{avatarSrc ? (
|
{avatarSrc ? (
|
||||||
<img src={avatarSrc} alt={island.username} />
|
<img src={avatarSrc} alt={islandUsername} />
|
||||||
) : (
|
) : (
|
||||||
<div className="island-profile-letter">
|
<div className="island-profile-letter">
|
||||||
{(island.username || "U")[0].toUpperCase()}
|
{(islandUsername || "U")[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="island-profile-meta">
|
<div className="island-profile-meta">
|
||||||
<div className="island-profile-name">@{island.username}</div>
|
<div className="island-profile-name">@{islandUsername}</div>
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
<div className="island-profile-actions">
|
<div className="island-profile-actions">
|
||||||
|
<div className="island-profile-row">
|
||||||
|
<label>Username</label>
|
||||||
|
<div className="island-profile-inline">
|
||||||
|
<input
|
||||||
|
className="island-profile-input"
|
||||||
|
value={usernameDraft}
|
||||||
|
onChange={(e) => setUsernameDraft(e.target.value)}
|
||||||
|
placeholder="Choose a username"
|
||||||
|
disabled={usernameSaving}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="island-profile-btn"
|
||||||
|
disabled={usernameSaving || !usernameDraft.trim()}
|
||||||
|
onClick={async () => {
|
||||||
|
const next = usernameDraft.trim();
|
||||||
|
if (!next) return;
|
||||||
|
setUsernameSaving(true);
|
||||||
|
setUsernameError("");
|
||||||
|
setUsernameInfo("");
|
||||||
|
const res = await updateProfileUsername(next);
|
||||||
|
if (res && res.ok && res.username) {
|
||||||
|
setLocalUsername(res.username);
|
||||||
|
if (setNeedsUsername) setNeedsUsername(false);
|
||||||
|
if (refreshProfile) await refreshProfile();
|
||||||
|
setUsernameInfo("Username updated.");
|
||||||
|
} else {
|
||||||
|
setUsernameError(res?.text || "Update failed.");
|
||||||
|
}
|
||||||
|
setUsernameSaving(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{usernameSaving ? "Saving..." : "Change"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="island-profile-row">
|
<div className="island-profile-row">
|
||||||
<label>Photo visibility</label>
|
<label>Photo visibility</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -286,6 +338,8 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
/>
|
/>
|
||||||
{uploadError ? <div className="island-profile-error">{uploadError}</div> : null}
|
{uploadError ? <div className="island-profile-error">{uploadError}</div> : null}
|
||||||
{uploadInfo ? <div className="island-profile-info">{uploadInfo}</div> : null}
|
{uploadInfo ? <div className="island-profile-info">{uploadInfo}</div> : null}
|
||||||
|
{usernameError ? <div className="island-profile-error">{usernameError}</div> : null}
|
||||||
|
{usernameInfo ? <div className="island-profile-info">{usernameInfo}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -388,14 +442,14 @@ function createIslandElement(seed, terrainType = 'tropical') {
|
||||||
el.className = 'island-terrain-marker';
|
el.className = 'island-terrain-marker';
|
||||||
|
|
||||||
const terrainColor = getTerrainColorCSS(terrainType);
|
const terrainColor = getTerrainColorCSS(terrainType);
|
||||||
const size = 200 + rng() * 50; // 200-250px
|
const size = 220 + rng() * 50; // 220-270px
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="
|
<div style="
|
||||||
width: ${size}px;
|
width: ${size}px;
|
||||||
height: ${size}px;
|
height: ${size}px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle at 30% 30%, ${terrainColor}dd, ${terrainColor}99, ${terrainColor}66);
|
background: radial-gradient(circle at 30% 30%, ${terrainColor}ee, ${terrainColor}aa, ${terrainColor}77);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 40px ${terrainColor}88,
|
0 0 40px ${terrainColor}88,
|
||||||
0 20px 40px rgba(0,0,0,0.4),
|
0 20px 40px rgba(0,0,0,0.4),
|
||||||
|
|
@ -404,6 +458,21 @@ function createIslandElement(seed, terrainType = 'tropical') {
|
||||||
position: relative;
|
position: relative;
|
||||||
animation: islandFloat 4s ease-in-out infinite;
|
animation: islandFloat 4s ease-in-out infinite;
|
||||||
">
|
">
|
||||||
|
<div style="
|
||||||
|
position: absolute;
|
||||||
|
inset: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 6px solid rgba(255, 243, 199, 0.55);
|
||||||
|
box-shadow: inset 0 0 12px rgba(255, 243, 199, 0.45);
|
||||||
|
"></div>
|
||||||
|
<div style="
|
||||||
|
position: absolute;
|
||||||
|
top: 12%;
|
||||||
|
right: 18%;
|
||||||
|
font-size: 22px;
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.4));
|
||||||
|
animation: contentFloat 3s ease-in-out infinite;
|
||||||
|
">✨</div>
|
||||||
${createIslandTexture(seed, terrainType)}
|
${createIslandTexture(seed, terrainType)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -559,10 +628,10 @@ function createContentElement(content) {
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="
|
<div style="
|
||||||
width: 40px;
|
width: 46px;
|
||||||
height: 60px;
|
height: 64px;
|
||||||
background: linear-gradient(135deg, ${color}ee, ${color}aa);
|
background: linear-gradient(135deg, ${color}ee, ${color}aa);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 12px rgba(0,0,0,0.4),
|
0 4px 12px rgba(0,0,0,0.4),
|
||||||
0 0 20px ${color}66;
|
0 0 20px ${color}66;
|
||||||
|
|
@ -582,6 +651,28 @@ function createContentElement(content) {
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveContentPosition(item, index, seed, used) {
|
||||||
|
const rng = seededRandom(seed + index * 7 + 13);
|
||||||
|
let x = Number.isFinite(item.position_x) ? item.position_x : 0.5;
|
||||||
|
let y = Number.isFinite(item.position_y) ? item.position_y : 0.5;
|
||||||
|
if (!Number.isFinite(x)) x = 0.5;
|
||||||
|
if (!Number.isFinite(y)) y = 0.5;
|
||||||
|
|
||||||
|
// If missing or stacked, scatter in a ring.
|
||||||
|
const rawKey = `${Math.round(x * 1000)}:${Math.round(y * 1000)}`;
|
||||||
|
const seen = used.get(rawKey) || 0;
|
||||||
|
used.set(rawKey, seen + 1);
|
||||||
|
if (seen > 0 || (x === 0.5 && y === 0.5)) {
|
||||||
|
const angle = rng() * Math.PI * 2;
|
||||||
|
const radius = 0.06 + rng() * 0.16 + seen * 0.03;
|
||||||
|
x = 0.5 + Math.cos(angle) * radius;
|
||||||
|
y = 0.5 + Math.sin(angle) * radius;
|
||||||
|
}
|
||||||
|
x = Math.max(0.1, Math.min(0.9, x));
|
||||||
|
y = Math.max(0.1, Math.min(0.9, y));
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seeded random number generator (deterministic)
|
* Seeded random number generator (deterministic)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
loginWithProvider,
|
loginWithProvider,
|
||||||
ssoEnabled,
|
ssoEnabled,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
profileLoaded,
|
||||||
|
needsUsername,
|
||||||
needsEmailVerification,
|
needsEmailVerification,
|
||||||
lastError,
|
lastError,
|
||||||
lastInfo,
|
lastInfo,
|
||||||
|
|
@ -72,12 +74,6 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
clearInfo,
|
clearInfo,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
|
||||||
const displayUsername = (() => {
|
|
||||||
const base = (username || "user").toString();
|
|
||||||
if (base.length <= 9) return base;
|
|
||||||
return base.slice(0, 9) + "..";
|
|
||||||
})();
|
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
const [mode, setMode] = useState("login");
|
const [mode, setMode] = useState("login");
|
||||||
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
||||||
|
|
@ -86,6 +82,15 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
const [usernameInfo, setUsernameInfo] = useState("");
|
const [usernameInfo, setUsernameInfo] = useState("");
|
||||||
const [usernameSaving, setUsernameSaving] = useState(false);
|
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||||
const usernamePromptTimer = useRef(null);
|
const usernamePromptTimer = useRef(null);
|
||||||
|
const invalidSinceRef = useRef(null);
|
||||||
|
const [stableUsername, setStableUsername] = useState("");
|
||||||
|
|
||||||
|
const displayUsername = (() => {
|
||||||
|
const raw = (stableUsername || username || "user").toString();
|
||||||
|
if (!stableUsername && raw.includes("@")) return "user";
|
||||||
|
if (raw.length <= 9) return raw;
|
||||||
|
return raw.slice(0, 9) + "..";
|
||||||
|
})();
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
const [loginUser, setLoginUser] = useState("");
|
||||||
const [loginPass, setLoginPass] = useState("");
|
const [loginPass, setLoginPass] = useState("");
|
||||||
|
|
@ -158,7 +163,22 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
}, [authenticated]);
|
}, [authenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const needsUsername = (name) => {
|
if (!authenticated) {
|
||||||
|
setStableUsername("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = (username || "").trim();
|
||||||
|
if (!next) return;
|
||||||
|
const looksLikeEmail = next.includes("@");
|
||||||
|
if (!looksLikeEmail) {
|
||||||
|
setStableUsername(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stableUsername) return;
|
||||||
|
}, [authenticated, username, stableUsername]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requiresUsername = (name) => {
|
||||||
if (!name) return false;
|
if (!name) return false;
|
||||||
if (name.includes("@")) return true;
|
if (name.includes("@")) return true;
|
||||||
const re = /^[A-Za-z0-9_.]{3,24}$/;
|
const re = /^[A-Za-z0-9_.]{3,24}$/;
|
||||||
|
|
@ -173,7 +193,8 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
setShowUsernameModal(false);
|
setShowUsernameModal(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (needsUsername(username)) {
|
const shouldPrompt = !!needsUsername || requiresUsername(username);
|
||||||
|
if (shouldPrompt) {
|
||||||
if (!showUsernameModal) {
|
if (!showUsernameModal) {
|
||||||
const base = (username || "").split("@")[0] || "";
|
const base = (username || "").split("@")[0] || "";
|
||||||
const cleaned = base.replace(/[^A-Za-z0-9_.]/g, "").slice(0, 24);
|
const cleaned = base.replace(/[^A-Za-z0-9_.]/g, "").slice(0, 24);
|
||||||
|
|
@ -182,12 +203,12 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
setUsernameInfo("");
|
setUsernameInfo("");
|
||||||
usernamePromptTimer.current = setTimeout(() => {
|
usernamePromptTimer.current = setTimeout(() => {
|
||||||
setShowUsernameModal(true);
|
setShowUsernameModal(true);
|
||||||
}, 350);
|
}, 600);
|
||||||
}
|
}
|
||||||
} else if (showUsernameModal) {
|
} else if (showUsernameModal) {
|
||||||
setShowUsernameModal(false);
|
setShowUsernameModal(false);
|
||||||
}
|
}
|
||||||
}, [authenticated, username, showUsernameModal, loading]);
|
}, [authenticated, username, showUsernameModal, loading, needsUsername]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import "../../styles/mapMarkers.css";
|
||||||
import "../../styles/posts.css";
|
import "../../styles/posts.css";
|
||||||
import "../../styles/filters.css";
|
import "../../styles/filters.css";
|
||||||
|
|
||||||
import { createPost } from "../../api/client";
|
import { createPost, fetchPostPreview } from "../../api/client";
|
||||||
import {
|
import {
|
||||||
FILTER_MAIN_CATEGORIES,
|
FILTER_MAIN_CATEGORIES,
|
||||||
FILTER_CATEGORY_MAP,
|
FILTER_CATEGORY_MAP,
|
||||||
|
|
@ -20,7 +20,7 @@ import FilterButtons from "../Filters/FilterButtons";
|
||||||
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
||||||
import SmartSearchBar from "../Search/SmartSearchBar";
|
import SmartSearchBar from "../Search/SmartSearchBar";
|
||||||
import UserProfile from "../Profile/UserProfile";
|
import UserProfile from "../Profile/UserProfile";
|
||||||
import { fetchPostById } from "../../api/client";
|
import { fetchPostById, fetchPostByUrl } from "../../api/client";
|
||||||
import { trackEvent } from "../../utils/analytics";
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
export default function MapView({
|
export default function MapView({
|
||||||
|
|
@ -33,6 +33,8 @@ export default function MapView({
|
||||||
onOpenIslands,
|
onOpenIslands,
|
||||||
}) {
|
}) {
|
||||||
const { authenticated, username, token, needsEmailVerification } = useAuth();
|
const { authenticated, username, token, needsEmailVerification } = useAuth();
|
||||||
|
const CLUSTERS_ENABLED = true;
|
||||||
|
const HEATMAP_ENABLED = false;
|
||||||
|
|
||||||
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
|
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
|
||||||
useMapCore(theme);
|
useMapCore(theme);
|
||||||
|
|
@ -49,6 +51,7 @@ export default function MapView({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [subFilter, setSubFilter] = useState("All");
|
const [subFilter, setSubFilter] = useState("All");
|
||||||
|
const [mainNewsOnly, setMainNewsOnly] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -63,12 +66,15 @@ export default function MapView({
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const skipAutoZoomRef = useRef(false);
|
const skipAutoZoomRef = useRef(false);
|
||||||
|
const [clusterFocus, setClusterFocus] = useState(null);
|
||||||
|
const [forceFullPostId, setForceFullPostId] = useState(null);
|
||||||
|
|
||||||
// Profile state (selectedIsland is now passed from App.jsx)
|
// Profile state (selectedIsland is now passed from App.jsx)
|
||||||
const [selectedProfile, setSelectedProfile] = useState(null);
|
const [selectedProfile, setSelectedProfile] = useState(null);
|
||||||
const openedPostRef = useRef(null);
|
const openedPostRef = useRef(null);
|
||||||
const queryPostRef = useRef(false);
|
const queryPostRef = useRef(false);
|
||||||
const overlayTimerRef = useRef(null);
|
const overlayTimerRef = useRef(null);
|
||||||
|
const panTimerRef = useRef(null);
|
||||||
const pendingOpenPostIdRef = useRef(null);
|
const pendingOpenPostIdRef = useRef(null);
|
||||||
const pendingOpenFullRef = useRef(false);
|
const pendingOpenFullRef = useRef(false);
|
||||||
const searchFitQueryRef = useRef("");
|
const searchFitQueryRef = useRef("");
|
||||||
|
|
@ -97,6 +103,55 @@ export default function MapView({
|
||||||
[mapRef, theme]
|
[mapRef, theme]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fitBoundsWhenReady = useCallback(
|
||||||
|
(bounds, opts = {}) => {
|
||||||
|
const tryFit = () => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return false;
|
||||||
|
try {
|
||||||
|
map.fitBounds(bounds, opts);
|
||||||
|
} catch {}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if (tryFit()) return;
|
||||||
|
if (panTimerRef.current) clearInterval(panTimerRef.current);
|
||||||
|
panTimerRef.current = setInterval(() => {
|
||||||
|
if (tryFit()) {
|
||||||
|
clearInterval(panTimerRef.current);
|
||||||
|
panTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
|
},
|
||||||
|
[mapRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const panToPostWhenReady = useCallback(
|
||||||
|
(post, zoom = 12) => {
|
||||||
|
const lat = Number(post?.lat);
|
||||||
|
const lon = Number(post?.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
|
||||||
|
const tryPan = () => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return false;
|
||||||
|
try {
|
||||||
|
map.easeTo({ center: [lon, lat], zoom, duration: 700 });
|
||||||
|
} catch {}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tryPan()) return;
|
||||||
|
if (panTimerRef.current) clearInterval(panTimerRef.current);
|
||||||
|
panTimerRef.current = setInterval(() => {
|
||||||
|
if (tryPan()) {
|
||||||
|
clearInterval(panTimerRef.current);
|
||||||
|
panTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
|
},
|
||||||
|
[mapRef]
|
||||||
|
);
|
||||||
|
|
||||||
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate } =
|
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate } =
|
||||||
usePostsEngine({
|
usePostsEngine({
|
||||||
theme,
|
theme,
|
||||||
|
|
@ -106,6 +161,11 @@ export default function MapView({
|
||||||
subFilter,
|
subFilter,
|
||||||
timeFilter,
|
timeFilter,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
clusterFocus,
|
||||||
|
mainNewsOnly,
|
||||||
|
clustersEnabled: CLUSTERS_ENABLED,
|
||||||
|
heatmapEnabled: HEATMAP_ENABLED,
|
||||||
|
forceFullPostId,
|
||||||
markersRef,
|
markersRef,
|
||||||
expandedElRef,
|
expandedElRef,
|
||||||
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
|
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
|
||||||
|
|
@ -116,12 +176,18 @@ export default function MapView({
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [draftTitle, setDraftTitle] = useState("");
|
const [draftTitle, setDraftTitle] = useState("");
|
||||||
const [draftBody, setDraftBody] = useState("");
|
const [draftBody, setDraftBody] = useState("");
|
||||||
|
const [draftUrl, setDraftUrl] = useState("");
|
||||||
|
const [draftSource, setDraftSource] = useState("");
|
||||||
|
const [draftPublishedAt, setDraftPublishedAt] = useState("");
|
||||||
const [draftCategory, setDraftCategory] = useState("News");
|
const [draftCategory, setDraftCategory] = useState("News");
|
||||||
const [draftSubCategory, setDraftSubCategory] = useState(
|
const [draftSubCategory, setDraftSubCategory] = useState(
|
||||||
CREATE_CATEGORY_MAP.News[0]
|
CREATE_CATEGORY_MAP.News[0]
|
||||||
);
|
);
|
||||||
const [draftCoords, setDraftCoords] = useState(null);
|
const [draftCoords, setDraftCoords] = useState(null);
|
||||||
const [draftImage, setDraftImage] = useState("");
|
const [draftImage, setDraftImage] = useState("");
|
||||||
|
const [draftImageSmall, setDraftImageSmall] = useState("");
|
||||||
|
const [draftImageMedium, setDraftImageMedium] = useState("");
|
||||||
|
const [draftImageLarge, setDraftImageLarge] = useState("");
|
||||||
const [draftImageFile, setDraftImageFile] = useState(null);
|
const [draftImageFile, setDraftImageFile] = useState(null);
|
||||||
const [draftImagePreview, setDraftImagePreview] = useState("");
|
const [draftImagePreview, setDraftImagePreview] = useState("");
|
||||||
const [useCustomImage, setUseCustomImage] = useState(false);
|
const [useCustomImage, setUseCustomImage] = useState(false);
|
||||||
|
|
@ -132,12 +198,28 @@ export default function MapView({
|
||||||
const [imageGroupId, setImageGroupId] = useState("");
|
const [imageGroupId, setImageGroupId] = useState("");
|
||||||
const [imageUsers, setImageUsers] = useState("");
|
const [imageUsers, setImageUsers] = useState("");
|
||||||
const [uploadingImage, setUploadingImage] = useState(false);
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
const [urlPreviewLoading, setUrlPreviewLoading] = useState(false);
|
||||||
|
const [urlPreviewError, setUrlPreviewError] = useState("");
|
||||||
|
const [titleTouched, setTitleTouched] = useState(false);
|
||||||
|
const [bodyTouched, setBodyTouched] = useState(false);
|
||||||
|
const [imageTouched, setImageTouched] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState("");
|
const [saveError, setSaveError] = useState("");
|
||||||
const [createStep, setCreateStep] = useState(0);
|
const [createStep, setCreateStep] = useState(0);
|
||||||
|
const previewTimerRef = useRef(null);
|
||||||
|
const lastPreviewUrlRef = useRef("");
|
||||||
|
|
||||||
const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"];
|
const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"];
|
||||||
|
|
||||||
|
const formatPreviewDate = (value) => {
|
||||||
|
if (!value) return "";
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(value);
|
||||||
|
return d.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLikelyUrl = (value) => /^https?:\/\//i.test(String(value || "").trim());
|
||||||
|
|
||||||
const getSubcatIcon = (main, sub) => {
|
const getSubcatIcon = (main, sub) => {
|
||||||
const M = {
|
const M = {
|
||||||
All: {
|
All: {
|
||||||
|
|
@ -235,7 +317,6 @@ export default function MapView({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queryPostRef.current) return;
|
if (queryPostRef.current) return;
|
||||||
queryPostRef.current = true;
|
|
||||||
const qs = new URLSearchParams(window.location.search || "");
|
const qs = new URLSearchParams(window.location.search || "");
|
||||||
let idRaw = qs.get("post_id") || qs.get("id") || qs.get("p");
|
let idRaw = qs.get("post_id") || qs.get("id") || qs.get("p");
|
||||||
if (!idRaw) {
|
if (!idRaw) {
|
||||||
|
|
@ -249,6 +330,7 @@ export default function MapView({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!idRaw) return;
|
if (!idRaw) return;
|
||||||
|
queryPostRef.current = true;
|
||||||
const id = Number(idRaw);
|
const id = Number(idRaw);
|
||||||
if (!Number.isFinite(id) || id <= 0) return;
|
if (!Number.isFinite(id) || id <= 0) return;
|
||||||
pendingOpenPostIdRef.current = id;
|
pendingOpenPostIdRef.current = id;
|
||||||
|
|
@ -262,9 +344,193 @@ export default function MapView({
|
||||||
});
|
});
|
||||||
}, [onSelectPost, openOverlayWhenReady]);
|
}, [onSelectPost, openOverlayWhenReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (queryPostRef.current) return;
|
||||||
|
if (!CLUSTERS_ENABLED) return;
|
||||||
|
const path = (window.location.pathname || "").trim();
|
||||||
|
const clusterMatch = path.match(/^\/cluster\/[^/]+\/\d{8}/);
|
||||||
|
if (!clusterMatch) return;
|
||||||
|
queryPostRef.current = true;
|
||||||
|
const fullUrl = `${window.location.origin}${clusterMatch[0]}`;
|
||||||
|
fetchPostByUrl(fullUrl).then((post) => {
|
||||||
|
if (!post) return;
|
||||||
|
const items = Array.isArray(post.cluster_items) ? post.cluster_items : [];
|
||||||
|
const ids = Array.isArray(post.cluster_members) ? post.cluster_members : [];
|
||||||
|
const points = items
|
||||||
|
.map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) }))
|
||||||
|
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
|
||||||
|
const mainPost = { ...post };
|
||||||
|
if (!Array.isArray(mainPost.cluster_items) || mainPost.cluster_items.length === 0) {
|
||||||
|
mainPost.cluster_items = items;
|
||||||
|
}
|
||||||
|
setClusterFocus({
|
||||||
|
key: post.cluster_key || "",
|
||||||
|
ids,
|
||||||
|
posts: items.map((it) => ({
|
||||||
|
id: Number(it.id || it.ID) || 0,
|
||||||
|
title: it.title || "Untitled",
|
||||||
|
snippet: it.snippet || "",
|
||||||
|
category: "NEWS",
|
||||||
|
sub_category: "World",
|
||||||
|
lat: Number(it.lat),
|
||||||
|
lon: Number(it.lon),
|
||||||
|
url: it.url || "",
|
||||||
|
source: it.source || "",
|
||||||
|
image_small: it.image_small || "",
|
||||||
|
image_medium: it.image_medium || "",
|
||||||
|
image_large: it.image_large || "",
|
||||||
|
created_at: it.published_at || "",
|
||||||
|
published_at: it.published_at || "",
|
||||||
|
})).concat([mainPost]),
|
||||||
|
points,
|
||||||
|
center: { lat: Number(post.lat), lon: Number(post.lon) },
|
||||||
|
});
|
||||||
|
onSelectPost?.(mainPost);
|
||||||
|
panToPostWhenReady(mainPost, 12);
|
||||||
|
openOverlayWhenReady(mainPost, { fullScreen: true });
|
||||||
|
});
|
||||||
|
}, [mapRef, onSelectPost, openOverlayWhenReady, panToPostWhenReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!CLUSTERS_ENABLED || !HEATMAP_ENABLED) return;
|
||||||
|
const handler = (ev) => {
|
||||||
|
const detail = ev?.detail || {};
|
||||||
|
const mode = detail.mode || "click";
|
||||||
|
const skipPan = !!detail.skipPan;
|
||||||
|
const key = (detail.clusterKey || "").toString();
|
||||||
|
const members = Array.isArray(detail.members) ? detail.members : [];
|
||||||
|
const postId = Number(detail.postId || 0);
|
||||||
|
const ids = members.length ? Array.from(new Set([...members, postId].filter(Boolean))) : (postId ? [postId] : []);
|
||||||
|
const items = Array.isArray(detail.items) ? detail.items : [];
|
||||||
|
const mainPost = detail.post && typeof detail.post === "object" ? detail.post : null;
|
||||||
|
|
||||||
|
const toExtraPosts = (list, basePost) => {
|
||||||
|
const out = [];
|
||||||
|
if (basePost) {
|
||||||
|
const withItems = { ...basePost };
|
||||||
|
if (!Array.isArray(withItems.cluster_items) || withItems.cluster_items.length === 0) {
|
||||||
|
withItems.cluster_items = list;
|
||||||
|
}
|
||||||
|
out.push(withItems);
|
||||||
|
}
|
||||||
|
for (const it of list) {
|
||||||
|
if (!it) continue;
|
||||||
|
const id = Number(it.id || it.ID);
|
||||||
|
out.push({
|
||||||
|
id: Number.isFinite(id) ? id : 0,
|
||||||
|
title: it.title || it.Title || "Untitled",
|
||||||
|
snippet: it.snippet || "",
|
||||||
|
category: "NEWS",
|
||||||
|
sub_category: "World",
|
||||||
|
lat: Number(it.lat),
|
||||||
|
lon: Number(it.lon),
|
||||||
|
url: it.url || "",
|
||||||
|
source: it.source || "",
|
||||||
|
image_small: it.image_small || "",
|
||||||
|
image_medium: it.image_medium || "",
|
||||||
|
image_large: it.image_large || "",
|
||||||
|
created_at: it.published_at || "",
|
||||||
|
published_at: it.published_at || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyClusterFocus = (list, basePost, points) => {
|
||||||
|
setClusterFocus((prev) => {
|
||||||
|
if (prev && prev.key && key && prev.key === key) {
|
||||||
|
return mode === "hover" ? prev : null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
ids,
|
||||||
|
posts: toExtraPosts(list, basePost),
|
||||||
|
points,
|
||||||
|
center: detail.center || null,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
try { map.__swForceFetchAt = Date.now(); } catch {}
|
||||||
|
|
||||||
|
const points = Array.isArray(detail.points) ? detail.points : [];
|
||||||
|
const center = detail.center || {};
|
||||||
|
const ensurePoints = (list) => {
|
||||||
|
const pts = list
|
||||||
|
.map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) }))
|
||||||
|
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
|
||||||
|
if (Number.isFinite(center.lat) && Number.isFinite(center.lon)) {
|
||||||
|
pts.push({ lat: center.lat, lon: center.lon });
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const usePoints = (pts) => {
|
||||||
|
if (skipPan) return;
|
||||||
|
const valid = pts.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
|
||||||
|
if (valid.length >= 2) {
|
||||||
|
let minLat = 90, maxLat = -90, minLon = 180, maxLon = -180;
|
||||||
|
for (const p of valid) {
|
||||||
|
minLat = Math.min(minLat, p.lat);
|
||||||
|
maxLat = Math.max(maxLat, p.lat);
|
||||||
|
minLon = Math.min(minLon, p.lon);
|
||||||
|
maxLon = Math.max(maxLon, p.lon);
|
||||||
|
}
|
||||||
|
fitBoundsWhenReady([[minLon, minLat], [maxLon, maxLat]], { padding: 80, duration: 700 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lat = Number(center.lat);
|
||||||
|
const lon = Number(center.lon);
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
panToPostWhenReady({ lat, lon }, 12);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!items.length && mainPost?.url) {
|
||||||
|
fetchPostByUrl(mainPost.url).then((full) => {
|
||||||
|
if (full && Array.isArray(full.cluster_items) && full.cluster_items.length > 0) {
|
||||||
|
const fullItems = full.cluster_items;
|
||||||
|
const pts = ensurePoints(fullItems);
|
||||||
|
applyClusterFocus(fullItems, { ...mainPost, ...full, cluster_items: fullItems }, pts);
|
||||||
|
usePoints(pts);
|
||||||
|
} else {
|
||||||
|
applyClusterFocus(items, mainPost, points);
|
||||||
|
usePoints(points);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pts = ensurePoints(items);
|
||||||
|
applyClusterFocus(items, mainPost, pts);
|
||||||
|
usePoints(pts);
|
||||||
|
};
|
||||||
|
window.addEventListener("sociowire:cluster-heatmap", handler);
|
||||||
|
return () => window.removeEventListener("sociowire:cluster-heatmap", handler);
|
||||||
|
}, [mapRef, fitBoundsWhenReady, panToPostWhenReady, CLUSTERS_ENABLED, HEATMAP_ENABLED]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!CLUSTERS_ENABLED || !HEATMAP_ENABLED) return;
|
||||||
|
const handler = () => {
|
||||||
|
setClusterFocus((prev) => (prev?.mode === "hover" ? null : prev));
|
||||||
|
};
|
||||||
|
window.addEventListener("sociowire:cluster-heatmap-clear", handler);
|
||||||
|
return () => window.removeEventListener("sociowire:cluster-heatmap-clear", handler);
|
||||||
|
}, [CLUSTERS_ENABLED, HEATMAP_ENABLED]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wantId = pendingOpenPostIdRef.current;
|
const wantId = pendingOpenPostIdRef.current;
|
||||||
if (!wantId || !Array.isArray(visiblePosts) || !visiblePosts.length) return;
|
if (!wantId || !Array.isArray(visiblePosts) || !visiblePosts.length) return;
|
||||||
|
const map = mapRef.current;
|
||||||
|
const overlay = map?.__swCenteredOverlay;
|
||||||
|
if (overlay && overlay.postId === wantId) {
|
||||||
|
pendingOpenPostIdRef.current = null;
|
||||||
|
pendingOpenFullRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId);
|
const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId);
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
pendingOpenPostIdRef.current = null;
|
pendingOpenPostIdRef.current = null;
|
||||||
|
|
@ -277,6 +543,7 @@ export default function MapView({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (overlayTimerRef.current) clearInterval(overlayTimerRef.current);
|
if (overlayTimerRef.current) clearInterval(overlayTimerRef.current);
|
||||||
|
if (panTimerRef.current) clearInterval(panTimerRef.current);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -346,6 +613,112 @@ export default function MapView({
|
||||||
coords = [item.lon, item.lat];
|
coords = [item.lon, item.lat];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If picking a post, open the real post (with likes/comments) after fetch.
|
||||||
|
const kind = (item?.kind || item?.type || "").toString().toLowerCase();
|
||||||
|
const raw = item?.raw || item;
|
||||||
|
const looksLikePost =
|
||||||
|
kind === "post" ||
|
||||||
|
kind === "posts" ||
|
||||||
|
Number(raw?.post_id || 0) > 0 ||
|
||||||
|
!!raw?.url_hash ||
|
||||||
|
(!!raw?.url && !kind.includes("profile") && !kind.includes("place"));
|
||||||
|
if (looksLikePost) {
|
||||||
|
skipAutoZoomRef.current = true;
|
||||||
|
setSearchQuery("");
|
||||||
|
setMainFilter("All");
|
||||||
|
setSubFilter("All");
|
||||||
|
setTimeFilter("PAST"); // Show all time periods
|
||||||
|
|
||||||
|
const postId = Number(raw?.post_id ?? raw?.id ?? item?.id ?? 0);
|
||||||
|
const rawUrl = raw?.url || item?.url || "";
|
||||||
|
if (Number.isFinite(postId) && postId > 0) {
|
||||||
|
setForceFullPostId(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusPost = (post) => {
|
||||||
|
if (!post) return;
|
||||||
|
const lat = Number(post?.lat);
|
||||||
|
const lon = Number(post?.lon);
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
const center = map.getCenter?.();
|
||||||
|
const dz = (zoom || 12);
|
||||||
|
const distKm = center ? Math.sqrt(Math.pow(center.lat - lat, 2) + Math.pow(center.lng - lon, 2)) * 111 : 999;
|
||||||
|
const curZoom = map.getZoom?.() || dz;
|
||||||
|
const targetZoom = distKm > 8 ? dz : Math.max(curZoom, Math.min(dz, curZoom + 0.8));
|
||||||
|
map.easeTo({
|
||||||
|
center: [lon, lat],
|
||||||
|
zoom: targetZoom,
|
||||||
|
duration: 650,
|
||||||
|
essential: true
|
||||||
|
});
|
||||||
|
map.__swForceFetchAt = Date.now();
|
||||||
|
}
|
||||||
|
openedPostRef.current = post?.id || null;
|
||||||
|
onSelectPost?.(post);
|
||||||
|
openOverlayWhenReady(post, { fullScreen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const stubPost = (() => {
|
||||||
|
const lat = Number(raw?.lat ?? item?.lat);
|
||||||
|
const lon = Number(raw?.lon ?? item?.lon);
|
||||||
|
const id = Number(postId || 0);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
return {
|
||||||
|
id: Number.isFinite(id) ? id : 0,
|
||||||
|
title: raw?.title || item?.title || "Post",
|
||||||
|
snippet: raw?.snippet || item?.snippet || "",
|
||||||
|
category: raw?.category || item?.category || "NEWS",
|
||||||
|
sub_category: raw?.sub_category || item?.sub_category || "",
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
url: raw?.url || item?.url || "",
|
||||||
|
image_small: raw?.image_small || item?.image_small || "",
|
||||||
|
image_medium: raw?.image_medium || item?.image_medium || "",
|
||||||
|
image_large: raw?.image_large || item?.image_large || "",
|
||||||
|
created_at: raw?.created_at || item?.created_at || "",
|
||||||
|
published_at: raw?.published_at || item?.published_at || "",
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const tryOpen = async (attempt = 0) => {
|
||||||
|
let post = null;
|
||||||
|
if (Number.isFinite(postId) && postId > 0) {
|
||||||
|
post = await fetchPostById(postId);
|
||||||
|
}
|
||||||
|
if (!post && rawUrl) {
|
||||||
|
post = await fetchPostByUrl(rawUrl);
|
||||||
|
}
|
||||||
|
if (post) {
|
||||||
|
pendingOpenPostIdRef.current = null;
|
||||||
|
pendingOpenFullRef.current = false;
|
||||||
|
focusPost(post);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt < 2) {
|
||||||
|
setTimeout(() => tryOpen(attempt + 1), 650 + attempt * 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stubPost) {
|
||||||
|
pendingOpenPostIdRef.current = null;
|
||||||
|
pendingOpenFullRef.current = false;
|
||||||
|
focusPost(stubPost);
|
||||||
|
}
|
||||||
|
if (coords) {
|
||||||
|
pendingOpenPostIdRef.current = null;
|
||||||
|
pendingOpenFullRef.current = false;
|
||||||
|
map.easeTo({
|
||||||
|
center: coords,
|
||||||
|
zoom: zoom || 12,
|
||||||
|
duration: 650,
|
||||||
|
essential: true
|
||||||
|
});
|
||||||
|
map.__swForceFetchAt = Date.now();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryOpen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!coords) {
|
if (!coords) {
|
||||||
console.warn('[MapView] No coords for suggestion:', item);
|
console.warn('[MapView] No coords for suggestion:', item);
|
||||||
return;
|
return;
|
||||||
|
|
@ -360,61 +733,21 @@ export default function MapView({
|
||||||
});
|
});
|
||||||
map.__swForceFetchAt = Date.now();
|
map.__swForceFetchAt = Date.now();
|
||||||
|
|
||||||
// If picking a post, clear filters and avoid query-locking
|
if (kind === 'city' || kind === 'region' || kind === 'place' || kind === 'poi') {
|
||||||
const kind = (item?.kind || item?.type || "").toString().toLowerCase();
|
|
||||||
|
|
||||||
if (kind === 'post') {
|
|
||||||
skipAutoZoomRef.current = true;
|
|
||||||
setSearchQuery("");
|
|
||||||
setMainFilter("All");
|
|
||||||
setSubFilter("All");
|
|
||||||
setTimeFilter("PAST"); // Show all time periods
|
|
||||||
|
|
||||||
const raw = item.raw || item;
|
|
||||||
const postId = raw?.post_id ?? raw?.id ?? item?.id ?? 0;
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const lat = typeof raw.lat === "number" ? raw.lat : coords[1];
|
|
||||||
const lon = typeof raw.lon === "number" ? raw.lon : coords[0];
|
|
||||||
const builtPost = {
|
|
||||||
id: raw.post_id ?? raw.id ?? 0,
|
|
||||||
title: raw.title || item.title || "Post",
|
|
||||||
snippet: raw.snippet || raw.summary || "",
|
|
||||||
category: raw.category || "NEWS",
|
|
||||||
sub_category: raw.sub_category || raw.subCategory || "ALL",
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
url: raw.url || "",
|
|
||||||
source: raw.source || "",
|
|
||||||
published_at: raw.published_at || "",
|
|
||||||
created_at: raw.published_at || "",
|
|
||||||
url_hash: raw.url_hash || "",
|
|
||||||
};
|
|
||||||
handleIncomingPost(builtPost);
|
|
||||||
if (postId > 0) {
|
|
||||||
fetchPostById(postId).then((full) => {
|
|
||||||
if (full) {
|
|
||||||
openOverlayWhenReady(full, { fullScreen: true });
|
|
||||||
} else {
|
|
||||||
openOverlayWhenReady(builtPost, { fullScreen: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
openOverlayWhenReady(builtPost, { fullScreen: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (kind === 'city' || kind === 'region' || kind === 'place' || kind === 'poi') {
|
|
||||||
// Place picks should move the map without forcing a text filter.
|
// Place picks should move the map without forcing a text filter.
|
||||||
skipAutoZoomRef.current = false;
|
skipAutoZoomRef.current = false;
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
} else {
|
setForceFullPostId(null);
|
||||||
const q = (item.title || item.name || "").toString().trim();
|
return;
|
||||||
skipAutoZoomRef.current = false;
|
}
|
||||||
setSearchQuery(q);
|
|
||||||
if (q) {
|
const q = (item.title || item.name || "").toString().trim();
|
||||||
setMainFilter("All");
|
skipAutoZoomRef.current = false;
|
||||||
setSubFilter("All");
|
setSearchQuery(q);
|
||||||
setTimeFilter("PAST");
|
if (q) {
|
||||||
}
|
setMainFilter("All");
|
||||||
|
setSubFilter("All");
|
||||||
|
setTimeFilter("PAST");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -422,6 +755,7 @@ export default function MapView({
|
||||||
console.log('[MapView] Search/filter by:', query);
|
console.log('[MapView] Search/filter by:', query);
|
||||||
skipAutoZoomRef.current = false;
|
skipAutoZoomRef.current = false;
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
|
if (!query.trim()) setForceFullPostId(null);
|
||||||
|
|
||||||
// Clear ALL filters to show all search results
|
// Clear ALL filters to show all search results
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
|
|
@ -563,6 +897,9 @@ export default function MapView({
|
||||||
setCreateStep(0);
|
setCreateStep(0);
|
||||||
setDraftTitle("");
|
setDraftTitle("");
|
||||||
setDraftBody("");
|
setDraftBody("");
|
||||||
|
setDraftUrl("");
|
||||||
|
setDraftSource("");
|
||||||
|
setDraftPublishedAt("");
|
||||||
setDraftImage("");
|
setDraftImage("");
|
||||||
setDraftImageFile(null);
|
setDraftImageFile(null);
|
||||||
setDraftImagePreview("");
|
setDraftImagePreview("");
|
||||||
|
|
@ -573,6 +910,12 @@ export default function MapView({
|
||||||
setImageVisibility("public");
|
setImageVisibility("public");
|
||||||
setImageGroupId("");
|
setImageGroupId("");
|
||||||
setImageUsers("");
|
setImageUsers("");
|
||||||
|
setUrlPreviewLoading(false);
|
||||||
|
setUrlPreviewError("");
|
||||||
|
setTitleTouched(false);
|
||||||
|
setBodyTouched(false);
|
||||||
|
setImageTouched(false);
|
||||||
|
lastPreviewUrlRef.current = "";
|
||||||
const main = mainFilter === "All" ? "News" : mainFilter;
|
const main = mainFilter === "All" ? "News" : mainFilter;
|
||||||
setDraftCategory(main);
|
setDraftCategory(main);
|
||||||
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
|
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
|
||||||
|
|
@ -591,6 +934,7 @@ export default function MapView({
|
||||||
const handleImageFileChange = async (e) => {
|
const handleImageFileChange = async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
setImageTouched(true);
|
||||||
|
|
||||||
// Vérifier le type de fichier
|
// Vérifier le type de fichier
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
|
|
@ -636,11 +980,24 @@ export default function MapView({
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data && data.url) {
|
const variants = data && data.variants ? data.variants : null;
|
||||||
setDraftImage(data.url);
|
const pickRef = (v) => {
|
||||||
} else if (data && data.asset_id) {
|
if (!v) return "";
|
||||||
setDraftImage(`asset://${data.asset_id}`);
|
if (v.asset_id) return `asset://${v.asset_id}`;
|
||||||
|
return v.url || "";
|
||||||
|
};
|
||||||
|
const smallRef = variants ? pickRef(variants.small) : "";
|
||||||
|
const mediumRef = variants ? pickRef(variants.medium) : "";
|
||||||
|
let largeRef = variants ? pickRef(variants.large) : "";
|
||||||
|
if (!largeRef) {
|
||||||
|
if (data && data.url) largeRef = data.url;
|
||||||
|
else if (data && data.asset_id) largeRef = `asset://${data.asset_id}`;
|
||||||
}
|
}
|
||||||
|
const fallbackRef = largeRef || mediumRef || smallRef;
|
||||||
|
setDraftImageSmall(smallRef || fallbackRef);
|
||||||
|
setDraftImageMedium(mediumRef || fallbackRef);
|
||||||
|
setDraftImageLarge(largeRef || fallbackRef);
|
||||||
|
if (fallbackRef) setDraftImage(fallbackRef);
|
||||||
setUploadingImage(false);
|
setUploadingImage(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Image upload error:', err);
|
console.error('Image upload error:', err);
|
||||||
|
|
@ -648,9 +1005,64 @@ export default function MapView({
|
||||||
setUploadingImage(false);
|
setUploadingImage(false);
|
||||||
setDraftImageFile(null);
|
setDraftImageFile(null);
|
||||||
setDraftImagePreview("");
|
setDraftImagePreview("");
|
||||||
|
setDraftImageSmall("");
|
||||||
|
setDraftImageMedium("");
|
||||||
|
setDraftImageLarge("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUrlPreview = async (rawOverride) => {
|
||||||
|
const raw = (rawOverride || draftUrl).trim();
|
||||||
|
if (!raw || urlPreviewLoading) return;
|
||||||
|
if (!/^https?:\/\//i.test(raw)) return;
|
||||||
|
if (raw === lastPreviewUrlRef.current) return;
|
||||||
|
setUrlPreviewLoading(true);
|
||||||
|
setUrlPreviewError("");
|
||||||
|
lastPreviewUrlRef.current = raw;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchPostPreview(raw, token);
|
||||||
|
if (!data) throw new Error("Preview unavailable");
|
||||||
|
|
||||||
|
const title = (data.title || "").trim();
|
||||||
|
const desc = (data.description || "").trim();
|
||||||
|
const image = (data.image || "").trim();
|
||||||
|
|
||||||
|
if (!titleTouched && title) setDraftTitle(title);
|
||||||
|
if (!bodyTouched && desc) setDraftBody(desc);
|
||||||
|
|
||||||
|
if (data.source) setDraftSource(String(data.source));
|
||||||
|
if (data.published_at) setDraftPublishedAt(String(data.published_at));
|
||||||
|
|
||||||
|
if (!imageTouched && image) {
|
||||||
|
setUseCustomImage(true);
|
||||||
|
if (!draftImageFile && !draftImagePreview) {
|
||||||
|
setDraftImage(image);
|
||||||
|
setDraftImageSmall(image);
|
||||||
|
setDraftImageMedium(image);
|
||||||
|
setDraftImageLarge(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setUrlPreviewError(err?.message || "Failed to fetch link preview");
|
||||||
|
} finally {
|
||||||
|
setUrlPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const raw = draftUrl.trim();
|
||||||
|
if (!raw || !/^https?:\/\//i.test(raw)) return;
|
||||||
|
if (raw === lastPreviewUrlRef.current) return;
|
||||||
|
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
||||||
|
previewTimerRef.current = setTimeout(() => {
|
||||||
|
handleUrlPreview(raw);
|
||||||
|
}, 650);
|
||||||
|
return () => {
|
||||||
|
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [draftUrl]);
|
||||||
|
|
||||||
const handleSubmitPost = async () => {
|
const handleSubmitPost = async () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setSaveError("");
|
setSaveError("");
|
||||||
|
|
@ -722,9 +1134,26 @@ export default function MapView({
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (useCustomImage && draftImage.trim()) {
|
if (draftUrl.trim()) {
|
||||||
payload.image_small = draftImage.trim();
|
payload.url = draftUrl.trim();
|
||||||
payload.image_large = draftImage.trim();
|
}
|
||||||
|
if (draftSource.trim()) {
|
||||||
|
payload.source = draftSource.trim();
|
||||||
|
}
|
||||||
|
if (draftPublishedAt.trim()) {
|
||||||
|
payload.published_at = draftPublishedAt.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
draftImageSmall ||
|
||||||
|
draftImageMedium ||
|
||||||
|
draftImageLarge ||
|
||||||
|
(useCustomImage && draftImage.trim())
|
||||||
|
) {
|
||||||
|
const fallback = draftImage.trim();
|
||||||
|
payload.image_small = (draftImageSmall || fallback).trim();
|
||||||
|
payload.image_medium = (draftImageMedium || fallback).trim();
|
||||||
|
payload.image_large = (draftImageLarge || fallback).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPost = await createPost(payload, token);
|
const newPost = await createPost(payload, token);
|
||||||
|
|
@ -789,6 +1218,18 @@ export default function MapView({
|
||||||
onPlaceTourWire={handleOpenCreate}
|
onPlaceTourWire={handleOpenCreate}
|
||||||
/>
|
/>
|
||||||
<div className="sw-search__side" aria-label="Search actions">
|
<div className="sw-search__side" aria-label="Search actions">
|
||||||
|
{CLUSTERS_ENABLED && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
"sw-search__iconBtn" + (mainNewsOnly ? " is-active" : "")
|
||||||
|
}
|
||||||
|
title="Main news"
|
||||||
|
onClick={() => setMainNewsOnly((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-bolt"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sw-search__iconBtn"
|
className="sw-search__iconBtn"
|
||||||
|
|
@ -928,44 +1369,154 @@ export default function MapView({
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className="create-input"
|
className="create-input"
|
||||||
placeholder="Short title..."
|
placeholder="Short title or paste a link..."
|
||||||
value={draftTitle}
|
value={draftTitle}
|
||||||
onChange={(e) => setDraftTitle(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
if (isLikelyUrl(next)) {
|
||||||
|
setDraftTitle(next);
|
||||||
|
setTitleTouched(false);
|
||||||
|
setDraftUrl(next.trim());
|
||||||
|
setUrlPreviewError("");
|
||||||
|
setDraftSource("");
|
||||||
|
setDraftPublishedAt("");
|
||||||
|
handleUrlPreview(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraftTitle(next);
|
||||||
|
setTitleTouched(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="create-hint">Give it a clear, punchy headline.</div>
|
<div className="create-hint">
|
||||||
|
Type a title or paste a link — 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createStep === 1 && (
|
{createStep === 1 && (
|
||||||
<div className="create-step-body">
|
<div className="create-step-body">
|
||||||
|
{(draftImagePreview ||
|
||||||
|
draftImageLarge ||
|
||||||
|
draftImageMedium ||
|
||||||
|
draftImageSmall ||
|
||||||
|
draftImage) && (
|
||||||
|
<div className="create-link-preview">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
draftImagePreview ||
|
||||||
|
draftImageLarge ||
|
||||||
|
draftImageMedium ||
|
||||||
|
draftImageSmall ||
|
||||||
|
draftImage
|
||||||
|
}
|
||||||
|
alt="Link preview"
|
||||||
|
/>
|
||||||
|
<div className="create-link-preview-meta">
|
||||||
|
{draftSource ? <span>{draftSource}</span> : null}
|
||||||
|
{draftPublishedAt ? (
|
||||||
|
<span>{formatPreviewDate(draftPublishedAt)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="create-textarea"
|
className="create-textarea"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Context (optional)..."
|
placeholder="Context (optional)..."
|
||||||
value={draftBody}
|
value={draftBody}
|
||||||
onChange={(e) => setDraftBody(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setDraftBody(e.target.value);
|
||||||
|
setBodyTouched(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="create-image-option">
|
<div className="create-image-choice">
|
||||||
<label className="create-checkbox">
|
<button
|
||||||
<input
|
type="button"
|
||||||
type="checkbox"
|
className={
|
||||||
checked={useCustomImage}
|
"create-image-choice-btn" +
|
||||||
onChange={(e) => setUseCustomImage(e.target.checked)}
|
(!useCustomImage ? " is-active" : "")
|
||||||
/>
|
}
|
||||||
<span>Use a custom image instead of the category icon</span>
|
onClick={() => {
|
||||||
</label>
|
setUseCustomImage(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use category icon
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
"create-image-choice-btn" +
|
||||||
|
(useCustomImage ? " is-active" : "")
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setUseCustomImage(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add an image
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="create-muted">
|
||||||
|
No image? 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 && (
|
{useCustomImage && (
|
||||||
<div className="create-image-panel">
|
<div className="create-image-panel">
|
||||||
<div className="create-image-row">
|
<div className="create-image-row">
|
||||||
<input
|
<label className="create-file-btn">
|
||||||
type="file"
|
Choose file
|
||||||
accept="image/*"
|
<input
|
||||||
onChange={handleImageFileChange}
|
className="create-file-input"
|
||||||
disabled={uploadingImage}
|
type="file"
|
||||||
/>
|
accept="image/*"
|
||||||
|
onChange={handleImageFileChange}
|
||||||
|
disabled={uploadingImage}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="create-file-btn create-file-btn--camera">
|
||||||
|
Take photo
|
||||||
|
<input
|
||||||
|
className="create-file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={handleImageFileChange}
|
||||||
|
disabled={uploadingImage}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
{uploadingImage && (
|
{uploadingImage && (
|
||||||
<span className="create-uploading">Uploading...</span>
|
<span className="create-uploading">Uploading...</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -987,20 +1538,24 @@ export default function MapView({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="create-muted">
|
{!draftUrl.trim() && !draftImageFile && !draftImagePreview ? (
|
||||||
Or paste an image URL:
|
<>
|
||||||
</div>
|
<div className="create-muted">
|
||||||
<input
|
Or paste an image URL:
|
||||||
className="create-input"
|
</div>
|
||||||
placeholder="https://example.com/image.jpg"
|
<input
|
||||||
value={draftImageFile ? "" : draftImage}
|
className="create-input"
|
||||||
onChange={(e) => {
|
placeholder="https://example.com/image.jpg"
|
||||||
setDraftImage(e.target.value);
|
value={draftImage}
|
||||||
setDraftImageFile(null);
|
onChange={(e) => {
|
||||||
setDraftImagePreview("");
|
setDraftImage(e.target.value);
|
||||||
}}
|
setDraftImageFile(null);
|
||||||
disabled={!!draftImageFile}
|
setDraftImagePreview("");
|
||||||
/>
|
setImageTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="create-privacy-row">
|
<div className="create-privacy-row">
|
||||||
<label>Image visibility</label>
|
<label>Image visibility</label>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ export default function TemplateMarkerCard({
|
||||||
const spec = mode === "mini" ? template?.mini_spec_json : template?.full_spec_json;
|
const spec = mode === "mini" ? template?.mini_spec_json : template?.full_spec_json;
|
||||||
|
|
||||||
const data = useMemo(() => {
|
const data = useMemo(() => {
|
||||||
const rawImage = post?.image_large || post?.image_small || post?.image || post?.media_url || "";
|
const rawImage =
|
||||||
|
mode === "mini"
|
||||||
|
? (post?.image_small || post?.image_medium || "")
|
||||||
|
: (post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || "");
|
||||||
return {
|
return {
|
||||||
headline: post?.title || "Untitled",
|
headline: post?.title || "Untitled",
|
||||||
source: post?.source || (post?.author ? `by ${post.author}` : "") || (post?.sub_category || ""),
|
source: post?.source || (post?.author ? `by ${post.author}` : "") || (post?.sub_category || ""),
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function getViewFromMap(map) {
|
||||||
radiusKm = 750;
|
radiusKm = 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
radiusKm = Math.min(Math.max(radiusKm, 25), 1000);
|
radiusKm = Math.min(Math.max(radiusKm * 1.2, 25), 1000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
center: [center.lng, center.lat],
|
center: [center.lng, center.lat],
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
const avatarCache = new Map();
|
const avatarCache = new Map();
|
||||||
const avatarInflight = new Map();
|
const avatarInflight = new Map();
|
||||||
|
const HEATMAP_ENABLED = false;
|
||||||
|
|
||||||
function applyAvatar(el, username, url) {
|
function applyAvatar(el, username, url) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -43,8 +44,9 @@ function applyAvatar(el, username, url) {
|
||||||
function resolvePostImage(post) {
|
function resolvePostImage(post) {
|
||||||
return normalizeImageUrl(
|
return normalizeImageUrl(
|
||||||
post?.image_small ||
|
post?.image_small ||
|
||||||
post?.image ||
|
post?.image_medium ||
|
||||||
post?.image_large ||
|
post?.image_large ||
|
||||||
|
post?.image ||
|
||||||
post?.media_url ||
|
post?.media_url ||
|
||||||
post?.image_url ||
|
post?.image_url ||
|
||||||
""
|
""
|
||||||
|
|
@ -224,6 +226,7 @@ function applyPostDetails(modalWrap, post) {
|
||||||
const title = (post?.title || post?.Title || "Untitled").toString();
|
const title = (post?.title || post?.Title || "Untitled").toString();
|
||||||
const summary = (post?.snippet || post?.body || "").toString();
|
const summary = (post?.snippet || post?.body || "").toString();
|
||||||
const img = heroImageForPost(post);
|
const img = heroImageForPost(post);
|
||||||
|
const fullImg = fullImageForPost(post);
|
||||||
const url = (post?.url || "").toString().trim();
|
const url = (post?.url || "").toString().trim();
|
||||||
|
|
||||||
const titleEl = modalWrap.querySelector(".sw-modal-title");
|
const titleEl = modalWrap.querySelector(".sw-modal-title");
|
||||||
|
|
@ -233,10 +236,13 @@ function applyPostDetails(modalWrap, post) {
|
||||||
if (summaryEl) summaryEl.textContent = summary;
|
if (summaryEl) summaryEl.textContent = summary;
|
||||||
|
|
||||||
const hero = modalWrap.querySelector(".sw-modal-hero");
|
const hero = modalWrap.querySelector(".sw-modal-hero");
|
||||||
if (hero) {
|
const clusterHero = modalWrap.querySelector(".sw-cluster-hero-media");
|
||||||
hero.innerHTML = img
|
if (hero || clusterHero) {
|
||||||
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
|
const target = hero || clusterHero;
|
||||||
|
target.innerHTML = img
|
||||||
|
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImg)}" alt="" loading="lazy" />`
|
||||||
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(post)}"></i><span>${escapeHtml(normCatLabel(post))}</span></div></div>`;
|
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(post)}"></i><span>${escapeHtml(normCatLabel(post))}</span></div></div>`;
|
||||||
|
bindHeroLightbox(modalWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
const badge = modalWrap.querySelector(".sw-modal-badge");
|
const badge = modalWrap.querySelector(".sw-modal-badge");
|
||||||
|
|
@ -257,6 +263,7 @@ function applyPostDetails(modalWrap, post) {
|
||||||
`;
|
`;
|
||||||
hydrateAvatars(modalWrap);
|
hydrateAvatars(modalWrap);
|
||||||
}
|
}
|
||||||
|
setModalTags(modalWrap, post);
|
||||||
|
|
||||||
const cta = modalWrap.querySelector(".sw-cta-primary");
|
const cta = modalWrap.querySelector(".sw-cta-primary");
|
||||||
if (cta) {
|
if (cta) {
|
||||||
|
|
@ -306,7 +313,11 @@ function renderComments(container, items) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountCard(container, spec, data, theme, post) {
|
function mountCard(container, spec, data, theme, post) {
|
||||||
const root = createRoot(container);
|
let root = container.__swReactRoot;
|
||||||
|
if (!root) {
|
||||||
|
root = createRoot(container);
|
||||||
|
container.__swReactRoot = root;
|
||||||
|
}
|
||||||
const tokens = cardTokens?.[theme] || cardTokens.blue;
|
const tokens = cardTokens?.[theme] || cardTokens.blue;
|
||||||
const score = truthScore(post || data || {});
|
const score = truthScore(post || data || {});
|
||||||
|
|
||||||
|
|
@ -374,6 +385,7 @@ function mountCard(container, spec, data, theme, post) {
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try { root.unmount(); } catch {}
|
try { root.unmount(); } catch {}
|
||||||
|
container.__swReactRoot = null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,6 +397,101 @@ function escapeHtml(str) {
|
||||||
.replace(/"/g, """);
|
.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) {
|
function normCatLabel(post) {
|
||||||
const c = (post?.category || "").toString().toUpperCase();
|
const c = (post?.category || "").toString().toUpperCase();
|
||||||
if (c === "NEWS") return "NEWS";
|
if (c === "NEWS") return "NEWS";
|
||||||
|
|
@ -409,8 +516,71 @@ function categoryIconClass(post) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isClusterMainPost(post) {
|
||||||
|
const clusterPostId = Number(post?.cluster_post_id);
|
||||||
|
const postId = Number(post?.post_id || post?.id);
|
||||||
|
if (Number.isFinite(clusterPostId) && Number.isFinite(postId) && clusterPostId > 0 && postId > 0) {
|
||||||
|
return clusterPostId === postId;
|
||||||
|
}
|
||||||
|
const key = (post?.cluster_key || post?.clusterKey || "").toString().trim();
|
||||||
|
const members = Array.isArray(post?.cluster_members) ? post.cluster_members : [];
|
||||||
|
if (key && members.length > 0) return true;
|
||||||
|
const url = (post?.url || "").toString();
|
||||||
|
return url.includes("/cluster/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchClusterHeatmap(post, opts = {}) {
|
||||||
|
if (!post) return;
|
||||||
|
const postId = Number(post?.id || post?.post_id || 0);
|
||||||
|
const members = Array.isArray(post?.cluster_members) ? post.cluster_members : [];
|
||||||
|
const items = Array.isArray(post?.cluster_items) ? post.cluster_items : [];
|
||||||
|
const points = items
|
||||||
|
.map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) }))
|
||||||
|
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("sociowire:cluster-heatmap", {
|
||||||
|
detail: {
|
||||||
|
clusterKey: post?.cluster_key || post?.clusterKey || "",
|
||||||
|
postId,
|
||||||
|
members,
|
||||||
|
points,
|
||||||
|
items,
|
||||||
|
post,
|
||||||
|
center: { lat: Number(post?.lat), lon: Number(post?.lon) },
|
||||||
|
mode: opts.mode || "click",
|
||||||
|
skipPan: !!opts.skipPan,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchClusterHeatmapClear() {
|
||||||
|
window.dispatchEvent(new CustomEvent("sociowire:cluster-heatmap-clear"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClusterItemsHtml(items) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return `<div class="sw-cluster-source-empty">No related reports yet.</div>`;
|
||||||
|
}
|
||||||
|
const rows = items.slice(0, 6).map((item) => {
|
||||||
|
const title = escapeHtml(item?.title || "Untitled");
|
||||||
|
const source = escapeHtml(item?.source || "");
|
||||||
|
const url = escapeHtml(item?.url || "");
|
||||||
|
const meta = source ? `<span class="sw-cluster-source">${source}</span>` : "";
|
||||||
|
const href = url ? ` data-related-url="${url}"` : "";
|
||||||
|
return `
|
||||||
|
<div class="sw-cluster-source-item"${href}>
|
||||||
|
<div class="sw-cluster-source-title">${title}</div>
|
||||||
|
${meta}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
return rows.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function heroImageForPost(post) {
|
function heroImageForPost(post) {
|
||||||
const raw =
|
const raw =
|
||||||
|
post?.image_medium ||
|
||||||
post?.image_large ||
|
post?.image_large ||
|
||||||
post?.image_small ||
|
post?.image_small ||
|
||||||
post?.image ||
|
post?.image ||
|
||||||
|
|
@ -422,6 +592,17 @@ function heroImageForPost(post) {
|
||||||
return `/og/category?cat=${encodeURIComponent(cat)}`;
|
return `/og/category?cat=${encodeURIComponent(cat)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fullImageForPost(post) {
|
||||||
|
const raw =
|
||||||
|
post?.image_large ||
|
||||||
|
post?.image_medium ||
|
||||||
|
post?.image_small ||
|
||||||
|
post?.image ||
|
||||||
|
post?.media_url ||
|
||||||
|
"";
|
||||||
|
return normalizeImageUrl(raw);
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelativeTime(createdAt) {
|
function formatRelativeTime(createdAt) {
|
||||||
try {
|
try {
|
||||||
if (!createdAt) return "now";
|
if (!createdAt) return "now";
|
||||||
|
|
@ -464,7 +645,7 @@ function truthScore(post) {
|
||||||
if (Number.isFinite(raw)) {
|
if (Number.isFinite(raw)) {
|
||||||
return clamp(Math.round(raw), 0, 100);
|
return clamp(Math.round(raw), 0, 100);
|
||||||
}
|
}
|
||||||
return 50;
|
return 65;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truthColor(score) {
|
function truthColor(score) {
|
||||||
|
|
@ -530,6 +711,12 @@ function closeCenteredOverlay(map, opts = {}) {
|
||||||
ov.historyClosed = true;
|
ov.historyClosed = true;
|
||||||
try { window.history.back(); } catch {}
|
try { window.history.back(); } catch {}
|
||||||
}
|
}
|
||||||
|
if (map) {
|
||||||
|
try {
|
||||||
|
map.__swForceFetchAt = Date.now();
|
||||||
|
map.fire("moveend");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -550,7 +737,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {}
|
try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {}
|
||||||
|
|
||||||
let postData = { ...(post || {}) };
|
let postData = { ...(post || {}) };
|
||||||
const data = adaptPostToTemplateData(postData);
|
const data = adaptPostToTemplateData(postData, { size: "full" });
|
||||||
const cat = normCatLabel(postData);
|
const cat = normCatLabel(postData);
|
||||||
const templateKey = getTemplateKeyForPost(postData);
|
const templateKey = getTemplateKeyForPost(postData);
|
||||||
const relTime = formatRelativeTime(getPostTime(postData));
|
const relTime = formatRelativeTime(getPostTime(postData));
|
||||||
|
|
@ -566,6 +753,13 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
const likeCount = readCount(postData, ["like_count", "likes", "likeCount"]);
|
const likeCount = readCount(postData, ["like_count", "likes", "likeCount"]);
|
||||||
const shareCount = readCount(postData, ["share_count", "shares", "shareCount"]);
|
const shareCount = readCount(postData, ["share_count", "shares", "shareCount"]);
|
||||||
const commentCount = readCount(postData, ["comment_count", "comments", "commentCount"]);
|
const commentCount = readCount(postData, ["comment_count", "comments", "commentCount"]);
|
||||||
|
const isCluster = isClusterMainPost(postData);
|
||||||
|
const clusterItems = Array.isArray(postData?.cluster_items) ? postData.cluster_items : [];
|
||||||
|
const clusterCount = Array.isArray(postData?.cluster_members) ? postData.cluster_members.length : 0;
|
||||||
|
const tagPills = renderTagPills(postData?.event_tags || postData?.tags);
|
||||||
|
const heatmapPill = HEATMAP_ENABLED
|
||||||
|
? `<div class="sw-stat-pill sw-heatmap-pill" data-action="cluster-heatmap">🔥 Heat map</div>`
|
||||||
|
: "";
|
||||||
trackEvent("post_open", {
|
trackEvent("post_open", {
|
||||||
post_id: postID,
|
post_id: postID,
|
||||||
source: postData?.source || postData?.Source || "",
|
source: postData?.source || postData?.Source || "",
|
||||||
|
|
@ -599,84 +793,181 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
modalWrap.style.pointerEvents = "auto";
|
modalWrap.style.pointerEvents = "auto";
|
||||||
|
|
||||||
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
|
// Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.)
|
||||||
modalWrap.innerHTML = `
|
if (isCluster) {
|
||||||
<div class="post-card sw-expanded-shell sw-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
|
modalWrap.innerHTML = `
|
||||||
<div class="sw-modal-toprow">
|
<div class="post-card sw-expanded-shell sw-modal sw-cluster-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
|
||||||
<div class="sw-modal-left">
|
<div class="sw-modal-toprow">
|
||||||
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
<div class="sw-modal-left">
|
||||||
<div class="sw-modal-meta">
|
<div class="sw-modal-badge">MAIN NEWS</div>
|
||||||
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
<div class="sw-modal-meta">
|
||||||
${author ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
||||||
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
${author ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
||||||
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
||||||
<span>• ${escapeHtml(relTime)}</span>
|
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
||||||
|
<span>• ${escapeHtml(relTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="sw-modal-x" type="button" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sw-cluster-title">${escapeHtml(postData?.cluster_title || postData?.title || "Untitled")}</div>
|
||||||
|
${tagPills}
|
||||||
|
|
||||||
|
<div class="sw-cluster-body">
|
||||||
|
<div class="sw-cluster-left">
|
||||||
|
<div class="sw-cluster-hero">
|
||||||
|
<div class="sw-truth-rail" data-score="${truth}">
|
||||||
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
|
||||||
|
<div class="sw-truth-rail-bar">
|
||||||
|
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-truth-rail-score">${truth}</div>
|
||||||
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
|
||||||
|
</div>
|
||||||
|
<div class="sw-cluster-hero-media">
|
||||||
|
${
|
||||||
|
img
|
||||||
|
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
|
||||||
|
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-cluster-actions">
|
||||||
|
<button class="post-card-btn" type="button" data-action="like">Like</button>
|
||||||
|
<button class="post-card-btn" type="button" data-action="share">Share</button>
|
||||||
|
<button class="post-card-btn" type="button">Fix</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sw-cluster-right">
|
||||||
|
<div class="sw-cluster-related-title">Related reports ${clusterCount ? `(${clusterCount})` : ""}</div>
|
||||||
|
<div class="sw-cluster-related-list">
|
||||||
|
${buildClusterItemsHtml(clusterItems)}
|
||||||
|
</div>
|
||||||
|
<div class="sw-cluster-generated">Generated news update</div>
|
||||||
|
<div class="sw-cluster-summary">
|
||||||
|
${escapeHtml(data?.summary || postData?.snippet || "")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="sw-modal-x" type="button" aria-label="Close">✕</button>
|
<div class="sw-stat-row">
|
||||||
</div>
|
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
|
||||||
|
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
|
||||||
|
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
|
||||||
|
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
|
||||||
|
${heatmapPill}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sw-modal-hero-row">
|
<div class="post-card-comments sw-livechat">
|
||||||
<div class="sw-truth-rail" data-score="${truth}">
|
<div class="sw-livechat-title">Live chat / comments</div>
|
||||||
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
|
<div class="sw-livechat-body">
|
||||||
<div class="sw-truth-rail-bar">
|
<div class="sw-chat-line">— loading…</div>
|
||||||
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
|
</div>
|
||||||
|
<div class="sw-livechat-inputrow">
|
||||||
|
<input class="sw-livechat-input" placeholder="Write a comment…" />
|
||||||
|
<button class="sw-livechat-post" type="button">POST</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sw-truth-rail-score">${truth}</div>
|
|
||||||
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sw-modal-hero">
|
|
||||||
${
|
</div>
|
||||||
img
|
`;
|
||||||
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
|
} else {
|
||||||
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
|
modalWrap.innerHTML = `
|
||||||
}
|
<div class="post-card sw-expanded-shell sw-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
|
||||||
|
<div class="sw-modal-toprow">
|
||||||
|
<div class="sw-modal-left">
|
||||||
|
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
||||||
|
<div class="sw-modal-meta">
|
||||||
|
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
||||||
|
${author ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
||||||
|
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
||||||
|
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
||||||
|
<span>• ${escapeHtml(relTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="sw-modal-x" type="button" aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="sw-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div>
|
|
||||||
|
|
||||||
<div class="sw-modal-summary">
|
<div class="sw-modal-hero-row">
|
||||||
${escapeHtml(data?.summary || postData?.snippet || "")}
|
<div class="sw-truth-rail" data-score="${truth}">
|
||||||
</div>
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
|
||||||
|
<div class="sw-truth-rail-bar">
|
||||||
<div class="sw-stat-row">
|
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
|
||||||
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
|
</div>
|
||||||
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
|
<div class="sw-truth-rail-score">${truth}</div>
|
||||||
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
|
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
|
||||||
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
|
</div>
|
||||||
<div class="sw-stat-pill">📍 ${escapeHtml(postData?.city || postData?.location_name || "Nearby")}</div>
|
<div class="sw-modal-hero">
|
||||||
</div>
|
${
|
||||||
|
img
|
||||||
<div class="sw-modal-cta-row">
|
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
|
||||||
<button class="sw-cta-primary" type="button" ${url ? "" : "disabled"} data-url="${escapeHtml(url)}">
|
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
|
||||||
Open source
|
}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<div class="sw-cta-actions">
|
|
||||||
<button class="post-card-btn" type="button">Contact</button>
|
|
||||||
<button class="post-card-btn" type="button" data-action="like">Like</button>
|
|
||||||
<button class="post-card-btn" type="button" data-action="share">Share</button>
|
|
||||||
<button class="post-card-btn" type="button">Fix</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="sw-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div>
|
||||||
|
${tagPills}
|
||||||
|
|
||||||
<div class="post-card-comments sw-livechat">
|
<div class="sw-modal-summary">
|
||||||
<div class="sw-livechat-title">Live chat / comments</div>
|
${escapeHtml(data?.summary || postData?.snippet || "")}
|
||||||
<div class="sw-livechat-body">
|
|
||||||
<div class="sw-chat-line">— loading…</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sw-livechat-inputrow">
|
|
||||||
<input class="sw-livechat-input" placeholder="Write a comment…" />
|
|
||||||
<button class="sw-livechat-post" type="button">POST</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
<div class="sw-stat-row">
|
||||||
`;
|
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
|
||||||
|
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
|
||||||
|
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
|
||||||
|
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
|
||||||
|
${heatmapPill}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sw-modal-cta-row">
|
||||||
|
<button class="sw-cta-primary" type="button" ${url ? "" : "disabled"} data-url="${escapeHtml(url)}">
|
||||||
|
Open source
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="sw-cta-actions">
|
||||||
|
<button class="post-card-btn" type="button">Contact</button>
|
||||||
|
<button class="post-card-btn" type="button" data-action="like">Like</button>
|
||||||
|
<button class="post-card-btn" type="button" data-action="share">Share</button>
|
||||||
|
<button class="post-card-btn" type="button">Fix</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-card-comments sw-livechat">
|
||||||
|
<div class="sw-livechat-title">Live chat / comments</div>
|
||||||
|
<div class="sw-livechat-body">
|
||||||
|
<div class="sw-chat-line">— loading…</div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-livechat-inputrow">
|
||||||
|
<input class="sw-livechat-input" placeholder="Write a comment…" />
|
||||||
|
<button class="sw-livechat-post" type="button">POST</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindHeroLightbox(modalWrap);
|
||||||
|
|
||||||
backdrop.appendChild(modalWrap);
|
backdrop.appendChild(modalWrap);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
hydrateAvatars(modalWrap);
|
hydrateAvatars(modalWrap);
|
||||||
|
const relatedLinks = modalWrap.querySelectorAll("[data-related-url]");
|
||||||
|
relatedLinks.forEach((el) => {
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
const target = el.getAttribute("data-related-url");
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
window.open(target, "_blank", "noopener");
|
||||||
|
} catch {
|
||||||
|
window.location.href = target;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { backdrop.classList.add("sw-enter-active"); } catch {}
|
try { backdrop.classList.add("sw-enter-active"); } catch {}
|
||||||
try { modalWrap.classList.add("sw-enter-active"); } catch {}
|
try { modalWrap.classList.add("sw-enter-active"); } catch {}
|
||||||
|
|
@ -864,6 +1155,14 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const heatBtn = modalWrap.querySelector('[data-action="cluster-heatmap"]');
|
||||||
|
if (HEATMAP_ENABLED && heatBtn && isCluster) {
|
||||||
|
heatBtn.addEventListener("click", () => {
|
||||||
|
closeCenteredOverlay(map, { force: true });
|
||||||
|
dispatchClusterHeatmap(postData, { mode: "click" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (postID > 0) {
|
if (postID > 0) {
|
||||||
fetchPostById(postID).then((fresh) => {
|
fetchPostById(postID).then((fresh) => {
|
||||||
if (!fresh) return;
|
if (!fresh) return;
|
||||||
|
|
@ -1077,26 +1376,28 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCompact() {
|
function renderCompact() {
|
||||||
unmountIfAny();
|
if (!root.__swCompactReady) {
|
||||||
root.className = "post-pin post-pin--compact sw-appear sw-appear-in";
|
root.className = "post-pin post-pin--compact sw-appear sw-appear-in";
|
||||||
root.style.zIndex = "1";
|
root.style.zIndex = "1";
|
||||||
|
|
||||||
// Add connecting line if offset is significant
|
// Add connecting line if offset is significant
|
||||||
// DISABLED: No longer using pixel offsets
|
// DISABLED: No longer using pixel offsets
|
||||||
const hasOffset = false;
|
const hasOffset = false;
|
||||||
const lineHTML = '';
|
const lineHTML = '';
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="sw-template-mini-wrap"></div>
|
<div class="sw-template-mini-wrap"></div>
|
||||||
<div class="post-pin-pointer-small"></div>
|
<div class="post-pin-pointer-small"></div>
|
||||||
${lineHTML}
|
${lineHTML}
|
||||||
`;
|
`;
|
||||||
|
root.__swCompactReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
const wrap = root.querySelector(".sw-template-mini-wrap");
|
const wrap = root.querySelector(".sw-template-mini-wrap");
|
||||||
if (wrap) {
|
if (wrap) {
|
||||||
const activePost = root.__post || post;
|
const activePost = root.__post || post;
|
||||||
const spec = getTemplateSpecForPost(activePost, "mini");
|
const spec = getTemplateSpecForPost(activePost, "mini");
|
||||||
const data = adaptPostToTemplateData(activePost);
|
const data = adaptPostToTemplateData(activePost, { size: "mini" });
|
||||||
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
||||||
root.__lastImage = data?.image || "";
|
root.__lastImage = data?.image || "";
|
||||||
root.__lastTitle = data?.headline || "";
|
root.__lastTitle = data?.headline || "";
|
||||||
|
|
@ -1109,6 +1410,18 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
renderCompact();
|
renderCompact();
|
||||||
root.__renderCompact = renderCompact;
|
root.__renderCompact = renderCompact;
|
||||||
|
|
||||||
|
if (HEATMAP_ENABLED && isClusterMainPost(post)) {
|
||||||
|
const handleEnter = () => {
|
||||||
|
dispatchClusterHeatmap(root.__post || post, { mode: "hover", skipPan: true });
|
||||||
|
};
|
||||||
|
const handleLeave = () => {
|
||||||
|
dispatchClusterHeatmapClear();
|
||||||
|
};
|
||||||
|
root.addEventListener("mouseenter", handleEnter);
|
||||||
|
root.addEventListener("mouseleave", handleLeave);
|
||||||
|
root.__swClusterHover = { handleEnter, handleLeave };
|
||||||
|
}
|
||||||
|
|
||||||
const marker = new maplibregl.Marker({
|
const marker = new maplibregl.Marker({
|
||||||
element: root,
|
element: root,
|
||||||
anchor: "bottom",
|
anchor: "bottom",
|
||||||
|
|
@ -1176,9 +1489,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
function renderSimple() {
|
function renderSimple() {
|
||||||
const activePost = root.__post || post;
|
const activePost = root.__post || post;
|
||||||
const color = getMarkerColorForCategory(activePost?.category);
|
const color = getMarkerColorForCategory(activePost?.category);
|
||||||
const img = normalizeImageUrl(
|
const img = normalizeImageUrl(activePost?.image_small || "");
|
||||||
activePost?.image_small || activePost?.image || activePost?.image_large || activePost?.media_url || "",
|
|
||||||
);
|
|
||||||
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
||||||
root.__lastImage = img || "";
|
root.__lastImage = img || "";
|
||||||
root.__lastTitle = (activePost?.title || "").toString();
|
root.__lastTitle = (activePost?.title || "").toString();
|
||||||
|
|
@ -1254,11 +1565,15 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
renderSimple();
|
renderSimple();
|
||||||
root.__renderSimple = renderSimple;
|
root.__renderSimple = renderSimple;
|
||||||
|
|
||||||
|
const isCluster = HEATMAP_ENABLED && isClusterMainPost(post);
|
||||||
const markerEl = root.querySelector(".sw-simple-marker");
|
const markerEl = root.querySelector(".sw-simple-marker");
|
||||||
const hoverCard = root.querySelector(".sw-simple-hover-card");
|
const hoverCard = root.querySelector(".sw-simple-hover-card");
|
||||||
|
|
||||||
// Hover effects
|
// Hover effects
|
||||||
root.addEventListener("mouseenter", () => {
|
root.addEventListener("mouseenter", () => {
|
||||||
|
if (isCluster) {
|
||||||
|
dispatchClusterHeatmap(root.__post || post, { mode: "hover", skipPan: true });
|
||||||
|
}
|
||||||
if (markerEl) {
|
if (markerEl) {
|
||||||
markerEl.style.transform = "scale(1.2)";
|
markerEl.style.transform = "scale(1.2)";
|
||||||
markerEl.style.boxShadow = "0 4px 16px rgba(0,0,0,0.5)";
|
markerEl.style.boxShadow = "0 4px 16px rgba(0,0,0,0.5)";
|
||||||
|
|
@ -1270,6 +1585,9 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
});
|
});
|
||||||
|
|
||||||
root.addEventListener("mouseleave", () => {
|
root.addEventListener("mouseleave", () => {
|
||||||
|
if (isCluster) {
|
||||||
|
dispatchClusterHeatmapClear();
|
||||||
|
}
|
||||||
if (markerEl) {
|
if (markerEl) {
|
||||||
markerEl.style.transform = "scale(1)";
|
markerEl.style.transform = "scale(1)";
|
||||||
markerEl.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)";
|
markerEl.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)";
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,30 @@
|
||||||
* - events
|
* - events
|
||||||
*/
|
*/
|
||||||
export const TEMPLATE_SPECS = {
|
export const TEMPLATE_SPECS = {
|
||||||
|
cluster: {
|
||||||
|
mini_spec: {
|
||||||
|
size: { w: 270, h: 108 },
|
||||||
|
background: { type: "gradient", value: "newsBlue" },
|
||||||
|
radius: 20,
|
||||||
|
layers: [
|
||||||
|
{ id: "icon", type: "icon", x: 12, y: 12, w: 72, h: 72, text: "\uf1ea", style: "text.icon", bind: "data.categoryIcon" },
|
||||||
|
{ id: "imageOverlay", type: "image", x: 12, y: 12, w: 72, h: 72, bind: "data.image", radius: 12 },
|
||||||
|
{ id: "badge", type: "chip", x: 96, y: 10, text: "MAIN NEWS", style: "chip.news" },
|
||||||
|
{ id: "title", type: "text", x: 96, y: 34, w: 156, h: 46, bind: "data.headline", style: "text.title", maxLines: 2 },
|
||||||
|
{ id: "meta", type: "text", x: 96, y: 82, w: 156, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
full_spec: {
|
||||||
|
size: { w: 380, h: 280 },
|
||||||
|
background: { type: "solid", value: "surface" },
|
||||||
|
radius: 24,
|
||||||
|
layers: [
|
||||||
|
{ id: "badge", type: "chip", x: 16, y: 12, text: "MAIN NEWS", style: "chip.news" },
|
||||||
|
{ id: "title", type: "text", x: 16, y: 52, w: 348, h: 76, bind: "data.headline", style: "text.h1", maxLines: 3 },
|
||||||
|
{ id: "summary", type: "text", x: 16, y: 136, w: 348, h: 90, bind: "data.summary", style: "text.body", maxLines: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
news: {
|
news: {
|
||||||
mini_spec: {
|
mini_spec: {
|
||||||
size: { w: 240, h: 96 },
|
size: { w: 240, h: 96 },
|
||||||
|
|
@ -185,6 +209,7 @@ function isBreaking(post) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normCat(post) {
|
function normCat(post) {
|
||||||
|
if (isClusterPost(post)) return "cluster";
|
||||||
const c = (post?.category || post?.Category || "").toString().toUpperCase();
|
const c = (post?.category || post?.Category || "").toString().toUpperCase();
|
||||||
const sub = (post?.sub_category || post?.subCategory || "").toString().toLowerCase();
|
const sub = (post?.sub_category || post?.subCategory || "").toString().toLowerCase();
|
||||||
|
|
||||||
|
|
@ -260,14 +285,19 @@ function getCategoryIcon(post) {
|
||||||
return categoryIcons[subCategory] || categoryIcons.default;
|
return categoryIcons[subCategory] || categoryIcons.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adaptPostToTemplateData(post) {
|
export function adaptPostToTemplateData(post, opts = {}) {
|
||||||
const headline = post?.title || "Untitled";
|
const headline = post?.cluster_title || post?.title || "Untitled";
|
||||||
const author = post?.author ? `by ${post.author}` : "";
|
const author = post?.author ? `by ${post.author}` : "";
|
||||||
const sub = post?.sub_category ? String(post.sub_category) : "";
|
const sub = post?.sub_category ? String(post.sub_category) : "";
|
||||||
const source = author || sub || "";
|
const clusterCount = Array.isArray(post?.cluster_members) ? post.cluster_members.length : 0;
|
||||||
|
const source = clusterCount > 1 ? `${clusterCount} related reports` : (author || sub || "");
|
||||||
const summary = post?.snippet || post?.body || "";
|
const summary = post?.snippet || post?.body || "";
|
||||||
const url = post?.url || "";
|
const url = post?.url || "";
|
||||||
const rawImage = post?.image_large || post?.image_small || post?.image || post?.media_url || "";
|
const size = (opts.size || "full").toString();
|
||||||
|
const rawImage =
|
||||||
|
size === "mini"
|
||||||
|
? (post?.image_small || post?.image_medium || post?.image || post?.media_url || "")
|
||||||
|
: (post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || "");
|
||||||
const image = normalizeImageUrl(rawImage) || `/og/category?cat=${encodeURIComponent((post?.category || "NEWS").toString().toUpperCase())}&variant=mini`;
|
const image = normalizeImageUrl(rawImage) || `/og/category?cat=${encodeURIComponent((post?.category || "NEWS").toString().toUpperCase())}&variant=mini`;
|
||||||
const categoryIcon = getCategoryIcon(post);
|
const categoryIcon = getCategoryIcon(post);
|
||||||
return { headline, source, summary, url, image, categoryIcon };
|
return { headline, source, summary, url, image, categoryIcon };
|
||||||
|
|
@ -280,3 +310,13 @@ function normalizeImageUrl(raw) {
|
||||||
if (value.startsWith("http://")) return `https://${value.slice(7)}`;
|
if (value.startsWith("http://")) return `https://${value.slice(7)}`;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isClusterPost(post) {
|
||||||
|
const clusterPostId = Number(post?.cluster_post_id);
|
||||||
|
const postId = Number(post?.post_id);
|
||||||
|
if (Number.isFinite(clusterPostId) && Number.isFinite(postId) && clusterPostId > 0 && postId > 0) {
|
||||||
|
return clusterPostId === postId;
|
||||||
|
}
|
||||||
|
const url = (post?.url || "").toString();
|
||||||
|
return url.includes("/cluster/");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,16 @@ export function useMapCore(theme) {
|
||||||
map.on("load", () => {
|
map.on("load", () => {
|
||||||
setViewParams(getViewFromMap(map));
|
setViewParams(getViewFromMap(map));
|
||||||
// Don't force pitch on load - let it stay as configured
|
// Don't force pitch on load - let it stay as configured
|
||||||
|
try {
|
||||||
|
map.__swForceFetchAt = Date.now();
|
||||||
|
map.fire("moveend");
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
map.__swForceFetchAt = Date.now();
|
||||||
|
map.fire("moveend");
|
||||||
|
} catch {}
|
||||||
|
}, 650);
|
||||||
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on("moveend", () => {
|
map.on("moveend", () => {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,15 @@ function getId(p) {
|
||||||
return p?.id ?? p?._id ?? null;
|
return p?.id ?? p?._id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePostId(post) {
|
||||||
|
if (!post || typeof post !== "object") return post;
|
||||||
|
const pid = Number(post?.post_id ?? post?.postId ?? post?.postID ?? 0);
|
||||||
|
if (Number.isFinite(pid) && pid > 0 && Number(post?.id) !== pid) {
|
||||||
|
return { ...post, reco_id: post?.id ?? post?.reco_id, id: pid };
|
||||||
|
}
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
function getPostKey(p) {
|
function getPostKey(p) {
|
||||||
const id = getId(p);
|
const id = getId(p);
|
||||||
if (id != null && id !== 0) return `id:${id}`;
|
if (id != null && id !== 0) return `id:${id}`;
|
||||||
|
|
@ -46,8 +55,9 @@ function getTemplateKey(post) {
|
||||||
function resolvePostImage(post) {
|
function resolvePostImage(post) {
|
||||||
return (
|
return (
|
||||||
post?.image_small ||
|
post?.image_small ||
|
||||||
post?.image ||
|
post?.image_medium ||
|
||||||
post?.image_large ||
|
post?.image_large ||
|
||||||
|
post?.image ||
|
||||||
post?.media_url ||
|
post?.media_url ||
|
||||||
post?.image_url ||
|
post?.image_url ||
|
||||||
""
|
""
|
||||||
|
|
@ -131,6 +141,142 @@ function getPostTimeMs(p) {
|
||||||
return d.getTime();
|
return d.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isClusterMainPost(p) {
|
||||||
|
const clusterPostId = Number(p?.cluster_post_id);
|
||||||
|
const postId = Number(p?.post_id || p?.id);
|
||||||
|
if (Number.isFinite(clusterPostId) && Number.isFinite(postId) && clusterPostId > 0 && postId > 0) {
|
||||||
|
return clusterPostId === postId;
|
||||||
|
}
|
||||||
|
const key = (p?.cluster_key || p?.clusterKey || "").toString().trim();
|
||||||
|
const members = Array.isArray(p?.cluster_members) ? p.cluster_members : [];
|
||||||
|
if (key && members.length > 0) return true;
|
||||||
|
const url = (p?.url || "").toString();
|
||||||
|
return url.includes("/cluster/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clusterColor(post) {
|
||||||
|
const c = (post?.category || "NEWS").toString().toUpperCase();
|
||||||
|
if (c === "EVENT" || c === "EVENTS") return "#a855f7";
|
||||||
|
if (c === "FRIENDS") return "#22c55e";
|
||||||
|
if (c === "MARKET") return "#facc15";
|
||||||
|
return "#38bdf8";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clusterLineColor(post) {
|
||||||
|
const c = (post?.category || "NEWS").toString().toUpperCase();
|
||||||
|
if (c === "EVENT" || c === "EVENTS") return "rgba(168,85,247,0.7)";
|
||||||
|
if (c === "FRIENDS") return "rgba(34,197,94,0.7)";
|
||||||
|
if (c === "MARKET") return "rgba(250,204,21,0.8)";
|
||||||
|
return "rgba(56,189,248,0.75)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function convexHull(points) {
|
||||||
|
if (!Array.isArray(points) || points.length < 3) return points || [];
|
||||||
|
const pts = [...points].sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]));
|
||||||
|
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||||
|
const lower = [];
|
||||||
|
for (const p of pts) {
|
||||||
|
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
|
||||||
|
lower.pop();
|
||||||
|
}
|
||||||
|
lower.push(p);
|
||||||
|
}
|
||||||
|
const upper = [];
|
||||||
|
for (let i = pts.length - 1; i >= 0; i--) {
|
||||||
|
const p = pts[i];
|
||||||
|
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
|
||||||
|
upper.pop();
|
||||||
|
}
|
||||||
|
upper.push(p);
|
||||||
|
}
|
||||||
|
upper.pop();
|
||||||
|
lower.pop();
|
||||||
|
return lower.concat(upper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chaikinSmooth(points, iterations = 2) {
|
||||||
|
let pts = points.slice();
|
||||||
|
if (pts.length < 4) return pts;
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
const next = [];
|
||||||
|
for (let j = 0; j < pts.length - 1; j++) {
|
||||||
|
const p0 = pts[j];
|
||||||
|
const p1 = pts[j + 1];
|
||||||
|
const q = [0.75 * p0[0] + 0.25 * p1[0], 0.75 * p0[1] + 0.25 * p1[1]];
|
||||||
|
const r = [0.25 * p0[0] + 0.75 * p1[0], 0.25 * p0[1] + 0.75 * p1[1]];
|
||||||
|
next.push(q, r);
|
||||||
|
}
|
||||||
|
next.push(next[0]);
|
||||||
|
pts = next;
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circlePolygon(center, radiusKm, steps = 24) {
|
||||||
|
const [lon, lat] = center;
|
||||||
|
const latDeg = radiusKm / 111;
|
||||||
|
const lonDeg = radiusKm / (111 * Math.cos((lat * Math.PI) / 180) || 1);
|
||||||
|
const coords = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const angle = (Math.PI * 2 * i) / steps;
|
||||||
|
coords.push([lon + Math.cos(angle) * lonDeg, lat + Math.sin(angle) * latDeg]);
|
||||||
|
}
|
||||||
|
return coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClusterAreaFeatures(posts) {
|
||||||
|
const features = [];
|
||||||
|
for (const p of posts || []) {
|
||||||
|
if (!isClusterMainPost(p)) continue;
|
||||||
|
const items = Array.isArray(p?.cluster_items) ? p.cluster_items : [];
|
||||||
|
const pts = items
|
||||||
|
.map((it) => [Number(it?.lon), Number(it?.lat)])
|
||||||
|
.filter((v) => Number.isFinite(v[0]) && Number.isFinite(v[1]));
|
||||||
|
if (pts.length === 0) continue;
|
||||||
|
|
||||||
|
let ring = [];
|
||||||
|
if (pts.length >= 3) {
|
||||||
|
ring = convexHull(pts);
|
||||||
|
if (ring.length < 3) {
|
||||||
|
ring = pts.slice(0, 3);
|
||||||
|
}
|
||||||
|
ring = [...ring, ring[0]];
|
||||||
|
ring = chaikinSmooth(ring, 2);
|
||||||
|
// Slight outward nudge so the bubble touches all points
|
||||||
|
const center = ring.slice(0, -1).reduce((acc, p) => [acc[0] + p[0], acc[1] + p[1]], [0, 0]);
|
||||||
|
const count = Math.max(1, ring.length - 1);
|
||||||
|
const centerPt = [center[0] / count, center[1] / count];
|
||||||
|
ring = ring.map((p, idx) => {
|
||||||
|
if (idx === ring.length - 1) return p;
|
||||||
|
const dx = p[0] - centerPt[0];
|
||||||
|
const dy = p[1] - centerPt[1];
|
||||||
|
return [centerPt[0] + dx * 1.32, centerPt[1] + dy * 1.32];
|
||||||
|
});
|
||||||
|
ring[ring.length - 1] = ring[0];
|
||||||
|
} else {
|
||||||
|
const centerLon = pts.reduce((s, v) => s + v[0], 0) / pts.length;
|
||||||
|
const centerLat = pts.reduce((s, v) => s + v[1], 0) / pts.length;
|
||||||
|
let maxKm = 0.0;
|
||||||
|
for (const [lon, lat] of pts) {
|
||||||
|
maxKm = Math.max(maxKm, haversineKm(centerLat, centerLon, lat, lon));
|
||||||
|
}
|
||||||
|
const radius = Math.min(80, Math.max(0.4, maxKm * 1.55 || 0.6));
|
||||||
|
ring = circlePolygon([centerLon, centerLat], radius, 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "Polygon", coordinates: [ring] },
|
||||||
|
properties: {
|
||||||
|
id: getId(p) || 0,
|
||||||
|
color: clusterColor(p),
|
||||||
|
line: clusterLineColor(p),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_POOL_POSTS = 75;
|
const MAX_POOL_POSTS = 75;
|
||||||
const MAX_VISIBLE_POSTS = 45;
|
const MAX_VISIBLE_POSTS = 45;
|
||||||
|
|
||||||
|
|
@ -159,6 +305,11 @@ export function usePostsEngine({
|
||||||
subFilter,
|
subFilter,
|
||||||
timeFilter,
|
timeFilter,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
clusterFocus,
|
||||||
|
mainNewsOnly,
|
||||||
|
clustersEnabled = true,
|
||||||
|
heatmapEnabled = true,
|
||||||
|
forceFullPostId = null,
|
||||||
markersRef,
|
markersRef,
|
||||||
expandedElRef,
|
expandedElRef,
|
||||||
onAutoWidenTimeFilter,
|
onAutoWidenTimeFilter,
|
||||||
|
|
@ -174,15 +325,20 @@ export function usePostsEngine({
|
||||||
const initialLoadRef = useRef(true);
|
const initialLoadRef = useRef(true);
|
||||||
const initialFetchKickRef = useRef(false);
|
const initialFetchKickRef = useRef(false);
|
||||||
const autoWidenRef = useRef(false);
|
const autoWidenRef = useRef(false);
|
||||||
|
const searchResultsRef = useRef(false);
|
||||||
|
const retryRef = useRef({ key: "", count: 0, ts: 0 });
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const clusterModeRef = useRef(false);
|
const clusterModeRef = useRef(false);
|
||||||
|
|
||||||
const CLUSTER_THRESHOLD = 140;
|
const CLUSTER_THRESHOLD = 140;
|
||||||
const SIMPLE_MARKER_LIMIT = 40;
|
const SIMPLE_MARKER_LIMIT = 50;
|
||||||
const CLUSTER_SOURCE_ID = "sw-posts";
|
const CLUSTER_SOURCE_ID = "sw-posts";
|
||||||
const CLUSTER_LAYER_ID = "sw-clusters";
|
const CLUSTER_LAYER_ID = "sw-clusters";
|
||||||
const CLUSTER_COUNT_ID = "sw-cluster-count";
|
const CLUSTER_COUNT_ID = "sw-cluster-count";
|
||||||
const UNCLUSTERED_ID = "sw-unclustered";
|
const UNCLUSTERED_ID = "sw-unclustered";
|
||||||
|
const CLUSTER_AREA_SOURCE_ID = "sw-cluster-areas";
|
||||||
|
const CLUSTER_AREA_FILL_ID = "sw-cluster-area-fill";
|
||||||
|
const CLUSTER_AREA_LINE_ID = "sw-cluster-area-line";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mapReady) return;
|
if (mapReady) return;
|
||||||
|
|
@ -215,7 +371,7 @@ export function usePostsEngine({
|
||||||
}, [mapReady, mapRef]);
|
}, [mapReady, mapRef]);
|
||||||
|
|
||||||
const priorityOpts = {
|
const priorityOpts = {
|
||||||
maxFullCards: 12,
|
maxFullCards: 15,
|
||||||
clusterRadius: 0.002,
|
clusterRadius: 0.002,
|
||||||
minScoreForCard: 50,
|
minScoreForCard: 50,
|
||||||
};
|
};
|
||||||
|
|
@ -333,6 +489,38 @@ export function usePostsEngine({
|
||||||
}
|
}
|
||||||
}, [onSelectPost]);
|
}, [onSelectPost]);
|
||||||
|
|
||||||
|
const ensureClusterAreaLayers = useCallback((map) => {
|
||||||
|
if (!map) return;
|
||||||
|
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
|
||||||
|
if (map.getSource(CLUSTER_AREA_SOURCE_ID)) return;
|
||||||
|
|
||||||
|
map.addSource(CLUSTER_AREA_SOURCE_ID, {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: CLUSTER_AREA_FILL_ID,
|
||||||
|
type: "fill",
|
||||||
|
source: CLUSTER_AREA_SOURCE_ID,
|
||||||
|
paint: {
|
||||||
|
"fill-color": ["get", "color"],
|
||||||
|
"fill-opacity": 0.42,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: CLUSTER_AREA_LINE_ID,
|
||||||
|
type: "line",
|
||||||
|
source: CLUSTER_AREA_SOURCE_ID,
|
||||||
|
paint: {
|
||||||
|
"line-color": ["get", "line"],
|
||||||
|
"line-width": 2,
|
||||||
|
"line-opacity": 0.7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [CLUSTER_AREA_SOURCE_ID, CLUSTER_AREA_FILL_ID, CLUSTER_AREA_LINE_ID]);
|
||||||
|
|
||||||
const setClusterVisibility = useCallback((map, visible) => {
|
const setClusterVisibility = useCallback((map, visible) => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
const v = visible ? "visible" : "none";
|
const v = visible ? "visible" : "none";
|
||||||
|
|
@ -341,6 +529,14 @@ export function usePostsEngine({
|
||||||
if (map.getLayer(UNCLUSTERED_ID)) map.setLayoutProperty(UNCLUSTERED_ID, "visibility", v);
|
if (map.getLayer(UNCLUSTERED_ID)) map.setLayoutProperty(UNCLUSTERED_ID, "visibility", v);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const updateClusterAreas = useCallback((map, posts) => {
|
||||||
|
if (!map) return;
|
||||||
|
const src = map.getSource(CLUSTER_AREA_SOURCE_ID);
|
||||||
|
if (!src || !src.setData) return;
|
||||||
|
const features = buildClusterAreaFeatures(posts || []);
|
||||||
|
src.setData({ type: "FeatureCollection", features });
|
||||||
|
}, [CLUSTER_AREA_SOURCE_ID]);
|
||||||
|
|
||||||
const updateClusterData = useCallback((map, posts) => {
|
const updateClusterData = useCallback((map, posts) => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
const src = map.getSource(CLUSTER_SOURCE_ID);
|
const src = map.getSource(CLUSTER_SOURCE_ID);
|
||||||
|
|
@ -380,6 +576,10 @@ export function usePostsEngine({
|
||||||
(visible) => {
|
(visible) => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return { fullCardPosts: [], clusterPosts: visible || [] };
|
if (!map) return { fullCardPosts: [], clusterPosts: visible || [] };
|
||||||
|
const searchActive = (searchQuery || "").trim().length > 0;
|
||||||
|
if (searchActive) {
|
||||||
|
return { fullCardPosts: [], clusterPosts: visible || [] };
|
||||||
|
}
|
||||||
const center = map.getCenter();
|
const center = map.getCenter();
|
||||||
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
|
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
|
||||||
const { fullCardPosts, simpleMarkerPosts } = prioritizePosts(
|
const { fullCardPosts, simpleMarkerPosts } = prioritizePosts(
|
||||||
|
|
@ -389,7 +589,7 @@ export function usePostsEngine({
|
||||||
);
|
);
|
||||||
return { fullCardPosts, clusterPosts: simpleMarkerPosts };
|
return { fullCardPosts, clusterPosts: simpleMarkerPosts };
|
||||||
},
|
},
|
||||||
[mapRef, priorityOpts]
|
[mapRef, priorityOpts, searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncPriorityMarkers = useCallback(
|
const syncPriorityMarkers = useCallback(
|
||||||
|
|
@ -458,17 +658,27 @@ export function usePostsEngine({
|
||||||
map.__swMarkersRef = markersRef;
|
map.__swMarkersRef = markersRef;
|
||||||
|
|
||||||
// If user is reading an expanded overlay, don't touch markers
|
// If user is reading an expanded overlay, don't touch markers
|
||||||
if (expandedElRef?.current) return;
|
if (expandedElRef?.current || map.__swCenteredOverlay) return;
|
||||||
|
|
||||||
// Use priority system to decide which posts show full cards vs simple markers
|
// Use priority system to decide which posts show full cards vs simple markers
|
||||||
const center = map.getCenter();
|
const center = map.getCenter();
|
||||||
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
|
const viewCenter = center ? { lng: center.lng, lat: center.lat } : null;
|
||||||
|
|
||||||
const { fullCardPosts, simpleMarkerPosts } = prioritizePosts(visible, viewCenter, {
|
const searchActive = (searchQuery || "").trim().length > 0;
|
||||||
maxFullCards: 12, // Max 12 cartes complètes (réduit de 15)
|
let fullCardPosts = [];
|
||||||
clusterRadius: 0.002, // ~200m clustering radius (augmenté pour plus de groupement)
|
let simpleMarkerPosts = [];
|
||||||
minScoreForCard: 50, // Score minimum pour carte complète (augmenté de 35 à 50)
|
if (searchActive) {
|
||||||
});
|
fullCardPosts = visible || [];
|
||||||
|
simpleMarkerPosts = [];
|
||||||
|
} else {
|
||||||
|
const prioritized = prioritizePosts(visible, viewCenter, {
|
||||||
|
maxFullCards: 15, // 10-15 cartes complètes selon le zoom
|
||||||
|
clusterRadius: 0.002, // ~200m clustering radius (augmenté pour plus de groupement)
|
||||||
|
minScoreForCard: 50, // Score minimum pour carte complète (augmenté de 35 à 50)
|
||||||
|
});
|
||||||
|
fullCardPosts = prioritized.fullCardPosts;
|
||||||
|
simpleMarkerPosts = prioritized.simpleMarkerPosts;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[usePostsEngine] Prioritized:', {
|
console.log('[usePostsEngine] Prioritized:', {
|
||||||
total: visible.length,
|
total: visible.length,
|
||||||
|
|
@ -482,7 +692,9 @@ export function usePostsEngine({
|
||||||
const id = getId(post);
|
const id = getId(post);
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
// Tag avec le type de marqueur souhaité
|
// Tag avec le type de marqueur souhaité
|
||||||
const isFullCard = fullCardPosts.some(p => getId(p) === id);
|
const isFullCard =
|
||||||
|
id === forceFullPostId ||
|
||||||
|
fullCardPosts.some(p => getId(p) === id);
|
||||||
wanted.set(id, { post, type: isFullCard ? 'full' : 'simple' });
|
wanted.set(id, { post, type: isFullCard ? 'full' : 'simple' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -548,36 +760,58 @@ export function usePostsEngine({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mapRef, markersRef, expandedElRef, theme]
|
[mapRef, markersRef, expandedElRef, theme, searchQuery, forceFullPostId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const computeVisible = useCallback(
|
const computeVisible = useCallback(
|
||||||
(tf) => {
|
(tf) => {
|
||||||
const posts = allPostsRef.current || [];
|
const basePosts = allPostsRef.current || [];
|
||||||
|
const extraPosts = Array.isArray(clusterFocus?.posts) ? clusterFocus.posts : [];
|
||||||
|
const posts = extraPosts.length
|
||||||
|
? Array.from(new Map([...extraPosts, ...basePosts].map((p) => {
|
||||||
|
const normalized = normalizePostId(p);
|
||||||
|
return [getPostKey(normalized), normalized];
|
||||||
|
})).values())
|
||||||
|
: basePosts;
|
||||||
const catCode = categoryCode(mainFilter);
|
const catCode = categoryCode(mainFilter);
|
||||||
|
const clusterIDs = clustersEnabled && Array.isArray(clusterFocus?.ids) ? clusterFocus.ids : null;
|
||||||
|
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
|
||||||
|
|
||||||
return posts.filter((p) => {
|
return posts.filter((p) => {
|
||||||
|
if (isClusterFocus) {
|
||||||
|
const id = Number(getId(p));
|
||||||
|
const clusterPostId = Number(p?.cluster_post_id);
|
||||||
|
if (clusterIDs && !clusterIDs.includes(id) && !clusterIDs.includes(clusterPostId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (clustersEnabled && mainNewsOnly && !isClusterMainPost(p)) return false;
|
||||||
if (catCode) {
|
if (catCode) {
|
||||||
const pc = (p.category || p.Category || "").toString().toUpperCase();
|
const pc = (p.category || p.Category || "").toString().toUpperCase();
|
||||||
if (pc !== catCode) return false;
|
if (pc !== catCode) return false;
|
||||||
}
|
}
|
||||||
if (!matchesSubFilter(p, subFilter)) return false;
|
if (!matchesSubFilter(p, subFilter)) return false;
|
||||||
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, tf)) return false;
|
if (!matchesTimeFilter(p.created_at || p.CreatedAt || p.createdAt, tf)) return false;
|
||||||
if (!matchesSearch(p, searchQuery)) return false;
|
if (!searchResultsRef.current && !matchesSearch(p, searchQuery)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mainFilter, subFilter, searchQuery]
|
[mainFilter, subFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshVisibleAndMarkers = useCallback(
|
const refreshVisibleAndMarkers = useCallback(
|
||||||
(tf, force = false) => {
|
(tf, force = false) => {
|
||||||
const vAll = computeVisible(tf);
|
const vAll = computeVisible(tf);
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
let v = (searchQuery || "").trim() ? vAll : filterByBounds(vAll, map);
|
const isClusterFocus = !!(clustersEnabled && Array.isArray(clusterFocus?.ids) && clusterFocus.ids.length > 0);
|
||||||
if (v.length > MAX_VISIBLE_POSTS) {
|
let v = vAll;
|
||||||
const center = map?.getCenter?.();
|
if (!isClusterFocus) {
|
||||||
v = prunePosts(v, [center?.lng, center?.lat], MAX_VISIBLE_POSTS);
|
v = filterByBounds(vAll, map);
|
||||||
|
if (v.length > MAX_VISIBLE_POSTS) {
|
||||||
|
const center = map?.getCenter?.();
|
||||||
|
v = prunePosts(v, [center?.lng, center?.lat], MAX_VISIBLE_POSTS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reduce wall blink: don't update if same ids
|
// reduce wall blink: don't update if same ids
|
||||||
|
|
@ -589,7 +823,12 @@ export function usePostsEngine({
|
||||||
// keep status silent (no annoying bubbles)
|
// keep status silent (no annoying bubbles)
|
||||||
setStatus("");
|
setStatus("");
|
||||||
|
|
||||||
if (map && v.length >= CLUSTER_THRESHOLD) {
|
if (map && clustersEnabled && heatmapEnabled) {
|
||||||
|
ensureClusterAreaLayers(map);
|
||||||
|
updateClusterAreas(map, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clustersEnabled && map && v.length >= CLUSTER_THRESHOLD) {
|
||||||
const { fullCardPosts, clusterPosts } = splitForClusters(v);
|
const { fullCardPosts, clusterPosts } = splitForClusters(v);
|
||||||
const simpleVisible = clusterPosts.slice(0, SIMPLE_MARKER_LIMIT);
|
const simpleVisible = clusterPosts.slice(0, SIMPLE_MARKER_LIMIT);
|
||||||
const clusterRemainder = clusterPosts.slice(SIMPLE_MARKER_LIMIT);
|
const clusterRemainder = clusterPosts.slice(SIMPLE_MARKER_LIMIT);
|
||||||
|
|
@ -609,13 +848,22 @@ export function usePostsEngine({
|
||||||
|
|
||||||
syncMarkers(v);
|
syncMarkers(v);
|
||||||
},
|
},
|
||||||
[computeVisible, syncMarkers, mapRef, ensureClusterLayers, updateClusterData, setClusterVisibility, splitForClusters, syncPriorityMarkers, viewParams]
|
[computeVisible, syncMarkers, mapRef, ensureClusterLayers, ensureClusterAreaLayers, updateClusterAreas, updateClusterData, setClusterVisibility, splitForClusters, syncPriorityMarkers, viewParams, clustersEnabled, heatmapEnabled, clusterFocus, searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
// On filter/search changes: recompute + sync (NO clear-all rebuild)
|
// On filter/search changes: recompute + sync (NO clear-all rebuild)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshVisibleAndMarkers(timeFilter);
|
refreshVisibleAndMarkers(timeFilter);
|
||||||
}, [timeFilter, mainFilter, subFilter, searchQuery, refreshVisibleAndMarkers]);
|
}, [timeFilter, mainFilter, subFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clustersEnabled) return;
|
||||||
|
if (!clusterFocus) return;
|
||||||
|
if (Array.isArray(clusterFocus.posts) && clusterFocus.posts.length > 0) {
|
||||||
|
allPostsRef.current = clusterFocus.posts;
|
||||||
|
}
|
||||||
|
refreshVisibleAndMarkers(timeFilter, true);
|
||||||
|
}, [clusterFocus, timeFilter, refreshVisibleAndMarkers, clustersEnabled]);
|
||||||
|
|
||||||
// On view change: re-filter markers/cards to current bounds even without fetch
|
// On view change: re-filter markers/cards to current bounds even without fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -637,7 +885,7 @@ export function usePostsEngine({
|
||||||
if ((searchQuery || "").trim()) return;
|
if ((searchQuery || "").trim()) return;
|
||||||
|
|
||||||
// If expanded overlay is open: delay fetch (do NOT disturb UI)
|
// If expanded overlay is open: delay fetch (do NOT disturb UI)
|
||||||
if (expandedElRef?.current) {
|
if (expandedElRef?.current || mapObj?.__swCenteredOverlay) {
|
||||||
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
|
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
|
||||||
delayedFetchRef.current = setTimeout(load, 1200);
|
delayedFetchRef.current = setTimeout(load, 1200);
|
||||||
return;
|
return;
|
||||||
|
|
@ -716,7 +964,7 @@ export function usePostsEngine({
|
||||||
lat,
|
lat,
|
||||||
lon: lng,
|
lon: lng,
|
||||||
radius_km: radiusKm,
|
radius_km: radiusKm,
|
||||||
limit: 45,
|
limit: 50,
|
||||||
zoom,
|
zoom,
|
||||||
bounds,
|
bounds,
|
||||||
username: username || undefined,
|
username: username || undefined,
|
||||||
|
|
@ -727,18 +975,20 @@ export function usePostsEngine({
|
||||||
const existing = allPostsRef.current || [];
|
const existing = allPostsRef.current || [];
|
||||||
const byId = new Map();
|
const byId = new Map();
|
||||||
for (const p of existing) {
|
for (const p of existing) {
|
||||||
const key = getPostKey(p);
|
const normalized = normalizePostId(p);
|
||||||
|
const key = getPostKey(normalized);
|
||||||
if (key) byId.set(key, p);
|
if (key) byId.set(key, p);
|
||||||
}
|
}
|
||||||
if (Array.isArray(newPosts)) {
|
if (Array.isArray(newPosts)) {
|
||||||
for (const p of newPosts) {
|
for (const p of newPosts) {
|
||||||
const key = getPostKey(p);
|
const normalized = normalizePostId(p);
|
||||||
|
const key = getPostKey(normalized);
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const prior = byId.get(key);
|
const prior = byId.get(key);
|
||||||
if (prior) {
|
if (prior) {
|
||||||
byId.set(key, { ...prior, ...p });
|
byId.set(key, { ...prior, ...normalized });
|
||||||
} else {
|
} else {
|
||||||
byId.set(key, p);
|
byId.set(key, normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -776,7 +1026,19 @@ export function usePostsEngine({
|
||||||
|
|
||||||
setLoadingPosts(false);
|
setLoadingPosts(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setLoadingPosts(false);
|
||||||
|
setLoadError("fetch failed");
|
||||||
|
const key = `${filterKey}|${Math.round(lat * 100)}|${Math.round(lng * 100)}|${Math.round(radiusKm)}`;
|
||||||
|
const now = Date.now();
|
||||||
|
if (retryRef.current.key !== key || now - retryRef.current.ts > 5000) {
|
||||||
|
retryRef.current = { key, count: 0, ts: now };
|
||||||
|
}
|
||||||
|
if (retryRef.current.count < 1) {
|
||||||
|
retryRef.current.count += 1;
|
||||||
|
retryRef.current.ts = now;
|
||||||
|
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
|
||||||
|
delayedFetchRef.current = setTimeout(load, 650);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -784,8 +1046,12 @@ export function usePostsEngine({
|
||||||
clearTimeout(moveDebounceRef.current);
|
clearTimeout(moveDebounceRef.current);
|
||||||
moveDebounceRef.current = null;
|
moveDebounceRef.current = null;
|
||||||
}
|
}
|
||||||
initialLoadRef.current = false;
|
if (initialLoadRef.current) {
|
||||||
load();
|
initialLoadRef.current = false;
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
moveDebounceRef.current = setTimeout(load, 300);
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (moveDebounceRef.current) {
|
if (moveDebounceRef.current) {
|
||||||
|
|
@ -822,7 +1088,9 @@ export function usePostsEngine({
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const posts = Array.isArray(data?.posts) ? data.posts : [];
|
const posts = Array.isArray(data?.posts) ? data.posts : [];
|
||||||
allPostsRef.current = prunePosts(posts, [lon, lat], MAX_POOL_POSTS);
|
const normalized = posts.map((p) => normalizePostId(p));
|
||||||
|
allPostsRef.current = prunePosts(normalized, [lon, lat], MAX_POOL_POSTS);
|
||||||
|
searchResultsRef.current = true;
|
||||||
refreshVisibleAndMarkers(timeFilter);
|
refreshVisibleAndMarkers(timeFilter);
|
||||||
trackEvent("search_filter_apply", {
|
trackEvent("search_filter_apply", {
|
||||||
query_len: q.length,
|
query_len: q.length,
|
||||||
|
|
@ -838,10 +1106,16 @@ export function usePostsEngine({
|
||||||
};
|
};
|
||||||
}, [searchQuery, viewParams, mapRef, refreshVisibleAndMarkers, timeFilter]);
|
}, [searchQuery, viewParams, mapRef, refreshVisibleAndMarkers, timeFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((searchQuery || "").trim()) return;
|
||||||
|
searchResultsRef.current = false;
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
const handleStyle = () => {
|
const handleStyle = () => {
|
||||||
|
if (!clustersEnabled) return;
|
||||||
if (!clusterModeRef.current) return;
|
if (!clusterModeRef.current) return;
|
||||||
ensureClusterLayers(map);
|
ensureClusterLayers(map);
|
||||||
setClusterVisibility(map, true);
|
setClusterVisibility(map, true);
|
||||||
|
|
@ -851,7 +1125,7 @@ export function usePostsEngine({
|
||||||
};
|
};
|
||||||
map.on("styledata", handleStyle);
|
map.on("styledata", handleStyle);
|
||||||
return () => map.off("styledata", handleStyle);
|
return () => map.off("styledata", handleStyle);
|
||||||
}, [mapRef, ensureClusterLayers, setClusterVisibility, updateClusterData, computeVisible, timeFilter, splitForClusters]);
|
}, [mapRef, ensureClusterLayers, setClusterVisibility, updateClusterData, computeVisible, timeFilter, splitForClusters, clustersEnabled]);
|
||||||
|
|
||||||
const handleIncomingPost = useCallback(
|
const handleIncomingPost = useCallback(
|
||||||
(p) => {
|
(p) => {
|
||||||
|
|
@ -862,7 +1136,8 @@ export function usePostsEngine({
|
||||||
}
|
}
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
const center = map?.getCenter?.();
|
const center = map?.getCenter?.();
|
||||||
allPostsRef.current = prunePosts([p, ...allPostsRef.current], [center?.lng, center?.lat], MAX_POOL_POSTS);
|
const normalized = normalizePostId(p);
|
||||||
|
allPostsRef.current = prunePosts([normalized, ...allPostsRef.current], [center?.lng, center?.lat], MAX_POOL_POSTS);
|
||||||
|
|
||||||
const catCode = categoryCode(mainFilter);
|
const catCode = categoryCode(mainFilter);
|
||||||
if (catCode) {
|
if (catCode) {
|
||||||
|
|
@ -879,13 +1154,13 @@ export function usePostsEngine({
|
||||||
}
|
}
|
||||||
|
|
||||||
// add marker if missing
|
// add marker if missing
|
||||||
const id = getId(p);
|
const id = getId(normalized);
|
||||||
const have = new Set((markersRef.current || []).map((x) => x.id));
|
const have = new Set((markersRef.current || []).map((x) => x.id));
|
||||||
if (id != null && !have.has(id)) {
|
if (id != null && !have.has(id)) {
|
||||||
createMarkerForPost(p, mapRef, markersRef, expandedElRef, theme);
|
createMarkerForPost(normalized, mapRef, markersRef, expandedElRef, theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
setVisiblePosts((current) => [p, ...current]);
|
setVisiblePosts((current) => [normalized, ...current]);
|
||||||
},
|
},
|
||||||
[mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers]
|
[mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers]
|
||||||
);
|
);
|
||||||
|
|
@ -917,7 +1192,7 @@ export function usePostsEngine({
|
||||||
updated.push(p);
|
updated.push(p);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const next = { ...p, ...(postPatch || {}) };
|
const next = normalizePostId({ ...p, ...(postPatch || {}) });
|
||||||
if (counts) {
|
if (counts) {
|
||||||
if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count;
|
if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count;
|
||||||
if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count;
|
if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count;
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,8 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
setPostGroupId((post?.group_id || "").toString());
|
setPostGroupId((post?.group_id || "").toString());
|
||||||
}, [post?.visibility, post?.group_id]);
|
}, [post?.visibility, post?.group_id]);
|
||||||
|
|
||||||
const rawImg = post?.image_large || post?.image_small || post?.image || post?.media_url || "";
|
const rawImg =
|
||||||
|
post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || "";
|
||||||
const img = normalizeImageUrl(rawImg);
|
const img = normalizeImageUrl(rawImg);
|
||||||
const title = post?.title || "Untitled";
|
const title = post?.title || "Untitled";
|
||||||
const snippet = post?.snippet || "";
|
const snippet = post?.snippet || "";
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default function PostCardTemplate({ post, theme = "blue", onOpen }) {
|
||||||
source: post.author ? `by ${post.author}` : (post.sub_category || ""),
|
source: post.author ? `by ${post.author}` : (post.sub_category || ""),
|
||||||
summary: post.snippet || post.body || "",
|
summary: post.snippet || post.body || "",
|
||||||
url: post.url || "",
|
url: post.url || "",
|
||||||
image: post.image || post.media_url || "",
|
image: post.image_medium || post.image_large || post.image_small || post.image || post.media_url || "",
|
||||||
};
|
};
|
||||||
}, [post]);
|
}, [post]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ export default function SmartSearchBar({
|
||||||
const abortRef = useRef(null);
|
const abortRef = useRef(null);
|
||||||
const skipSearchRef = useRef(false);
|
const skipSearchRef = useRef(false);
|
||||||
const boxRef = useRef(null);
|
const boxRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onDocDown(e) {
|
function onDocDown(e) {
|
||||||
|
|
@ -137,13 +138,14 @@ export default function SmartSearchBar({
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
const showList = open && (items.length > 0 || loading || err);
|
const showList = open && (items.length > 0 || loading || err);
|
||||||
|
const clearOnFocusRef = useRef(false);
|
||||||
|
|
||||||
const pick = (it, reason = "click") => {
|
const pick = (it, reason = "click") => {
|
||||||
if (!it) return;
|
if (!it) return;
|
||||||
const zoom = defaultZoomForKind(it.kind);
|
const zoom = defaultZoomForKind(it.kind);
|
||||||
skipSearchRef.current = true;
|
skipSearchRef.current = true;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQ(it.title || q);
|
clearOnFocusRef.current = true;
|
||||||
|
|
||||||
trackEvent("search_pick", {
|
trackEvent("search_pick", {
|
||||||
kind: it.kind || "",
|
kind: it.kind || "",
|
||||||
|
|
@ -152,6 +154,9 @@ export default function SmartSearchBar({
|
||||||
title_len: (it.title || "").length,
|
title_len: (it.title || "").length,
|
||||||
});
|
});
|
||||||
onPick && onPick(it, { zoom, reason });
|
onPick && onPick(it, { zoom, reason });
|
||||||
|
if (inputRef.current) {
|
||||||
|
try { inputRef.current.blur(); } catch {}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (e) => {
|
const onKeyDown = (e) => {
|
||||||
|
|
@ -171,6 +176,9 @@ export default function SmartSearchBar({
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
trackEvent("search_submit", { query_len: q.trim().length });
|
trackEvent("search_submit", { query_len: q.trim().length });
|
||||||
onSearch && onSearch(q.trim());
|
onSearch && onSearch(q.trim());
|
||||||
|
if (inputRef.current) {
|
||||||
|
try { inputRef.current.blur(); } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
@ -194,6 +202,7 @@ export default function SmartSearchBar({
|
||||||
<div className="sw-search" ref={boxRef}>
|
<div className="sw-search" ref={boxRef}>
|
||||||
<div className="sw-search__bar">
|
<div className="sw-search__bar">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
className="sw-search__input"
|
className="sw-search__input"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -205,10 +214,13 @@ export default function SmartSearchBar({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (q.trim()) {
|
if (clearOnFocusRef.current) {
|
||||||
|
clearOnFocusRef.current = false;
|
||||||
setQ("");
|
setQ("");
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setOpen(true);
|
||||||
onSearch && onSearch("");
|
onSearch && onSearch("");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
// Si pas encore cherché et query vide, charger suggestions populaires
|
// Si pas encore cherché et query vide, charger suggestions populaires
|
||||||
|
|
@ -261,12 +273,25 @@ export default function SmartSearchBar({
|
||||||
key={it.id}
|
key={it.id}
|
||||||
className={"sw-search__row sw-search__item" + (idx === active ? " is-active" : "")}
|
className={"sw-search__row sw-search__item" + (idx === active ? " is-active" : "")}
|
||||||
onMouseEnter={() => setActive(idx)}
|
onMouseEnter={() => setActive(idx)}
|
||||||
onClick={() => pick(it, "click")}
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pick(it, "click");
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sw-search__title">{truncateText(it.title || "Untitled", 140)}</div>
|
<div className="sw-search__title">{truncateText(it.title || "Untitled", 140)}</div>
|
||||||
<div className="sw-search__sub">
|
<div className="sw-search__sub">
|
||||||
{truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)}
|
{truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)}
|
||||||
</div>
|
</div>
|
||||||
|
{Array.isArray(it.tags) && it.tags.length > 0 && (
|
||||||
|
<div className="sw-search__tags">
|
||||||
|
{it.tags.slice(0, 4).map((tag) => (
|
||||||
|
<span key={tag} className="sw-search__tag">#{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ function normalizeOne(r) {
|
||||||
|
|
||||||
const type = (r.type ?? r.kind ?? r.entity ?? "").toString().toLowerCase().trim();
|
const type = (r.type ?? r.kind ?? r.entity ?? "").toString().toLowerCase().trim();
|
||||||
const kind = type || (coords ? "place" : "item");
|
const kind = type || (coords ? "place" : "item");
|
||||||
|
const tags =
|
||||||
|
Array.isArray(r.event_tags)
|
||||||
|
? r.event_tags
|
||||||
|
: Array.isArray(r.tags)
|
||||||
|
? r.tags
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: (r.id ?? r._id ?? r.key ?? title).toString(),
|
id: (r.id ?? r._id ?? r.key ?? title).toString(),
|
||||||
|
|
@ -52,6 +58,7 @@ function normalizeOne(r) {
|
||||||
coords,
|
coords,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
|
tags,
|
||||||
raw: r,
|
raw: r,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@
|
||||||
.sw-icon-active .sw-icon-circle { background: rgba(37, 99, 235, 0.82); border-color:#60a5fa; box-shadow:0 0 14px rgba(56,189,248,.95); }
|
.sw-icon-active .sw-icon-circle { background: rgba(37, 99, 235, 0.82); border-color:#60a5fa; box-shadow:0 0 14px rgba(56,189,248,.95); }
|
||||||
.sw-icon-active .sw-icon-label { color:#bfdbfe; font-weight:600; }
|
.sw-icon-active .sw-icon-label { color:#bfdbfe; font-weight:600; }
|
||||||
|
|
||||||
|
/* Right-side filter stack alignment */
|
||||||
|
.map-overlay-right .sw-icon-column { align-items:flex-end; }
|
||||||
|
.map-overlay-right .sw-icon-btn { align-items:flex-end; }
|
||||||
|
.map-overlay-right .sw-icon-label { text-align:right; }
|
||||||
|
|
||||||
.sw-bottom-row {
|
.sw-bottom-row {
|
||||||
display:flex;
|
display:flex;
|
||||||
gap:.35rem;
|
gap:.35rem;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 92%;
|
width: 92%;
|
||||||
max-width: 1300px;
|
max-width: 1300px;
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow-y: auto;
|
||||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
padding: 2.5rem;
|
padding: 2.5rem;
|
||||||
|
|
@ -64,6 +66,19 @@
|
||||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.island-viewer-container::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
.island-viewer-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(56, 189, 248, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid rgba(15, 23, 42, 0.9);
|
||||||
|
}
|
||||||
|
.island-viewer-container::-webkit-scrollbar-track {
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
body[data-theme="blue"] .island-viewer-modal{
|
body[data-theme="blue"] .island-viewer-modal{
|
||||||
background: linear-gradient(135deg, rgba(3, 12, 40, 0.95) 0%, rgba(2, 8, 28, 0.98) 100%);
|
background: linear-gradient(135deg, rgba(3, 12, 40, 0.95) 0%, rgba(2, 8, 28, 0.98) 100%);
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +219,10 @@ body[data-theme="blue"] .island-viewer-container{
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
background: rgba(15, 23, 42, 0.6);
|
background: linear-gradient(135deg, rgba(30, 58, 138, 0.35), rgba(15, 23, 42, 0.7));
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px rgba(0, 0, 0, 0.35),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,10 +302,18 @@ body[data-theme="blue"] .island-viewer-container{
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.island-profile-name {
|
.island-profile-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(2, 6, 23, 0.5);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
.island-profile-actions {
|
.island-profile-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -311,6 +337,15 @@ body[data-theme="blue"] .island-viewer-container{
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #cbd5f5;
|
color: #cbd5f5;
|
||||||
}
|
}
|
||||||
|
.island-profile-inline {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.island-profile-inline .island-profile-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
.island-profile-row select {
|
.island-profile-row select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: rgba(15, 23, 42, 0.6);
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
/* Left / right: filtres */
|
/* Left / right: filtres */
|
||||||
.map-overlay-left{
|
.map-overlay-left{
|
||||||
left:.7rem;
|
left:.7rem;
|
||||||
top:44%;
|
top:50%;
|
||||||
transform:translateY(-50%);
|
transform:translateY(-50%);
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
|
|
@ -91,13 +91,14 @@
|
||||||
}
|
}
|
||||||
.map-overlay-right{
|
.map-overlay-right{
|
||||||
right:.7rem;
|
right:.7rem;
|
||||||
top:44%;
|
top:50%;
|
||||||
transform:translateY(-50%);
|
transform:translateY(-50%);
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
gap:.5rem;
|
gap:.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* BOTTOM: sub-cats au-dessus du Sociowall */
|
/* BOTTOM: sub-cats au-dessus du Sociowall */
|
||||||
.map-overlay-bottom{
|
.map-overlay-bottom{
|
||||||
bottom: calc(3.2rem + var(--sw-below-overlap, 0px) + env(safe-area-inset-bottom) + var(--sw-subcats-nudge, 0px));
|
bottom: calc(3.2rem + var(--sw-below-overlap, 0px) + env(safe-area-inset-bottom) + var(--sw-subcats-nudge, 0px));
|
||||||
|
|
@ -230,6 +231,42 @@
|
||||||
gap:.5rem;
|
gap:.5rem;
|
||||||
margin-bottom:.4rem;
|
margin-bottom:.4rem;
|
||||||
}
|
}
|
||||||
|
.create-link-row{
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap:.5rem;
|
||||||
|
margin-bottom:.35rem;
|
||||||
|
}
|
||||||
|
.create-link-input{
|
||||||
|
flex:1;
|
||||||
|
margin-bottom:0;
|
||||||
|
}
|
||||||
|
.create-link-btn{
|
||||||
|
border-radius:999px;
|
||||||
|
border:1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
background:#0f172a;
|
||||||
|
color:#e2e8f0;
|
||||||
|
padding:.35rem .7rem;
|
||||||
|
font-size:.7rem;
|
||||||
|
cursor:pointer;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.create-link-btn:disabled{
|
||||||
|
opacity:.6;
|
||||||
|
cursor:not-allowed;
|
||||||
|
}
|
||||||
|
.create-link-meta{
|
||||||
|
display:flex;
|
||||||
|
gap:.6rem;
|
||||||
|
font-size:.68rem;
|
||||||
|
color:#94a3b8;
|
||||||
|
margin-bottom:.35rem;
|
||||||
|
}
|
||||||
|
.create-link-error{
|
||||||
|
font-size:.68rem;
|
||||||
|
color:#f87171;
|
||||||
|
margin-bottom:.35rem;
|
||||||
|
}
|
||||||
.crosshair-label{
|
.crosshair-label{
|
||||||
font-size:.75rem;
|
font-size:.75rem;
|
||||||
color:#cbd5f5;
|
color:#cbd5f5;
|
||||||
|
|
@ -286,14 +323,69 @@
|
||||||
gap:.4rem;
|
gap:.4rem;
|
||||||
margin-top:.35rem;
|
margin-top:.35rem;
|
||||||
}
|
}
|
||||||
|
.create-image-choice{
|
||||||
|
display:flex;
|
||||||
|
gap:.5rem;
|
||||||
|
align-items:center;
|
||||||
|
margin:.25rem 0 .35rem;
|
||||||
|
}
|
||||||
|
.create-image-choice-btn{
|
||||||
|
flex:1;
|
||||||
|
padding:.4rem .6rem;
|
||||||
|
border-radius:999px;
|
||||||
|
border:1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background:rgba(15, 23, 42, 0.6);
|
||||||
|
color:#e2e8f0;
|
||||||
|
font-size:.72rem;
|
||||||
|
font-weight:700;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.create-image-choice-btn.is-active{
|
||||||
|
border-color: rgba(56, 189, 248, 0.7);
|
||||||
|
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
.create-image-placeholder{
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap:.6rem;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:12px;
|
||||||
|
border:1px dashed rgba(148, 163, 184, 0.4);
|
||||||
|
background:rgba(15, 23, 42, 0.45);
|
||||||
|
margin:.35rem 0 .65rem;
|
||||||
|
}
|
||||||
|
.create-image-placeholder-title{
|
||||||
|
font-size:.8rem;
|
||||||
|
font-weight:800;
|
||||||
|
color:#e2e8f0;
|
||||||
|
}
|
||||||
|
.create-image-placeholder-sub{
|
||||||
|
font-size:.7rem;
|
||||||
|
color:#94a3b8;
|
||||||
|
}
|
||||||
.create-image-row{
|
.create-image-row{
|
||||||
display:flex;
|
display:flex;
|
||||||
gap:.5rem;
|
gap:.5rem;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
}
|
}
|
||||||
.create-image-row input[type="file"]{
|
.create-file-input{
|
||||||
flex:1;
|
display:none;
|
||||||
|
}
|
||||||
|
.create-file-btn{
|
||||||
|
display:inline-flex;
|
||||||
|
align-items:center;
|
||||||
|
gap:.4rem;
|
||||||
|
padding:.35rem .7rem;
|
||||||
|
border-radius:999px;
|
||||||
|
border:1px solid rgba(148, 163, 184, 0.4);
|
||||||
color:#e2e8f0;
|
color:#e2e8f0;
|
||||||
|
font-size:.7rem;
|
||||||
|
background:rgba(15, 23, 42, 0.8);
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.create-file-btn--camera{
|
||||||
|
border-color: rgba(56, 189, 248, 0.65);
|
||||||
|
color:#e0f2fe;
|
||||||
}
|
}
|
||||||
.create-uploading{
|
.create-uploading{
|
||||||
font-size:.7rem;
|
font-size:.7rem;
|
||||||
|
|
@ -319,6 +411,30 @@
|
||||||
border-radius:999px;
|
border-radius:999px;
|
||||||
font-size:.68rem;
|
font-size:.68rem;
|
||||||
}
|
}
|
||||||
|
.create-link-preview{
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap:.65rem;
|
||||||
|
padding:.45rem .6rem;
|
||||||
|
border-radius:12px;
|
||||||
|
border:1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background:rgba(15, 23, 42, 0.55);
|
||||||
|
margin-bottom:.6rem;
|
||||||
|
}
|
||||||
|
.create-link-preview img{
|
||||||
|
width:58px;
|
||||||
|
height:58px;
|
||||||
|
border-radius:10px;
|
||||||
|
object-fit:cover;
|
||||||
|
border:1px solid rgba(148, 163, 184, 0.5);
|
||||||
|
}
|
||||||
|
.create-link-preview-meta{
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
gap:.2rem;
|
||||||
|
font-size:.7rem;
|
||||||
|
color:#cbd5f5;
|
||||||
|
}
|
||||||
.create-muted{
|
.create-muted{
|
||||||
font-size:.7rem;
|
font-size:.7rem;
|
||||||
color:#94a3b8;
|
color:#94a3b8;
|
||||||
|
|
@ -442,7 +558,8 @@ body[data-theme="blue"] .create-post-panel{
|
||||||
body[data-theme="blue"] .create-row select,
|
body[data-theme="blue"] .create-row select,
|
||||||
body[data-theme="blue"] .create-input,
|
body[data-theme="blue"] .create-input,
|
||||||
body[data-theme="blue"] .create-textarea,
|
body[data-theme="blue"] .create-textarea,
|
||||||
body[data-theme="blue"] .create-privacy-row select{
|
body[data-theme="blue"] .create-privacy-row select,
|
||||||
|
body[data-theme="blue"] .create-link-btn{
|
||||||
background: #0b1b3b;
|
background: #0b1b3b;
|
||||||
border-color: rgba(96, 165, 250, 0.5);
|
border-color: rgba(96, 165, 250, 0.5);
|
||||||
color:#e2e8f0;
|
color:#e2e8f0;
|
||||||
|
|
@ -476,7 +593,8 @@ body[data-theme="light"] .create-post-panel{
|
||||||
body[data-theme="light"] .create-row select,
|
body[data-theme="light"] .create-row select,
|
||||||
body[data-theme="light"] .create-input,
|
body[data-theme="light"] .create-input,
|
||||||
body[data-theme="light"] .create-textarea,
|
body[data-theme="light"] .create-textarea,
|
||||||
body[data-theme="light"] .create-privacy-row select{
|
body[data-theme="light"] .create-privacy-row select,
|
||||||
|
body[data-theme="light"] .create-link-btn{
|
||||||
background:#ffffff;
|
background:#ffffff;
|
||||||
border-color: rgba(148, 163, 184, 0.7);
|
border-color: rgba(148, 163, 184, 0.7);
|
||||||
color:#0f172a;
|
color:#0f172a;
|
||||||
|
|
|
||||||
|
|
@ -607,7 +607,7 @@ body[data-theme="light"] .sw-chat-bubble::before{
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-modal-hero{
|
.sw-modal-hero{
|
||||||
width:100%;
|
width:auto;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
@ -991,8 +991,15 @@ body[data-theme="light"] .sw-modal-hero-fallback{
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.sw-truth-rail{
|
.sw-truth-rail{
|
||||||
width: 12px;
|
width: 22px;
|
||||||
min-width: 12px;
|
min-width: 22px;
|
||||||
|
height: 140px;
|
||||||
|
min-height: 140px;
|
||||||
|
margin: 0 8px 0 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.sw-cluster-hero{
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1080,15 +1087,145 @@ body[data-theme="light"] .sw-modal-x{
|
||||||
|
|
||||||
.post-pin.sw-appear{
|
.post-pin.sw-appear{
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate3d(0,0,0) scale(0.96);
|
transition: opacity 640ms ease;
|
||||||
transition: opacity 840ms ease, transform 840ms ease;
|
|
||||||
}
|
}
|
||||||
.post-pin.sw-appear.sw-appear-in{
|
.post-pin.sw-appear.sw-appear-in{
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate3d(0,0,0) scale(1);
|
|
||||||
}
|
}
|
||||||
.post-pin.sw-disappear{
|
.post-pin.sw-disappear{
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate3d(0,0,0) scale(0.96);
|
transition: opacity 640ms ease;
|
||||||
transition: opacity 840ms ease, transform 840ms ease;
|
}
|
||||||
|
.post-pin--simple.sw-appear{
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 520ms ease;
|
||||||
|
}
|
||||||
|
.post-pin--simple.sw-appear.sw-appear-in{
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.post-pin--simple.sw-disappear{
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 520ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cluster main news layout */
|
||||||
|
.sw-cluster-modal .sw-modal-toprow{
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.sw-cluster-title{
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 4px 0 14px;
|
||||||
|
color: var(--sw-modal-text, #e5e7eb);
|
||||||
|
}
|
||||||
|
.sw-modal-tags{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: -6px 0 14px;
|
||||||
|
}
|
||||||
|
.sw-modal-tag{
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
color: var(--sw-modal-text, #e5e7eb);
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.sw-cluster-body{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 44%) minmax(240px, 56%);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.sw-cluster-hero{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(0,1fr);
|
||||||
|
grid-auto-flow: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.sw-cluster-hero .sw-truth-rail{
|
||||||
|
grid-row: 1;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.sw-cluster-hero-media{
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: radial-gradient(circle at 30% 30%, var(--sw-modal-accent-soft, rgba(56,189,248,0.18)), rgba(3,14,32,0.98));
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.35), 0 0 18px var(--sw-modal-accent-glow, rgba(56,189,248,0.45));
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.sw-cluster-hero-media img{
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.sw-cluster-actions{
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sw-cluster-related-title{
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--sw-modal-meta, #9eb7ff);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.sw-cluster-related-list{
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.sw-cluster-source-item{
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(15,23,42,0.5);
|
||||||
|
border: 1px solid rgba(148,163,184,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sw-cluster-source-item:hover{
|
||||||
|
border-color: var(--sw-modal-accent, rgba(56,189,248,0.6));
|
||||||
|
box-shadow: 0 0 0 1px var(--sw-modal-accent-soft, rgba(56,189,248,0.2));
|
||||||
|
}
|
||||||
|
.sw-cluster-source-title{
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sw-modal-text, #e5e7eb);
|
||||||
|
}
|
||||||
|
.sw-cluster-source{
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--sw-modal-meta, #9eb7ff);
|
||||||
|
}
|
||||||
|
.sw-cluster-source-empty{
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--sw-modal-meta, #9eb7ff);
|
||||||
|
}
|
||||||
|
.sw-cluster-generated{
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--sw-modal-meta, #9eb7ff);
|
||||||
|
margin: 6px 0 6px;
|
||||||
|
}
|
||||||
|
.sw-cluster-summary{
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--sw-modal-text, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px){
|
||||||
|
.sw-cluster-body{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sw-cluster-hero{
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,11 @@
|
||||||
color: var(--sw-text, rgba(255,255,255,0.92));
|
color: var(--sw-text, rgba(255,255,255,0.92));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sw-search__iconBtn.is-active {
|
||||||
|
background: linear-gradient(135deg, rgba(56,189,248,0.9), rgba(34,211,238,0.9));
|
||||||
|
color: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
.sw-search__iconBtn:active {
|
.sw-search__iconBtn:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
@ -144,6 +149,20 @@
|
||||||
color: var(--sw-muted, rgba(255,255,255,0.60));
|
color: var(--sw-muted, rgba(255,255,255,0.60));
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.sw-search__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.sw-search__tag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
color: rgba(255,255,255,0.88);
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
.sw-search__muted {
|
.sw-search__muted {
|
||||||
color: var(--sw-muted, rgba(255,255,255,0.60));
|
color: var(--sw-muted, rgba(255,255,255,0.60));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue