1455 lines
43 KiB
JavaScript
1455 lines
43 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Invalidate all smart-feed cache entries (called when filter changes)
|
|
export function invalidateSmartFeedCache() {
|
|
invalidateCacheMatching("smart-feed");
|
|
}
|
|
|
|
function cloneJson(value) {
|
|
try {
|
|
return JSON.parse(JSON.stringify(value));
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
async function fetchJsonCached(url, { ttlMs = 0, headers = {}, credentials = "include", signal } = {}) {
|
|
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, signal });
|
|
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",
|
|
...authHeaders(), // Always include auth headers from stored token
|
|
};
|
|
if (token) {
|
|
headers["Authorization"] = `Bearer ${token}`; // Override with explicit token if provided
|
|
}
|
|
|
|
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: 3000 });
|
|
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);
|
|
if (!data) return null;
|
|
// Normalize response - API returns { truth: {...} }
|
|
const truth = data?.truth || data || {};
|
|
|
|
// truth_score is the main combined score (0-100)
|
|
const truthScore = truth.truth_score ?? null;
|
|
const aiScore = truth.ai_score ?? null;
|
|
const voteCount = truth.total_votes ?? truth.vote_count ?? 0;
|
|
|
|
// Return null only if we have no data at all
|
|
if (truthScore === null && aiScore === null) return null;
|
|
|
|
return {
|
|
truth_score: truthScore ?? 50, // Main display score
|
|
ai_score: aiScore,
|
|
user_score: truthScore, // For backward compatibility
|
|
total_votes: voteCount,
|
|
vote_count: voteCount,
|
|
user_vote: truth.user_vote ?? 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(() => ({}));
|
|
// Normalize response - API returns { ok: true, truth: {...} }
|
|
const truth = data?.truth || data || {};
|
|
const voteCount = truth.vote_count ?? truth.total_votes ?? truth.count ?? 0;
|
|
const aiScore = truth.ai_score ?? truth.ai ?? 50;
|
|
const truthScore = truth.truth_score ?? truth.TruthScore ?? 50; // Final calculated score
|
|
const userVoteEffect = truth.user_score ?? 0; // Vote effect (+/- points), not the score
|
|
return {
|
|
ai_score: aiScore,
|
|
user_score: userVoteEffect,
|
|
truth_score: truthScore, // Main display score - the final calculated value
|
|
total_votes: voteCount,
|
|
vote_count: voteCount,
|
|
user_vote: vote, // The vote that was just cast
|
|
};
|
|
}
|
|
|
|
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 = {}, { signal } = {}) {
|
|
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()}`;
|
|
console.log('[fetchSmartFeed] URL:', url);
|
|
|
|
try {
|
|
const data = await fetchJsonCached(url, { ttlMs: 10000, signal });
|
|
const items = Array.isArray(data) ? data : data.results || data.items || [];
|
|
console.log('[fetchSmartFeed] Got', items.length, 'posts, content_types:', [...new Set(items.map(p => p.content_type))]);
|
|
return await resolveAssetRefsInPosts(items);
|
|
} catch (err) {
|
|
if (err.name === "AbortError") return []; // silently ignore aborted requests
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch posts for an island (by username)
|
|
*/
|
|
export async function fetchIslandPosts(usernameOrId, params = {}) {
|
|
const username = typeof usernameOrId === 'string' ? usernameOrId : String(usernameOrId);
|
|
if (!username) return [];
|
|
return fetchPosts({
|
|
username,
|
|
limit: params.limit || 50,
|
|
...params
|
|
});
|
|
}
|
|
|
|
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 { ...data, ok: res.ok };
|
|
} 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, error: "Missing post ID" };
|
|
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),
|
|
});
|
|
if (!res.ok) {
|
|
// Try to get error message from response
|
|
const text = await res.text().catch(() => "");
|
|
return { ok: false, error: text || `HTTP ${res.status}`, status: res.status };
|
|
}
|
|
const data = await res.json().catch(() => ({}));
|
|
return { ok: true, ...data };
|
|
} catch (err) {
|
|
return { ok: false, error: err?.message || "Network error" };
|
|
}
|
|
}
|
|
|
|
// 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: [] };
|
|
}
|
|
}
|
|
|
|
// Emit event when friends list changes
|
|
function emitFriendsChanged() {
|
|
try {
|
|
window.dispatchEvent(new CustomEvent("sociowire:friends-changed"));
|
|
} catch {}
|
|
}
|
|
|
|
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(() => ({}));
|
|
if (res.ok) emitFriendsChanged();
|
|
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(() => ({}));
|
|
if (res.ok) emitFriendsChanged();
|
|
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(() => ({}));
|
|
if (res.ok) emitFriendsChanged();
|
|
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(() => ({}));
|
|
if (res.ok) emitFriendsChanged();
|
|
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(() => ({}));
|
|
if (res.ok) emitFriendsChanged();
|
|
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 "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch event clusters for map visualization
|
|
* Returns clusters with bounding boxes for impact zones
|
|
*/
|
|
export async function fetchEventClusters(params = {}) {
|
|
const qs = new URLSearchParams();
|
|
|
|
if (typeof params.minLat === "number") qs.set("min_lat", String(params.minLat));
|
|
if (typeof params.maxLat === "number") qs.set("max_lat", String(params.maxLat));
|
|
if (typeof params.minLon === "number") qs.set("min_lon", String(params.minLon));
|
|
if (typeof params.maxLon === "number") qs.set("max_lon", String(params.maxLon));
|
|
if (typeof params.minPosts === "number") qs.set("min_posts", String(params.minPosts));
|
|
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
|
|
|
|
const url = `${apiBase()}/events?${qs.toString()}`;
|
|
|
|
try {
|
|
const data = await fetchJsonCached(url, { ttlMs: 30000 });
|
|
return {
|
|
clusters: Array.isArray(data?.clusters) ? data.clusters : [],
|
|
count: data?.count || 0,
|
|
};
|
|
} catch (err) {
|
|
console.warn("fetchEventClusters error:", err);
|
|
return { clusters: [], count: 0 };
|
|
}
|
|
}
|
|
|
|
// 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" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload media file (image or video) for posts
|
|
* Returns { ok, url, thumbnail_url, error }
|
|
*/
|
|
export async function uploadPostMedia(file, opts = {}) {
|
|
if (!file) return { ok: false, error: "No file provided" };
|
|
|
|
// Use main API for uploads (asset-service proxy)
|
|
const url = `${apiBase()}/assets/upload`;
|
|
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
if (opts.type) form.append("type", opts.type); // "image" or "video"
|
|
|
|
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, error: text || `HTTP ${res.status}` };
|
|
}
|
|
|
|
const data = await res.json();
|
|
return {
|
|
ok: true,
|
|
url: data.url || data.file_url || "",
|
|
thumbnail_url: data.thumbnail_url || data.thumb_url || "",
|
|
...data,
|
|
};
|
|
} catch (err) {
|
|
console.warn("uploadPostMedia error:", err);
|
|
return { ok: false, error: err?.message || "Upload failed" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user's current geolocation
|
|
* Returns { ok, lat, lng, error }
|
|
*/
|
|
export function getCurrentLocation() {
|
|
return new Promise((resolve) => {
|
|
if (!navigator.geolocation) {
|
|
resolve({ ok: false, error: "Geolocation not supported" });
|
|
return;
|
|
}
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
resolve({
|
|
ok: true,
|
|
lat: pos.coords.latitude,
|
|
lng: pos.coords.longitude,
|
|
accuracy: pos.coords.accuracy,
|
|
});
|
|
},
|
|
(err) => {
|
|
resolve({ ok: false, error: err.message || "Location access denied" });
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
|
|
);
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// ISLAND CUSTOMIZATION API
|
|
// ============================================
|
|
|
|
/**
|
|
* Update island customization (terrain, colors, size)
|
|
*/
|
|
export async function updateIsland(islandId, updates) {
|
|
if (!islandId) return { ok: false, error: "Missing island ID" };
|
|
try {
|
|
const res = await fetch(`${apiBase()}/islands/${encodeURIComponent(islandId)}`, {
|
|
method: "PATCH",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
return { ok: res.ok, ...data };
|
|
} catch (err) {
|
|
console.warn("updateIsland error:", err);
|
|
return { ok: false, error: err?.message || "Update failed" };
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// PLANETS API (Group spaces)
|
|
// ============================================
|
|
|
|
/**
|
|
* Fetch list of planets (group spaces)
|
|
*/
|
|
export async function fetchPlanets(params = {}) {
|
|
const qs = new URLSearchParams();
|
|
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
|
|
if (typeof params.offset === "number") qs.set("offset", String(params.offset));
|
|
if (params.search) qs.set("search", params.search);
|
|
|
|
const url = `${apiBase()}/planets/nearby?${qs.toString()}`;
|
|
|
|
try {
|
|
const res = await fetch(url, { credentials: "include" });
|
|
if (!res.ok) {
|
|
console.warn("fetchPlanets failed", res.status);
|
|
return [];
|
|
}
|
|
const data = await res.json();
|
|
return Array.isArray(data) ? data : data.planets || [];
|
|
} catch (err) {
|
|
console.warn("fetchPlanets error:", err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch single planet by ID
|
|
*/
|
|
export async function fetchPlanet(planetId) {
|
|
if (!planetId) return null;
|
|
const url = `${apiBase()}/planets/${encodeURIComponent(planetId)}`;
|
|
|
|
try {
|
|
const res = await fetch(url, { credentials: "include" });
|
|
if (!res.ok) {
|
|
console.warn("fetchPlanet failed", res.status);
|
|
return null;
|
|
}
|
|
return await res.json();
|
|
} catch (err) {
|
|
console.warn("fetchPlanet error:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new planet
|
|
*/
|
|
export async function createPlanet(payload) {
|
|
try {
|
|
const res = await fetch(`${apiBase()}/planets/create`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
return { ok: res.ok, ...data };
|
|
} catch (err) {
|
|
console.warn("createPlanet error:", err);
|
|
return { ok: false, error: err?.message || "Create failed" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update planet settings
|
|
*/
|
|
export async function updatePlanet(planetId, updates) {
|
|
if (!planetId) return { ok: false, error: "Missing planet ID" };
|
|
try {
|
|
const res = await fetch(`${apiBase()}/planets/${encodeURIComponent(planetId)}`, {
|
|
method: "PATCH",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
return { ok: res.ok, ...data };
|
|
} catch (err) {
|
|
console.warn("updatePlanet error:", err);
|
|
return { ok: false, error: err?.message || "Update failed" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Join a planet
|
|
*/
|
|
export async function joinPlanet(planetId) {
|
|
if (!planetId) return { ok: false };
|
|
try {
|
|
const res = await fetch(`${apiBase()}/planets/${encodeURIComponent(planetId)}/join`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { ...authHeaders() },
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
return { ok: res.ok, ...data };
|
|
} catch (err) {
|
|
console.warn("joinPlanet error:", err);
|
|
return { ok: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Leave a planet
|
|
*/
|
|
export async function leavePlanet(planetId) {
|
|
if (!planetId) return { ok: false };
|
|
try {
|
|
const res = await fetch(`${apiBase()}/planets/${encodeURIComponent(planetId)}/leave`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { ...authHeaders() },
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
return { ok: res.ok, ...data };
|
|
} catch (err) {
|
|
console.warn("leavePlanet error:", err);
|
|
return { ok: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch planet members
|
|
*/
|
|
export async function fetchPlanetMembers(planetId, params = {}) {
|
|
if (!planetId) return [];
|
|
const qs = new URLSearchParams();
|
|
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
|
|
|
|
try {
|
|
const res = await fetch(`${apiBase()}/planets/${encodeURIComponent(planetId)}/members?${qs.toString()}`, {
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
return Array.isArray(data) ? data : data.members || [];
|
|
} catch (err) {
|
|
console.warn("fetchPlanetMembers error:", err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch user's planets (planets they've joined)
|
|
*/
|
|
export async function fetchUserPlanets() {
|
|
try {
|
|
const res = await fetch(`${apiBase()}/planets/my`, {
|
|
credentials: "include",
|
|
headers: { ...authHeaders() },
|
|
});
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
return Array.isArray(data) ? data : data.planets || [];
|
|
} catch (err) {
|
|
console.warn("fetchUserPlanets error:", err);
|
|
return [];
|
|
}
|
|
}
|