/** * SocioWire frontend API client */ import { loadAuth } from "../auth/authStorage"; function apiBase() { const raw = (import.meta?.env?.VITE_API_BASE_URL || "") .toString() .replace(/\/+$/, ""); if (raw) return raw + "/api"; try { if (typeof window !== "undefined") { const host = window.location.hostname || ""; if (host.startsWith("www.")) { return `https://${host.replace(/^www\./, "")}/api`; } } } catch {} return "/api"; } function authHeaders() { try { const auth = loadAuth(); const token = auth?.access_token; if (!token) return {}; return { Authorization: `Bearer ${token}` }; } catch { return {}; } } const responseCache = new Map(); function cacheKeyFor(url, headers = {}) { const auth = headers?.Authorization || headers?.authorization; if (auth) return ""; // avoid caching authenticated responses return `GET:${url}`; } // Invalidate cache entries matching a pattern function invalidateCacheMatching(pattern) { for (const key of responseCache.keys()) { if (key.includes(pattern)) { responseCache.delete(key); } } } function cloneJson(value) { try { return JSON.parse(JSON.stringify(value)); } catch { return value; } } async function fetchJsonCached(url, { ttlMs = 0, headers = {}, credentials = "include" } = {}) { const key = cacheKeyFor(url, headers); if (ttlMs > 0 && key) { const hit = responseCache.get(key); if (hit && hit.expiresAt > Date.now()) { return cloneJson(hit.value); } } const res = await fetch(url, { credentials, headers }); if (!res.ok) { const text = await res.text().catch(() => ""); const err = new Error(text || `HTTP ${res.status}`); err.status = res.status; throw err; } const data = await res.json().catch(() => null); if (ttlMs > 0 && key) { responseCache.set(key, { value: data, expiresAt: Date.now() + ttlMs }); } return cloneJson(data); } function isAssetRef(value) { return typeof value === "string" && value.startsWith("asset://"); } async function resolveAssetURLs(ids) { if (!ids || !ids.length) return {}; const qs = new URLSearchParams(); qs.set("ids", ids.join(",")); try { const res = await fetch(`${apiBase()}/assets/resolve?${qs.toString()}`, { credentials: "include", headers: { ...authHeaders() }, }); if (!res.ok) return {}; const data = await res.json(); return (data && data.urls) || {}; } catch (err) { console.warn("resolveAssetURLs error:", err); return {}; } } async function resolveAssetRef(value) { if (!isAssetRef(value)) return value; const id = value.replace("asset://", ""); const resolved = await resolveAssetURLs([id]); return resolved[id] || value; } export { resolveAssetRef }; 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_medium, p?.image_large, p?.image, p?.media_url, p?.video_url].forEach((v) => { if (isAssetRef(v)) ids.push(v.replace("asset://", "")); }); if (Array.isArray(p?.media_urls)) { p.media_urls.forEach((v) => { if (isAssetRef(v)) ids.push(v.replace("asset://", "")); }); } } const uniq = Array.from(new Set(ids)); if (!uniq.length) return 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_medium", "image_large", "image", "media_url", "video_url"].forEach((k) => { const v = clone[k]; if (isAssetRef(v)) { const id = v.replace("asset://", ""); if (resolved[id]) clone[k] = resolved[id]; } }); if (Array.isArray(clone.media_urls)) { clone.media_urls = clone.media_urls.map((value) => { if (isAssetRef(value)) { const id = value.replace("asset://", ""); return resolved[id] || value; } return value; }); } return clone; }); } async function resolveAssetRefsInIslands(islands) { if (!Array.isArray(islands) || islands.length === 0) return islands; const ids = []; for (const i of islands) { if (isAssetRef(i?.avatar_url)) ids.push(i.avatar_url.replace("asset://", "")); } const uniq = Array.from(new Set(ids)); if (!uniq.length) return islands; const resolved = await resolveAssetURLs(uniq); return islands.map((i) => { if (isAssetRef(i?.avatar_url)) { const id = i.avatar_url.replace("asset://", ""); if (resolved[id]) return { ...i, avatar_url: resolved[id] }; } return i; }); } /** * Récupère la liste des posts, avec filtres optionnels. */ export async function fetchPosts(filters = {}) { const params = new URLSearchParams(); 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); // Validate and set lat/lon with proper bounds checking if (typeof filters.lat === "number") { if (filters.lat >= -90 && filters.lat <= 90) { params.set("lat", String(filters.lat)); } else { console.warn("Invalid latitude value (must be -90 to 90):", filters.lat); } } if (typeof filters.lon === "number") { if (filters.lon >= -180 && filters.lon <= 180) { params.set("lon", String(filters.lon)); } else { console.warn("Invalid longitude value (must be -180 to 180):", filters.lon); } } if (typeof filters.radiusKm === "number") { if (filters.radiusKm > 0 && filters.radiusKm <= 20000) { params.set("radius_km", String(filters.radiusKm)); } else { console.warn("Invalid radius value (must be > 0 and <= 20000 km):", filters.radiusKm); } } if (typeof filters.limit === "number" && filters.limit > 0) { params.set("limit", String(filters.limit)); } const qs = params.toString(); const url = qs ? `${apiBase()}/posts?${qs}` : `${apiBase()}/posts`; try { const data = await fetchJsonCached(url, { ttlMs: 15000 }); const items = Array.isArray(data) ? data : data?.items || []; return await resolveAssetRefsInPosts(items); } catch (err) { console.warn("fetchPosts failed (treated as empty)", err?.status || err); return []; } } /** * Récupère la position approx de l'utilisateur depuis le backend. */ export async function fetchIpLocation() { try { const data = await fetchJsonCached(`${apiBase()}/ip-location`, { ttlMs: 10 * 60 * 1000 }); if (!data || data === "null") return null; const lat = data.lat; const lon = data.lon; if ( typeof lat === "number" && typeof lon === "number" && !Number.isNaN(lat) && !Number.isNaN(lon) ) { return [lon, lat]; // [lng, lat] } console.warn("fetchIpLocation: invalid payload", data); return null; } catch (err) { console.warn("fetchIpLocation: network error", err); return null; } } /** * Crée un nouveau post * token: access token Keycloak (string) ou null */ export async function createPost(payload, token) { const headers = { "Content-Type": "application/json", }; if (token) { headers["Authorization"] = `Bearer ${token}`; } const res = await fetch(`${apiBase()}/post`, { method: "POST", headers, credentials: "include", body: JSON.stringify(payload), }); if (!res.ok) { const text = await res.text().catch(() => ""); let json = null; try { json = text ? JSON.parse(text) : null; } catch {} const errMsg = (json && (json.message || json.error_description || json.error)) || (text && text.slice(0, 300)) || `HTTP ${res.status}`; const err = new Error(errMsg); err.status = res.status; err.body = text; // Common backend shapes err.code = (json && (json.code || json.error)) || (res.status === 403 && /email[_\s-]*not[_\s-]*verified/i.test(text || "") ? "email_not_verified" : "") || ""; throw err; } 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(`${apiBase()}/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) */ export async function fetchPostById(postId) { if (!postId) return null; const qs = new URLSearchParams(); qs.set("id", String(postId)); try { const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 }); 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 fetchPostByUrl(url) { const raw = (url || "").toString().trim(); if (!raw) return null; const qs = new URLSearchParams(); qs.set("url", raw); try { const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 }); 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(); qs.set("post_id", String(postId)); try { const res = await fetch(`${apiBase()}/post/truth?${qs.toString()}`, { credentials: "include", headers: { ...authHeaders() }, }); if (!res.ok) return null; const data = await res.json().catch(() => null); return data?.truth || null; } catch { return null; } } export async function votePostTruth(postId, vote) { if (!postId) throw new Error("post_id required"); const body = JSON.stringify({ post_id: postId, vote }); const res = await fetch(`${apiBase()}/post/truth-vote`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, credentials: "include", body, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text || "truth vote failed"); } const data = await res.json().catch(() => ({})); return data?.truth || null; } export async function updatePostVisibility(postId, payload = {}, token) { if (!postId) throw new Error("post_id required"); const headers = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; const body = JSON.stringify({ post_id: postId, ...payload }); const res = await fetch(`${apiBase()}/post/visibility`, { method: "PATCH", headers, credentials: "include", body, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text || `HTTP ${res.status}`); } return res.json().catch(() => ({})); } /** * Crée un nouvel utilisateur via le backend * (backend: POST /api/signup) * payload: { username, email, password, firstName, lastName } */ export async function registerUser(payload) { const res = await fetch(`${apiBase()}/signup`, { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify(payload), }); if (!res.ok) { const text = await res.text().catch(() => ""); console.error("registerUser failed", res.status, text); throw new Error(text || "Failed to register user"); } return res.json().catch(() => ({})); } /** * NEW: Fetch intelligent personalized feed with screen-aware prioritization * Uses backend /api/smart-feed endpoint (which proxies to reco-service) */ export async function fetchSmartFeed(params = {}) { const qs = new URLSearchParams(); if (params.user_id) qs.set("user_id", String(params.user_id)); if (params.username) qs.set("username", params.username); if (typeof params.lat === "number") qs.set("lat", String(params.lat)); if (typeof params.lon === "number") qs.set("lon", String(params.lon)); if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); if (typeof params.zoom === "number") qs.set("zoom", String(params.zoom)); if (params.category) qs.set("category", String(params.category)); if (params.sub_category) qs.set("sub_category", String(params.sub_category)); if (params.time_hours) qs.set("time_hours", String(params.time_hours)); // NEW: Screen bounds for on-screen prioritization if (params.bounds) { const { minLat, minLon, maxLat, maxLon } = params.bounds; if (minLat != null) qs.set("minLat", String(minLat)); if (minLon != null) qs.set("minLon", String(minLon)); if (maxLat != null) qs.set("maxLat", String(maxLat)); if (maxLon != null) qs.set("maxLon", String(maxLon)); } const url = `${apiBase()}/smart-feed?${qs.toString()}`; try { const data = await fetchJsonCached(url, { ttlMs: 10000 }); const items = Array.isArray(data) ? data : data.results || data.items || []; return await resolveAssetRefsInPosts(items); } catch (err) { console.warn("fetchSmartFeed error:", err); return []; } } /** * NEW: Unified search across posts, profiles, and places * Uses backend /api/unified-search endpoint (which proxies to reco-service) */ export async function fetchUnifiedSearch(query, params = {}) { const qs = new URLSearchParams(); qs.set("q", query); if (params.type) qs.set("type", params.type); // all|posts|profiles|places if (typeof params.lat === "number") qs.set("lat", String(params.lat)); if (typeof params.lon === "number") qs.set("lon", String(params.lon)); if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); const url = `${apiBase()}/unified-search?${qs.toString()}`; try { const data = await fetchJsonCached(url, { ttlMs: 15000 }); 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: [] }; } catch (err) { console.warn("fetchUnifiedSearch error:", err); return { posts: [], profiles: [], places: [] }; } } /** * NEW: Poll for new posts (socket.io compatible) * Uses reco-service /api/poll endpoint */ export async function pollNewPosts(params = {}) { const qs = new URLSearchParams(); if (typeof params.since_id === "number") qs.set("since_id", String(params.since_id)); if (typeof params.lat === "number") qs.set("lat", String(params.lat)); if (typeof params.lon === "number") qs.set("lon", String(params.lon)); if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); if (params.category) qs.set("category", params.category); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); const url = `${apiBase()}/poll?${qs.toString()}`; try { const res = await fetch(url, { credentials: "include" }); if (!res.ok) { console.warn("pollNewPosts failed", res.status); return { posts: [], count: 0, max_id: 0 }; } const data = await res.json(); if (data && Array.isArray(data.posts)) { data.posts = await resolveAssetRefsInPosts(data.posts); } return data; } catch (err) { console.warn("pollNewPosts error:", err); return { posts: [], count: 0, max_id: 0 }; } } /** * ISLAND SERVICE: Fetch islands in viewport * Queries island-service microservice (Go) for user islands * * NOTE: Currently using MOCK DATA for testing * Replace with real API call when island-service is ready */ export async function fetchIslands(params = {}) { const qs = new URLSearchParams(); if (typeof params.lat === "number") qs.set("lat", String(params.lat)); if (typeof params.lon === "number") qs.set("lon", String(params.lon)); if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); const url = `${apiBase()}/islands/nearby?${qs.toString()}`; try { const res = await fetch(url, { credentials: "include" }); if (!res.ok) { console.warn("fetchIslands failed", res.status); return []; } const data = await res.json(); const items = Array.isArray(data) ? data : data.islands || []; return await resolveAssetRefsInIslands(items); } catch (err) { console.warn("fetchIslands error:", err); return []; } } /** * ISLAND SERVICE: Fetch single island details * Gets full island data including content (posts, apps) * * NOTE: Currently using MOCK DATA for testing */ export async function fetchIsland(islandId) { if (!islandId) return null; const url = `${apiBase()}/islands/${encodeURIComponent(islandId)}`; try { const res = await fetch(url, { credentials: "include" }); if (!res.ok) { console.warn("fetchIsland failed", res.status); return null; } const data = await res.json(); if (data && data.avatar_url && isAssetRef(data.avatar_url)) { const resolved = await resolveAssetRefsInIslands([data]); return resolved[0] || data; } return data; } catch (err) { console.warn("fetchIsland error:", err); return null; } } export async function fetchIslandByUsername(username) { if (!username) return null; const url = `${apiBase()}/islands/by-username?username=${encodeURIComponent(username)}`; try { const res = await fetch(url, { credentials: "include" }); if (!res.ok) { console.warn("fetchIslandByUsername failed", res.status); return null; } const data = await res.json(); if (data && data.avatar_url && isAssetRef(data.avatar_url)) { const resolved = await resolveAssetRefsInIslands([data]); return resolved[0] || data; } return data; } catch (err) { console.warn("fetchIslandByUsername error:", err); return null; } } export async function uploadProfileAvatar(file, opts = {}) { if (!file) return { ok: false }; const url = `${apiBase()}/profile/avatar`; const form = new FormData(); form.append("file", file); if (opts.visibility) form.append("visibility", opts.visibility); if (opts.group_id) form.append("group_id", opts.group_id); if (opts.users && opts.users.length) form.append("users", opts.users.join(",")); try { const res = await fetch(url, { method: "POST", body: form, credentials: "include", headers: { ...authHeaders() }, }); if (!res.ok) { const text = await res.text().catch(() => ""); return { ok: false, status: res.status, text }; } 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 = `${apiBase()}/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() ? `${apiBase()}/profile?${qs}` : `${apiBase()}/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 { const res = await fetch(`${apiBase()}/post/view`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ post_id: postId }), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } export async function togglePostLike(postId, like = true) { if (!postId) return { ok: false }; try { const res = await fetch(`${apiBase()}/post/like`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, credentials: "include", body: JSON.stringify({ post_id: postId, like }), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } export async function recordPostShare(postId) { if (!postId) return { ok: false }; try { const res = await fetch(`${apiBase()}/post/share`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ post_id: postId }), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } export async function fetchPostComments(postId, limit = 20) { if (!postId) return []; const qs = new URLSearchParams(); qs.set("post_id", String(postId)); if (limit) qs.set("limit", String(limit)); try { const res = await fetch(`${apiBase()}/post/comments?${qs.toString()}`, { credentials: "include", }); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data : []; } catch { return []; } } export async function createPostComment(postId, body) { if (!postId || !body) return { ok: false }; try { const res = await fetch(`${apiBase()}/post/comment`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, credentials: "include", body: JSON.stringify({ post_id: postId, body }), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } export async function fetchPostLikeState(postId) { if (!postId) return { ok: false }; const qs = new URLSearchParams(); qs.set("post_id", String(postId)); try { const res = await fetch(`${apiBase()}/post/like-state?${qs.toString()}`, { credentials: "include", headers: { ...authHeaders() }, }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } // Edit post export async function editPost(postId, payload, token) { if (!postId) return { ok: false }; const qs = new URLSearchParams(); qs.set("id", String(postId)); try { const headers = { "Content-Type": "application/json", ...authHeaders() }; if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(`${apiBase()}/post?${qs.toString()}`, { method: "PATCH", credentials: "include", headers, body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } // Send debug log to server function debugLog(msg) { try { const data = JSON.stringify({ level: "info", msg: msg, ts: new Date().toISOString(), src: "delete" }); const blob = new Blob([data], { type: "application/json" }); navigator.sendBeacon?.(`${apiBase()}/debug-log`, blob); } catch {} } // Delete post export async function deletePost(postId, token) { if (!postId) return { ok: false }; const qs = new URLSearchParams(); qs.set("id", String(postId)); const url = `${apiBase()}/post?${qs.toString()}`; debugLog(`START postId=${postId} url=${url}`); const t0 = Date.now(); try { const headers = { ...authHeaders() }; if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(url, { method: "DELETE", credentials: "include", headers, }); debugLog(`FETCH done ${Date.now() - t0}ms status=${res.status}`); const data = await res.json().catch(() => ({})); debugLog(`JSON done ${Date.now() - t0}ms ok=${res.ok}`); if (res.ok) { // Invalidate cache for this post invalidateCacheMatching(`id=${postId}`); invalidateCacheMatching(`post_id=${postId}`); } return { ok: res.ok, ...data }; } catch (err) { debugLog(`ERROR ${Date.now() - t0}ms err=${err?.message || err}`); return { ok: false }; } } // Friends API export async function fetchFriends() { try { const res = await fetch(`${apiBase()}/friends`, { credentials: "include", headers: { ...authHeaders() }, }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false, friends: [] }; } } export async function fetchFriendRequests() { try { const res = await fetch(`${apiBase()}/friends/requests`, { credentials: "include", headers: { ...authHeaders() }, }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false, requests: [] }; } } export async function sendFriendRequest(username) { try { const res = await fetch(`${apiBase()}/friends/request`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ username }), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } export async function acceptFriendRequest(username) { try { const res = await fetch(`${apiBase()}/friends/accept`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ username }), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } export async function rejectFriendRequest(username) { try { const res = await fetch(`${apiBase()}/friends/reject`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ username }), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } export async function removeFriend(username) { try { const res = await fetch(`${apiBase()}/friends/remove`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ username }), }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false }; } } export async function fetchFriendStatus(username) { if (!username) return { ok: false, status: "none" }; const qs = new URLSearchParams(); qs.set("username", username); try { const res = await fetch(`${apiBase()}/friends/status?${qs.toString()}`, { credentials: "include", headers: { ...authHeaders() }, }); const data = await res.json().catch(() => ({})); return { ok: res.ok, ...data }; } catch { return { ok: false, status: "none" }; } }