patx/youtube-clone

/** Upload a video, and edit the details of one you already own. */
import { h, icon, mount } from '../dom.js';
import { api, uploadVideo } from '../api.js';
import { state } from '../store.js';
import { navigate } from '../router.js';
import { toast, emptyState, spinner } from '../ui.js';
import { fileSize, timecode } from '../format.js';

const ACCEPT = 'video/mp4,video/webm,video/quicktime,video/x-m4v,video/ogg,.mp4,.webm,.mov,.m4v,.ogg';
const MAX_BYTES = 512 * 1024 * 1024;

export async function uploadView() {
  if (!state.user) {
    return emptyState('Sign in to upload', 'You need an account before you can publish a video.',
      h('a.btn.btn--primary', { href: '/signin?next=%2Fupload' }, 'Sign in'));
  }
  document.title = 'Upload · MeTube';

  let file = null;

  const input = h('input', { type: 'file', accept: ACCEPT, hidden: true });
  const dropzone = h('div.dropzone', { tabindex: '0', role: 'button', 'aria-label': 'Choose a video file' },
    icon('upload', 38),
    h('div.dropzone__title', { text: 'Drop a video here' }),
    h('div.dropzone__hint', { text: 'or click to browse · MP4, WebM, MOV or OGG · up to 512 MB' }),
  );
  const fileSlot = h('div', {}, dropzone);

  const title = h('input.input', { maxlength: '120', placeholder: 'Give it a clear, specific title', required: true });
  const description = h('textarea.textarea', { maxlength: '5000', placeholder: 'What is in this video? Add chapters, links or credits.' });
  const category = h('select.select', {},
    state.categories.filter((c) => c !== 'All').map((c) => h('option', { value: c, selected: c === 'General' }, c)),
  );

  const error = h('div.formerror', { hidden: true, role: 'alert' });
  const progressFill = h('div.progress__fill');
  const progressPct = h('span.num', { text: '0%' });
  const progressBytes = h('span', { text: '' });
  const progressBox = h('div', { hidden: true },
    h('div.progress', {}, progressFill),
    h('div.progress__label', {}, h('span', {}, 'Uploading… ', progressPct), progressBytes),
  );

  const submit = h('button.btn.btn--primary', { type: 'submit', disabled: true }, icon('upload', 18), 'Publish video');

  const setFile = (chosen) => {
    if (!chosen) return;
    if (chosen.size > MAX_BYTES) {
      showError(`That file is ${fileSize(chosen.size)}. The limit is 512 MB.`);
      return;
    }
    if (!/^video\//.test(chosen.type) && !/\.(mp4|webm|mov|m4v|ogg)$/i.test(chosen.name)) {
      showError('That is not a video file. Choose an MP4, WebM, MOV or OGG.');
      return;
    }
    file = chosen;
    error.hidden = true;
    submit.disabled = false;
    if (!title.value.trim()) title.value = chosen.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').slice(0, 120);

    const media = h('video', { src: URL.createObjectURL(chosen), muted: true, preload: 'metadata',
      style: { width: '104px', borderRadius: '8px', background: '#000' } });
    const meta = h('div.filecard__size', { text: fileSize(chosen.size) });
    media.addEventListener('loadedmetadata', () => {
      meta.textContent = `${fileSize(chosen.size)} · ${timecode(media.duration)}`;
    });

    mount(fileSlot, h('div.filecard', {},
      media,
      h('div', { style: { minWidth: 0, flex: '1 1 auto' } },
        h('div.filecard__name', { text: chosen.name }),
        meta,
      ),
      h('button.btn.btn--ghost.btn--sm', {
        type: 'button',
        onclick: () => {
          URL.revokeObjectURL(media.src);
          file = null;
          submit.disabled = true;
          input.value = '';
          mount(fileSlot, dropzone);
        },
      }, 'Change'),
    ));
  };

  const showError = (message) => {
    error.textContent = message;
    error.hidden = false;
  };

  dropzone.addEventListener('click', () => input.click());
  dropzone.addEventListener('keydown', (event) => {
    if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); input.click(); }
  });
  input.addEventListener('change', () => setFile(input.files[0]));

  for (const type of ['dragenter', 'dragover']) {
    dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.add('is-over'); });
  }
  for (const type of ['dragleave', 'drop']) {
    dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove('is-over'); });
  }
  dropzone.addEventListener('drop', (event) => setFile(event.dataTransfer?.files?.[0]));

  const form = h('form.panel', {
    onsubmit: async (event) => {
      event.preventDefault();
      if (!file) return showError('Choose a video file first.');
      if (!title.value.trim()) return showError('Give your video a title.');

      error.hidden = true;
      submit.disabled = true;
      progressBox.hidden = false;

      const data = new FormData();
      data.append('title', title.value.trim());
      data.append('description', description.value.trim());
      data.append('category', category.value);
      data.append('video', file, file.name);

      try {
        const { video } = await uploadVideo(data, (fraction, loaded, total) => {
          const pct = Math.round(fraction * 100);
          progressFill.style.width = `${pct}%`;
          progressPct.textContent = `${pct}%`;
          progressBytes.textContent = `${fileSize(loaded)} of ${fileSize(total)}`;
          if (pct === 100) progressBytes.textContent = 'Processing on the server…';
        });
        toast('Published', 'good');
        navigate(`/watch/${video.id}`);
      } catch (err) {
        showError(err.message);
        submit.disabled = false;
        progressBox.hidden = true;
        progressFill.style.width = '0';
      }
    },
  },
    error,
    h('div.field', {}, h('label.field__label', { text: 'Video file' }), fileSlot, input),
    h('div.field', {}, h('label.field__label', { text: 'Title' }), title),
    h('div.field', {}, h('label.field__label', { text: 'Description' }), description),
    h('div.field', {}, h('label.field__label', { text: 'Category' }), category),
    progressBox,
    h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '9px', marginTop: '18px' } },
      h('a.btn.btn--ghost', { href: '/' }, 'Cancel'),
      submit,
    ),
  );

  return h('div', { style: { maxWidth: '760px', margin: '0 auto' } },
    h('div.pagehead', {},
      h('div', {},
        h('span.eyebrow', { text: `Publishing as @${state.user.username}` }),
        h('h1.pagehead__title', { text: 'Upload a video' }),
      ),
    ),
    form,
  );
}

