diff --git a/public/silent-check-sso.html b/public/silent-check-sso.html new file mode 100644 index 0000000..eae56e9 --- /dev/null +++ b/public/silent-check-sso.html @@ -0,0 +1,12 @@ + + + + + Silent SSO + + + + + diff --git a/src/App.jsx b/src/App.jsx index 43515cd..c280b31 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,6 +3,7 @@ import TopBar from "./components/Layout/TopBar"; import PostList from "./components/Posts/PostList"; import IslandUniverse from "./components/Island/IslandUniverse"; import IslandViewer from "./components/Island/IslandViewer"; +import { fetchIslandByUsername } from "./api/client"; const MapView = React.lazy(() => import("./components/Map/MapView")); @@ -110,12 +111,25 @@ export default function App() { 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 (
@@ -128,6 +142,7 @@ export default function App() { onSelectPost={setSelectedPost} selectedIsland={selectedIsland} onSelectIsland={setSelectedIsland} + onOpenIslands={handleOpenUniverse} />
diff --git a/src/api/client.js b/src/api/client.js index c8a8918..ca35a12 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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) { if (!Array.isArray(posts) || posts.length === 0) return posts; const ids = []; @@ -53,6 +60,12 @@ async function resolveAssetRefsInPosts(posts) { const resolved = await resolveAssetURLs(uniq); return posts.map((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) => { const v = clone[k]; if (isAssetRef(v)) { @@ -91,6 +104,7 @@ export async function fetchPosts(filters = {}) { if (filters.category) params.set("category", filters.category); if (filters.subCategory) params.set("sub_category", filters.subCategory); 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.lon === "number") params.set("lon", String(filters.lon)); if (typeof filters.radiusKm === "number") { @@ -341,6 +355,9 @@ export async function fetchUnifiedSearch(query, params = {}) { } const data = await res.json(); if (data && typeof data === "object" && !Array.isArray(data)) { + if (Array.isArray(data.posts)) { + data.posts = await resolveAssetRefsInPosts(data.posts); + } return data; } return { posts: [], profiles: [], places: [] }; @@ -373,9 +390,8 @@ export async function pollNewPosts(params = {}) { return { posts: [], count: 0, max_id: 0 }; } const data = await res.json(); - if (data && data.avatar_url && isAssetRef(data.avatar_url)) { - const resolved = await resolveAssetRefsInIslands([data]); - return resolved[0] || data; + if (data && Array.isArray(data.posts)) { + data.posts = await resolveAssetRefsInPosts(data.posts); } return data; } catch (err) { @@ -484,13 +500,63 @@ export async function uploadProfileAvatar(file, opts = {}) { const text = await res.text().catch(() => ""); 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) { console.warn("uploadProfileAvatar error:", err); 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) { if (!postId) return { ok: false }; try { diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index dd4877c..9b5fc83 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -46,6 +46,10 @@ function apiBase() { return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, ""); } +function silentSsoRedirectUri() { + return window.location.origin + "/silent-check-sso.html"; +} + async function apiFetch(path, opts = {}) { const url = apiBase() + path; const res = await fetch(url, { @@ -131,6 +135,7 @@ async function me(accessToken) { status: res.status, json, text, + username: (u && (u.username || u.user || u.preferred_username)) || "", emailVerified, avatarUrl: u && typeof u.avatar_url === "string" ? u.avatar_url : "", }; @@ -224,6 +229,9 @@ export function AuthProvider({ children }) { if (mr.avatarUrl) { setAvatarUrl(mr.avatarUrl); } + if (mr.username) { + setUser({ username: mr.username }); + } if (typeof verified !== "boolean") { const tv = tokenEmailVerified(at); @@ -326,6 +334,13 @@ export function AuthProvider({ children }) { setLastInfo(""); const saved = loadAuth(); if (!saved?.access_token) { + if (keycloakEnabled()) { + const ok = await trySilentKeycloakSession(); + if (ok) { + scheduleAutoRefresh(); + return; + } + } setAnon(""); return; } @@ -384,10 +399,17 @@ export function AuthProvider({ children }) { return { ok: true }; } - function logout() { + async function logout() { if (refreshTimer.current) clearInterval(refreshTimer.current); refreshTimer.current = null; + const redirectUri = window.location.origin + window.location.pathname; + const doSsoLogout = keycloakEnabled(); setAnon(""); + if (!doSsoLogout) return; + try { + const kc = getKeycloak(); + await kc.logout({ redirectUri }); + } catch {} } async function resendVerificationEmail() { @@ -487,6 +509,50 @@ export function AuthProvider({ children }) { } 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) { if (!keycloakEnabled()) { setAnon("SSO is not configured."); diff --git a/src/components/Island/IslandViewer.jsx b/src/components/Island/IslandViewer.jsx index aa2862f..940298b 100644 --- a/src/components/Island/IslandViewer.jsx +++ b/src/components/Island/IslandViewer.jsx @@ -2,8 +2,9 @@ import React, { useEffect, useRef, useState } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import '../../styles/island.css'; -import { uploadProfileAvatar } from '../../api/client'; +import { fetchPosts, uploadProfileAvatar } from '../../api/client'; import { useAuth } from '../../auth/AuthContext'; +import { trackEvent } from "../../utils/analytics"; /** * IslandViewer: 2.5D immersive island visualization using MapLibre @@ -16,14 +17,21 @@ export default function IslandViewer({ island, onClose }) { const fileRef = useRef(null); const { authenticated, username, setAvatarUrl } = useAuth(); const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(""); + const [uploadInfo, setUploadInfo] = useState(""); const [avatarVisibility, setAvatarVisibility] = useState("public"); const [avatarGroupId, setAvatarGroupId] = useState(""); const [avatarUsers, setAvatarUsers] = useState(""); + const [avatarSrc, setAvatarSrc] = useState(""); + const [activeTab, setActiveTab] = useState("profile"); + const [userPosts, setUserPosts] = useState([]); + const [postsLoading, setPostsLoading] = useState(false); useEffect(() => { if (!island || !containerRef.current) return; console.log('[IslandViewer] Rendering island with MapLibre:', island.username, island.seed); + trackEvent("island_open", { username: island.username || "" }); // Center of island in virtual coordinates const centerLng = 0; @@ -138,6 +146,32 @@ export default function IslandViewer({ island, onClose }) { }; }, [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; const isSelf = authenticated && username && island.username && @@ -149,101 +183,161 @@ export default function IslandViewer({ island, onClose }) {
-

{island.display_name || `${island.username}'s Island`}

-

{island.bio || 'Welcome to my island'}

+
+
+ {avatarSrc ? ( + {island.username} + ) : ( + {(island.username || "U")[0].toUpperCase()} + )} +
+
+

{island.display_name || `${island.username}'s Island`}

+

{island.bio || 'Welcome to my island'}

+
+
-
-
- {island.avatar_url ? ( - {island.username} + {activeTab === "profile" ? ( +
+
+ {avatarSrc ? ( + {island.username} + ) : ( +
+ {(island.username || "U")[0].toUpperCase()} +
+ )} +
+
+
@{island.username}
+ {isSelf ? ( +
+
+ + +
+ {avatarVisibility === "group" ? ( + setAvatarGroupId(e.target.value)} + /> + ) : null} + {avatarVisibility === "custom" ? ( + setAvatarUsers(e.target.value)} + /> + ) : null} + + { + 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 ?
{uploadError}
: null} + {uploadInfo ?
{uploadInfo}
: null} +
+ ) : null} +
+
+ ) : ( +
+ {postsLoading ? ( +
Loading posts...
+ ) : userPosts.length ? ( + userPosts.map((post) => ( +
+
{post.title || "Untitled"}
+ {post.snippet ? ( +
{post.snippet}
+ ) : null} +
+ {post.created_at ? post.created_at.slice(0, 10) : ""} +
+
+ )) ) : ( -
- {(island.username || "U")[0].toUpperCase()} -
+
No posts yet.
)}
-
-
@{island.username}
- {isSelf ? ( -
-
- - -
- {avatarVisibility === "group" ? ( - setAvatarGroupId(e.target.value)} - /> - ) : null} - {avatarVisibility === "custom" ? ( - setAvatarUsers(e.target.value)} - /> - ) : null} - - { - 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); - }} - /> -
- ) : null} + )} + +
+
+
+ +
-
-
{island.content_count || 0} diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index 6eca19d..ab3c740 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -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 { registerUser } from "../../api/client"; +import { registerUser, updateProfileUsername } from "../../api/client"; import AuthToast from "../Auth/AuthToast"; import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall'; +import { trackEvent } from "../../utils/analytics"; const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; const LAST_SIGNUP_KEY = "sociowire:lastSignup"; @@ -53,13 +54,14 @@ function parseMergedParams() { return { qs, hs, get, hash }; } -export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }) { +export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) { const { loading, authenticated, username, login, logout, + restore, loginWithProvider, ssoEnabled, avatarUrl, @@ -70,8 +72,20 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } clearInfo, } = useAuth(); + const displayUsername = (() => { + const base = (username || "user").toString(); + if (base.length <= 9) return base; + return base.slice(0, 9) + ".."; + })(); + const [showAuth, setShowAuth] = useState(false); const [mode, setMode] = useState("login"); + const [showUsernameModal, setShowUsernameModal] = useState(false); + 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 [loginPass, setLoginPass] = useState(""); @@ -87,10 +101,23 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } const [showInstallBtn, setShowInstallBtn] = useState(false); 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) => { setMode(nextMode); setShowAuth(true); setSignupError(""); + trackEvent("auth_modal_open", { mode: nextMode || "login" }); }; const closeModal = () => { @@ -100,10 +127,77 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } 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(() => { if (authenticated) setShowAuth(false); }, [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(() => { const handler = (e) => { const detail = e?.detail || {}; @@ -212,7 +306,9 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } const handleLoginSubmit = async (e) => { 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) => { @@ -227,6 +323,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } try { setSignupLoading(true); + trackEvent("signup_submit"); const u = regUser.trim(); const em = regEmail.trim(); @@ -244,6 +341,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } } catch {} setSignupInfo("✅ Account created (" + em + "). Check your emails (spam too), then log in."); + trackEvent("signup_success"); setMode("login"); setLoginUser(u || em); setLoginPass(""); @@ -316,15 +414,20 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } {/* User info */}
{authenticated ? ( -
+
+ ) : (
@@ -336,6 +439,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
)} +
{/* Action buttons - separate section */} @@ -352,21 +456,18 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } )} - {/* Islands Universe button */} - - {/* Login/Logout button */} @@ -446,7 +554,10 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } type="button" className="btn-oauth btn-oauth-google" disabled={loading} - onClick={() => loginWithProvider && loginWithProvider("google")} + onClick={() => { + trackEvent("login_provider_click", { provider: "google" }); + loginWithProvider && loginWithProvider("google"); + }} > Continue with Google @@ -500,6 +611,35 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
) : null} + + {showUsernameModal ? ( +
{}}> +
e.stopPropagation()}> +
+
Choose username
+
+
+ Pick a public username to complete your profile. +
+ {usernameError ?
{usernameError}
: null} + {usernameInfo ?
{usernameInfo}
: null} +
+ + +
+
+
+ ) : null} ); } diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index d9c028a..f659c09 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -21,6 +21,7 @@ import TimeFilterButtons from "../Filters/TimeFilterButtons"; import SmartSearchBar from "../Search/SmartSearchBar"; import UserProfile from "../Profile/UserProfile"; import { fetchPostById } from "../../api/client"; +import { trackEvent } from "../../utils/analytics"; export default function MapView({ theme = "dark", @@ -29,6 +30,7 @@ export default function MapView({ onSelectPost, selectedIsland, onSelectIsland, + onOpenIslands, }) { const { authenticated, username, token, needsEmailVerification } = useAuth(); @@ -216,6 +218,18 @@ export default function MapView({ } }, [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(() => { if (queryPostRef.current) return; queryPostRef.current = true; @@ -348,10 +362,11 @@ export default function MapView({ setTimeFilter("PAST"); // Show all time periods const raw = item.raw || item; + const postId = raw?.post_id ?? raw?.id ?? item?.id ?? 0; if (raw && typeof raw === "object") { const lat = typeof raw.lat === "number" ? raw.lat : coords[1]; const lon = typeof raw.lon === "number" ? raw.lon : coords[0]; - handleIncomingPost({ + const builtPost = { id: raw.post_id ?? raw.id ?? 0, title: raw.title || item.title || "Post", snippet: raw.snippet || raw.summary || "", @@ -364,7 +379,19 @@ export default function MapView({ published_at: raw.published_at || "", created_at: raw.published_at || "", url_hash: raw.url_hash || "", - }); + }; + handleIncomingPost(builtPost); + if (postId > 0) { + fetchPostById(postId).then((full) => { + if (full) { + openOverlayWhenReady(full, { fullScreen: true }); + } else { + openOverlayWhenReady(builtPost, { fullScreen: true }); + } + }); + } else { + openOverlayWhenReady(builtPost, { fullScreen: true }); + } } } else if (kind === 'city' || kind === 'region' || kind === 'place' || kind === 'poi') { // Place picks should move the map without forcing a text filter. @@ -724,13 +751,33 @@ export default function MapView({ {/* TOP: Search avec suggestions intelligentes */}
- +
+ +
+ + +
+
diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index faf1500..c2526b0 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -17,7 +17,60 @@ import { fetchPostComments, createPostComment, fetchPostLikeState, + fetchProfile, } 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) { if (markersRef.current) { @@ -90,6 +143,7 @@ function requestAuth(message, mode = "login") { }) ); } catch {} + trackEvent("auth_required", { context: "map", mode: mode || "login" }); } function applyPostDetails(modalWrap, post) { @@ -124,10 +178,12 @@ function applyPostDetails(modalWrap, post) { const metaLeft = author ? `by ${author}` : ""; const metaRight = sub ? sub : ""; meta.innerHTML = ` + ${author ? `
` : ""} ${metaLeft ? `${escapeHtml(metaLeft)}` : ""} ${metaRight ? `• ${escapeHtml(metaRight)}` : ""} • ${escapeHtml(relTime)} `; + hydrateAvatars(modalWrap); } const cta = modalWrap.querySelector(".sw-cta-primary"); @@ -165,12 +221,16 @@ function renderComments(container, items) { const ts = (c?.created_at || "").toString().trim(); return `
-
${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}
+
+
+
${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}
+
${escapeHtml(body)}
`; }) .join(""); + hydrateAvatars(container); } function mountCard(container, spec, data, theme, post) { @@ -327,9 +387,10 @@ function closeCenteredOverlay(map, opts = {}) { try { const ov = map?.__swCenteredOverlay; if (!ov) return; + if (!opts.force && ov.openedAt && Date.now() - ov.openedAt < 700) return; if (ov.closing) return; ov.closing = true; - const skipHistory = !!opts.skipHistory; + const skipHistory = !!opts.skipHistory || opts.reason === "switch"; if (ov.onKey) window.removeEventListener("keydown", ov.onKey); 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 }) { if (!map) return; - closeCenteredOverlay(map); + closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true }); let postData = { ...(post || {}) }; 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 shareCount = readCount(postData, ["share_count", "shares", "shareCount"]); 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 const backdrop = document.createElement("div"); @@ -413,6 +481,8 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
${escapeHtml(cat)}
+ ${author ? `
` : ""} + ${author ? `
` : ""} ${metaLeft ? `${escapeHtml(metaLeft)}` : ""} ${metaRight ? `• ${escapeHtml(metaRight)}` : ""} • ${escapeHtml(relTime)} @@ -483,6 +553,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { backdrop.appendChild(modalWrap); document.body.appendChild(backdrop); + hydrateAvatars(modalWrap); requestAnimationFrame(() => { try { backdrop.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 {} - // outside click closes - backdrop.addEventListener("click", () => closeCenteredOverlay(map)); + // outside click closes (ignore drag/scroll on 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 modalWrap.addEventListener("click", (e) => e.stopPropagation()); // close button 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 const onKey = (e) => { - if (e.key === "Escape") closeCenteredOverlay(map); + if (e.key === "Escape") closeCenteredOverlay(map, { force: true }); }; window.addEventListener("keydown", onKey); @@ -538,7 +638,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { const ov = map.__swCenteredOverlay; if (ov && ov.postId === postID) { ov.historyClosed = true; - closeCenteredOverlay(map, { skipHistory: true }); + closeCenteredOverlay(map, { skipHistory: true, force: true }); } }; 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."); return; } + trackEvent("post_like_click", { post_id: postID, context: "map" }); likeBtn.disabled = true; const res = await togglePostLike(postID, !liked); if (res?.ok) { @@ -613,6 +714,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { shareBtn.addEventListener("click", async () => { const shareUrl = `${window.location.origin}/p/${postID}`; const shareTitle = postData?.title || postData?.Title || "SocioWire"; + trackEvent("post_share_click", { post_id: postID, context: "map" }); try { if (navigator.share) { await navigator.share({ title: shareTitle, url: shareUrl }); @@ -649,6 +751,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { } const body = (commentInput.value || "").trim(); if (!body) return; + trackEvent("post_comment_submit", { post_id: postID, context: "map" }); commentBtn.disabled = true; const res = await createPostComment(postID, body); if (res?.ok) { @@ -712,6 +815,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { unmountMini: null, historyPushed, historyClosed: false, + openedAt: Date.now(), }; } @@ -860,7 +964,8 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the const existing = map.__swCenteredOverlay; if (existing && existing.postId === post?.id) { - closeCenteredOverlay(map); + if (existing.openedAt && Date.now() - existing.openedAt < 800) return; + closeCenteredOverlay(map, { force: true }); return; } @@ -1011,7 +1116,8 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe const existing = map.__swCenteredOverlay; if (existing && existing.postId === post?.id) { - closeCenteredOverlay(map); + if (existing.openedAt && Date.now() - existing.openedAt < 800) return; + closeCenteredOverlay(map, { force: true }); return; } diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 8df7c4c..569e3ec 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -4,6 +4,7 @@ import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter"; import { getCoords, getViewFromMap, haversineKm } from "./mapGeo"; import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager"; import { prioritizePosts } from "./postPriority"; +import { trackEvent } from "../../utils/analytics"; function getId(p) { 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 delayedFetchRef = useRef(null); const moveDebounceRef = useRef(null); + const analyticsDebounceRef = useRef(0); const initialLoadRef = useRef(true); + const initialFetchKickRef = useRef(false); const autoWidenRef = useRef(false); const [mapReady, setMapReady] = useState(false); const clusterModeRef = useRef(false); @@ -182,6 +185,19 @@ export function usePostsEngine({ }; }, [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 = { maxFullCards: 12, clusterRadius: 0.002, @@ -520,7 +536,7 @@ export function usePostsEngine({ (tf) => { const vAll = computeVisible(tf); const map = mapRef.current; - let v = filterByBounds(vAll, map); + let v = (searchQuery || "").trim() ? vAll : filterByBounds(vAll, map); if (v.length > MAX_VISIBLE_POSTS) { const center = map?.getCenter?.(); 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 refreshVisibleAndMarkers(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 ( !autoWidenRef.current && timeFilter && @@ -744,13 +771,17 @@ export function usePostsEngine({ type: "posts", lat, lon, - radius_km: viewParams?.radiusKm, limit: 60, }); if (cancelled) return; const posts = Array.isArray(data?.posts) ? data.posts : []; allPostsRef.current = prunePosts(posts, [lon, lat], MAX_POOL_POSTS); refreshVisibleAndMarkers(timeFilter); + trackEvent("search_filter_apply", { + query_len: q.length, + results: posts.length, + context: "map", + }); } catch {} } diff --git a/src/components/Posts/PostCard.jsx b/src/components/Posts/PostCard.jsx index d1b6271..f9e7c1c 100644 --- a/src/components/Posts/PostCard.jsx +++ b/src/components/Posts/PostCard.jsx @@ -5,8 +5,10 @@ import { recordPostShare, togglePostLike, updatePostVisibility, + fetchProfile, } from "../../api/client"; import { useAuth } from "../../auth/AuthContext"; +import { trackEvent } from "../../utils/analytics"; function isAuthed() { try { @@ -25,6 +27,7 @@ function requestAuth(message) { }) ); } catch {} + trackEvent("auth_required", { context: "wall", mode: "login" }); } function formatRelativeTime(createdAt) { @@ -103,6 +106,29 @@ function normalizeImageUrl(raw) { 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 }) { const [liked, setLiked] = useState(false); 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 isAuthor = username && author && author.toLowerCase() === username.toLowerCase(); + const [authorAvatar, setAuthorAvatar] = useState(""); const sub = (post?.sub_category || post?.subCategory || "").toString(); const timeAgo = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt); const cat = categoryLabel(post); @@ -178,13 +205,34 @@ export default function PostCard({ post, selected, onSelect }) { return parts.join(" • "); }, [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 (
onSelect && onSelect(post)} + onClick={() => { + trackEvent("post_open", { post_id: postId, context: "wall" }); + onSelect && onSelect(post); + }} >
-
{author ? author[0]?.toUpperCase() : "U"}
+
+ {authorAvatar ? {author} : (author ? author[0]?.toUpperCase() : "U")} +
{author}
{timeAgo}
@@ -230,6 +278,7 @@ export default function PostCard({ post, selected, onSelect }) { requestAuth("Please sign in or create a free account to like this post."); return; } + trackEvent("post_like_click", { post_id: postId, context: "wall" }); const res = await togglePostLike(postId, !liked); if (res?.ok) { 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."); return; } + trackEvent("post_comment_open", { post_id: postId, context: "wall" }); if (onSelect) onSelect(post); }} > @@ -265,6 +315,7 @@ export default function PostCard({ post, selected, onSelect }) { onClick={async (e) => { e.stopPropagation(); const shareUrl = `${window.location.origin}/p/${postId}`; + trackEvent("post_share_click", { post_id: postId, context: "wall" }); try { if (navigator.share) { await navigator.share({ title: title || "SocioWire", url: shareUrl }); diff --git a/src/components/Search/SmartSearchBar.jsx b/src/components/Search/SmartSearchBar.jsx index 6c35139..6eb9f77 100644 --- a/src/components/Search/SmartSearchBar.jsx +++ b/src/components/Search/SmartSearchBar.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { recoSearch } from "../../services/recoSearch"; import "../../styles/smartSearchBar.css"; +import { trackEvent } from "../../utils/analytics"; function defaultZoomForKind(kind) { const k = (kind || "").toLowerCase(); @@ -144,6 +145,12 @@ export default function SmartSearchBar({ setOpen(false); 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 }); }; @@ -162,6 +169,7 @@ export default function SmartSearchBar({ // Always filter on Enter; do not auto-pick dropdown items. skipSearchRef.current = true; setOpen(false); + trackEvent("search_submit", { query_len: q.trim().length }); onSearch && onSearch(q.trim()); } } else if (e.key === "Escape") { @@ -178,6 +186,7 @@ export default function SmartSearchBar({ setQ(""); setItems([]); setOpen(false); + trackEvent("search_clear"); onSearch && onSearch(""); // Clear filters }; @@ -196,6 +205,11 @@ export default function SmartSearchBar({ } }} onFocus={() => { + if (q.trim()) { + setQ(""); + setItems([]); + onSearch && onSearch(""); + } setOpen(true); // Si pas encore cherché et query vide, charger suggestions populaires if (!hasSearched && !q.trim()) { @@ -222,20 +236,14 @@ export default function SmartSearchBar({ )}
- - diff --git a/src/styles/auth-modal.css b/src/styles/auth-modal.css index 631acef..9e11599 100644 --- a/src/styles/auth-modal.css +++ b/src/styles/auth-modal.css @@ -80,6 +80,21 @@ body[data-theme="light"] .auth-form input { 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; } .auth-divider{ diff --git a/src/styles/island.css b/src/styles/island.css index 0aa1ae4..7f4f39c 100644 --- a/src/styles/island.css +++ b/src/styles/island.css @@ -112,6 +112,66 @@ 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 { color: #f1f5f9; font-size: 2.25rem; @@ -139,10 +199,51 @@ background: rgba(15, 23, 42, 0.6); 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 { width: 64px; height: 64px; - border-radius: 999px; + border-radius: 14px; overflow: hidden; border: 1px solid rgba(56, 189, 248, 0.6); background: rgba(56, 189, 248, 0.2); @@ -160,6 +261,16 @@ font-weight: 800; font-size: 1.4rem; } + +.island-viewer-canvas-wrap{ + position: relative; +} + +.island-tabbar-overlay{ + position: absolute; + right: 16px; + top: 16px; +} .island-profile-meta { flex: 1; min-width: 0; @@ -175,6 +286,16 @@ flex-direction: column; gap: 0.35rem; } + +.island-profile-error{ + font-size: 0.75rem; + color: #f87171; +} + +.island-profile-info{ + font-size: 0.75rem; + color: #86efac; +} .island-profile-row { display: flex; align-items: center; diff --git a/src/styles/mapMarkers.css b/src/styles/mapMarkers.css index bae947d..6d2f21d 100644 --- a/src/styles/mapMarkers.css +++ b/src/styles/mapMarkers.css @@ -234,6 +234,26 @@ 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-bubble{ @@ -246,6 +266,23 @@ background: rgba(15,23,42,0.55); 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{ content:""; position:absolute; @@ -365,6 +402,7 @@ body[data-theme="light"] .sw-chat-bubble::before{ height: 640px !important; max-height: 640px !important; overflow: hidden !important; + box-sizing: border-box; } /* 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{ display: block !important; - width: 440px !important; + width: 100% !important; + max-width: 100% !important; height: 640px !important; + box-sizing: border-box; } .sw-template-full-wrap > *{ display: block !important; + max-width: 100% !important; + box-sizing: border-box; } /* Expanded header */ @@ -513,6 +555,19 @@ body[data-theme="light"] .sw-chat-bubble::before{ 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{ border:none; border-radius:999px; @@ -530,7 +585,9 @@ body[data-theme="light"] .sw-chat-bubble::before{ width:100%; height: 180px; min-height: 180px; - flex: 0 0 auto; + flex: 1 1 auto; + min-width: 0; + box-sizing: border-box; border-radius: 16px; overflow:hidden; border: 3px solid #000 !important; /* contour plus épais */ @@ -543,6 +600,8 @@ body[data-theme="light"] .sw-chat-bubble::before{ display:flex; gap:10px; align-items: stretch; + min-width: 0; + width: 100%; } .sw-truth-rail{ @@ -646,6 +705,7 @@ body[data-theme="light"] .sw-chat-bubble::before{ width:100%; height:100%; object-fit: cover; + object-position: center center; display:block; } diff --git a/src/styles/posts.css b/src/styles/posts.css index cc19519..6988b4b 100644 --- a/src/styles/posts.css +++ b/src/styles/posts.css @@ -74,7 +74,7 @@ .sw-wall-avatar{ width:34px; height:34px; - border-radius:999px; + border-radius:10px; background: rgba(56,189,248,0.22); border:1px solid rgba(56,189,248,0.55); color:#e2f2ff; @@ -82,6 +82,13 @@ display:flex; align-items: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-author{ diff --git a/src/styles/smartSearchBar.css b/src/styles/smartSearchBar.css index 6044696..0216e3a 100644 --- a/src/styles/smartSearchBar.css +++ b/src/styles/smartSearchBar.css @@ -6,6 +6,23 @@ 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 { position: relative; display: flex; diff --git a/src/styles/topbar.css b/src/styles/topbar.css index 00d0790..8dede93 100644 --- a/src/styles/topbar.css +++ b/src/styles/topbar.css @@ -43,6 +43,7 @@ flex-shrink: 0; } + .theme-switch{ display:flex; gap:.28rem; } .theme-dot{ width:22px; height:22px; border-radius:999px; @@ -482,10 +483,20 @@ body[data-theme="light"] .user-avatar{ 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{ width: 26px; height: 26px; - border-radius: 999px; + border-radius: 8px; display: grid; place-items: center; background: rgba(56,189,248,0.18); diff --git a/src/utils/analytics.js b/src/utils/analytics.js new file mode 100644 index 0000000..7d5e0e6 --- /dev/null +++ b/src/utils/analytics.js @@ -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 }); +}