Files
J621/frontend/src/lib/api.ts
T
JakeBreath 555c25d77b M0: scaffold SPA + API monorepo
Backend (Django 6.1 + DRF):
- Token auth with a custom User model (register/login/logout/me)
- Library models (MediaItem, MediaLocation) and REST endpoints
- File list/detail with search, rating filter, sorting, pagination
- Multipart upload with optional rating/tags/notes
- Range-aware media serving (video seeking) and ffmpeg thumbnails
- scan_files management command for the watched folder

Frontend (React 19 + Vite + TypeScript):
- Catppuccin Mocha design tokens from the design docs
- App shell, token persistence, protected routes
- Library grid with filters, file detail with custom data editor
- Upload page with per-file progress via XHR
- Dev proxy to the Django API
2026-09-17 07:56:47 -05:00

121 lines
3.3 KiB
TypeScript

import type { MediaItem } from "@/lib/types";
const TOKEN_KEY = "j621.token";
export function getToken(): string | null {
return window.localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null): void {
if (token) {
window.localStorage.setItem(TOKEN_KEY, token);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
}
export class ApiError extends Error {
status: number;
data: unknown;
constructor(status: number, data: unknown) {
super(`Request failed with status ${status}`);
this.name = "ApiError";
this.status = status;
this.data = data;
}
}
export function errorMessage(error: unknown): string {
if (!(error instanceof ApiError)) {
return error instanceof Error ? error.message : "Something went wrong.";
}
const data = error.data;
if (data && typeof data === "object") {
const record = data as Record<string, unknown>;
if (typeof record.detail === "string") return record.detail;
for (const [field, value] of Object.entries(record)) {
if (Array.isArray(value) && value.length > 0) {
return `${field}: ${String(value[0])}`;
}
if (typeof value === "string") {
return `${field}: ${value}`;
}
}
}
return `Request failed (${error.status}).`;
}
interface ApiOptions extends Omit<RequestInit, "body"> {
json?: unknown;
body?: BodyInit;
}
export async function api<T>(path: string, options: ApiOptions = {}): Promise<T> {
const { json, headers, ...rest } = options;
const finalHeaders = new Headers(headers);
let body = options.body;
if (json !== undefined) {
finalHeaders.set("Content-Type", "application/json");
body = JSON.stringify(json);
}
const token = getToken();
if (token) finalHeaders.set("Authorization", `Token ${token}`);
const response = await fetch(path, { ...rest, headers: finalHeaders, body });
if (!response.ok) {
let data: unknown = null;
try {
data = await response.json();
} catch {
data = null;
}
throw new ApiError(response.status, data);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function uploadFile(
file: File,
fields: Record<string, string> = {},
onProgress?: (percent: number) => void,
): Promise<MediaItem> {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append("file", file);
for (const [key, value] of Object.entries(fields)) {
form.append(key, value);
}
const request = new XMLHttpRequest();
request.open("POST", "/api/uploads/");
const token = getToken();
if (token) request.setRequestHeader("Authorization", `Token ${token}`);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && onProgress) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
});
request.addEventListener("load", () => {
let data: unknown = null;
try {
data = JSON.parse(request.responseText);
} catch {
data = null;
}
if (request.status >= 200 && request.status < 300) {
resolve(data as MediaItem);
} else {
reject(new ApiError(request.status, data));
}
});
request.addEventListener("error", () => {
reject(new ApiError(0, { detail: "Network error" }));
});
request.send(form);
});
}