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