patx/youtube-clone

/** Home, search results, subscriptions feed, trending and the library lists. */
import { h, icon, mount } from '../dom.js';
import { api } from '../api.js';
import { state } from '../store.js';
import { navigate, currentQuery } from '../router.js';
import { videoGrid, videoCard, emptyState, spinner, toast, confirmDialog, avatar } from '../ui.js';
import { compact, plural } from '../format.js';

const PAGE = 24;

function categoryChips(active, onPick) {
  return h('div.chips', { role: 'tablist', 'aria-label': 'Categories' },
    state.categories.map((name) => h('button.chip', {
      type: 'button',
      role: 'tab',
      class: name === active ? 'is-on' : '',
      'aria-selected': String(name === active),
      onclick: () => onPick(name),
    }, name)),
  );
}

/** Grid plus a "Load more" button that appends without re-rendering the page. */
function paginatedGrid(container, fetchPage, { emptyTitle, emptyText, emptyAction }) {
  let offset = 0;
  let total = 0;

  const grid = h('div.grid');
  const moreWrap = h('div', { style: { display: 'flex', justifyContent: 'center', marginTop: '34px' } });
  const loadMore = h('button.btn.btn--ghost', { type: 'button' }, 'Load more videos');

  const load = async (first = false) => {
    loadMore.disabled = true;
    loadMore.textContent = 'Loading…';
    try {
      const data = await fetchPage(offset, PAGE);
      total = data.total ?? data.videos.length;
      offset += data.videos.length;

      if (first && !data.videos.length) {
        mount(container, emptyState(emptyTitle, emptyText, emptyAction));
        return;
      }
      grid.append(...data.videos.map((v) => videoCard(v)));
      mount(moreWrap, offset < total ? loadMore : null);
    } catch (err) {
      if (first) mount(container, emptyState('That did not load', err.message));
      else toast(err.message, 'error');
    } finally {
      loadMore.disabled = false;
      loadMore.textContent = 'Load more videos';
    }
  };

  loadMore.addEventListener('click', () => load(false));
  container.append(grid, moreWrap);
  load(true);
  return container;
}

export async function homeView() {
  const category = currentQuery().get('category') || 'All';
  document.title = category === 'All' ? 'MeTube' : `${category} · MeTube`;
  const root = h('div');
  const body = h('div');

  root.append(
    categoryChips(category, (name) => navigate(name === 'All' ? '/' : `/?category=${encodeURIComponent(name)}`)),
    body,
  );

  paginatedGrid(body, (offset, limit) => api.videos({ category, offset, limit, sort: 'new' }), {
    emptyTitle: category === 'All' ? 'No videos yet' : `Nothing in ${category} yet`,
    emptyText: category === 'All'
      ? 'Upload the first one and it will appear here straight away.'
      : 'Try another category, or upload something that fits.',
    emptyAction: h('a.btn.btn--primary', { href: '/upload' }, icon('upload', 18), 'Upload a video'),
  });

  return root;
}

export async function trendingView() {
  document.title = 'Trending · MeTube';
  const root = h('div', {},
    h('div.pagehead', {},
      h('div', {},
        h('span.eyebrow', { text: 'Most watched' }),
        h('h1.pagehead__title', { text: 'Trending' }),
      ),
    ),
  );
  const body = h('div');
  root.append(body);
  paginatedGrid(body, (offset, limit) => api.videos({ sort: 'popular', offset, limit }), {
    emptyTitle: 'Nothing trending yet',
    emptyText: 'Once videos start collecting views they will show up here.',
  });
  return root;
}

export async function searchView() {
  const q = currentQuery().get('q') || '';
  const sort = currentQuery().get('sort') || 'popular';
  document.title = q ? `${q} · MeTube` : 'Search · MeTube';
  const root = h('div');

  const sorter = h('select.select', {
    'aria-label': 'Sort results',
    style: { width: 'auto' },
    onchange: (event) => navigate(`/search?q=${encodeURIComponent(q)}&sort=${event.target.value}`),
  },
    [['popular', 'Most viewed'], ['new', 'Newest first'], ['liked', 'Most liked'], ['oldest', 'Oldest first']]
      .map(([value, label]) => h('option', { value, selected: value === sort }, label)),
  );

  const head = h('div.pagehead', {},
    h('div', {},
      h('span.eyebrow', { text: 'Search' }),
      h('h1.pagehead__title', { text: q ? `“${q}”` : 'Search' }),
      h('div.pagehead__sub', { id: 'result-count' }),
    ),
    h('div.pagehead__actions', {}, sorter),
  );
  root.append(head);

  if (!q) {
    root.append(emptyState('Search MeTube', 'Type a title, a topic or a channel name in the box above.'));
    return root;
  }

  const body = h('div');
  root.append(body, spinner());

  const [{ videos, total }, { channels }] = await Promise.all([
    api.videos({ q, sort, limit: 40 }),
    api.channels().catch(() => ({ channels: [] })),
  ]);
  root.lastChild.remove();

  const needle = q.toLowerCase();
  const matchedChannels = channels.filter(
    (c) => c.displayName.toLowerCase().includes(needle) || c.username.toLowerCase().includes(needle),
  ).slice(0, 3);

  head.querySelector('#result-count').textContent =
    `${total} ${total === 1 ? 'video' : 'videos'}${matchedChannels.length ? ` · ${matchedChannels.length} ${matchedChannels.length === 1 ? 'channel' : 'channels'}` : ''}`;

  if (matchedChannels.length) {
    body.append(h('div', { style: { marginBottom: '28px' } },
      matchedChannels.map((c) => h('a.channelhero', {
        href: `/@${c.username}`,
        style: { marginBottom: '10px', padding: '16px 18px' },
      },
        avatar(c, 'lg'),
        h('div.channelhero__info', {},
          h('div', { style: { fontWeight: 700, fontSize: '17px' }, text: c.displayName }),
          h('div.channelhero__handle', { text: `@${c.username}` }),
          h('div.channelhero__stats', {},
            h('span', {}, h('span.num', { text: compact(c.subscriberCount) }), ` ${c.subscriberCount === 1 ? 'subscriber' : 'subscribers'}`),
            h('span', {}, h('span.num', { text: compact(c.videoCount) }), ` ${c.videoCount === 1 ? 'video' : 'videos'}`),
          ),
        ),
      )),
    ));
  }

  body.append(videos.length
    ? videoGrid(videos)
    : emptyState('No videos matched', `Nothing came back for “${q}”. Try fewer words or a different spelling.`));
  return root;
}

