sw-fe/src/api/client.js

1100 lines
32 KiB
JavaScript

/**
* SocioWire frontend API client
*/
import { loadAuth } from "../auth/authStorage";
/**
* Convert image URL to small variant (160x160)
* Asset URLs: ..._l.jpg -> ..._s.webp, ..._m.webp -> ..._s.webp
*/
export function toSmallImageUrl(url) {
if (!url || typeof url !== "string") return url;
if (url.includes("_l.jpg") || url.includes("_l.jpeg")) {
return url.replace(/_l\.jpe?g/i, "_s.webp");
}
if (url.includes("_m.webp")) {
return url.replace("_m.webp", "_s.webp");
}
return url;
}
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));
if (params.content_type) qs.set("content_type", String(params.content_type));
// 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 cancelFriendRequest(username) {
try {
const res = await fetch(`${apiBase()}/friends/cancel`, {
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" };
}
}
export async function searchUsers(query) {
if (!query || query.length < 2) return { ok: true, users: [] };
const qs = new URLSearchParams();
qs.set("q", query);
try {
const res = await fetch(`${apiBase()}/users/search?${qs.toString()}`, {
credentials: "include",
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false, users: [] };
}
}
export async function updateTimezone(timezone) {
try {
const res = await fetch(`${apiBase()}/profile/timezone`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ timezone }),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false };
}
}
export function getBrowserTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
} catch {
return "";
}
}
// Weather API
export async function fetchWeatherByCity(city) {
const url = `${apiBase()}/weather?city=${encodeURIComponent(city)}`;
const res = await fetch(url);
if (!res.ok) return null;
return res.json();
}
export async function fetchWeatherByCoords(lat, lon) {
const url = `${apiBase()}/weather?lat=${lat}&lon=${lon}`;
const res = await fetch(url);
if (!res.ok) return null;
return res.json();
}
export async function fetchWeatherByPostId(postId) {
const url = `${apiBase()}/weather?post_id=${postId}`;
const res = await fetch(url);
if (!res.ok) return null;
return res.json();
}
export async function fetchWeatherPosts() {
// Fetch all weather cities globally (lat=0,lon=0 with huge radius)
const url = `${apiBase()}/posts?content_type=weather&lat=0&lon=0&radius_km=50000&limit=100`;
const res = await fetch(url);
if (!res.ok) return [];
return res.json();
}
/**
* Change password for the authenticated user
* @param {string} oldPassword - Current password (optional but recommended)
* @param {string} newPassword - New password (min 8 chars)
*/
export async function changePassword(oldPassword, newPassword) {
const url = `${apiBase()}/change-password`;
try {
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({
old_password: oldPassword || "",
new_password: newPassword,
}),
});
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,
error: json?.error || (res.ok ? null : text || "Password change failed"),
message: json?.message || "",
};
} catch (err) {
console.warn("changePassword error:", err);
return { ok: false, status: 0, json: null, error: err?.message || "error" };
}
}