patx/youtube-clone

/** Bootstraps MeTube: routes, chrome (sidebar + account menu) and theme. */
import { h, icon, mount, clear } from './dom.js';
import { api } from './api.js';
import { state, bootstrap, onChange, setUser } from './store.js';
import { define, resolve, start, navigate, currentPath, currentQuery } from './router.js';
import { avatar, dropdown, menuItem, toast, teardown, spinner, emptyState } from './ui.js';
import { compact } from './format.js';
import { homeView, searchView, trendingView, subscriptionsView, channelsView, libraryView } from './views/browse.js';
import { watchView } from './views/watch.js';
import { channelView } from './views/channel.js';
import { authView } from './views/auth.js';
import { uploadView, editView } from './views/upload.js';

/* ---------------------------------------------------------------- routes -- */

define('/', homeView);
define('/search', searchView);
define('/trending', trendingView);
define('/subscriptions', subscriptionsView);
define('/channels', channelsView);
define('/history', libraryView('history'));
define('/liked', libraryView('liked'));
define('/later', libraryView('saved'));
define('/watch/:id', watchView);
define('/upload', uploadView);
define('/edit/:id', editView);
define('/signin', authView('signin'));
define('/signup', authView('signup'));
define('/@:username', channelView);

/* ----------------------------------------------------------------- theme -- */

const themeToggle = document.getElementById('theme-toggle');

