Files
tanabata/frontend/src/lib/api/client.ts
T
H1K0 b9cace2997 feat(frontend): implement file upload with drag-and-drop and per-file progress
- client.ts: add uploadWithProgress() using XHR for upload progress events
- FileUpload.svelte: drag-drop zone wrapper, multi-file queue with individual
  progress bars, success/error status, MIME rejection message, dismiss panel
- Header.svelte: optional onUpload prop renders upload icon button
- files/+page.svelte: wire upload button, prepend uploaded files to grid
- vite-mock-plugin.ts: handle POST /files, unshift new file into mock array
- Fix crypto.randomUUID() crash on non-secure HTTP context (use Date.now + Math.random)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:02:26 +03:00

140 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { get } from 'svelte/store';
import { authStore } from '$lib/stores/auth';
const BASE = '/api/v1';
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
public readonly details?: Array<{ field?: string; message?: string }>,
) {
super(message);
this.name = 'ApiError';
}
}
// Deduplicates concurrent 401 refresh attempts into a single in-flight request.
let refreshPromise: Promise<void> | null = null;
async function refreshTokens(): Promise<void> {
const { refreshToken } = get(authStore);
if (!refreshToken) {
authStore.set({ accessToken: null, refreshToken: null, user: null });
throw new ApiError(401, 'unauthorized', 'Session expired');
}
const res = await fetch(`${BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
authStore.set({ accessToken: null, refreshToken: null, user: null });
throw new ApiError(401, 'unauthorized', 'Session expired');
}
const data = await res.json();
authStore.update((s) => ({
...s,
accessToken: data.access_token ?? null,
refreshToken: data.refresh_token ?? null,
}));
}
function buildHeaders(init: RequestInit | undefined, accessToken: string | null): HeadersInit {
const isFormData = init?.body instanceof FormData;
const base: Record<string, string> = isFormData ? {} : { 'Content-Type': 'application/json' };
if (accessToken) base['Authorization'] = `Bearer ${accessToken}`;
return { ...base, ...(init?.headers as Record<string, string> | undefined) };
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res = await fetch(BASE + path, {
...init,
headers: buildHeaders(init, get(authStore).accessToken),
});
if (res.status === 401) {
if (!refreshPromise) {
refreshPromise = refreshTokens().finally(() => {
refreshPromise = null;
});
}
try {
await refreshPromise;
} catch {
throw new ApiError(401, 'unauthorized', 'Session expired');
}
res = await fetch(BASE + path, {
...init,
headers: buildHeaders(init, get(authStore).accessToken),
});
}
if (!res.ok) {
let body: { code?: string; message?: string; details?: Array<{ field?: string; message?: string }> } = {};
try {
body = await res.json();
} catch {
// ignore parse failure
}
throw new ApiError(res.status, body.code ?? 'error', body.message ?? res.statusText, body.details);
}
if (res.status === 204) return undefined as T;
return res.json();
}
/** Upload with XHR so we can track progress via onProgress(0100). */
export function uploadWithProgress<T>(
path: string,
formData: FormData,
onProgress: (pct: number) => void,
): Promise<T> {
return new Promise((resolve, reject) => {
const token = get(authStore).accessToken;
const xhr = new XMLHttpRequest();
xhr.open('POST', BASE + path);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText) as T);
} catch {
resolve(undefined as T);
}
} else {
let body: { code?: string; message?: string } = {};
try {
body = JSON.parse(xhr.responseText);
} catch { /* ignore */ }
reject(new ApiError(xhr.status, body.code ?? 'error', body.message ?? xhr.statusText));
}
};
xhr.onerror = () => reject(new ApiError(0, 'network_error', 'Network error'));
xhr.send(formData);
});
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
upload: <T>(path: string, formData: FormData) =>
request<T>(path, { method: 'POST', body: formData }),
};