chrysopedia/frontend/src/api/client.ts
jlightner 9139d5a93a feat: Built Tiptap rich text post editor with file attachments, multipa…
- "frontend/src/pages/PostEditor.tsx"
- "frontend/src/pages/PostEditor.module.css"
- "frontend/src/api/posts.ts"
- "frontend/src/api/client.ts"
- "frontend/src/App.tsx"
- "frontend/src/pages/CreatorDashboard.tsx"

GSD-Task: S01/T03
2026-04-04 09:13:48 +00:00

102 lines
2.6 KiB
TypeScript

/**
* Shared API client infrastructure: request helper, error class, auth token management.
*/
export const BASE = "/api/v1";
export const AUTH_TOKEN_KEY = "chrysopedia_token";
export class ApiError extends Error {
constructor(
public status: number,
public detail: string,
) {
super(`API ${status}: ${detail}`);
this.name = "ApiError";
}
}
function getStoredToken(): string | null {
try {
return localStorage.getItem(AUTH_TOKEN_KEY);
} catch {
return null;
}
}
export async function request<T>(url: string, init?: RequestInit): Promise<T> {
const token = getStoredToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(init?.headers as Record<string, string>),
};
if (token && !headers["Authorization"]) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(url, {
...init,
headers,
});
if (!res.ok) {
let detail = res.statusText;
try {
const body: unknown = await res.json();
if (typeof body === "object" && body !== null && "detail" in body) {
const d = (body as { detail: unknown }).detail;
detail = typeof d === "string" ? d : Array.isArray(d) ? d.map((e: any) => e.msg || JSON.stringify(e)).join("; ") : JSON.stringify(d);
}
} catch {
// body not JSON — keep statusText
}
throw new ApiError(res.status, detail);
}
return res.json() as Promise<T>;
}
/**
* Multipart form-data request — does NOT set Content-Type (browser adds
* multipart boundary automatically). Still attaches auth token.
*/
export async function requestMultipart<T>(
url: string,
formData: FormData,
init?: RequestInit,
): Promise<T> {
const token = getStoredToken();
const headers: Record<string, string> = {
...(init?.headers as Record<string, string>),
};
if (token && !headers["Authorization"]) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(url, {
method: "POST",
...init,
headers,
body: formData,
});
if (!res.ok) {
let detail = res.statusText;
try {
const body: unknown = await res.json();
if (typeof body === "object" && body !== null && "detail" in body) {
const d = (body as { detail: unknown }).detail;
detail =
typeof d === "string"
? d
: Array.isArray(d)
? d.map((e: any) => e.msg || JSON.stringify(e)).join("; ")
: JSON.stringify(d);
}
} catch {
// body not JSON — keep statusText
}
throw new ApiError(res.status, detail);
}
return res.json() as Promise<T>;
}