patx/youtube-clone

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