Enhance analytics, map overlay, and UI fixes

This commit is contained in:
Your Name 2025-12-26 19:21:04 -05:00
parent 6417194d90
commit b8ac7d2010
18 changed files with 1034 additions and 150 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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 {

View File

@ -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.");

View File

@ -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,101 +183,161 @@ 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">
<h2>{island.display_name || `${island.username}'s Island`}</h2> <div className="island-header-row">
<p>{island.bio || 'Welcome to my island'}</p> <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>
<p>{island.bio || 'Welcome to my island'}</p>
</div>
</div>
</div> </div>
<div className="island-profile-panel"> {activeTab === "profile" ? (
<div className="island-profile-avatar"> <div className="island-profile-panel">
{island.avatar_url ? ( <div className="island-profile-avatar">
<img src={island.avatar_url} alt={island.username} /> {avatarSrc ? (
<img src={avatarSrc} alt={island.username} />
) : (
<div className="island-profile-letter">
{(island.username || "U")[0].toUpperCase()}
</div>
)}
</div>
<div className="island-profile-meta">
<div className="island-profile-name">@{island.username}</div>
{isSelf ? (
<div className="island-profile-actions">
<div className="island-profile-row">
<label>Photo visibility</label>
<select
value={avatarVisibility}
onChange={(e) => {
const next = e.target.value;
setAvatarVisibility(next);
if (next !== "group") setAvatarGroupId("");
if (next !== "custom") setAvatarUsers("");
}}
>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="group">Group</option>
<option value="custom">Custom</option>
</select>
</div>
{avatarVisibility === "group" ? (
<input
className="island-profile-input"
placeholder="Group ID"
value={avatarGroupId}
onChange={(e) => setAvatarGroupId(e.target.value)}
/>
) : null}
{avatarVisibility === "custom" ? (
<input
className="island-profile-input"
placeholder="Usernames (comma separated)"
value={avatarUsers}
onChange={(e) => setAvatarUsers(e.target.value)}
/>
) : null}
<button
type="button"
className="island-profile-btn"
disabled={uploading}
onClick={() => fileRef.current && fileRef.current.click()}
>
{uploading ? "Uploading..." : "Change photo"}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
setUploading(true);
setUploadError("");
setUploadInfo("");
const users = avatarUsers
.split(",")
.map((u) => u.trim())
.filter(Boolean);
const res = await uploadProfileAvatar(file, {
visibility: avatarVisibility,
group_id: avatarGroupId.trim(),
users,
});
if (res && res.ok && res.avatar_url) {
setAvatarSrc(res.avatar_url);
if (setAvatarUrl) setAvatarUrl(res.avatar_url);
setUploadInfo("Photo updated.");
} else {
setUploadError(res?.text || "Upload failed.");
}
setUploading(false);
}}
/>
{uploadError ? <div className="island-profile-error">{uploadError}</div> : null}
{uploadInfo ? <div className="island-profile-info">{uploadInfo}</div> : null}
</div>
) : null}
</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-profile-letter"> <div className="island-posts-empty">No posts yet.</div>
{(island.username || "U")[0].toUpperCase()}
</div>
)} )}
</div> </div>
<div className="island-profile-meta"> )}
<div className="island-profile-name">@{island.username}</div>
{isSelf ? ( <div className="island-viewer-canvas-wrap">
<div className="island-profile-actions"> <div
<div className="island-profile-row"> ref={containerRef}
<label>Photo visibility</label> className="island-viewer-canvas"
<select style={{ width: '100%', height: '600px' }}
value={avatarVisibility} />
onChange={(e) => { <div className="island-tabbar island-tabbar-overlay">
const next = e.target.value; <button
setAvatarVisibility(next); type="button"
if (next !== "group") setAvatarGroupId(""); className={"island-tab" + (activeTab === "posts" ? " is-active" : "")}
if (next !== "custom") setAvatarUsers(""); onClick={() => setActiveTab("posts")}
}} title="Posts"
> >
<option value="public">Public</option> <i className="fa-solid fa-newspaper"></i>
<option value="private">Private</option> </button>
<option value="group">Group</option> <button
<option value="custom">Custom</option> type="button"
</select> className={"island-tab" + (activeTab === "profile" ? " is-active" : "")}
</div> onClick={() => setActiveTab("profile")}
{avatarVisibility === "group" ? ( title="Profile"
<input >
className="island-profile-input" <i className="fa-solid fa-user-gear"></i>
placeholder="Group ID" </button>
value={avatarGroupId}
onChange={(e) => setAvatarGroupId(e.target.value)}
/>
) : null}
{avatarVisibility === "custom" ? (
<input
className="island-profile-input"
placeholder="Usernames (comma separated)"
value={avatarUsers}
onChange={(e) => setAvatarUsers(e.target.value)}
/>
) : null}
<button
type="button"
className="island-profile-btn"
disabled={uploading}
onClick={() => fileRef.current && fileRef.current.click()}
>
{uploading ? "Uploading..." : "Change photo"}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
setUploading(true);
const users = avatarUsers
.split(",")
.map((u) => u.trim())
.filter(Boolean);
const res = await uploadProfileAvatar(file, {
visibility: avatarVisibility,
group_id: avatarGroupId.trim(),
users,
});
if (res && res.ok && res.avatar_url) {
island.avatar_url = res.avatar_url;
if (setAvatarUrl) setAvatarUrl(res.avatar_url);
}
setUploading(false);
}}
/>
</div>
) : null}
</div> </div>
</div> </div>
<div
ref={containerRef}
className="island-viewer-canvas"
style={{ width: '100%', height: '600px' }}
/>
<div className="island-viewer-stats"> <div className="island-viewer-stats">
<div className="stat"> <div className="stat">
<span className="stat-value">{island.content_count || 0}</span> <span className="stat-value">{island.content_count || 0}</span>

View File

@ -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}
</> </>
); );
} }

View File

@ -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,13 +751,33 @@ 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">
<SmartSearchBar <div className="sw-search-row">
placeholder="Search cities, places, posts..." <SmartSearchBar
onPick={handlePickSuggestion} placeholder="Search cities, places, posts..."
onSearch={handleSearch} onPick={handlePickSuggestion}
onMySpot={handleFlyToMe} onSearch={handleSearch}
onPlaceTourWire={handleOpenCreate} onMySpot={handleFlyToMe}
/> 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>

View File

@ -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-meta">${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}</div> <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>
<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;
} }

View File

@ -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 {}
} }

View File

@ -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 });

View File

@ -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>

View File

@ -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{

View File

@ -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;

View File

@ -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;
} }

View File

@ -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{

View File

@ -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;

View File

@ -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);

17
src/utils/analytics.js Normal file
View File

@ -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 });
}