export async function editView({ id }) {
  if (!state.user) {
    return emptyState('Sign in to edit', 'You need to be signed in as the owner of this video.',
      h('a.btn.btn--primary', { href: `/signin?next=%2Fedit%2F${id}` }, 'Sign in'));
  }

  const root = h('div', { style: { maxWidth: '760px', margin: '0 auto' } }, spinner());
  let video;
  try {
    ({ video } = await api.video(id));
  } catch (err) {
    return emptyState('That video is not here', err.message, h('a.btn.btn--primary', { href: '/' }, 'Back to home'));
  }
  if (!video.isOwner) {
    return emptyState('You cannot edit this video', 'Only the channel that uploaded a video can change its details.',
      h('a.btn.btn--primary', { href: `/watch/${video.id}` }, 'Watch it instead'));
  }
  mount(root);
  document.title = `Edit · ${video.title}`;

  const title = h('input.input', { value: video.title, maxlength: '120', required: true });
  const description = h('textarea.textarea', { maxlength: '5000' }, video.description);
  const category = h('select.select', {},
    state.categories.filter((c) => c !== 'All').map((c) => h('option', { value: c, selected: c === video.category }, c)),
  );
  const error = h('div.formerror', { hidden: true, role: 'alert' });
  const save = h('button.btn.btn--primary', { type: 'submit' }, 'Save changes');

  const form = h('form.panel', {
    onsubmit: async (event) => {
      event.preventDefault();
      save.disabled = true;
      error.hidden = true;
      try {
        await api.updateVideo(video.id, {
          title: title.value.trim(),
          description: description.value.trim(),
          category: category.value,
        });
        toast('Details saved', 'good');
        navigate(`/watch/${video.id}`);
      } catch (err) {
        error.textContent = err.message;
        error.hidden = false;
        save.disabled = false;
      }
    },
  },
    error,
    h('div.field', {}, h('label.field__label', { text: 'Title' }), title),
    h('div.field', {}, h('label.field__label', { text: 'Description' }), description),
    h('div.field', {}, h('label.field__label', { text: 'Category' }), category),
    h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '9px', marginTop: '18px' } },
      h('a.btn.btn--ghost', { href: `/watch/${video.id}` }, 'Cancel'),
      save,
    ),
  );

  root.append(
    h('div.pagehead', {},
      h('div', {},
        h('span.eyebrow', { text: 'Edit details' }),
        h('h1.pagehead__title', { text: video.title }),
      ),
    ),
    h('div', { style: { display: 'flex', gap: '16px', marginBottom: '20px', alignItems: 'center' } },
      video.thumb ? h('img', { src: video.thumb, alt: '', style: { width: '190px', borderRadius: '10px' } }) : null,
      h('div', { style: { color: 'var(--haze)', fontSize: '13px' } },
        h('div', {}, 'Thumbnail is generated from the video.'),
        h('a', { href: `/watch/${video.id}`, style: { color: 'var(--amber)', fontWeight: '600' } }, 'Open watch page'),
      ),
    ),
    form,
  );
  return root;
}