patx/youtube-clone

/** 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;
  });
}