function applyTheme(theme) {
  document.documentElement.dataset.theme = theme;
  document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', theme === 'dark' ? 'dark light' : 'light dark');
  themeToggle.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`);
  try { localStorage.setItem('metube:theme', theme); } catch { /* private mode */ }
}

function initTheme() {
  let stored = null;
  try { stored = localStorage.getItem('metube:theme'); } catch { /* private mode */ }
  const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
  applyTheme(stored ?? (prefersLight ? 'light' : 'dark'));
}

themeToggle.addEventListener('click', () => {
  applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
});

/* --------------------------------------------------------------- sidebar -- */

const sidebar = document.getElementById('sidebar');
const scrim = document.getElementById('sidebar-scrim');
const navToggle = document.getElementById('nav-toggle');
const isNarrow = () => window.matchMedia('(max-width: 1000px)').matches;

const closeDrawer = () => {
  sidebar.classList.remove('is-open');
  scrim.hidden = true;
  navToggle.setAttribute('aria-expanded', 'false');
};

navToggle.addEventListener('click', () => {
  if (isNarrow()) {
    const open = sidebar.classList.toggle('is-open');
    scrim.hidden = !open;
    navToggle.setAttribute('aria-expanded', String(open));
  } else {
    sidebar.classList.toggle('is-hidden');
    navToggle.setAttribute('aria-expanded', String(!sidebar.classList.contains('is-hidden')));
  }
});
scrim.addEventListener('click', closeDrawer);

const NAV_MAIN = [
  ['/', 'home', 'Home'],
  ['/subscriptions', 'subs', 'Subscriptions'],
  ['/trending', 'fire', 'Trending'],
  ['/channels', 'channel', 'Channels'],
];

const NAV_LIBRARY = [
  ['/history', 'history', 'History'],
  ['/liked', 'like', 'Liked videos'],
  ['/later', 'clock', 'Watch later'],
];

function navLink(href, iconName, label, extra = null) {
  const active = currentPath() === href && !(href === '/' && currentQuery().get('category'));
  return h('a.navlink', { href, class: active ? 'is-active' : '', 'aria-current': active ? 'page' : null },
    icon(iconName, 21), h('span', { text: label }), extra);
}

function renderSidebar() {
  const path = currentPath();
  const groups = [
    h('div.navgroup', {}, NAV_MAIN.map(([href, ic, label]) => navLink(href, ic, label))),
    h('div.navgroup', {},
      h('div.navgroup__title', { text: 'Library' }),
      NAV_LIBRARY.map(([href, ic, label]) => navLink(href, ic, label)),
      state.user
        ? navLink(`/@${state.user.username}`, 'film', 'Your channel')
        : null,
      state.user
        ? navLink('/upload', 'upload', 'Upload')
        : null,
    ),
  ];

  if (state.user && state.subscriptions.length) {
    groups.push(h('div.navgroup', {},
      h('div.navgroup__title', { text: 'Subscriptions' }),
      state.subscriptions.map((channel) => {
        const href = `/@${channel.username}`;
        const active = decodeURIComponent(path) === href;
        return h('a.navlink', { href, class: active ? 'is-active' : '' },
          avatar(channel, 'sm'),
          h('span', { text: channel.displayName }),
          h('span.navlink__count.num', { text: compact(channel.videoCount) }),
        );
      }),
    ));
  }

  if (!state.user) {
    groups.push(h('div.navgroup', {},
      h('div', { style: { padding: '14px 12px', color: 'var(--haze)', fontSize: '13px', lineHeight: '1.6' } },
        'Sign in to like videos, comment and subscribe.',
        h('a.btn.btn--ghost.btn--full', { href: '/signin', style: { marginTop: '12px' } }, icon('channel', 18), 'Sign in'),
      ),
    ));
  }

  mount(sidebar, groups);
}

/* --------------------------------------------------------- account menu -- */

function renderAccount() {
  const slot = document.getElementById('account-slot');
  if (!state.user) {
    mount(slot, h('a.btn.btn--primary.btn--sm', { href: '/signin' }, 'Sign in'));
    return;
  }

  const trigger = h('button.iconbtn', { type: 'button', 'aria-label': 'Your account' }, avatar(state.user));
  mount(slot, dropdown(trigger, (close) => [
    h('div.menu__head', {},
      h('div.menu__name', { text: state.user.displayName }),
      h('div.menu__handle', { text: `@${state.user.username}` }),
    ),
    menuItem('Your channel', 'film', () => { close(); navigate(`/@${state.user.username}`); }),
    menuItem('Upload a video', 'upload', () => { close(); navigate('/upload'); }),
    menuItem('Watch later', 'clock', () => { close(); navigate('/later'); }),
    menuItem('Liked videos', 'like', () => { close(); navigate('/liked'); }),
    h('div.menu__sep'),
    menuItem('Sign out', 'signout', async () => {
      close();
      try {
        await api.logout();
        setUser(null);
        toast('Signed out');
        navigate('/');
      } catch (err) {
        toast(err.message, 'error');
      }
    }),
  ]));
}

/* ---------------------------------------------------------------- search -- */

const searchForm = document.getElementById('search-form');
const searchInput = document.getElementById('search-input');
const searchClear = document.getElementById('search-clear');

searchForm.addEventListener('submit', (event) => {
  event.preventDefault();
  const q = searchInput.value.trim();
  navigate(q ? `/search?q=${encodeURIComponent(q)}` : '/');
  searchInput.blur();
});
searchInput.addEventListener('input', () => { searchClear.hidden = !searchInput.value; });
searchClear.addEventListener('click', () => {
  searchInput.value = '';
  searchClear.hidden = true;
  searchInput.focus();
});

document.addEventListener('keydown', (event) => {
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName) || event.target.isContentEditable;
  if (event.key === '/' && !typing) {
    event.preventDefault();
    searchInput.focus();
    searchInput.select();
  }
});

/* ----------------------------------------------------------------- render -- */

const view = document.getElementById('view');
let renderToken = 0;

async function render() {
  const token = ++renderToken;
  const path = currentPath();
  const match = resolve(path);

  closeDrawer();
  renderSidebar();

  const q = currentQuery().get('q');
  searchInput.value = path === '/search' && q ? q : '';
  searchClear.hidden = !searchInput.value;

  teardown(view);
  mount(view, spinner());
  document.title = 'MeTube';

  if (!match) {
    mount(view, emptyState('Page not found',
      `There is nothing at ${path}. It may have been moved or deleted.`,
      h('a.btn.btn--primary', { href: '/' }, 'Back to home')));
    return;
  }

  let node;
  try {
    node = await match.view(match.params);
  } catch (err) {
    console.error(err);
    node = emptyState('Something went wrong', err.message || 'Try reloading the page.',
      h('button.btn.btn--primary', { type: 'button', onclick: () => render() }, 'Try again'));
  }

  if (token !== renderToken) return; // a newer navigation won
  mount(view, node);
  renderSidebar();

  if (!window.history.state?.keepScroll) window.scrollTo({ top: 0, behavior: 'instant' });
}

window.addEventListener('metube:rerender', () => render());
onChange(() => { renderSidebar(); renderAccount(); });

initTheme();
renderAccount();
renderSidebar();

// Resolve the session first so the first paint already knows who is signed in.
await bootstrap().catch((err) => {
  console.error('Could not load your session', err);
  state.ready = true;
});
renderAccount();
start(render);