export async function subscriptionsView() {
  document.title = 'Subscriptions · MeTube';
  if (!state.user) return signedOutPrompt('Subscriptions', 'Sign in to see the latest from channels you follow.');

  const root = h('div', {},
    h('div.pagehead', {},
      h('div', {},
        h('span.eyebrow', { text: `${state.subscriptions.length} ${state.subscriptions.length === 1 ? 'channel' : 'channels'}` }),
        h('h1.pagehead__title', { text: 'Subscriptions' }),
      ),
    ),
  );
  const body = h('div');
  root.append(body);
  paginatedGrid(body, (offset, limit) => api.videos({ feed: 'subscriptions', offset, limit, sort: 'new' }), {
    emptyTitle: 'No subscriptions yet',
    emptyText: 'Subscribe to a channel and its newest videos will collect here.',
    emptyAction: h('a.btn.btn--primary', { href: '/channels' }, 'Browse channels'),
  });
  return root;
}

export async function channelsView() {
  document.title = 'Channels · MeTube';
  const root = h('div', {},
    h('div.pagehead', {},
      h('div', {},
        h('span.eyebrow', { text: 'Everyone publishing' }),
        h('h1.pagehead__title', { text: 'Channels' }),
      ),
    ),
    spinner(),
  );

  const { channels } = await api.channels();
  root.lastChild.remove();

  if (!channels.length) {
    root.append(emptyState('No channels yet', 'Once someone uploads a video their channel appears here.'));
    return root;
  }

  root.append(h('div.grid.grid--tight', {},
    channels.map((c) => h('a.panel', {
      href: `/@${c.username}`,
      style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px', textAlign: 'center' },
    },
      avatar(c, 'lg'),
      h('div', { style: { fontWeight: 700, letterSpacing: '-0.01em' }, text: c.displayName }),
      h('div', { style: { color: 'var(--haze)', fontSize: '13px' }, text: `@${c.username}` }),
      h('div', { style: { color: 'var(--haze-dim)', fontSize: '12.5px' } },
        h('span.num', { text: compact(c.subscriberCount) }),
        ` ${c.subscriberCount === 1 ? 'subscriber' : 'subscribers'} · `,
        h('span.num', { text: compact(c.videoCount) }),
        ` ${c.videoCount === 1 ? 'video' : 'videos'}`,
      ),
    )),
  ));
  return root;
}

const LIBRARY = {
  history: {
    title: 'History', eyebrow: 'Recently watched', fetch: () => api.history(),
    empty: ['Nothing watched yet', 'Videos you play show up here so you can find them again.'],
    clearable: true,
  },
  liked: {
    title: 'Liked videos', eyebrow: 'Your likes', fetch: () => api.liked(),
    empty: ['No liked videos yet', 'Hit like on a video and it will be listed here.'],
  },
  saved: {
    title: 'Watch later', eyebrow: 'Saved for later', fetch: () => api.saved(),
    empty: ['Nothing saved yet', 'Use the bookmark on any video thumbnail to save it for later.'],
  },
};

export function libraryView(kind) {
  return async () => {
    const config = LIBRARY[kind];
    document.title = `${config.title} · MeTube`;
    if (!state.user) return signedOutPrompt(config.title, `Sign in to see your ${config.title.toLowerCase()}.`);

    const root = h('div');
    const head = h('div.pagehead', {},
      h('div', {},
        h('span.eyebrow', { text: config.eyebrow }),
        h('h1.pagehead__title', { text: config.title }),
      ),
    );
    root.append(head, spinner());

    const { videos } = await config.fetch();
    root.lastChild.remove();

    if (config.clearable && videos.length) {
      head.append(h('div.pagehead__actions', {},
        h('button.btn.btn--ghost', {
          type: 'button',
          onclick: async () => {
            const ok = await confirmDialog({
              title: 'Clear watch history?',
              text: 'This removes every video from your history. It cannot be undone.',
              confirmLabel: 'Clear history',
              danger: true,
            });
            if (!ok) return;
            await api.clearHistory();
            toast('Watch history cleared', 'good');
            navigate('/history', { replace: true });
            window.dispatchEvent(new CustomEvent('metube:rerender'));
          },
        }, icon('trash', 18), 'Clear history'),
      ));
    }

    head.querySelector('.eyebrow').textContent = videos.length
      ? `${plural(videos.length, 'video')}`
      : config.eyebrow;

    root.append(videos.length
      ? videoGrid(videos, { compactRow: false })
      : emptyState(config.empty[0], config.empty[1], h('a.btn.btn--primary', { href: '/' }, 'Browse videos')));
    return root;
  };
}

export function signedOutPrompt(title, text) {
  return h('div', {},
    h('div.pagehead', {}, h('div', {}, h('h1.pagehead__title', { text }))),
    emptyState(title, text,
      h('a.btn.btn--primary', { href: `/signin?next=${encodeURIComponent(location.pathname)}` }, 'Sign in')),
  );
}