/** Channel page: hero, tabs for videos/about, and inline profile editing. */
import { h, icon, mount } from '../dom.js';
import { api } from '../api.js';
import { state, setUser, refreshSubscriptions } from '../store.js';
import { navigate, currentQuery } from '../router.js';
import { videoGrid, avatar, subscribeButton, emptyState, spinner, toast } from '../ui.js';
import { compact, full, longDate } from '../format.js';
export async function channelView({ username }) {
const handle = String(username).replace(/^@/, '');
const tab = currentQuery().get('tab') || 'videos';
const sort = currentQuery().get('sort') || 'new';
let data;
try {
data = await api.channel(handle, { sort });
} catch (err) {
return emptyState('Channel not found', err.message, h('a.btn.btn--primary', { href: '/' }, 'Back to home'));
}
const { channel, videos, totalViews } = data;
document.title = `${channel.displayName} ยท MeTube`;
const hero = h('header.channelhero', {},
avatar(channel, 'xl'),
h('div.channelhero__info', {},
h('h1.channelhero__name', { text: channel.displayName }),
h('div.channelhero__handle', { text: `@${channel.username}` }),
h('div.channelhero__stats', {},
h('span', {}, h('span.num', { text: compact(channel.subscriberCount) }), ` ${channel.subscriberCount === 1 ? 'subscriber' : 'subscribers'}`),
h('span', {}, h('span.num', { text: compact(channel.videoCount) }), ` ${channel.videoCount === 1 ? 'video' : 'videos'}`),
h('span', {}, h('span.num', { text: compact(totalViews) }), ' total views'),
),
channel.bio ? h('p.channelhero__bio', { text: channel.bio }) : null,
),
h('div.channelhero__actions', {},
channel.isSelf ? h('a.btn.btn--primary', { href: '/upload' }, icon('upload', 18), 'Upload') : null,
channel.isSelf
? h('button.btn.btn--ghost', { type: 'button', onclick: () => openEditor(channel, hero) }, icon('edit', 18), 'Edit profile')
: subscribeButton(channel),
),
);
hero.style.setProperty('--hue', String(channel.avatarHue));
const link = (key, label) => h('button.tab', {
type: 'button',
class: key === tab ? 'is-on' : '',
'aria-current': key === tab ? 'page' : null,
onclick: () => navigate(`/@${channel.username}${key === 'videos' ? '' : `?tab=${key}`}`),
}, label);
const root = h('div', {}, hero, h('div.tabs', {}, link('videos', 'Videos'), link('about', 'About')));
if (tab === 'about') {
root.append(h('div.panel', { style: { maxWidth: '720px' } },
h('span.eyebrow', { text: 'About' }),
h('p', { style: { whiteSpace: 'pre-wrap', margin: '0 0 18px', lineHeight: '1.65' },
text: channel.bio || 'This channel has not written a description yet.' }),
h('div.channelhero__stats', { style: { marginTop: 0 } },
h('span', {}, 'Joined ', longDate(channel.createdAt)),
h('span', {}, h('span.num', { text: full(totalViews) }), ' total views'),
),
));
return root;
}
const sorter = h('select.select', {
'aria-label': 'Sort videos',
style: { width: 'auto', marginBottom: '18px' },
onchange: (event) => navigate(`/@${channel.username}?sort=${event.target.value}`),
},
[['new', 'Newest first'], ['popular', 'Most viewed'], ['liked', 'Most liked'], ['oldest', 'Oldest first']]
.map(([value, label]) => h('option', { value, selected: value === sort }, label)),
);
root.append(
videos.length ? sorter : null,
videos.length
? videoGrid(videos)
: emptyState(
channel.isSelf ? 'You have not uploaded anything yet' : 'No videos yet',
channel.isSelf
? 'Your uploads will appear here as soon as they finish processing.'
: `${channel.displayName} has not published a video yet. Subscribe to hear about the first one.`,
channel.isSelf ? h('a.btn.btn--primary', { href: '/upload' }, icon('upload', 18), 'Upload a video') : null,
),
);
return root;
}
/** Inline profile editor, swapped in place of the hero. */
function openEditor(channel, hero) {
const name = h('input.input', { value: channel.displayName, maxlength: '40', required: true });
const bio = h('textarea.textarea', { maxlength: '600', placeholder: 'Tell people what this channel is about.' }, channel.bio);
const hue = h('input', {
type: 'range', min: '0', max: '359', value: String(channel.avatarHue),
style: { width: '100%', accentColor: 'var(--amber)' },
'aria-label': 'Avatar colour',
});
const preview = avatar(channel, 'lg');
const syncPreview = () => {
preview.style.setProperty('--hue', hue.value);
preview.textContent = (name.value.trim() || channel.username).split(/\s+/).slice(0, 2).map((w) => w[0] ?? '').join('').toUpperCase() || '?';
};
hue.addEventListener('input', syncPreview);
name.addEventListener('input', syncPreview);
const error = h('div.formerror', { hidden: true });
const save = h('button.btn.btn--primary', { type: 'submit' }, 'Save changes');
const form = h('form.panel', {
style: { marginBottom: '24px' },
onsubmit: async (event) => {
event.preventDefault();
save.disabled = true;
error.hidden = true;
try {
const { user } = await api.updateMe({
displayName: name.value.trim(),
bio: bio.value.trim(),
avatarHue: Number(hue.value),
});
setUser(user);
await refreshSubscriptions();
toast('Profile updated', 'good');
window.dispatchEvent(new CustomEvent('metube:rerender'));
} catch (err) {
error.textContent = err.message;
error.hidden = false;
save.disabled = false;
}
},
},
h('span.eyebrow', { text: 'Edit profile' }),
error,
h('div', { style: { display: 'flex', gap: '22px', alignItems: 'flex-start', flexWrap: 'wrap' } },
h('div', { style: { display: 'grid', gap: '12px', justifyItems: 'center' } }, preview, h('div', { style: { width: '120px' } }, hue)),
h('div', { style: { flex: '1 1 300px', minWidth: '260px' } },
h('div.field', {}, h('label.field__label', { text: 'Display name' }), name),
h('div.field', {}, h('label.field__label', { text: 'About' }), bio),
),
),
h('div', { style: { display: 'flex', gap: '9px', justifyContent: 'flex-end' } },
h('button.btn.btn--ghost', { type: 'button', onclick: () => window.dispatchEvent(new CustomEvent('metube:rerender')) }, 'Cancel'),
save,
),
);
syncPreview();
hero.replaceWith(form);
name.focus();
}