diff --git a/src/api/client.js b/src/api/client.js index 07c354e..88858d7 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -51,7 +51,7 @@ async function resolveAssetRefsInPosts(posts) { if (!Array.isArray(posts) || posts.length === 0) return posts; const ids = []; for (const p of posts) { - [p?.image_small, p?.image_large, p?.image, p?.media_url].forEach((v) => { + [p?.image_small, p?.image_medium, p?.image_large, p?.image, p?.media_url].forEach((v) => { if (isAssetRef(v)) ids.push(v.replace("asset://", "")); }); } @@ -66,7 +66,7 @@ async function resolveAssetRefsInPosts(posts) { clone.id = clone.post_id; } } - ["image_small", "image_large", "image", "media_url"].forEach((k) => { + ["image_small", "image_medium", "image_large", "image", "media_url"].forEach((k) => { const v = clone[k]; if (isAssetRef(v)) { const id = v.replace("asset://", ""); @@ -225,6 +225,33 @@ export async function createPost(payload, token) { return res.json().catch(() => ({})); } +/** + * Fetch link preview data for a URL (auth required). + */ +export async function fetchPostPreview(url, token) { + const raw = (url || "").toString().trim(); + if (!raw) return null; + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + else Object.assign(headers, authHeaders()); + + const res = await fetch(`${API_BASE}/post/preview`, { + method: "POST", + headers, + credentials: "include", + body: JSON.stringify({ url: raw }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + const err = new Error(text || `HTTP ${res.status}`); + err.status = res.status; + throw err; + } + + return res.json().catch(() => null); +} + /** * Récupère un post par ID (détails + counts) */ @@ -246,6 +273,25 @@ export async function fetchPostById(postId) { } } +export async function fetchPostByUrl(url) { + const raw = (url || "").toString().trim(); + if (!raw) return null; + const qs = new URLSearchParams(); + qs.set("url", raw); + try { + const res = await fetch(`${API_BASE}/post?${qs.toString()}`, { + credentials: "include", + }); + if (!res.ok) return null; + const data = await res.json().catch(() => null); + if (!data || typeof data !== "object") return null; + const resolved = await resolveAssetRefsInPosts([data]); + return resolved && resolved[0] ? resolved[0] : data; + } catch { + return null; + } +} + export async function fetchPostTruth(postId) { if (!postId) return null; const qs = new URLSearchParams(); diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index 9b5fc83..28ceab6 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -138,6 +138,12 @@ async function me(accessToken) { username: (u && (u.username || u.user || u.preferred_username)) || "", emailVerified, avatarUrl: u && typeof u.avatar_url === "string" ? u.avatar_url : "", + needsUsername: + u && typeof u.needs_username === "boolean" + ? u.needs_username + : u && typeof u.needsUsername === "boolean" + ? u.needsUsername + : null, }; } @@ -189,10 +195,12 @@ export function AuthProvider({ children }) { const [user, setUser] = useState(null); // { username } const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... } const [avatarUrl, setAvatarUrl] = useState(""); + const [profileLoaded, setProfileLoaded] = useState(false); const [error, setError] = useState(""); const [lastInfo, setLastInfo] = useState(""); const [needsEmailVerification, setNeedsEmailVerification] = useState(false); + const [needsUsername, setNeedsUsername] = useState(false); const refreshTimer = useRef(null); const lastAuthStatusRef = useRef(null); @@ -202,9 +210,11 @@ export function AuthProvider({ children }) { setUser(null); setTokens(null); setAvatarUrl(""); + setProfileLoaded(false); setError(msg || ""); setLastInfo(""); setNeedsEmailVerification(false); + setNeedsUsername(false); clearAuth(); } @@ -216,6 +226,7 @@ export function AuthProvider({ children }) { setUser(u ? { username: u } : null); setStatus("auth"); + setProfileLoaded(false); setError(""); } @@ -232,6 +243,11 @@ export function AuthProvider({ children }) { if (mr.username) { setUser({ username: mr.username }); } + if (typeof mr.needsUsername === "boolean") { + setNeedsUsername(mr.needsUsername); + } else { + setNeedsUsername(false); + } if (typeof verified !== "boolean") { const tv = tokenEmailVerified(at); @@ -240,11 +256,13 @@ export function AuthProvider({ children }) { if (typeof verified === "boolean") { setNeedsEmailVerification(!verified); + setProfileLoaded(true); return { ok: true, verified }; } // If cannot determine, do not block UI aggressively setNeedsEmailVerification(false); + setProfileLoaded(true); return { ok: mr.ok, verified: null }; } @@ -334,6 +352,12 @@ export function AuthProvider({ children }) { setLastInfo(""); const saved = loadAuth(); if (!saved?.access_token) { + try { + if (sessionStorage.getItem("sociowire:logout")) { + setAnon(""); + return; + } + } catch {} if (keycloakEnabled()) { const ok = await trySilentKeycloakSession(); if (ok) { @@ -404,6 +428,9 @@ export function AuthProvider({ children }) { refreshTimer.current = null; const redirectUri = window.location.origin + window.location.pathname; const doSsoLogout = keycloakEnabled(); + try { + sessionStorage.setItem("sociowire:logout", String(Date.now())); + } catch {} setAnon(""); if (!doSsoLogout) return; try { @@ -434,6 +461,9 @@ export function AuthProvider({ children }) { async function completeKeycloakLogin() { if (!keycloakEnabled()) return { ok: false, reason: "disabled" }; + try { + sessionStorage.removeItem("sociowire:logout"); + } catch {} setStatus("loading"); setError(""); @@ -511,6 +541,11 @@ export function AuthProvider({ children }) { async function trySilentKeycloakSession() { let kc = null; + try { + if (sessionStorage.getItem("sociowire:logout")) { + return false; + } + } catch {} try { kc = getKeycloak(); } catch { @@ -558,6 +593,9 @@ export function AuthProvider({ children }) { setAnon("SSO is not configured."); return; } + try { + sessionStorage.removeItem("sociowire:logout"); + } catch {} const redirectUri = window.location.origin + window.location.pathname; try { const kc = getKeycloak(); @@ -626,6 +664,8 @@ export function AuthProvider({ children }) { tokens, error, avatarUrl, + profileLoaded, + needsUsername, // expected by UI loading, @@ -636,6 +676,7 @@ export function AuthProvider({ children }) { lastError: error || "", lastInfo: lastInfo || "", needsEmailVerification: !!needsEmailVerification, + needsUsername: !!needsUsername, resendVerificationEmail, loginWithProvider, ssoEnabled: keycloakEnabled(), @@ -648,10 +689,11 @@ export function AuthProvider({ children }) { setError, setLastInfo, setNeedsEmailVerification, + setNeedsUsername, clearError: () => setError(""), clearInfo: () => setLastInfo(""), }; - }, [status, user, tokens, error, lastInfo, needsEmailVerification, avatarUrl]); + }, [status, user, tokens, error, lastInfo, needsEmailVerification, needsUsername, avatarUrl, profileLoaded]); return {children}; } diff --git a/src/components/Island/IslandViewer.jsx b/src/components/Island/IslandViewer.jsx index 940298b..4c267c2 100644 --- a/src/components/Island/IslandViewer.jsx +++ b/src/components/Island/IslandViewer.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import '../../styles/island.css'; -import { fetchPosts, uploadProfileAvatar } from '../../api/client'; +import { fetchPosts, updateProfileUsername, uploadProfileAvatar } from '../../api/client'; import { useAuth } from '../../auth/AuthContext'; import { trackEvent } from "../../utils/analytics"; @@ -15,7 +15,7 @@ export default function IslandViewer({ island, onClose }) { const mapRef = useRef(null); const animationRef = useRef(null); const fileRef = useRef(null); - const { authenticated, username, setAvatarUrl } = useAuth(); + const { authenticated, username, refreshProfile, setAvatarUrl, setNeedsUsername } = useAuth(); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(""); const [uploadInfo, setUploadInfo] = useState(""); @@ -26,12 +26,18 @@ export default function IslandViewer({ island, onClose }) { const [activeTab, setActiveTab] = useState("profile"); const [userPosts, setUserPosts] = useState([]); const [postsLoading, setPostsLoading] = useState(false); + const [localUsername, setLocalUsername] = useState(""); + const [usernameDraft, setUsernameDraft] = useState(""); + const [usernameSaving, setUsernameSaving] = useState(false); + const [usernameError, setUsernameError] = useState(""); + const [usernameInfo, setUsernameInfo] = useState(""); + const islandUsername = localUsername || island?.username || ""; useEffect(() => { if (!island || !containerRef.current) return; - console.log('[IslandViewer] Rendering island with MapLibre:', island.username, island.seed); - trackEvent("island_open", { username: island.username || "" }); + console.log('[IslandViewer] Rendering island with MapLibre:', islandUsername, island.seed); + trackEvent("island_open", { username: islandUsername || "" }); // Center of island in virtual coordinates const centerLng = 0; @@ -112,15 +118,17 @@ export default function IslandViewer({ island, onClose }) { // Add content items as markers if (island.content && island.content.length > 0) { console.log('[IslandViewer] Adding', island.content.length, 'content items'); - island.content.forEach(item => { + const usedSlots = new Map(); + island.content.forEach((item, idx) => { const contentEl = createContentElement(item); + const pos = resolveContentPosition(item, idx, island.seed, usedSlots); new maplibregl.Marker({ element: contentEl, anchor: 'bottom' }) .setLngLat([ - centerLng + (item.position_x - 0.5) * 0.001, - centerLat + (item.position_y - 0.5) * 0.001 + centerLng + (pos.x - 0.5) * 0.0012, + centerLat + (pos.y - 0.5) * 0.0012 ]) .addTo(map); }); @@ -151,10 +159,18 @@ export default function IslandViewer({ island, onClose }) { }, [island?.avatar_url]); useEffect(() => { - if (!island?.username) return; + setLocalUsername(island?.username || ""); + }, [island?.username]); + + useEffect(() => { + setUsernameDraft(islandUsername); + }, [islandUsername]); + + useEffect(() => { + if (!islandUsername) return; let cancelled = false; setPostsLoading(true); - fetchPosts({ username: island.username, limit: 40 }) + fetchPosts({ username: islandUsername, limit: 40 }) .then((items) => { if (cancelled) return; setUserPosts(Array.isArray(items) ? items : []); @@ -174,8 +190,8 @@ export default function IslandViewer({ island, onClose }) { if (!island) return null; const isSelf = - authenticated && username && island.username && - username.toLowerCase() === island.username.toLowerCase(); + authenticated && username && islandUsername && + username.toLowerCase() === islandUsername.toLowerCase(); return (
@@ -183,36 +199,72 @@ export default function IslandViewer({ island, onClose }) {
-
-
- {avatarSrc ? ( - {island.username} - ) : ( - {(island.username || "U")[0].toUpperCase()} - )} +
+
+ {avatarSrc ? ( + {islandUsername} + ) : ( + {(islandUsername || "U")[0].toUpperCase()} + )} +
+
+

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

+

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

+
-
-

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

-

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

-
-
{activeTab === "profile" ? (
{avatarSrc ? ( - {island.username} + {islandUsername} ) : (
- {(island.username || "U")[0].toUpperCase()} + {(islandUsername || "U")[0].toUpperCase()}
)}
-
@{island.username}
+
@{islandUsername}
{isSelf ? (
+
+ +
+ setUsernameDraft(e.target.value)} + placeholder="Choose a username" + disabled={usernameSaving} + /> + +
+