Enhance analytics, map overlay, and UI fixes
This commit is contained in:
parent
6417194d90
commit
b8ac7d2010
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Silent SSO</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
15
src/App.jsx
15
src/App.jsx
|
|
@ -3,6 +3,7 @@ import TopBar from "./components/Layout/TopBar";
|
|||
import PostList from "./components/Posts/PostList";
|
||||
import 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 (
|
||||
<div className="app-root">
|
||||
<TopBar
|
||||
theme={theme}
|
||||
onChangeTheme={setTheme}
|
||||
onIslandsClick={handleOpenUniverse}
|
||||
onUserIsland={handleOpenUserIsland}
|
||||
/>
|
||||
|
||||
<div className="main-shell">
|
||||
|
|
@ -128,6 +142,7 @@ export default function App() {
|
|||
onSelectPost={setSelectedPost}
|
||||
selectedIsland={selectedIsland}
|
||||
onSelectIsland={setSelectedIsland}
|
||||
onOpenIslands={handleOpenUniverse}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@ async function resolveAssetURLs(ids) {
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveAssetRef(value) {
|
||||
if (!isAssetRef(value)) return value;
|
||||
const id = value.replace("asset://", "");
|
||||
const resolved = await resolveAssetURLs([id]);
|
||||
return resolved[id] || value;
|
||||
}
|
||||
|
||||
async function resolveAssetRefsInPosts(posts) {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<button className="island-viewer-close" onClick={onClose}>×</button>
|
||||
|
||||
<div className="island-viewer-header">
|
||||
<h2>{island.display_name || `${island.username}'s Island`}</h2>
|
||||
<p>{island.bio || 'Welcome to my island'}</p>
|
||||
<div className="island-header-row">
|
||||
<div className="island-header-avatar">
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt={island.username} />
|
||||
) : (
|
||||
<span>{(island.username || "U")[0].toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="island-header-text">
|
||||
<h2>{island.display_name || `${island.username}'s Island`}</h2>
|
||||
<p>{island.bio || 'Welcome to my island'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="island-profile-panel">
|
||||
<div className="island-profile-avatar">
|
||||
{island.avatar_url ? (
|
||||
<img src={island.avatar_url} alt={island.username} />
|
||||
{activeTab === "profile" ? (
|
||||
<div className="island-profile-panel">
|
||||
<div className="island-profile-avatar">
|
||||
{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">
|
||||
{(island.username || "U")[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="island-posts-empty">No posts yet.</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);
|
||||
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 className="island-viewer-canvas-wrap">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="island-viewer-canvas"
|
||||
style={{ width: '100%', height: '600px' }}
|
||||
/>
|
||||
<div className="island-tabbar island-tabbar-overlay">
|
||||
<button
|
||||
type="button"
|
||||
className={"island-tab" + (activeTab === "posts" ? " is-active" : "")}
|
||||
onClick={() => setActiveTab("posts")}
|
||||
title="Posts"
|
||||
>
|
||||
<i className="fa-solid fa-newspaper"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={"island-tab" + (activeTab === "profile" ? " is-active" : "")}
|
||||
onClick={() => setActiveTab("profile")}
|
||||
title="Profile"
|
||||
>
|
||||
<i className="fa-solid fa-user-gear"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="island-viewer-canvas"
|
||||
style={{ width: '100%', height: '600px' }}
|
||||
/>
|
||||
|
||||
<div className="island-viewer-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-value">{island.content_count || 0}</span>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="topbar-user-section">
|
||||
{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">
|
||||
{avatarUrl ? <img src={avatarUrl} alt={username} /> : <span>{initialLetter(username)}</span>}
|
||||
</div>
|
||||
<div className="sw-usertext">
|
||||
<div className="sw-userline">Online</div>
|
||||
<div className="sw-userhandle">@{username || "user"}</div>
|
||||
<div className="sw-userhandle">@{displayUsername}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="sw-userchip" title="Guest">
|
||||
<div className="sw-avatar">
|
||||
|
|
@ -336,6 +439,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Action buttons - separate section */}
|
||||
|
|
@ -352,21 +456,18 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
|||
</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 */}
|
||||
<button
|
||||
className="btn-action btn-action-auth"
|
||||
type="button"
|
||||
onClick={() => (authenticated ? logout() : openModal("login"))}
|
||||
onClick={() => {
|
||||
if (authenticated) {
|
||||
trackEvent("logout_click");
|
||||
logout();
|
||||
} else {
|
||||
openModal("login");
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
title={authenticated ? "Logout" : "Login"}
|
||||
>
|
||||
|
|
@ -428,6 +529,13 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
|||
autoComplete="current-password"
|
||||
/>
|
||||
</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}>
|
||||
{loading ? "..." : "Sign in"}
|
||||
</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");
|
||||
}}
|
||||
>
|
||||
<i className="fa-brands fa-google"></i>
|
||||
<span>Continue with Google</span>
|
||||
|
|
@ -500,6 +611,35 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showUsernameModal ? (
|
||||
<div className="auth-modal-backdrop" onClick={() => {}}>
|
||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="auth-modal-header">
|
||||
<div className="auth-tab auth-tab-active">Choose username</div>
|
||||
</div>
|
||||
<div className="auth-notice auth-notice-info">
|
||||
Pick a public username to complete your profile.
|
||||
</div>
|
||||
{usernameError ? <div className="auth-notice auth-notice-error">{usernameError}</div> : null}
|
||||
{usernameInfo ? <div className="auth-notice auth-notice-success">{usernameInfo}</div> : null}
|
||||
<form className="auth-form" onSubmit={handleUsernameSave}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={desiredUsername}
|
||||
onChange={(e) => setDesiredUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn-primary btn-full" disabled={usernameSaving}>
|
||||
{usernameSaving ? "Saving..." : "Save username"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
|||
import SmartSearchBar from "../Search/SmartSearchBar";
|
||||
import 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 */}
|
||||
<div className="map-overlay map-overlay-top">
|
||||
<div className="sw-top-row">
|
||||
<SmartSearchBar
|
||||
placeholder="Search cities, places, posts..."
|
||||
onPick={handlePickSuggestion}
|
||||
onSearch={handleSearch}
|
||||
onMySpot={handleFlyToMe}
|
||||
onPlaceTourWire={handleOpenCreate}
|
||||
/>
|
||||
<div className="sw-search-row">
|
||||
<SmartSearchBar
|
||||
placeholder="Search cities, places, posts..."
|
||||
onPick={handlePickSuggestion}
|
||||
onSearch={handleSearch}
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
||||
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
||||
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
||||
<span>• ${escapeHtml(relTime)}</span>
|
||||
`;
|
||||
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 `
|
||||
<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>
|
||||
`;
|
||||
})
|
||||
.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 }) {
|
|||
<div class="sw-modal-left">
|
||||
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
||||
<div class="sw-modal-meta">
|
||||
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
||||
${author ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
|
||||
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
|
||||
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
||||
<span>• ${escapeHtml(relTime)}</span>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<article
|
||||
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-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-author">{author}</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.");
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
)}
|
||||
|
||||
<div className="sw-search__icons" aria-label="Search actions">
|
||||
<button
|
||||
type="button"
|
||||
className="sw-search__iconBtn"
|
||||
title={rightTitle}
|
||||
onClick={() => onPlaceTourWire && onPlaceTourWire()}
|
||||
>
|
||||
<IconPlus />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="sw-search__iconBtn"
|
||||
title="My spot"
|
||||
onClick={() => onMySpot && onMySpot()}
|
||||
onClick={() => {
|
||||
trackEvent("search_my_spot");
|
||||
onMySpot && onMySpot();
|
||||
}}
|
||||
>
|
||||
<IconPin />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
export function trackEvent(name, params = {}) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (typeof window.gtag !== "function") return;
|
||||
window.gtag("event", name, params);
|
||||
}
|
||||
|
||||
export function trackPageView(path, title) {
|
||||
if (typeof window === "undefined") return;
|
||||
const pagePath = path || window.location.pathname || "/";
|
||||
const pageTitle = title || document.title || "SocioWire";
|
||||
trackEvent("page_view", { page_path: pagePath, page_title: pageTitle });
|
||||
}
|
||||
|
||||
export function trackSection(section, params = {}) {
|
||||
if (!section) return;
|
||||
trackEvent("section_view", { section, ...params });
|
||||
}
|
||||
Loading…
Reference in New Issue