patx/youtube-clone

/** Shared components: cards, avatars, buttons, toasts, modals, menus. */
import { h, icon, mount, clear } from './dom.js';
import { compact, timecode, timeAgo, initials, plural } from './format.js';
import { api } from './api.js';
import { state, refreshSubscriptions } from './store.js';
import { navigate } from './router.js';

const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

/* ---------------------------------------------------------------- toasts -- */

export function toast(message, kind = '') {
  const root = document.getElementById('toasts');
  const node = h(`div.toast${kind ? `.toast--${kind}` : ''}`, {}, message);
  root.append(node);
  setTimeout(() => {
    node.classList.add('is-out');
    node.addEventListener('animationend', () => node.remove(), { once: true });
    setTimeout(() => node.remove(), 400);
  }, 3200);
}

/* ---------------------------------------------------------------- modals -- */

export function confirmDialog({ title, text, confirmLabel = 'Confirm', danger = false }) {
  return new Promise((resolve) => {
    const root = document.getElementById('modal-root');
    const close = (answer) => {
      clear(root);
      document.removeEventListener('keydown', onKey);
      resolve(answer);
    };
    const onKey = (event) => { if (event.key === 'Escape') close(false); };
    document.addEventListener('keydown', onKey);

    const confirmBtn = h('button.btn', {
      class: danger ? 'btn--danger' : 'btn--primary',
      type: 'button',
      onclick: () => close(true),
    }, confirmLabel);

    mount(root, h('div.modal', {
      role: 'dialog',
      'aria-modal': 'true',
      onclick: (event) => { if (event.target.classList.contains('modal')) close(false); },
    },
      h('div.modal__card', {},
        h('h2.modal__title', { text: title }),
        h('p.modal__text', { text }),
        h('div.modal__actions', {},
          h('button.btn.btn--ghost', { type: 'button', onclick: () => close(false) }, 'Cancel'),
          confirmBtn,
        ),
      ),
    ));
    confirmBtn.focus();
  });
}

/* --------------------------------------------------------------- avatars -- */

export function avatar(person, size = '') {
  const node = h(`div.avatar${size ? `.avatar--${size}` : ''}`, {
    'aria-hidden': 'true',
    text: initials(person?.displayName || person?.username || '?'),
  });
  node.style.setProperty('--hue', String(person?.avatarHue ?? 210));
  return node;
}

export const channelAvatarLink = (channel, size = '') =>
  h('a', { href: `/@${channel.username}`, 'aria-label': channel.displayName }, avatar(channel, size));

/* ------------------------------------------------------------ empty state -- */

export const emptyState = (title, text, action = null) =>
  h('div.empty', {}, icon('film', 34), h('div.empty__title', { text: title }), h('p.empty__text', { text }), action);

export const spinner = () => h('div.spinner', { role: 'status', 'aria-label': 'Loading' });

/* ------------------------------------------------------ subscribe button -- */

export function subscribeButton(channel, { onUpdate } = {}) {
  const btn = h('button.btn.btn--subscribe', { type: 'button' });
  let subscribed = !!channel.isSubscribed;

  const paint = () => {
    btn.classList.toggle('is-on', subscribed);
    mount(btn, subscribed ? icon('check', 18) : icon('bell', 18), subscribed ? 'Subscribed' : 'Subscribe');
    btn.setAttribute('aria-pressed', String(subscribed));
  };

  btn.addEventListener('click', async () => {
    if (!state.user) return requireSignIn('Sign in to subscribe to channels.');
    btn.disabled = true;
    try {
      const result = await api.subscribe(channel.username);
      subscribed = result.isSubscribed;
      channel.isSubscribed = subscribed;
      channel.subscriberCount = result.subscriberCount;
      paint();
      onUpdate?.(result);
      await refreshSubscriptions();
      toast(subscribed ? `Subscribed to ${channel.displayName}` : `Unsubscribed from ${channel.displayName}`, subscribed ? 'good' : '');
    } catch (err) {
      toast(err.message, 'error');
    } finally {
      btn.disabled = false;
    }
  });

  paint();
  return channel.isSelf ? h('a.btn.btn--ghost', { href: `/@${channel.username}` }, icon('channel', 18), 'Your channel') : btn;
}

export function requireSignIn(message = 'Sign in to continue.') {
  toast(message);
  navigate(`/signin?next=${encodeURIComponent(location.pathname + location.search)}`);
}

/* ----------------------------------------------------------- video cards -- */

/**
 * The signature interaction: hovering a card cross-fades the still into the
 * real video, muted, while an amber hairline sweeps the frame in time with it.
 */
