$(addMangaToCollection crashed when the backend returned 201/200 with no body — the shared client only short-circuited 204. Now any empty body returns undefined.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
3.0 KiB
TypeScript
91 lines
3.0 KiB
TypeScript
// All backend calls go through this module. Components and routes import
|
|
// the typed helpers below — they do not call fetch directly.
|
|
|
|
const BASE = import.meta.env?.VITE_API_BASE ?? '/api';
|
|
|
|
/**
|
|
* Builds an absolute URL to the streaming `/files/{key}` endpoint so
|
|
* components can use it directly in `<img src>` etc., without
|
|
* reconstructing the API base in each call site.
|
|
*/
|
|
export function fileUrl(key: string): string {
|
|
return `${BASE}/v1/files/${key}`;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public readonly status: number,
|
|
public readonly code: string,
|
|
message: string
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
type ErrorEnvelope = { error?: { code?: unknown; message?: unknown } };
|
|
|
|
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
// Forward credentials (session cookie) explicitly so cross-origin
|
|
// deployments — those configured via CORS_ALLOWED_ORIGINS — keep
|
|
// working. For same-origin requests this is a no-op compared to the
|
|
// default 'same-origin', so the same-origin happy path is
|
|
// unchanged.
|
|
const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init });
|
|
if (!res.ok) {
|
|
let code = 'http_error';
|
|
let message = `${res.status} ${res.statusText}`;
|
|
const ct = res.headers.get('content-type') ?? '';
|
|
try {
|
|
if (ct.includes('application/json')) {
|
|
const body = (await res.json()) as ErrorEnvelope;
|
|
if (body?.error) {
|
|
if (typeof body.error.code === 'string' && body.error.code) {
|
|
code = body.error.code;
|
|
}
|
|
if (typeof body.error.message === 'string' && body.error.message) {
|
|
message = body.error.message;
|
|
}
|
|
}
|
|
} else {
|
|
const text = await res.text();
|
|
if (text) message = text;
|
|
}
|
|
} catch {
|
|
// Body wasn't parseable; keep the http_error fallback.
|
|
}
|
|
throw new ApiError(res.status, code, message);
|
|
}
|
|
// Any empty body (not just 204) returns undefined — the manga-add
|
|
// endpoint, for instance, signals create-vs-already-present via
|
|
// 201/200 with no body, and callers typed `request<void>` would
|
|
// otherwise blow up on `res.json()` parsing an empty string.
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
const text = await res.text();
|
|
if (!text) {
|
|
return undefined as T;
|
|
}
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
export type Manga = {
|
|
id: string;
|
|
title: string;
|
|
status: MangaStatus;
|
|
alt_titles: string[];
|
|
description: string | null;
|
|
cover_image_path: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type MangaStatus = 'ongoing' | 'completed';
|
|
|
|
export type Page = {
|
|
limit: number;
|
|
offset: number;
|
|
total: number | null;
|
|
};
|