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
121 lines
3.3 KiB
TypeScript
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);
|
|
});
|
|
}
|