function attachPreview(card, frame, video, sweep, timeBadge, duration) {
  if (reducedMotion.matches) return;
  let timer = null;
  let raf = null;

  const tick = () => {
    if (video.duration) {
      const pct = Math.min(1, video.currentTime / video.duration);
      sweep.style.width = `${pct * 100}%`;
      timeBadge.textContent = timecode(Math.max(0, video.duration - video.currentTime));
    }
    raf = requestAnimationFrame(tick);
  };

  const stop = () => {
    clearTimeout(timer);
    cancelAnimationFrame(raf);
    raf = null;
    card.classList.remove('is-previewing');
    sweep.style.width = '0';
    timeBadge.textContent = timecode(duration);
    video.pause();
    if (video.src) { video.removeAttribute('src'); video.load(); }
  };

  const start = () => {
    timer = setTimeout(() => {
      if (!video.src) video.src = video.dataset.src;
      video.currentTime = 0;
      video.play().then(() => {
        card.classList.add('is-previewing');
        if (raf == null) tick();
      }).catch(() => {});
    }, 420);
  };

  frame.addEventListener('pointerenter', (event) => { if (event.pointerType !== 'touch') start(); });
  frame.addEventListener('pointerleave', stop);
  frame.addEventListener('focusin', start);
  frame.addEventListener('focusout', stop);
  card.addEventListener('metube:teardown', stop);
}

export function videoCard(video, { compactRow = false, onRemove = null } = {}) {
  const href = `/watch/${video.id}`;
  const sweep = h('div.card__sweep');
  const timeBadge = h('span.card__time.num', { text: timecode(video.duration) });

  const still = video.thumb
    ? h('img', { src: video.thumb, alt: '', loading: 'lazy', decoding: 'async' })
    : h('div.card__placeholder', {}, icon('film', 30));

  const preview = h('video', {
    muted: true,
    loop: true,
    playsinline: true,
    preload: 'none',
    tabindex: '-1',
    'aria-hidden': 'true',
    data: { src: video.src },
  });
  preview.muted = true;

  const frame = h('a.card__frame', {
    href,
    'aria-label': `Watch ${video.title}`,
  }, still, preview, sweep, timeBadge);

  const card = h(`article.card${compactRow ? '.rowcard' : ''}`, {}, frame);

  if (state.user) {
    const saveBtn = h('button.card__save', {
      type: 'button',
      title: video.saved ? 'Remove from Watch later' : 'Save to Watch later',
      'aria-label': video.saved ? 'Remove from Watch later' : 'Save to Watch later',
      onclick: async (event) => {
        event.preventDefault();
        event.stopPropagation();
        try {
          const { saved } = await api.toggleSave(video.id);
          video.saved = saved;
          saveBtn.classList.toggle('is-on', saved);
          saveBtn.title = saved ? 'Remove from Watch later' : 'Save to Watch later';
          toast(saved ? 'Saved to Watch later' : 'Removed from Watch later', saved ? 'good' : '');
          if (!saved) onRemove?.(video);
        } catch (err) {
          toast(err.message, 'error');
        }
      },
    }, icon('bookmark', 17));
    saveBtn.classList.toggle('is-on', !!video.saved);
    frame.append(saveBtn);
  }

  const meta = h('div', {},
    h('a.card__channel', { href: `/@${video.channel.username}`, text: video.channel.displayName }),
    h('div.card__meta', {},
      h('span.num', { text: compact(video.views) }),
      video.views === 1 ? ' view · ' : ' views · ',
      timeAgo(video.createdAt),
    ),
  );

  const body = compactRow
    ? h('div.rowcard__body', {},
        h('h3.card__title', {}, h('a', { href, text: video.title })),
        meta,
      )
    : h('div.card__body', {},
        channelAvatarLink(video.channel),
        h('div', { style: { minWidth: 0 } },
          h('h3.card__title', {}, h('a', { href, text: video.title })),
          meta,
        ),
      );

  card.append(body);
  attachPreview(card, frame, preview, sweep, timeBadge, video.duration);
  return card;
}

export const videoGrid = (videos, options = {}) =>
  h(`div.grid${options.tight ? '.grid--tight' : ''}`, {}, videos.map((v) => videoCard(v, options)));

/** Stop every in-flight preview before a view is swapped out. */
export function teardown(container) {
  for (const card of container.querySelectorAll('.card')) {
    card.dispatchEvent(new CustomEvent('metube:teardown'));
  }
}

/* ----------------------------------------------------------------- menus -- */

export function dropdown(trigger, buildItems) {
  const wrap = h('div.menu', {}, trigger);
  let panel = null;

  const close = () => {
    panel?.remove();
    panel = null;
    document.removeEventListener('click', onDocClick, true);
    document.removeEventListener('keydown', onKey);
    trigger.setAttribute('aria-expanded', 'false');
  };
  const onDocClick = (event) => { if (!wrap.contains(event.target)) close(); };
  const onKey = (event) => { if (event.key === 'Escape') { close(); trigger.focus(); } };

  trigger.setAttribute('aria-haspopup', 'menu');
  trigger.setAttribute('aria-expanded', 'false');
  trigger.addEventListener('click', (event) => {
    event.preventDefault();
    event.stopPropagation();
    if (panel) return close();
    panel = h('div.menu__panel', { role: 'menu' }, buildItems(close));
    wrap.append(panel);
    trigger.setAttribute('aria-expanded', 'true');
    document.addEventListener('click', onDocClick, true);
    document.addEventListener('keydown', onKey);
  });

  return wrap;
}

export const menuItem = (label, iconName, onClick, { danger = false } = {}) =>
  h(`button.menu__item${danger ? '.menu__item--danger' : ''}`, { type: 'button', role: 'menuitem', onclick: onClick },
    icon(iconName, 17), label);

export const statLine = (...parts) => h('div.channelhero__stats', {}, parts.filter(Boolean));

export { plural };