/** Thin fetch wrapper. Every failure surfaces as an Error with the server's message. */
export class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
async function request(method, path, body) {
const options = { method, credentials: 'same-origin', headers: {} };
if (body !== undefined) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(path, options);
} catch {
throw new ApiError('Cannot reach the server. Check your connection and try again.', 0);
}
const text = await res.text();
const data = text ? safeParse(text) : {};
if (!res.ok) throw new ApiError(data?.error || `Request failed (${res.status}).`, res.status);
return data;
}
const safeParse = (text) => { try { return JSON.parse(text); } catch { return {}; } };
const qs = (params = {}) => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== '') search.set(key, value);
}
const out = search.toString();
return out ? `?${out}` : '';
};
export const api = {
me: () => request('GET', '/api/auth/me'),
signup: (payload) => request('POST', '/api/auth/signup', payload),
login: (payload) => request('POST', '/api/auth/login', payload),
logout: () => request('POST', '/api/auth/logout', {}),
updateMe: (payload) => request('PATCH', '/api/me', payload),
categories: () => request('GET', '/api/categories'),
videos: (params) => request('GET', `/api/videos${qs(params)}`),
video: (id) => request('GET', `/api/videos/${id}`),
countView: (id) => request('POST', `/api/videos/${id}/view`, {}),
vote: (id, value) => request('POST', `/api/videos/${id}/like`, { value }),
toggleSave: (id) => request('POST', `/api/videos/${id}/save`, {}),
updateVideo: (id, payload) => request('PATCH', `/api/videos/${id}`, payload),
deleteVideo: (id) => request('DELETE', `/api/videos/${id}`),
comments: (id, sort) => request('GET', `/api/videos/${id}/comments${qs({ sort })}`),
addComment: (id, body, parentId = null) => request('POST', `/api/videos/${id}/comments`, { body, parentId }),
likeComment: (id, value) => request('POST', `/api/comments/${id}/like`, { value }),
deleteComment: (id) => request('DELETE', `/api/comments/${id}`),
channel: (username, params) => request('GET', `/api/channels/${encodeURIComponent(username)}${qs(params)}`),
subscribe: (username) => request('POST', `/api/channels/${encodeURIComponent(username)}/subscribe`, {}),
subscriptions: () => request('GET', '/api/subscriptions'),
channels: () => request('GET', '/api/channels'),
liked: () => request('GET', '/api/library/liked'),
history: () => request('GET', '/api/library/history'),
saved: () => request('GET', '/api/library/saved'),
clearHistory: () => request('DELETE', '/api/library/history'),
};
/** Uploads need progress reporting, so this one goes through XHR. */
export function uploadVideo(formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/videos');
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) onProgress?.(event.loaded / event.total, event.loaded, event.total);
});
xhr.addEventListener('load', () => {
const data = safeParse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new ApiError(data?.error || `Upload failed (${xhr.status}).`, xhr.status));
});
xhr.addEventListener('error', () => reject(new ApiError('The upload was interrupted.', 0)));
xhr.addEventListener('abort', () => reject(new ApiError('Upload cancelled.', 0)));
xhr.send(formData);
resolve.xhr = xhr;
});
}