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