b9cace2997
- 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>
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
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(0–100). */
|
||
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 }),
|
||
}; |