280 lines
9.5 KiB
TypeScript
280 lines
9.5 KiB
TypeScript
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<T>(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<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
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<BookQueryDto>): 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<ReaderPreferencesDto>;
|
|
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<Session> {
|
|
try {
|
|
const result = await request<{ user: UserDto }>("/auth/me");
|
|
return { user: result.user, degraded: false };
|
|
} catch {
|
|
return { user: null, degraded: false };
|
|
}
|
|
},
|
|
async authStatus(): Promise<AuthStatusDto> {
|
|
return request<AuthStatusDto>("/auth/status");
|
|
},
|
|
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
|
|
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
|
|
return result;
|
|
},
|
|
async login(input: LoginDto): Promise<UserDto> {
|
|
const result = await request<{ user: UserDto }>("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
|
return result.user;
|
|
},
|
|
async logout(): Promise<void> {
|
|
await request<{ ok: true }>("/auth/logout", { method: "POST" });
|
|
},
|
|
async updateMe(input: UpdateAccountDto): Promise<UserDto> {
|
|
return request<UserDto>("/auth/me", { method: "PATCH", body: JSON.stringify(input) });
|
|
},
|
|
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
|
|
return request<BookDto[]>(`/books${queryString(query)}`, { fallback: mockBooks });
|
|
},
|
|
async search(query: string): Promise<BookDto[]> {
|
|
return request<BookDto[]>(`/books/search${queryString({ q: query })}`, { fallback: mockBooks });
|
|
},
|
|
async book(id: number): Promise<BookDto> {
|
|
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
|
|
return request<BookDto>(`/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<CbzPagesDto> {
|
|
return request<CbzPagesDto>(`/books/${id}/pages`);
|
|
},
|
|
cbzPageUrl(id: number, page: number): string {
|
|
return `${API_BASE}/books/${id}/pages/${page}`;
|
|
},
|
|
async progress(bookId: number): Promise<ProgressDto | null> {
|
|
try {
|
|
return await request<ProgressDto>(`/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<ProgressDto> {
|
|
return request<ProgressDto>(`/progress/${bookId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(input),
|
|
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
|
|
});
|
|
},
|
|
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {
|
|
try {
|
|
const preferences = await request<ReaderPreferencesDto>(`/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<ReaderPreferencesDto> {
|
|
writeLocalReaderPreferences(bookId, input);
|
|
try {
|
|
return await request<ReaderPreferencesDto>(`/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<ContinueItem[]> {
|
|
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
|
|
},
|
|
async libraries(): Promise<LibraryDto[]> {
|
|
return request<LibraryDto[]>("/admin/libraries", { fallback: mockLibraries });
|
|
},
|
|
async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> {
|
|
return request<LibraryDto>("/admin/libraries", {
|
|
method: "POST",
|
|
body: JSON.stringify(input)
|
|
});
|
|
},
|
|
async deleteLibrary(id: number): Promise<void> {
|
|
await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" });
|
|
},
|
|
async scanLibrary(id: number): Promise<JobDto> {
|
|
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST" });
|
|
},
|
|
async jobs(): Promise<JobDto[]> {
|
|
return request<JobDto[]>("/admin/jobs", { fallback: mockJobs });
|
|
},
|
|
async users(): Promise<UserDto[]> {
|
|
return request<UserDto[]>("/admin/users", { fallback: [mockUser] });
|
|
},
|
|
async metadataSources(): Promise<MetadataSourcesConfigDto> {
|
|
return request<MetadataSourcesConfigDto>("/admin/metadata-sources", { fallback: mockMetadataSources });
|
|
},
|
|
async updateMetadataSources(input: UpdateMetadataSourcesConfigDto): Promise<MetadataSourcesConfigDto> {
|
|
return request<MetadataSourcesConfigDto>("/admin/metadata-sources", {
|
|
method: "PUT",
|
|
body: JSON.stringify(input),
|
|
fallback: mockMetadataSources
|
|
});
|
|
},
|
|
async automationSettings(): Promise<AutomationSettingsDto> {
|
|
return request<AutomationSettingsDto>("/admin/automation", { fallback: mockAutomationSettings });
|
|
},
|
|
async updateAutomationSettings(input: UpdateAutomationSettingsDto): Promise<AutomationSettingsDto> {
|
|
return request<AutomationSettingsDto>("/admin/automation", {
|
|
method: "PUT",
|
|
body: JSON.stringify(input),
|
|
fallback: mockAutomationSettings
|
|
});
|
|
},
|
|
async runAutomationScan(): Promise<JobDto> {
|
|
return request<JobDto>("/admin/automation/run-scan", { method: "POST", fallback: mockJobs[0] });
|
|
},
|
|
async runAutomationEnrich(): Promise<JobDto> {
|
|
return request<JobDto>("/admin/automation/run-enrich", { method: "POST", fallback: mockJobs[0] });
|
|
}
|
|
};
|