Enhance analytics, map overlay, and UI fixes
This commit is contained in:
parent
6417194d90
commit
b8ac7d2010
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Silent SSO</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
parent.postMessage(location.href, location.origin);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
src/App.jsx
15
src/App.jsx
|
|
@ -3,6 +3,7 @@ import TopBar from "./components/Layout/TopBar";
|
||||||
import PostList from "./components/Posts/PostList";
|
import PostList from "./components/Posts/PostList";
|
||||||
import IslandUniverse from "./components/Island/IslandUniverse";
|
import IslandUniverse from "./components/Island/IslandUniverse";
|
||||||
import IslandViewer from "./components/Island/IslandViewer";
|
import IslandViewer from "./components/Island/IslandViewer";
|
||||||
|
import { fetchIslandByUsername } from "./api/client";
|
||||||
|
|
||||||
const MapView = React.lazy(() => import("./components/Map/MapView"));
|
const MapView = React.lazy(() => import("./components/Map/MapView"));
|
||||||
|
|
||||||
|
|
@ -110,12 +111,25 @@ export default function App() {
|
||||||
setSelectedIsland(island); // Open island viewer
|
setSelectedIsland(island); // Open island viewer
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenUserIsland = async (uname) => {
|
||||||
|
const u = (uname || "").trim();
|
||||||
|
if (!u) return;
|
||||||
|
try {
|
||||||
|
const island = await fetchIslandByUsername(u);
|
||||||
|
if (island) {
|
||||||
|
setShowUniverse(false);
|
||||||
|
setSelectedIsland(island);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<TopBar
|
<TopBar
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onChangeTheme={setTheme}
|
onChangeTheme={setTheme}
|
||||||
onIslandsClick={handleOpenUniverse}
|
onIslandsClick={handleOpenUniverse}
|
||||||
|
onUserIsland={handleOpenUserIsland}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="main-shell">
|
<div className="main-shell">
|
||||||
|
|
@ -128,6 +142,7 @@ export default function App() {
|
||||||
onSelectPost={setSelectedPost}
|
onSelectPost={setSelectedPost}
|
||||||
selectedIsland={selectedIsland}
|
selectedIsland={selectedIsland}
|
||||||
onSelectIsland={setSelectedIsland}
|
onSelectIsland={setSelectedIsland}
|
||||||
|
onOpenIslands={handleOpenUniverse}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,13 @@ async function resolveAssetURLs(ids) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveAssetRef(value) {
|
||||||
|
if (!isAssetRef(value)) return value;
|
||||||
|
const id = value.replace("asset://", "");
|
||||||
|
const resolved = await resolveAssetURLs([id]);
|
||||||
|
return resolved[id] || value;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveAssetRefsInPosts(posts) {
|
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 = [];
|
||||||
|
|
@ -53,6 +60,12 @@ async function resolveAssetRefsInPosts(posts) {
|
||||||
const resolved = await resolveAssetURLs(uniq);
|
const resolved = await resolveAssetURLs(uniq);
|
||||||
return posts.map((p) => {
|
return posts.map((p) => {
|
||||||
const clone = { ...p };
|
const clone = { ...p };
|
||||||
|
if (clone.post_id && Number(clone.post_id) > 0) {
|
||||||
|
if (!clone.id || Number(clone.id) !== Number(clone.post_id)) {
|
||||||
|
clone.reco_id = clone.id;
|
||||||
|
clone.id = clone.post_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
["image_small", "image_large", "image", "media_url"].forEach((k) => {
|
["image_small", "image_large", "image", "media_url"].forEach((k) => {
|
||||||
const v = clone[k];
|
const v = clone[k];
|
||||||
if (isAssetRef(v)) {
|
if (isAssetRef(v)) {
|
||||||
|
|
@ -91,6 +104,7 @@ export async function fetchPosts(filters = {}) {
|
||||||
if (filters.category) params.set("category", filters.category);
|
if (filters.category) params.set("category", filters.category);
|
||||||
if (filters.subCategory) params.set("sub_category", filters.subCategory);
|
if (filters.subCategory) params.set("sub_category", filters.subCategory);
|
||||||
if (filters.time) params.set("time", filters.time);
|
if (filters.time) params.set("time", filters.time);
|
||||||
|
if (filters.username) params.set("username", filters.username);
|
||||||
if (typeof filters.lat === "number") params.set("lat", String(filters.lat));
|
if (typeof filters.lat === "number") params.set("lat", String(filters.lat));
|
||||||
if (typeof filters.lon === "number") params.set("lon", String(filters.lon));
|
if (typeof filters.lon === "number") params.set("lon", String(filters.lon));
|
||||||
if (typeof filters.radiusKm === "number") {
|
if (typeof filters.radiusKm === "number") {
|
||||||
|
|
@ -341,6 +355,9 @@ export async function fetchUnifiedSearch(query, params = {}) {
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||||
|
if (Array.isArray(data.posts)) {
|
||||||
|
data.posts = await resolveAssetRefsInPosts(data.posts);
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
return { posts: [], profiles: [], places: [] };
|
return { posts: [], profiles: [], places: [] };
|
||||||
|
|
@ -373,9 +390,8 @@ export async function pollNewPosts(params = {}) {
|
||||||
return { posts: [], count: 0, max_id: 0 };
|
return { posts: [], count: 0, max_id: 0 };
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data && data.avatar_url && isAssetRef(data.avatar_url)) {
|
if (data && Array.isArray(data.posts)) {
|
||||||
const resolved = await resolveAssetRefsInIslands([data]);
|
data.posts = await resolveAssetRefsInPosts(data.posts);
|
||||||
return resolved[0] || data;
|
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -484,13 +500,63 @@ export async function uploadProfileAvatar(file, opts = {}) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
return { ok: false, status: res.status, text };
|
return { ok: false, status: res.status, text };
|
||||||
}
|
}
|
||||||
return await res.json();
|
const out = await res.json();
|
||||||
|
if (out && out.avatar_url) {
|
||||||
|
out.avatar_url = await resolveAssetRef(out.avatar_url);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("uploadProfileAvatar error:", err);
|
console.warn("uploadProfileAvatar error:", err);
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProfileUsername(username) {
|
||||||
|
const url = `${API_BASE}/profile/username`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
let json = null;
|
||||||
|
try {
|
||||||
|
json = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
json = null;
|
||||||
|
}
|
||||||
|
return { ok: res.ok, status: res.status, json, text };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("updateProfileUsername error:", err);
|
||||||
|
return { ok: false, status: 0, json: null, text: err?.message || "error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProfile(username = "") {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (username) qs.set("username", username);
|
||||||
|
const url = qs.toString() ? `${API_BASE}/profile?${qs}` : `${API_BASE}/profile`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { ...authHeaders() },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.avatar_url) {
|
||||||
|
data.avatar_url = await resolveAssetRef(data.avatar_url);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("fetchProfile error:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function recordPostView(postId) {
|
export async function recordPostView(postId) {
|
||||||
if (!postId) return { ok: false };
|
if (!postId) return { ok: false };
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ function apiBase() {
|
||||||
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function silentSsoRedirectUri() {
|
||||||
|
return window.location.origin + "/silent-check-sso.html";
|
||||||
|
}
|
||||||
|
|
||||||
async function apiFetch(path, opts = {}) {
|
async function apiFetch(path, opts = {}) {
|
||||||
const url = apiBase() + path;
|
const url = apiBase() + path;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
|
|
@ -131,6 +135,7 @@ async function me(accessToken) {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
json,
|
json,
|
||||||
text,
|
text,
|
||||||
|
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 : "",
|
||||||
};
|
};
|
||||||
|
|
@ -224,6 +229,9 @@ export function AuthProvider({ children }) {
|
||||||
if (mr.avatarUrl) {
|
if (mr.avatarUrl) {
|
||||||
setAvatarUrl(mr.avatarUrl);
|
setAvatarUrl(mr.avatarUrl);
|
||||||
}
|
}
|
||||||
|
if (mr.username) {
|
||||||
|
setUser({ username: mr.username });
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof verified !== "boolean") {
|
if (typeof verified !== "boolean") {
|
||||||
const tv = tokenEmailVerified(at);
|
const tv = tokenEmailVerified(at);
|
||||||
|
|
@ -326,6 +334,13 @@ export function AuthProvider({ children }) {
|
||||||
setLastInfo("");
|
setLastInfo("");
|
||||||
const saved = loadAuth();
|
const saved = loadAuth();
|
||||||
if (!saved?.access_token) {
|
if (!saved?.access_token) {
|
||||||
|
if (keycloakEnabled()) {
|
||||||
|
const ok = await trySilentKeycloakSession();
|
||||||
|
if (ok) {
|
||||||
|
scheduleAutoRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setAnon("");
|
setAnon("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -384,10 +399,17 @@ export function AuthProvider({ children }) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
async function logout() {
|
||||||
if (refreshTimer.current) clearInterval(refreshTimer.current);
|
if (refreshTimer.current) clearInterval(refreshTimer.current);
|
||||||
refreshTimer.current = null;
|
refreshTimer.current = null;
|
||||||
|
const redirectUri = window.location.origin + window.location.pathname;
|
||||||
|
const doSsoLogout = keycloakEnabled();
|
||||||
setAnon("");
|
setAnon("");
|
||||||
|
if (!doSsoLogout) return;
|
||||||
|
try {
|
||||||
|
const kc = getKeycloak();
|
||||||
|
await kc.logout({ redirectUri });
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resendVerificationEmail() {
|
async function resendVerificationEmail() {
|
||||||
|
|
@ -487,6 +509,50 @@ export function AuthProvider({ children }) {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function trySilentKeycloakSession() {
|
||||||
|
let kc = null;
|
||||||
|
try {
|
||||||
|
kc = getKeycloak();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = await kc.init({
|
||||||
|
onLoad: "check-sso",
|
||||||
|
pkceMethod: "S256",
|
||||||
|
checkLoginIframe: false,
|
||||||
|
silentCheckSsoRedirectUri: silentSsoRedirectUri(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok || !kc.authenticated || !kc.token) return false;
|
||||||
|
|
||||||
|
const t = {
|
||||||
|
access_token: kc.token,
|
||||||
|
refresh_token: kc.refreshToken || "",
|
||||||
|
id_token: kc.idToken || "",
|
||||||
|
token_type: "bearer",
|
||||||
|
obtained_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setAuthFromTokens(t);
|
||||||
|
|
||||||
|
const w = await whoami(t.access_token);
|
||||||
|
if (!w.ok || !w.authenticated) {
|
||||||
|
setAnon("Login desynced. Please login again.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(w.username ? { username: w.username } : null);
|
||||||
|
setStatus("auth");
|
||||||
|
await refreshProfile(t.access_token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function loginWithProvider(provider) {
|
function loginWithProvider(provider) {
|
||||||
if (!keycloakEnabled()) {
|
if (!keycloakEnabled()) {
|
||||||
setAnon("SSO is not configured.");
|
setAnon("SSO is not configured.");
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ 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 { uploadProfileAvatar } from '../../api/client';
|
import { fetchPosts, uploadProfileAvatar } from '../../api/client';
|
||||||
import { useAuth } from '../../auth/AuthContext';
|
import { useAuth } from '../../auth/AuthContext';
|
||||||
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IslandViewer: 2.5D immersive island visualization using MapLibre
|
* IslandViewer: 2.5D immersive island visualization using MapLibre
|
||||||
|
|
@ -16,14 +17,21 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
const { authenticated, username, setAvatarUrl } = useAuth();
|
const { authenticated, username, setAvatarUrl } = useAuth();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState("");
|
||||||
|
const [uploadInfo, setUploadInfo] = useState("");
|
||||||
const [avatarVisibility, setAvatarVisibility] = useState("public");
|
const [avatarVisibility, setAvatarVisibility] = useState("public");
|
||||||
const [avatarGroupId, setAvatarGroupId] = useState("");
|
const [avatarGroupId, setAvatarGroupId] = useState("");
|
||||||
const [avatarUsers, setAvatarUsers] = useState("");
|
const [avatarUsers, setAvatarUsers] = useState("");
|
||||||
|
const [avatarSrc, setAvatarSrc] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState("profile");
|
||||||
|
const [userPosts, setUserPosts] = useState([]);
|
||||||
|
const [postsLoading, setPostsLoading] = useState(false);
|
||||||
|
|
||||||
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:', island.username, island.seed);
|
||||||
|
trackEvent("island_open", { username: island.username || "" });
|
||||||
|
|
||||||
// Center of island in virtual coordinates
|
// Center of island in virtual coordinates
|
||||||
const centerLng = 0;
|
const centerLng = 0;
|
||||||
|
|
@ -138,6 +146,32 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
};
|
};
|
||||||
}, [island]);
|
}, [island]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAvatarSrc(island?.avatar_url || "");
|
||||||
|
}, [island?.avatar_url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!island?.username) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setPostsLoading(true);
|
||||||
|
fetchPosts({ username: island.username, limit: 40 })
|
||||||
|
.then((items) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setUserPosts(Array.isArray(items) ? items : []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setUserPosts([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPostsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [island?.username]);
|
||||||
|
|
||||||
if (!island) return null;
|
if (!island) return null;
|
||||||
const isSelf =
|
const isSelf =
|
||||||
authenticated && username && island.username &&
|
authenticated && username && island.username &&
|
||||||
|
|
@ -149,14 +183,26 @@ 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-avatar">
|
||||||
|
{avatarSrc ? (
|
||||||
|
<img src={avatarSrc} alt={island.username} />
|
||||||
|
) : (
|
||||||
|
<span>{(island.username || "U")[0].toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="island-header-text">
|
||||||
<h2>{island.display_name || `${island.username}'s Island`}</h2>
|
<h2>{island.display_name || `${island.username}'s Island`}</h2>
|
||||||
<p>{island.bio || 'Welcome to my island'}</p>
|
<p>{island.bio || 'Welcome to my island'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "profile" ? (
|
||||||
<div className="island-profile-panel">
|
<div className="island-profile-panel">
|
||||||
<div className="island-profile-avatar">
|
<div className="island-profile-avatar">
|
||||||
{island.avatar_url ? (
|
{avatarSrc ? (
|
||||||
<img src={island.avatar_url} alt={island.username} />
|
<img src={avatarSrc} alt={island.username} />
|
||||||
) : (
|
) : (
|
||||||
<div className="island-profile-letter">
|
<div className="island-profile-letter">
|
||||||
{(island.username || "U")[0].toUpperCase()}
|
{(island.username || "U")[0].toUpperCase()}
|
||||||
|
|
@ -217,6 +263,8 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
const file = e.target.files && e.target.files[0];
|
const file = e.target.files && e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadError("");
|
||||||
|
setUploadInfo("");
|
||||||
const users = avatarUsers
|
const users = avatarUsers
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((u) => u.trim())
|
.map((u) => u.trim())
|
||||||
|
|
@ -227,22 +275,68 @@ export default function IslandViewer({ island, onClose }) {
|
||||||
users,
|
users,
|
||||||
});
|
});
|
||||||
if (res && res.ok && res.avatar_url) {
|
if (res && res.ok && res.avatar_url) {
|
||||||
island.avatar_url = res.avatar_url;
|
setAvatarSrc(res.avatar_url);
|
||||||
if (setAvatarUrl) setAvatarUrl(res.avatar_url);
|
if (setAvatarUrl) setAvatarUrl(res.avatar_url);
|
||||||
|
setUploadInfo("Photo updated.");
|
||||||
|
} else {
|
||||||
|
setUploadError(res?.text || "Upload failed.");
|
||||||
}
|
}
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{uploadError ? <div className="island-profile-error">{uploadError}</div> : null}
|
||||||
|
{uploadInfo ? <div className="island-profile-info">{uploadInfo}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="island-posts-panel">
|
||||||
|
{postsLoading ? (
|
||||||
|
<div className="island-posts-empty">Loading posts...</div>
|
||||||
|
) : userPosts.length ? (
|
||||||
|
userPosts.map((post) => (
|
||||||
|
<div key={post.id} className="island-post-row">
|
||||||
|
<div className="island-post-title">{post.title || "Untitled"}</div>
|
||||||
|
{post.snippet ? (
|
||||||
|
<div className="island-post-snippet">{post.snippet}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="island-post-meta">
|
||||||
|
{post.created_at ? post.created_at.slice(0, 10) : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="island-posts-empty">No posts yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="island-viewer-canvas-wrap">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="island-viewer-canvas"
|
className="island-viewer-canvas"
|
||||||
style={{ width: '100%', height: '600px' }}
|
style={{ width: '100%', height: '600px' }}
|
||||||
/>
|
/>
|
||||||
|
<div className="island-tabbar island-tabbar-overlay">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"island-tab" + (activeTab === "posts" ? " is-active" : "")}
|
||||||
|
onClick={() => setActiveTab("posts")}
|
||||||
|
title="Posts"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-newspaper"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"island-tab" + (activeTab === "profile" ? " is-active" : "")}
|
||||||
|
onClick={() => setActiveTab("profile")}
|
||||||
|
title="Profile"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-user-gear"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="island-viewer-stats">
|
<div className="island-viewer-stats">
|
||||||
<div className="stat">
|
<div className="stat">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { registerUser } from "../../api/client";
|
import { registerUser, updateProfileUsername } from "../../api/client";
|
||||||
import AuthToast from "../Auth/AuthToast";
|
import AuthToast from "../Auth/AuthToast";
|
||||||
import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
|
import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
|
||||||
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
||||||
const LAST_SIGNUP_KEY = "sociowire:lastSignup";
|
const LAST_SIGNUP_KEY = "sociowire:lastSignup";
|
||||||
|
|
@ -53,13 +54,14 @@ function parseMergedParams() {
|
||||||
return { qs, hs, get, hash };
|
return { qs, hs, get, hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }) {
|
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) {
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
authenticated,
|
authenticated,
|
||||||
username,
|
username,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
restore,
|
||||||
loginWithProvider,
|
loginWithProvider,
|
||||||
ssoEnabled,
|
ssoEnabled,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
|
@ -70,8 +72,20 @@ 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 [desiredUsername, setDesiredUsername] = useState("");
|
||||||
|
const [usernameError, setUsernameError] = useState("");
|
||||||
|
const [usernameInfo, setUsernameInfo] = useState("");
|
||||||
|
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||||
|
const usernamePromptTimer = useRef(null);
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
const [loginUser, setLoginUser] = useState("");
|
||||||
const [loginPass, setLoginPass] = useState("");
|
const [loginPass, setLoginPass] = useState("");
|
||||||
|
|
@ -87,10 +101,23 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
const [showInstallBtn, setShowInstallBtn] = useState(false);
|
const [showInstallBtn, setShowInstallBtn] = useState(false);
|
||||||
const [authNotice, setAuthNotice] = useState("");
|
const [authNotice, setAuthNotice] = useState("");
|
||||||
|
|
||||||
|
const resetPasswordUrl = useMemo(() => {
|
||||||
|
const base = (import.meta?.env?.VITE_KC_URL || "https://auth.sociowire.com").replace(/\/+$/, "");
|
||||||
|
const realm = import.meta?.env?.VITE_KC_REALM || "sociowire";
|
||||||
|
const clientId = import.meta?.env?.VITE_KC_CLIENT_ID || "sociowire-frontend";
|
||||||
|
const redirectUri = window.location.origin + window.location.pathname;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
});
|
||||||
|
return `${base}/realms/${realm}/login-actions/reset-credentials?${params.toString()}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
const openModal = (nextMode) => {
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
setShowAuth(true);
|
setShowAuth(true);
|
||||||
setSignupError("");
|
setSignupError("");
|
||||||
|
trackEvent("auth_modal_open", { mode: nextMode || "login" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
|
|
@ -100,10 +127,77 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
setAuthNotice("");
|
setAuthNotice("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUsernameSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = (desiredUsername || "").trim();
|
||||||
|
const re = /^[A-Za-z0-9_.]{3,24}$/;
|
||||||
|
if (!re.test(next) || next.includes("@")) {
|
||||||
|
setUsernameError("Use 3-24 letters, numbers, _ or .");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUsernameSaving(true);
|
||||||
|
setUsernameError("");
|
||||||
|
setUsernameInfo("");
|
||||||
|
const res = await updateProfileUsername(next);
|
||||||
|
setUsernameSaving(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 409) {
|
||||||
|
setUsernameError("Username already taken.");
|
||||||
|
} else {
|
||||||
|
setUsernameError(res.text || "Could not update username.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUsernameInfo("Username saved.");
|
||||||
|
setShowUsernameModal(false);
|
||||||
|
await restore();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authenticated) setShowAuth(false);
|
if (authenticated) setShowAuth(false);
|
||||||
}, [authenticated]);
|
}, [authenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const needsUsername = (name) => {
|
||||||
|
if (!name) return false;
|
||||||
|
if (name.includes("@")) return true;
|
||||||
|
const re = /^[A-Za-z0-9_.]{3,24}$/;
|
||||||
|
return !re.test(name);
|
||||||
|
};
|
||||||
|
if (usernamePromptTimer.current) {
|
||||||
|
clearTimeout(usernamePromptTimer.current);
|
||||||
|
usernamePromptTimer.current = null;
|
||||||
|
}
|
||||||
|
if (loading) return;
|
||||||
|
if (!authenticated) {
|
||||||
|
setShowUsernameModal(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (needsUsername(username)) {
|
||||||
|
if (!showUsernameModal) {
|
||||||
|
const base = (username || "").split("@")[0] || "";
|
||||||
|
const cleaned = base.replace(/[^A-Za-z0-9_.]/g, "").slice(0, 24);
|
||||||
|
setDesiredUsername(cleaned);
|
||||||
|
setUsernameError("");
|
||||||
|
setUsernameInfo("");
|
||||||
|
usernamePromptTimer.current = setTimeout(() => {
|
||||||
|
setShowUsernameModal(true);
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
} else if (showUsernameModal) {
|
||||||
|
setShowUsernameModal(false);
|
||||||
|
}
|
||||||
|
}, [authenticated, username, showUsernameModal, loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (usernamePromptTimer.current) {
|
||||||
|
clearTimeout(usernamePromptTimer.current);
|
||||||
|
usernamePromptTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
const detail = e?.detail || {};
|
const detail = e?.detail || {};
|
||||||
|
|
@ -212,7 +306,9 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
const handleLoginSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await login(loginUser, loginPass);
|
trackEvent("login_submit");
|
||||||
|
const res = await login(loginUser, loginPass);
|
||||||
|
if (res?.ok) trackEvent("login_success");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSignupSubmit = async (e) => {
|
const handleSignupSubmit = async (e) => {
|
||||||
|
|
@ -227,6 +323,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSignupLoading(true);
|
setSignupLoading(true);
|
||||||
|
trackEvent("signup_submit");
|
||||||
|
|
||||||
const u = regUser.trim();
|
const u = regUser.trim();
|
||||||
const em = regEmail.trim();
|
const em = regEmail.trim();
|
||||||
|
|
@ -244,6 +341,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
setSignupInfo("✅ Account created (" + em + "). Check your emails (spam too), then log in.");
|
setSignupInfo("✅ Account created (" + em + "). Check your emails (spam too), then log in.");
|
||||||
|
trackEvent("signup_success");
|
||||||
setMode("login");
|
setMode("login");
|
||||||
setLoginUser(u || em);
|
setLoginUser(u || em);
|
||||||
setLoginPass("");
|
setLoginPass("");
|
||||||
|
|
@ -316,15 +414,20 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className="topbar-user-section">
|
<div className="topbar-user-section">
|
||||||
{authenticated ? (
|
{authenticated ? (
|
||||||
<div className="sw-userchip" title={username}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-userchip sw-userchip-btn"
|
||||||
|
title={username}
|
||||||
|
onClick={() => onUserIsland && onUserIsland(username)}
|
||||||
|
>
|
||||||
<div className="sw-avatar">
|
<div className="sw-avatar">
|
||||||
{avatarUrl ? <img src={avatarUrl} alt={username} /> : <span>{initialLetter(username)}</span>}
|
{avatarUrl ? <img src={avatarUrl} alt={username} /> : <span>{initialLetter(username)}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="sw-usertext">
|
<div className="sw-usertext">
|
||||||
<div className="sw-userline">Online</div>
|
<div className="sw-userline">Online</div>
|
||||||
<div className="sw-userhandle">@{username || "user"}</div>
|
<div className="sw-userhandle">@{displayUsername}</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="sw-userchip" title="Guest">
|
<div className="sw-userchip" title="Guest">
|
||||||
<div className="sw-avatar">
|
<div className="sw-avatar">
|
||||||
|
|
@ -336,6 +439,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons - separate section */}
|
{/* Action buttons - separate section */}
|
||||||
|
|
@ -352,21 +456,18 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Islands Universe button */}
|
|
||||||
<button
|
|
||||||
className="btn-action btn-action-islands"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onIslandsClick && onIslandsClick()}
|
|
||||||
title="Island Universe"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-earth-americas"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Login/Logout button */}
|
{/* Login/Logout button */}
|
||||||
<button
|
<button
|
||||||
className="btn-action btn-action-auth"
|
className="btn-action btn-action-auth"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (authenticated ? logout() : openModal("login"))}
|
onClick={() => {
|
||||||
|
if (authenticated) {
|
||||||
|
trackEvent("logout_click");
|
||||||
|
logout();
|
||||||
|
} else {
|
||||||
|
openModal("login");
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title={authenticated ? "Logout" : "Login"}
|
title={authenticated ? "Logout" : "Login"}
|
||||||
>
|
>
|
||||||
|
|
@ -428,6 +529,13 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auth-link"
|
||||||
|
onClick={() => window.open(resetPasswordUrl, "_blank", "noopener,noreferrer")}
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
||||||
{loading ? "..." : "Sign in"}
|
{loading ? "..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -446,7 +554,10 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-oauth btn-oauth-google"
|
className="btn-oauth btn-oauth-google"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => loginWithProvider && loginWithProvider("google")}
|
onClick={() => {
|
||||||
|
trackEvent("login_provider_click", { provider: "google" });
|
||||||
|
loginWithProvider && loginWithProvider("google");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<i className="fa-brands fa-google"></i>
|
<i className="fa-brands fa-google"></i>
|
||||||
<span>Continue with Google</span>
|
<span>Continue with Google</span>
|
||||||
|
|
@ -500,6 +611,35 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showUsernameModal ? (
|
||||||
|
<div className="auth-modal-backdrop" onClick={() => {}}>
|
||||||
|
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="auth-modal-header">
|
||||||
|
<div className="auth-tab auth-tab-active">Choose username</div>
|
||||||
|
</div>
|
||||||
|
<div className="auth-notice auth-notice-info">
|
||||||
|
Pick a public username to complete your profile.
|
||||||
|
</div>
|
||||||
|
{usernameError ? <div className="auth-notice auth-notice-error">{usernameError}</div> : null}
|
||||||
|
{usernameInfo ? <div className="auth-notice auth-notice-success">{usernameInfo}</div> : null}
|
||||||
|
<form className="auth-form" onSubmit={handleUsernameSave}>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={desiredUsername}
|
||||||
|
onChange={(e) => setDesiredUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn-primary btn-full" disabled={usernameSaving}>
|
||||||
|
{usernameSaving ? "Saving..." : "Save username"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ 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 } from "../../api/client";
|
||||||
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
export default function MapView({
|
export default function MapView({
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
|
|
@ -29,6 +30,7 @@ export default function MapView({
|
||||||
onSelectPost,
|
onSelectPost,
|
||||||
selectedIsland,
|
selectedIsland,
|
||||||
onSelectIsland,
|
onSelectIsland,
|
||||||
|
onOpenIslands,
|
||||||
}) {
|
}) {
|
||||||
const { authenticated, username, token, needsEmailVerification } = useAuth();
|
const { authenticated, username, token, needsEmailVerification } = useAuth();
|
||||||
|
|
||||||
|
|
@ -216,6 +218,18 @@ export default function MapView({
|
||||||
}
|
}
|
||||||
}, [selectedPost, mapRef, theme]);
|
}, [selectedPost, mapRef, theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent("filter_main_change", { filter: mainFilter });
|
||||||
|
}, [mainFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent("filter_sub_change", { filter: subFilter, parent: mainFilter });
|
||||||
|
}, [subFilter, mainFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent("filter_time_change", { filter: timeFilter });
|
||||||
|
}, [timeFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queryPostRef.current) return;
|
if (queryPostRef.current) return;
|
||||||
queryPostRef.current = true;
|
queryPostRef.current = true;
|
||||||
|
|
@ -348,10 +362,11 @@ export default function MapView({
|
||||||
setTimeFilter("PAST"); // Show all time periods
|
setTimeFilter("PAST"); // Show all time periods
|
||||||
|
|
||||||
const raw = item.raw || item;
|
const raw = item.raw || item;
|
||||||
|
const postId = raw?.post_id ?? raw?.id ?? item?.id ?? 0;
|
||||||
if (raw && typeof raw === "object") {
|
if (raw && typeof raw === "object") {
|
||||||
const lat = typeof raw.lat === "number" ? raw.lat : coords[1];
|
const lat = typeof raw.lat === "number" ? raw.lat : coords[1];
|
||||||
const lon = typeof raw.lon === "number" ? raw.lon : coords[0];
|
const lon = typeof raw.lon === "number" ? raw.lon : coords[0];
|
||||||
handleIncomingPost({
|
const builtPost = {
|
||||||
id: raw.post_id ?? raw.id ?? 0,
|
id: raw.post_id ?? raw.id ?? 0,
|
||||||
title: raw.title || item.title || "Post",
|
title: raw.title || item.title || "Post",
|
||||||
snippet: raw.snippet || raw.summary || "",
|
snippet: raw.snippet || raw.summary || "",
|
||||||
|
|
@ -364,7 +379,19 @@ export default function MapView({
|
||||||
published_at: raw.published_at || "",
|
published_at: raw.published_at || "",
|
||||||
created_at: raw.published_at || "",
|
created_at: raw.published_at || "",
|
||||||
url_hash: raw.url_hash || "",
|
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') {
|
} 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.
|
||||||
|
|
@ -724,6 +751,7 @@ export default function MapView({
|
||||||
{/* TOP: Search avec suggestions intelligentes */}
|
{/* TOP: Search avec suggestions intelligentes */}
|
||||||
<div className="map-overlay map-overlay-top">
|
<div className="map-overlay map-overlay-top">
|
||||||
<div className="sw-top-row">
|
<div className="sw-top-row">
|
||||||
|
<div className="sw-search-row">
|
||||||
<SmartSearchBar
|
<SmartSearchBar
|
||||||
placeholder="Search cities, places, posts..."
|
placeholder="Search cities, places, posts..."
|
||||||
onPick={handlePickSuggestion}
|
onPick={handlePickSuggestion}
|
||||||
|
|
@ -731,6 +759,25 @@ export default function MapView({
|
||||||
onMySpot={handleFlyToMe}
|
onMySpot={handleFlyToMe}
|
||||||
onPlaceTourWire={handleOpenCreate}
|
onPlaceTourWire={handleOpenCreate}
|
||||||
/>
|
/>
|
||||||
|
<div className="sw-search__side" aria-label="Search actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-search__iconBtn"
|
||||||
|
title="Place / Tour / Wire"
|
||||||
|
onClick={() => handleOpenCreate && handleOpenCreate()}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-search__iconBtn"
|
||||||
|
title="Profile Island"
|
||||||
|
onClick={() => onOpenIslands && onOpenIslands()}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-umbrella-beach"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,60 @@ import {
|
||||||
fetchPostComments,
|
fetchPostComments,
|
||||||
createPostComment,
|
createPostComment,
|
||||||
fetchPostLikeState,
|
fetchPostLikeState,
|
||||||
|
fetchProfile,
|
||||||
} from "../../api/client";
|
} from "../../api/client";
|
||||||
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
|
const avatarCache = new Map();
|
||||||
|
const avatarInflight = new Map();
|
||||||
|
|
||||||
|
function applyAvatar(el, username, url) {
|
||||||
|
if (!el) return;
|
||||||
|
const initial = (username || "U")[0]?.toUpperCase() || "U";
|
||||||
|
el.innerHTML = "";
|
||||||
|
if (url) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = url;
|
||||||
|
img.alt = username || "user";
|
||||||
|
el.appendChild(img);
|
||||||
|
} else {
|
||||||
|
el.textContent = initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvatar(username) {
|
||||||
|
const key = (username || "").toLowerCase().trim();
|
||||||
|
if (!key) return "";
|
||||||
|
if (avatarCache.has(key)) return avatarCache.get(key);
|
||||||
|
if (avatarInflight.has(key)) return avatarInflight.get(key);
|
||||||
|
const p = (async () => {
|
||||||
|
const profile = await fetchProfile(username);
|
||||||
|
const url = (profile && profile.avatar_url) ? String(profile.avatar_url) : "";
|
||||||
|
avatarCache.set(key, url);
|
||||||
|
avatarInflight.delete(key);
|
||||||
|
return url;
|
||||||
|
})().catch(() => {
|
||||||
|
avatarCache.set(key, "");
|
||||||
|
avatarInflight.delete(key);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
avatarInflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateAvatars(root) {
|
||||||
|
if (!root) return;
|
||||||
|
const nodes = root.querySelectorAll("[data-username]");
|
||||||
|
nodes.forEach((el) => {
|
||||||
|
const username = el.getAttribute("data-username") || "";
|
||||||
|
if (!username) return;
|
||||||
|
el.classList.add("sw-user-avatar");
|
||||||
|
applyAvatar(el, username, avatarCache.get(username.toLowerCase().trim()) || "");
|
||||||
|
loadAvatar(username).then((url) => {
|
||||||
|
applyAvatar(el, username, url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function clearAllMarkers(markersRef, expandedElRef) {
|
export function clearAllMarkers(markersRef, expandedElRef) {
|
||||||
if (markersRef.current) {
|
if (markersRef.current) {
|
||||||
|
|
@ -90,6 +143,7 @@ function requestAuth(message, mode = "login") {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
trackEvent("auth_required", { context: "map", mode: mode || "login" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPostDetails(modalWrap, post) {
|
function applyPostDetails(modalWrap, post) {
|
||||||
|
|
@ -124,10 +178,12 @@ function applyPostDetails(modalWrap, post) {
|
||||||
const metaLeft = author ? `by ${author}` : "";
|
const metaLeft = author ? `by ${author}` : "";
|
||||||
const metaRight = sub ? sub : "";
|
const metaRight = sub ? sub : "";
|
||||||
meta.innerHTML = `
|
meta.innerHTML = `
|
||||||
|
${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>` : ""}
|
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
||||||
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
||||||
<span>• ${escapeHtml(relTime)}</span>
|
<span>• ${escapeHtml(relTime)}</span>
|
||||||
`;
|
`;
|
||||||
|
hydrateAvatars(modalWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cta = modalWrap.querySelector(".sw-cta-primary");
|
const cta = modalWrap.querySelector(".sw-cta-primary");
|
||||||
|
|
@ -165,12 +221,16 @@ function renderComments(container, items) {
|
||||||
const ts = (c?.created_at || "").toString().trim();
|
const ts = (c?.created_at || "").toString().trim();
|
||||||
return `
|
return `
|
||||||
<div class="sw-chat-bubble">
|
<div class="sw-chat-bubble">
|
||||||
|
<div class="sw-chat-head">
|
||||||
|
<div class="sw-chat-avatar" data-username="${escapeHtml(who)}"></div>
|
||||||
<div class="sw-chat-meta">${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}</div>
|
<div class="sw-chat-meta">${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}</div>
|
||||||
|
</div>
|
||||||
<div class="sw-chat-text">${escapeHtml(body)}</div>
|
<div class="sw-chat-text">${escapeHtml(body)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
hydrateAvatars(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountCard(container, spec, data, theme, post) {
|
function mountCard(container, spec, data, theme, post) {
|
||||||
|
|
@ -327,9 +387,10 @@ function closeCenteredOverlay(map, opts = {}) {
|
||||||
try {
|
try {
|
||||||
const ov = map?.__swCenteredOverlay;
|
const ov = map?.__swCenteredOverlay;
|
||||||
if (!ov) return;
|
if (!ov) return;
|
||||||
|
if (!opts.force && ov.openedAt && Date.now() - ov.openedAt < 700) return;
|
||||||
if (ov.closing) return;
|
if (ov.closing) return;
|
||||||
ov.closing = true;
|
ov.closing = true;
|
||||||
const skipHistory = !!opts.skipHistory;
|
const skipHistory = !!opts.skipHistory || opts.reason === "switch";
|
||||||
|
|
||||||
if (ov.onKey) window.removeEventListener("keydown", ov.onKey);
|
if (ov.onKey) window.removeEventListener("keydown", ov.onKey);
|
||||||
if (ov.onAuth) window.removeEventListener("sociowire:auth-changed", ov.onAuth);
|
if (ov.onAuth) window.removeEventListener("sociowire:auth-changed", ov.onAuth);
|
||||||
|
|
@ -363,7 +424,7 @@ function closeCenteredOverlay(map, opts = {}) {
|
||||||
export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
closeCenteredOverlay(map);
|
closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true });
|
||||||
|
|
||||||
let postData = { ...(post || {}) };
|
let postData = { ...(post || {}) };
|
||||||
const data = adaptPostToTemplateData(postData);
|
const data = adaptPostToTemplateData(postData);
|
||||||
|
|
@ -381,6 +442,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"]);
|
||||||
|
trackEvent("post_open", {
|
||||||
|
post_id: postID,
|
||||||
|
source: postData?.source || postData?.Source || "",
|
||||||
|
category: postData?.category || postData?.Category || "",
|
||||||
|
context: "map",
|
||||||
|
fullscreen: !!fullScreen,
|
||||||
|
});
|
||||||
|
|
||||||
// Backdrop catches outside click to close
|
// Backdrop catches outside click to close
|
||||||
const backdrop = document.createElement("div");
|
const backdrop = document.createElement("div");
|
||||||
|
|
@ -413,6 +481,8 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
<div class="sw-modal-left">
|
<div class="sw-modal-left">
|
||||||
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
||||||
<div class="sw-modal-meta">
|
<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>` : ""}
|
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
||||||
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
||||||
<span>• ${escapeHtml(relTime)}</span>
|
<span>• ${escapeHtml(relTime)}</span>
|
||||||
|
|
@ -483,6 +553,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
|
|
||||||
backdrop.appendChild(modalWrap);
|
backdrop.appendChild(modalWrap);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
|
hydrateAvatars(modalWrap);
|
||||||
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 {}
|
||||||
|
|
@ -509,18 +580,47 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// outside click closes
|
// outside click closes (ignore drag/scroll on map)
|
||||||
backdrop.addEventListener("click", () => closeCenteredOverlay(map));
|
let backdropDown = null;
|
||||||
|
const trackDown = (e) => {
|
||||||
|
backdropDown = {
|
||||||
|
x: e.clientX || 0,
|
||||||
|
y: e.clientY || 0,
|
||||||
|
t: Date.now(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const shouldCloseBackdrop = (e) => {
|
||||||
|
if (map?.__swCenteredOverlay?.openedAt) {
|
||||||
|
if (Date.now() - map.__swCenteredOverlay.openedAt < 350) return false;
|
||||||
|
}
|
||||||
|
if (!backdropDown) return true;
|
||||||
|
const dx = (e.clientX || 0) - backdropDown.x;
|
||||||
|
const dy = (e.clientY || 0) - backdropDown.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const dt = Date.now() - backdropDown.t;
|
||||||
|
return dist < 8 && dt < 700;
|
||||||
|
};
|
||||||
|
backdrop.addEventListener("pointerdown", trackDown);
|
||||||
|
backdrop.addEventListener("mousedown", trackDown);
|
||||||
|
backdrop.addEventListener("touchstart", (e) => {
|
||||||
|
const touch = e.touches && e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
trackDown({ clientX: touch.clientX, clientY: touch.clientY });
|
||||||
|
}, { passive: true });
|
||||||
|
backdrop.addEventListener("click", (e) => {
|
||||||
|
if (!shouldCloseBackdrop(e)) return;
|
||||||
|
closeCenteredOverlay(map, { force: true });
|
||||||
|
});
|
||||||
// inside click doesn't close
|
// inside click doesn't close
|
||||||
modalWrap.addEventListener("click", (e) => e.stopPropagation());
|
modalWrap.addEventListener("click", (e) => e.stopPropagation());
|
||||||
|
|
||||||
// close button
|
// close button
|
||||||
const xbtn = modalWrap.querySelector(".sw-modal-x");
|
const xbtn = modalWrap.querySelector(".sw-modal-x");
|
||||||
if (xbtn) xbtn.addEventListener("click", () => closeCenteredOverlay(map));
|
if (xbtn) xbtn.addEventListener("click", () => closeCenteredOverlay(map, { force: true }));
|
||||||
|
|
||||||
// ESC closes
|
// ESC closes
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
if (e.key === "Escape") closeCenteredOverlay(map);
|
if (e.key === "Escape") closeCenteredOverlay(map, { force: true });
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
|
@ -538,7 +638,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
const ov = map.__swCenteredOverlay;
|
const ov = map.__swCenteredOverlay;
|
||||||
if (ov && ov.postId === postID) {
|
if (ov && ov.postId === postID) {
|
||||||
ov.historyClosed = true;
|
ov.historyClosed = true;
|
||||||
closeCenteredOverlay(map, { skipHistory: true });
|
closeCenteredOverlay(map, { skipHistory: true, force: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
|
|
@ -591,6 +691,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
requestAuth("Please sign in or create a free account to like this post.");
|
requestAuth("Please sign in or create a free account to like this post.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
trackEvent("post_like_click", { post_id: postID, context: "map" });
|
||||||
likeBtn.disabled = true;
|
likeBtn.disabled = true;
|
||||||
const res = await togglePostLike(postID, !liked);
|
const res = await togglePostLike(postID, !liked);
|
||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
|
|
@ -613,6 +714,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
shareBtn.addEventListener("click", async () => {
|
shareBtn.addEventListener("click", async () => {
|
||||||
const shareUrl = `${window.location.origin}/p/${postID}`;
|
const shareUrl = `${window.location.origin}/p/${postID}`;
|
||||||
const shareTitle = postData?.title || postData?.Title || "SocioWire";
|
const shareTitle = postData?.title || postData?.Title || "SocioWire";
|
||||||
|
trackEvent("post_share_click", { post_id: postID, context: "map" });
|
||||||
try {
|
try {
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
await navigator.share({ title: shareTitle, url: shareUrl });
|
await navigator.share({ title: shareTitle, url: shareUrl });
|
||||||
|
|
@ -649,6 +751,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
}
|
}
|
||||||
const body = (commentInput.value || "").trim();
|
const body = (commentInput.value || "").trim();
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
trackEvent("post_comment_submit", { post_id: postID, context: "map" });
|
||||||
commentBtn.disabled = true;
|
commentBtn.disabled = true;
|
||||||
const res = await createPostComment(postID, body);
|
const res = await createPostComment(postID, body);
|
||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
|
|
@ -712,6 +815,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
unmountMini: null,
|
unmountMini: null,
|
||||||
historyPushed,
|
historyPushed,
|
||||||
historyClosed: false,
|
historyClosed: false,
|
||||||
|
openedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -860,7 +964,8 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
|
|
||||||
const existing = map.__swCenteredOverlay;
|
const existing = map.__swCenteredOverlay;
|
||||||
if (existing && existing.postId === post?.id) {
|
if (existing && existing.postId === post?.id) {
|
||||||
closeCenteredOverlay(map);
|
if (existing.openedAt && Date.now() - existing.openedAt < 800) return;
|
||||||
|
closeCenteredOverlay(map, { force: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1011,7 +1116,8 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
|
|
||||||
const existing = map.__swCenteredOverlay;
|
const existing = map.__swCenteredOverlay;
|
||||||
if (existing && existing.postId === post?.id) {
|
if (existing && existing.postId === post?.id) {
|
||||||
closeCenteredOverlay(map);
|
if (existing.openedAt && Date.now() - existing.openedAt < 800) return;
|
||||||
|
closeCenteredOverlay(map, { force: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
|
||||||
import { getCoords, getViewFromMap, haversineKm } from "./mapGeo";
|
import { getCoords, getViewFromMap, haversineKm } from "./mapGeo";
|
||||||
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
|
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
|
||||||
import { prioritizePosts } from "./postPriority";
|
import { prioritizePosts } from "./postPriority";
|
||||||
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
function getId(p) {
|
function getId(p) {
|
||||||
return p?.id ?? p?._id ?? null;
|
return p?.id ?? p?._id ?? null;
|
||||||
|
|
@ -153,7 +154,9 @@ export function usePostsEngine({
|
||||||
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "", timeFilter: "", bounds: null });
|
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "", timeFilter: "", bounds: null });
|
||||||
const delayedFetchRef = useRef(null);
|
const delayedFetchRef = useRef(null);
|
||||||
const moveDebounceRef = useRef(null);
|
const moveDebounceRef = useRef(null);
|
||||||
|
const analyticsDebounceRef = useRef(0);
|
||||||
const initialLoadRef = useRef(true);
|
const initialLoadRef = useRef(true);
|
||||||
|
const initialFetchKickRef = useRef(false);
|
||||||
const autoWidenRef = useRef(false);
|
const autoWidenRef = useRef(false);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const clusterModeRef = useRef(false);
|
const clusterModeRef = useRef(false);
|
||||||
|
|
@ -182,6 +185,19 @@ export function usePostsEngine({
|
||||||
};
|
};
|
||||||
}, [mapReady, mapRef]);
|
}, [mapReady, mapRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapReady || initialFetchKickRef.current) return;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
initialFetchKickRef.current = true;
|
||||||
|
map.__swForceFetchAt = Date.now();
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
map.fire("moveend");
|
||||||
|
} catch {}
|
||||||
|
}, 120);
|
||||||
|
}, [mapReady, mapRef]);
|
||||||
|
|
||||||
const priorityOpts = {
|
const priorityOpts = {
|
||||||
maxFullCards: 12,
|
maxFullCards: 12,
|
||||||
clusterRadius: 0.002,
|
clusterRadius: 0.002,
|
||||||
|
|
@ -520,7 +536,7 @@ export function usePostsEngine({
|
||||||
(tf) => {
|
(tf) => {
|
||||||
const vAll = computeVisible(tf);
|
const vAll = computeVisible(tf);
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
let v = filterByBounds(vAll, map);
|
let v = (searchQuery || "").trim() ? vAll : filterByBounds(vAll, map);
|
||||||
if (v.length > MAX_VISIBLE_POSTS) {
|
if (v.length > MAX_VISIBLE_POSTS) {
|
||||||
const center = map?.getCenter?.();
|
const center = map?.getCenter?.();
|
||||||
v = prunePosts(v, [center?.lng, center?.lat], MAX_VISIBLE_POSTS);
|
v = prunePosts(v, [center?.lng, center?.lat], MAX_VISIBLE_POSTS);
|
||||||
|
|
@ -686,6 +702,17 @@ export function usePostsEngine({
|
||||||
// Smooth update: sync markers/clusters + wall without clearing everything
|
// Smooth update: sync markers/clusters + wall without clearing everything
|
||||||
refreshVisibleAndMarkers(timeFilter);
|
refreshVisibleAndMarkers(timeFilter);
|
||||||
const visible = computeVisible(timeFilter);
|
const visible = computeVisible(timeFilter);
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - analyticsDebounceRef.current > 4000) {
|
||||||
|
analyticsDebounceRef.current = now;
|
||||||
|
trackEvent("map_fetch", {
|
||||||
|
count: Array.isArray(newPosts) ? newPosts.length : 0,
|
||||||
|
radius_km: Math.round(radiusKm || 0),
|
||||||
|
zoom: typeof zoom === "number" ? Math.round(zoom * 10) / 10 : undefined,
|
||||||
|
filter: filterKey,
|
||||||
|
time_filter: timeFilter || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!autoWidenRef.current &&
|
!autoWidenRef.current &&
|
||||||
timeFilter &&
|
timeFilter &&
|
||||||
|
|
@ -744,13 +771,17 @@ export function usePostsEngine({
|
||||||
type: "posts",
|
type: "posts",
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
radius_km: viewParams?.radiusKm,
|
|
||||||
limit: 60,
|
limit: 60,
|
||||||
});
|
});
|
||||||
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);
|
allPostsRef.current = prunePosts(posts, [lon, lat], MAX_POOL_POSTS);
|
||||||
refreshVisibleAndMarkers(timeFilter);
|
refreshVisibleAndMarkers(timeFilter);
|
||||||
|
trackEvent("search_filter_apply", {
|
||||||
|
query_len: q.length,
|
||||||
|
results: posts.length,
|
||||||
|
context: "map",
|
||||||
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import {
|
||||||
recordPostShare,
|
recordPostShare,
|
||||||
togglePostLike,
|
togglePostLike,
|
||||||
updatePostVisibility,
|
updatePostVisibility,
|
||||||
|
fetchProfile,
|
||||||
} from "../../api/client";
|
} from "../../api/client";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
function isAuthed() {
|
function isAuthed() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -25,6 +27,7 @@ function requestAuth(message) {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
trackEvent("auth_required", { context: "wall", mode: "login" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(createdAt) {
|
function formatRelativeTime(createdAt) {
|
||||||
|
|
@ -103,6 +106,29 @@ function normalizeImageUrl(raw) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wallAvatarCache = new Map();
|
||||||
|
const wallAvatarInflight = new Map();
|
||||||
|
|
||||||
|
async function getAvatarForUser(name) {
|
||||||
|
const key = (name || "").toLowerCase().trim();
|
||||||
|
if (!key) return "";
|
||||||
|
if (wallAvatarCache.has(key)) return wallAvatarCache.get(key);
|
||||||
|
if (wallAvatarInflight.has(key)) return wallAvatarInflight.get(key);
|
||||||
|
const p = (async () => {
|
||||||
|
const profile = await fetchProfile(name);
|
||||||
|
const url = (profile && profile.avatar_url) ? String(profile.avatar_url) : "";
|
||||||
|
wallAvatarCache.set(key, url);
|
||||||
|
wallAvatarInflight.delete(key);
|
||||||
|
return url;
|
||||||
|
})().catch(() => {
|
||||||
|
wallAvatarCache.set(key, "");
|
||||||
|
wallAvatarInflight.delete(key);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
wallAvatarInflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PostCard({ post, selected, onSelect }) {
|
export default function PostCard({ post, selected, onSelect }) {
|
||||||
const [liked, setLiked] = useState(false);
|
const [liked, setLiked] = useState(false);
|
||||||
const [authed, setAuthed] = useState(isAuthed());
|
const [authed, setAuthed] = useState(isAuthed());
|
||||||
|
|
@ -168,6 +194,7 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
const author = (post?.author || post?.Author || "anon").toString();
|
const author = (post?.author || post?.Author || "anon").toString();
|
||||||
const isAuthor =
|
const isAuthor =
|
||||||
username && author && author.toLowerCase() === username.toLowerCase();
|
username && author && author.toLowerCase() === username.toLowerCase();
|
||||||
|
const [authorAvatar, setAuthorAvatar] = useState("");
|
||||||
const sub = (post?.sub_category || post?.subCategory || "").toString();
|
const sub = (post?.sub_category || post?.subCategory || "").toString();
|
||||||
const timeAgo = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt);
|
const timeAgo = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt);
|
||||||
const cat = categoryLabel(post);
|
const cat = categoryLabel(post);
|
||||||
|
|
@ -178,13 +205,34 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
return parts.join(" • ");
|
return parts.join(" • ");
|
||||||
}, [sub, post?.region]);
|
}, [sub, post?.region]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const who = (author || "").trim();
|
||||||
|
if (!who) {
|
||||||
|
setAuthorAvatar("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getAvatarForUser(who).then((url) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAuthorAvatar(url || "");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [author]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={"post-card sw-wall-card" + (selected ? " selected" : "")}
|
className={"post-card sw-wall-card" + (selected ? " selected" : "")}
|
||||||
onClick={() => onSelect && onSelect(post)}
|
onClick={() => {
|
||||||
|
trackEvent("post_open", { post_id: postId, context: "wall" });
|
||||||
|
onSelect && onSelect(post);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sw-wall-head">
|
<div className="sw-wall-head">
|
||||||
<div className="sw-wall-avatar">{author ? author[0]?.toUpperCase() : "U"}</div>
|
<div className="sw-wall-avatar">
|
||||||
|
{authorAvatar ? <img src={authorAvatar} alt={author} /> : (author ? author[0]?.toUpperCase() : "U")}
|
||||||
|
</div>
|
||||||
<div className="sw-wall-meta">
|
<div className="sw-wall-meta">
|
||||||
<div className="sw-wall-author">{author}</div>
|
<div className="sw-wall-author">{author}</div>
|
||||||
<div className="sw-wall-time">{timeAgo}</div>
|
<div className="sw-wall-time">{timeAgo}</div>
|
||||||
|
|
@ -230,6 +278,7 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
requestAuth("Please sign in or create a free account to like this post.");
|
requestAuth("Please sign in or create a free account to like this post.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
trackEvent("post_like_click", { post_id: postId, context: "wall" });
|
||||||
const res = await togglePostLike(postId, !liked);
|
const res = await togglePostLike(postId, !liked);
|
||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
setLiked(!!res.liked);
|
setLiked(!!res.liked);
|
||||||
|
|
@ -254,6 +303,7 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
requestAuth("Please sign in or create a free account to comment.");
|
requestAuth("Please sign in or create a free account to comment.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
trackEvent("post_comment_open", { post_id: postId, context: "wall" });
|
||||||
if (onSelect) onSelect(post);
|
if (onSelect) onSelect(post);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -265,6 +315,7 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const shareUrl = `${window.location.origin}/p/${postId}`;
|
const shareUrl = `${window.location.origin}/p/${postId}`;
|
||||||
|
trackEvent("post_share_click", { post_id: postId, context: "wall" });
|
||||||
try {
|
try {
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
await navigator.share({ title: title || "SocioWire", url: shareUrl });
|
await navigator.share({ title: title || "SocioWire", url: shareUrl });
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { recoSearch } from "../../services/recoSearch";
|
import { recoSearch } from "../../services/recoSearch";
|
||||||
import "../../styles/smartSearchBar.css";
|
import "../../styles/smartSearchBar.css";
|
||||||
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
||||||
function defaultZoomForKind(kind) {
|
function defaultZoomForKind(kind) {
|
||||||
const k = (kind || "").toLowerCase();
|
const k = (kind || "").toLowerCase();
|
||||||
|
|
@ -144,6 +145,12 @@ export default function SmartSearchBar({
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQ(it.title || q);
|
setQ(it.title || q);
|
||||||
|
|
||||||
|
trackEvent("search_pick", {
|
||||||
|
kind: it.kind || "",
|
||||||
|
reason,
|
||||||
|
query_len: (q || "").length,
|
||||||
|
title_len: (it.title || "").length,
|
||||||
|
});
|
||||||
onPick && onPick(it, { zoom, reason });
|
onPick && onPick(it, { zoom, reason });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -162,6 +169,7 @@ export default function SmartSearchBar({
|
||||||
// Always filter on Enter; do not auto-pick dropdown items.
|
// Always filter on Enter; do not auto-pick dropdown items.
|
||||||
skipSearchRef.current = true;
|
skipSearchRef.current = true;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
trackEvent("search_submit", { query_len: q.trim().length });
|
||||||
onSearch && onSearch(q.trim());
|
onSearch && onSearch(q.trim());
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
|
|
@ -178,6 +186,7 @@ export default function SmartSearchBar({
|
||||||
setQ("");
|
setQ("");
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
trackEvent("search_clear");
|
||||||
onSearch && onSearch(""); // Clear filters
|
onSearch && onSearch(""); // Clear filters
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -196,6 +205,11 @@ export default function SmartSearchBar({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
|
if (q.trim()) {
|
||||||
|
setQ("");
|
||||||
|
setItems([]);
|
||||||
|
onSearch && onSearch("");
|
||||||
|
}
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
// Si pas encore cherché et query vide, charger suggestions populaires
|
// Si pas encore cherché et query vide, charger suggestions populaires
|
||||||
if (!hasSearched && !q.trim()) {
|
if (!hasSearched && !q.trim()) {
|
||||||
|
|
@ -222,20 +236,14 @@ export default function SmartSearchBar({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="sw-search__icons" aria-label="Search actions">
|
<div className="sw-search__icons" aria-label="Search actions">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-search__iconBtn"
|
|
||||||
title={rightTitle}
|
|
||||||
onClick={() => onPlaceTourWire && onPlaceTourWire()}
|
|
||||||
>
|
|
||||||
<IconPlus />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sw-search__iconBtn"
|
className="sw-search__iconBtn"
|
||||||
title="My spot"
|
title="My spot"
|
||||||
onClick={() => onMySpot && onMySpot()}
|
onClick={() => {
|
||||||
|
trackEvent("search_my_spot");
|
||||||
|
onMySpot && onMySpot();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<IconPin />
|
<IconPin />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,21 @@ body[data-theme="light"] .auth-form input {
|
||||||
border-color: rgba(148, 163, 184, 0.9);
|
border-color: rgba(148, 163, 184, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-link{
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255,255,255,.75);
|
||||||
|
text-align: left;
|
||||||
|
font-size: .78rem;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.auth-link:hover{
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-full { width:100%; margin-top:.4rem; }
|
.btn-full { width:100%; margin-top:.4rem; }
|
||||||
|
|
||||||
.auth-divider{
|
.auth-divider{
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,66 @@
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.island-header-row{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-header-avatar{
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.6);
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-header-avatar img{
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-header-text{
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-tabbar{
|
||||||
|
margin-top: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-tab{
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #e2e8f0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-tab.is-active{
|
||||||
|
background: rgba(56, 189, 248, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 10px rgba(56, 189, 248, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.island-viewer-header h2 {
|
.island-viewer-header h2 {
|
||||||
color: #f1f5f9;
|
color: #f1f5f9;
|
||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
|
|
@ -139,10 +199,51 @@
|
||||||
background: rgba(15, 23, 42, 0.6);
|
background: rgba(15, 23, 42, 0.6);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.island-posts-panel{
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-post-row{
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(2, 6, 23, 0.4);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-post-title{
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-post-snippet{
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(226, 232, 240, 0.8);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-post-meta{
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: rgba(148, 163, 184, 0.85);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-posts-empty{
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(226, 232, 240, 0.75);
|
||||||
|
}
|
||||||
.island-profile-avatar {
|
.island-profile-avatar {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
border-radius: 999px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(56, 189, 248, 0.6);
|
border: 1px solid rgba(56, 189, 248, 0.6);
|
||||||
background: rgba(56, 189, 248, 0.2);
|
background: rgba(56, 189, 248, 0.2);
|
||||||
|
|
@ -160,6 +261,16 @@
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.island-viewer-canvas-wrap{
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-tabbar-overlay{
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 16px;
|
||||||
|
}
|
||||||
.island-profile-meta {
|
.island-profile-meta {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -175,6 +286,16 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.island-profile-error{
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-profile-info{
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
.island-profile-row {
|
.island-profile-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,26 @@
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sw-user-avatar{
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(56,189,248,0.18);
|
||||||
|
border: 1px solid rgba(56,189,248,0.35);
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-weight: 800;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sw-user-avatar img{
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.sw-chat-line{ font-size: 10px; color:#c7e3ff; }
|
.sw-chat-line{ font-size: 10px; color:#c7e3ff; }
|
||||||
|
|
||||||
.sw-chat-bubble{
|
.sw-chat-bubble{
|
||||||
|
|
@ -246,6 +266,23 @@
|
||||||
background: rgba(15,23,42,0.55);
|
background: rgba(15,23,42,0.55);
|
||||||
border:1px solid rgba(56,189,248,0.18);
|
border:1px solid rgba(56,189,248,0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sw-chat-head{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.sw-chat-avatar{
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-inline-avatar{
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
.sw-chat-bubble::before{
|
.sw-chat-bubble::before{
|
||||||
content:"";
|
content:"";
|
||||||
position:absolute;
|
position:absolute;
|
||||||
|
|
@ -365,6 +402,7 @@ body[data-theme="light"] .sw-chat-bubble::before{
|
||||||
height: 640px !important;
|
height: 640px !important;
|
||||||
max-height: 640px !important;
|
max-height: 640px !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override hidden overflow for centered modal to allow scroll */
|
/* Override hidden overflow for centered modal to allow scroll */
|
||||||
|
|
@ -379,12 +417,16 @@ body[data-theme="light"] .sw-chat-bubble::before{
|
||||||
|
|
||||||
.sw-template-full-wrap{
|
.sw-template-full-wrap{
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 440px !important;
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
height: 640px !important;
|
height: 640px !important;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-template-full-wrap > *{
|
.sw-template-full-wrap > *{
|
||||||
display: block !important;
|
display: block !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Expanded header */
|
/* Expanded header */
|
||||||
|
|
@ -513,6 +555,19 @@ body[data-theme="light"] .sw-chat-bubble::before{
|
||||||
flex-wrap:wrap;
|
flex-wrap:wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sw-modal-author-avatar{
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(56,189,248,0.35);
|
||||||
|
background: rgba(56,189,248,0.18);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.sw-modal-x{
|
.sw-modal-x{
|
||||||
border:none;
|
border:none;
|
||||||
border-radius:999px;
|
border-radius:999px;
|
||||||
|
|
@ -530,7 +585,9 @@ body[data-theme="light"] .sw-chat-bubble::before{
|
||||||
width:100%;
|
width:100%;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
flex: 0 0 auto;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
border: 3px solid #000 !important; /* contour plus épais */
|
border: 3px solid #000 !important; /* contour plus épais */
|
||||||
|
|
@ -543,6 +600,8 @@ body[data-theme="light"] .sw-chat-bubble::before{
|
||||||
display:flex;
|
display:flex;
|
||||||
gap:10px;
|
gap:10px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-truth-rail{
|
.sw-truth-rail{
|
||||||
|
|
@ -646,6 +705,7 @@ body[data-theme="light"] .sw-chat-bubble::before{
|
||||||
width:100%;
|
width:100%;
|
||||||
height:100%;
|
height:100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
object-position: center center;
|
||||||
display:block;
|
display:block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
.sw-wall-avatar{
|
.sw-wall-avatar{
|
||||||
width:34px;
|
width:34px;
|
||||||
height:34px;
|
height:34px;
|
||||||
border-radius:999px;
|
border-radius:10px;
|
||||||
background: rgba(56,189,248,0.22);
|
background: rgba(56,189,248,0.22);
|
||||||
border:1px solid rgba(56,189,248,0.55);
|
border:1px solid rgba(56,189,248,0.55);
|
||||||
color:#e2f2ff;
|
color:#e2f2ff;
|
||||||
|
|
@ -82,6 +82,13 @@
|
||||||
display:flex;
|
display:flex;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
justify-content:center;
|
justify-content:center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sw-wall-avatar img{
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
.sw-wall-meta{ flex:1; min-width:0; }
|
.sw-wall-meta{ flex:1; min-width:0; }
|
||||||
.sw-wall-author{
|
.sw-wall-author{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,23 @@
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sw-search-row{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-search__side{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--sw-panel-bg, rgba(15, 18, 28, 0.62));
|
||||||
|
border: 1px solid var(--sw-panel-border, rgba(255, 255, 255, 0.10));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
.sw-search__bar {
|
.sw-search__bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.theme-switch{ display:flex; gap:.28rem; }
|
.theme-switch{ display:flex; gap:.28rem; }
|
||||||
.theme-dot{
|
.theme-dot{
|
||||||
width:22px; height:22px; border-radius:999px;
|
width:22px; height:22px; border-radius:999px;
|
||||||
|
|
@ -482,10 +483,20 @@ body[data-theme="light"] .user-avatar{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sw-userchip-btn{
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-userchip-btn:hover{
|
||||||
|
box-shadow: 0 6px 16px rgba(56,189,248,.35);
|
||||||
|
border-color: rgba(56,189,248,.7);
|
||||||
|
}
|
||||||
|
|
||||||
.sw-avatar{
|
.sw-avatar{
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: 999px;
|
border-radius: 8px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: rgba(56,189,248,0.18);
|
background: rgba(56,189,248,0.18);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
export function trackEvent(name, params = {}) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (typeof window.gtag !== "function") return;
|
||||||
|
window.gtag("event", name, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackPageView(path, title) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const pagePath = path || window.location.pathname || "/";
|
||||||
|
const pageTitle = title || document.title || "SocioWire";
|
||||||
|
trackEvent("page_view", { page_path: pagePath, page_title: pageTitle });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackSection(section, params = {}) {
|
||||||
|
if (!section) return;
|
||||||
|
trackEvent("section_view", { section, ...params });
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue