import type { BookDto, BookQueryDto, AuthStatusDto, AutomationSettingsDto, BootstrapAdminDto, CreateLibraryDto, JobDto, LibraryDto, LoginDto, MetadataSourcesConfigDto, ProgressDto, UpdateAutomationSettingsDto, UpdateAccountDto, UpdateMetadataSourcesConfigDto, UpdateProgressDto, UserDto } from "@readabook/shared"; import { mockAutomationSettings, mockBooks, mockContinue, mockJobs, mockLibraries, mockMetadataSources, mockProgress, mockUser } from "./mockData"; import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:"; type RequestOptions = RequestInit & { fallback?: unknown; }; export class ApiFallbackError extends Error { constructor( message: string, public readonly fallback: unknown ) { super(message); } } export class ApiHttpError extends Error { constructor( public readonly status: number, message: string ) { super(message); } } export function getApiFallback(error: unknown): T | undefined { return error instanceof ApiFallbackError ? (error.fallback as T) : undefined; } function apiErrorMessage(detail: string, fallback: string): string { if (!detail) return fallback; try { const parsed = JSON.parse(detail) as { message?: unknown; error?: unknown }; if (typeof parsed.message === "string") return parsed.message; if (Array.isArray(parsed.message)) return parsed.message.join(", "); if (typeof parsed.error === "string") return parsed.error; } catch { return detail; } return fallback; } function requestHeaders(options: RequestOptions): Headers { const headers = new Headers(options.headers); if (options.body !== undefined && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } return headers; } async function request(path: string, options: RequestOptions = {}): Promise { try { const response = await fetch(`${API_BASE}${path}`, { ...options, credentials: "include", headers: requestHeaders(options) }); if (!response.ok) { if (response.status === 401 && typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("readabook:session-expired")); } const detail = await response.text(); throw new ApiHttpError(response.status, apiErrorMessage(detail, `${response.status} ${response.statusText}`)); } return (await response.json()) as T; } catch (error) { if (options.fallback !== undefined) { throw new ApiFallbackError(error instanceof Error ? error.message : String(error), options.fallback); } throw error; } } function queryString(query: Partial): string { const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== "") params.set(key, String(value)); }); const value = params.toString(); return value ? `?${value}` : ""; } function readerPreferencesKey(bookId: number): string { return `${READER_PREFERENCES_PREFIX}${bookId}`; } function readLocalReaderPreferences(bookId: number): ReaderPreferencesDto { if (typeof localStorage === "undefined") return { mode: "horizontal", fit: "page" }; const raw = localStorage.getItem(readerPreferencesKey(bookId)); if (!raw) return { mode: "horizontal", fit: "page" }; try { const parsed = JSON.parse(raw) as Partial; return { mode: parsed.mode === "vertical" ? "vertical" : "horizontal", fit: parsed.fit === "width" ? "width" : "page" }; } catch { return { mode: "horizontal", fit: "page" }; } } function writeLocalReaderPreferences(bookId: number, preferences: ReaderPreferencesDto): void { if (typeof localStorage === "undefined") return; localStorage.setItem(readerPreferencesKey(bookId), JSON.stringify(preferences)); } export const api = { async session(): Promise { try { const result = await request<{ user: UserDto }>("/auth/me"); return { user: result.user, degraded: false }; } catch { return { user: null, degraded: false }; } }, async authStatus(): Promise { return request("/auth/status"); }, async bootstrap(input: BootstrapAdminDto): Promise { const result = await request("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) }); return result; }, async login(input: LoginDto): Promise { const result = await request<{ user: UserDto }>("/auth/login", { method: "POST", body: JSON.stringify(input) }); return result.user; }, async logout(): Promise { await request<{ ok: true }>("/auth/logout", { method: "POST" }); }, async updateMe(input: UpdateAccountDto): Promise { return request("/auth/me", { method: "PATCH", body: JSON.stringify(input) }); }, async books(query: Partial = {}): Promise { return request(`/books${queryString(query)}`, { fallback: mockBooks }); }, async search(query: string): Promise { return request(`/books/search${queryString({ q: query })}`, { fallback: mockBooks }); }, async book(id: number): Promise { const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0]; return request(`/books/${id}`, { fallback }); }, bookFileUrl(id: number): string { return `${API_BASE}/books/${id}/file`; }, bookCoverUrl(id: number): string { return `${API_BASE}/books/${id}/cover`; }, async cbzPages(id: number): Promise { return request(`/books/${id}/pages`); }, cbzPageUrl(id: number, page: number): string { return `${API_BASE}/books/${id}/pages/${page}`; }, async progress(bookId: number): Promise { try { return await request(`/progress/${bookId}`, { fallback: mockProgress.find((progress) => progress.bookId === bookId) ?? null }); } catch (error) { if (error instanceof ApiFallbackError) return error.fallback as ProgressDto | null; return null; } }, async saveProgress(bookId: number, input: UpdateProgressDto): Promise { return request(`/progress/${bookId}`, { method: "PUT", body: JSON.stringify(input), fallback: { bookId, ...input, updatedAt: new Date().toISOString() } }); }, async readerPreferences(bookId: number): Promise { try { const preferences = await request(`/reader/preferences/${bookId}`, { fallback: readLocalReaderPreferences(bookId) }); writeLocalReaderPreferences(bookId, preferences); return preferences; } catch (error) { if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto; return readLocalReaderPreferences(bookId); } }, async saveReaderPreferences(bookId: number, input: ReaderPreferencesDto): Promise { writeLocalReaderPreferences(bookId, input); try { return await request(`/reader/preferences/${bookId}`, { method: "PUT", body: JSON.stringify(input), fallback: input }); } catch (error) { if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto; return input; } }, async continueReading(): Promise { return request("/progress/continue", { fallback: mockContinue }); }, async libraries(): Promise { return request("/admin/libraries", { fallback: mockLibraries }); }, async createLibrary(input: CreateLibraryDto): Promise { return request("/admin/libraries", { method: "POST", body: JSON.stringify(input) }); }, async deleteLibrary(id: number): Promise { await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" }); }, async scanLibrary(id: number): Promise { return request(`/admin/libraries/${id}/scan`, { method: "POST" }); }, async jobs(): Promise { return request("/admin/jobs", { fallback: mockJobs }); }, async users(): Promise { return request("/admin/users", { fallback: [mockUser] }); }, async metadataSources(): Promise { return request("/admin/metadata-sources", { fallback: mockMetadataSources }); }, async updateMetadataSources(input: UpdateMetadataSourcesConfigDto): Promise { return request("/admin/metadata-sources", { method: "PUT", body: JSON.stringify(input), fallback: mockMetadataSources }); }, async automationSettings(): Promise { return request("/admin/automation", { fallback: mockAutomationSettings }); }, async updateAutomationSettings(input: UpdateAutomationSettingsDto): Promise { return request("/admin/automation", { method: "PUT", body: JSON.stringify(input), fallback: mockAutomationSettings }); }, async runAutomationScan(): Promise { return request("/admin/automation/run-scan", { method: "POST", fallback: mockJobs[0] }); }, async runAutomationEnrich(): Promise { return request("/admin/automation/run-enrich", { method: "POST", fallback: mockJobs[0] }); } };