1017 lines
30 KiB
JavaScript
1017 lines
30 KiB
JavaScript
/**
|
|
* 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 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 "";
|
|
}
|
|
}
|