patx/youtube-clone

/** Tiny DOM builder. Everything goes in as text, never as HTML. */

const SVG_NS = 'http://www.w3.org/2000/svg';

/**
 * h('div.card#id', { class: 'extra', text: 'hi', onclick: fn, data: {…} }, ...children)
 * Falsy children are skipped, so `cond && node` works inline.
 */
export function h(spec, props = null, ...children) {
  const [tag, ...rest] = String(spec).split(/(?=[.#])/);
  const el = document.createElement(tag || 'div');

  for (const token of rest) {
    if (token[0] === '.') el.classList.add(token.slice(1));
    else el.id = token.slice(1);
  }

  if (props && (typeof props !== 'object' || props.nodeType || Array.isArray(props))) {
    children.unshift(props);
    props = null;
  }

  for (const [key, value] of Object.entries(props ?? {})) {
    if (value == null || value === false) continue;
    if (key === 'class') el.classList.add(...String(value).split(/\s+/).filter(Boolean));
    else if (key === 'text') el.textContent = value;
    else if (key === 'style' && typeof value === 'object') Object.assign(el.style, value);
    else if (key === 'data') for (const [k, v] of Object.entries(value)) el.dataset[k] = v;
    else if (key.startsWith('on') && typeof value === 'function') el.addEventListener(key.slice(2), value);
    else if (key === 'value' || key === 'checked' || key === 'disabled') el[key] = value;
    else el.setAttribute(key, value === true ? '' : value);
  }

  append(el, children);
  return el;
}

export function append(parent, children) {
  for (const child of children.flat(Infinity)) {
    if (child == null || child === false || child === '') continue;
    parent.append(child.nodeType ? child : document.createTextNode(String(child)));
  }
  return parent;
}

/** Inline icon from a path spec; keeps markup out of the rest of the app. */
export function icon(name, size = 22) {
  const svg = document.createElementNS(SVG_NS, 'svg');
  svg.setAttribute('viewBox', '0 0 24 24');
  svg.setAttribute('aria-hidden', 'true');
  svg.setAttribute('width', size);
  svg.setAttribute('height', size);
  for (const d of ICONS[name] ?? ICONS.play) {
    const node = document.createElementNS(SVG_NS, d.startsWith('circle:') ? 'circle' : 'path');
    if (d.startsWith('circle:')) {
      const [cx, cy, r] = d.slice(7).split(',');
      node.setAttribute('cx', cx); node.setAttribute('cy', cy); node.setAttribute('r', r);
    } else {
      node.setAttribute('d', d);
    }
    svg.append(node);
  }
  return svg;
}

const ICONS = {
  home: ['M4 10.6 12 4l8 6.6V20a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z'],
  subs: ['M4 7h16M6 11h12M4.5 15h15a1 1 0 0 1 .8 1.6l-2 2.6a1.5 1.5 0 0 1-1.2.6H6.9a1.5 1.5 0 0 1-1.2-.6l-2-2.6A1 1 0 0 1 4.5 15z'],
  fire: ['M12 3s5 4 5 8.5a5 5 0 0 1-10 0C7 9 9 7.5 9 7.5s.5 2 1.5 2S12 6 12 3z'],
  history: ['M3.2 12a8.8 8.8 0 1 0 2.6-6.2', 'M3 4v4.5h4.5', 'M12 8v4.4l3 1.8'],
  like: ['M7 21V10l5-7 1 .6a2 2 0 0 1 .9 2.2L13 10h5.4a2 2 0 0 1 2 2.5l-1.7 7A2 2 0 0 1 16.7 21z', 'M7 10H4v11h3z'],
  dislike: ['M17 3v11l-5 7-1-.6a2 2 0 0 1-.9-2.2L11 14H5.6a2 2 0 0 1-2-2.5l1.7-7A2 2 0 0 1 7.3 3z', 'M17 14h3V3h-3z'],
  clock: ['circle:12,12,8.6', 'M12 7v5.2l3.2 1.9'],
  bookmark: ['M6.5 4h11a1 1 0 0 1 1 1v15.2a.6.6 0 0 1-.94.5L12 16.8l-5.56 3.9a.6.6 0 0 1-.94-.5V5a1 1 0 0 1 1-1z'],
  channel: ['circle:12,8,4', 'M4.5 20a7.5 7.5 0 0 1 15 0'],
  upload: ['M12 16V4', 'M7.5 8.5 12 4l4.5 4.5', 'M4 16v2.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V16'],
  comment: ['M20 4H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h3v4l5-4h8a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z'],
  reply: ['M9 8 4 12.5 9 17', 'M4 12.5h9a7 7 0 0 1 7 7V21'],
  share: ['circle:18,5.5,2.6', 'circle:6,12,2.6', 'circle:18,18.5,2.6', 'M8.3 10.7 15.7 6.8', 'M8.3 13.3l7.4 3.9'],
  trash: ['M4 7h16', 'M9.5 7V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2', 'M6.5 7l.9 12a1 1 0 0 0 1 .9h7.2a1 1 0 0 0 1-.9L17.5 7'],
  edit: ['M4 20h4L20 8l-4-4L4 16z', 'M14.5 5.5 18.5 9.5'],
  play: ['M8 5.5v13l11-6.5z'],
  film: ['M3 5h18v14H3z', 'M8 5v14M16 5v14', 'M3 12h18'],
  search: ['circle:11,11,7', 'm20 20-3.5-3.5'],
  signout: ['M15 7V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-2', 'M10 12h10', 'm17 9 3 3-3 3'],
  check: ['m5 12.5 4.5 4.5L19 7'],
  x: ['M6 6 18 18M18 6 6 18'],
  bell: ['M18 15V10a6 6 0 1 0-12 0v5l-2 3h16z', 'M10 21h4'],
  eye: ['M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12z', 'circle:12,12,3.2'],
  dots: ['circle:12,5,1.6', 'circle:12,12,1.6', 'circle:12,19,1.6'],
};

export const clear = (node) => { while (node.firstChild) node.firstChild.remove(); return node; };

export function mount(node, ...children) {
  clear(node);
  append(node, children);
  return node;
}