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