Candidate:Gadget-pickipedia-show.js

From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Revision as of 20:18, 2 June 2026 by Magent (talk | contribs) (v3: absorb {{Ensemble|...}} blocks (not ensemble= field); upgrade poster thumb to 800px; render poster_artist credit)
Jump to navigationJump to search
// |status=proposed -- bot-staged; remove this line when promoting.
/**
 * Pickipedia — Show: page hero.
 *
 * For mainspace pages whose title starts with "Show:" (the legacy
 * mainspace convention used pre-namespace), adds .pickipedia-show
 * to body and absorbs Template:Show's infobox into a poster-style
 * hero. If the page does not use Template:Show only the body class
 * is added — the setlist typography in Common.css still applies.
 */
(function () {
  'use strict';

  if (typeof mw === 'undefined' || !mw.config) return;

  var nsNumber = mw.config.get('wgNamespaceNumber');
  var title    = mw.config.get('wgTitle') || '';
  var pageName = mw.config.get('wgPageName') || '';
  var action   = mw.config.get('wgAction') || 'view';

  if (nsNumber !== 0) return;
  if (action !== 'view') return;
  if (title.indexOf('Show:') !== 0) return;

  var body = document.body;
  body.classList.add('pickipedia-show');

  var content = document.querySelector('.mw-parser-output');
  if (!content) return;

  var infobox = content.querySelector('.show-infobox');
  if (!infobox) return;  // Bare show page — body class only.

  // ---------- Pull fields out of the existing infobox ----------
  // Template:Show emits rows of <div><strong>Label:</strong> value</div>
  // plus a top banner with "Artists at Venue" and optional image.
  function fieldValue(label) {
    var rows = infobox.querySelectorAll('div');
    for (var i = 0; i < rows.length; i++) {
      var s = rows[i].querySelector('strong');
      if (!s) continue;
      var labelText = s.textContent.replace(/[:\s]*$/, '').trim();
      if (labelText !== label) continue;
      var clone = rows[i].cloneNode(true);
      var strong = clone.querySelector('strong');
      if (strong) strong.remove();
      return clone.innerHTML.trim().replace(/^[\s:]+/, '');
    }
    return '';
  }

  var artists      = fieldValue('Artists');
  var venue        = fieldValue('Venue');
  var showtime     = fieldValue('Showtime');
  var doors        = fieldValue('Doors');
  var show         = fieldValue('Show');
  var posterArtist = fieldValue('Poster art');
  var tickets      = fieldValue('Tickets');
  var price        = fieldValue('Price');
  var ages         = fieldValue('Ages');

  var imageImg = infobox.querySelector('img');

  var status = '';
  if (infobox.classList.contains('show-verified')) status = 'verified';
  else if (infobox.classList.contains('bot-proposal')) status = 'proposed';
  else if (infobox.classList.contains('show-unverified')) status = 'unverified';

  // ---------- Build the poster hero ----------
  // Showtime is rendered by Template:Show as
  // [https://etherscan.io/block/N N] — pull the block number out and
  // format it with commas so the kicker reads "BLOCK 24,144,194".
  function formatShowtime(htmlIn) {
    if (!htmlIn) return '';
    var tmp = document.createElement('div');
    tmp.innerHTML = htmlIn;
    var anchor = tmp.querySelector('a');
    var blockNum = (anchor ? anchor.textContent : tmp.textContent).trim();
    var asInt = parseInt(blockNum.replace(/[^\d]/g, ''), 10);
    if (isNaN(asInt)) return blockNum;
    var formatted = asInt.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
    var href = anchor ? anchor.href : '';
    if (href) {
      return '<a href="' + href + '" target="_blank" rel="noopener">Block ' + formatted + '</a>';
    }
    return 'Block ' + formatted;
  }

  var kickerBits = [];
  var showtimeHtml = formatShowtime(showtime);
  if (showtimeHtml) kickerBits.push(showtimeHtml);
  if (doors) kickerBits.push('Doors ' + doors);
  if (show)  kickerBits.push('Show ' + show);

  var kickerHtml = kickerBits.length
    ? '<div class="pp-kicker">' + kickerBits.join('<span class="pp-sep">·</span>') + '</div>'
    : '';

  var venueHtml = venue ? '<p class="pp-venue">at ' + venue + '</p>' : '';

  // Ensembles: find every Template:Ensemble block on the page. Each renders
  // as a .ensemble-infobox with a colored header div (the band title) and
  // a body div with one musician per line (via Template:m). Multiple
  // ensembles per page are common — headliner + opener(s) + sit-ins.
  var ensembleBlocks = content.querySelectorAll('.ensemble-infobox');
  var ensembleHtml = '';
  ensembleBlocks.forEach(function (eb) {
    // Header div carries the band name (or default "Ensemble").
    var bandName = '';
    var headerEl = eb.querySelector('div:first-child');
    if (headerEl) bandName = headerEl.textContent.trim();

    // Member list: the second-child div has musicians separated by <br>.
    var listEl = eb.querySelector('div:nth-child(2)');
    if (!listEl) return;
    // Each "row" is text between <br>s; pull text content and split.
    var raw = listEl.innerHTML
      .split(/<br\s*\/?>/i)
      .map(function (chunk) {
        var tmp = document.createElement('div');
        tmp.innerHTML = chunk;
        return tmp.textContent.trim();
      })
      .filter(Boolean);
    if (!raw.length) return;

    var labelHtml = bandName && bandName.toLowerCase() !== 'ensemble'
      ? '<span class="pp-ensemble-label">' + escapeHtml(bandName) + '</span>'
      : '<span class="pp-ensemble-label">Featuring</span>';
    ensembleHtml += '<p class="pp-ensemble">' + labelHtml + ' ' +
      raw.map(escapeHtml).join(' <span class="pp-sep">·</span> ') + '</p>';

    eb.classList.add('is-absorbed');
  });

  var metaBits = [];
  if (price)
    metaBits.push('<span><span class="pp-meta-label">Price</span><span class="pp-meta-value">$' +
      escapeHtml(price.replace(/^\$/, '')) + '</span></span>');
  if (ages)
    metaBits.push('<span><span class="pp-meta-label">Ages</span><span class="pp-meta-value">' +
      ages + '</span></span>');
  var metaHtml = metaBits.length
    ? '<div class="pp-meta">' + metaBits.join('') + '</div>'
    : '';

  // Upgrade the poster thumbnail: Template:Show renders [[File:foo|220px]]
  // which gives us /images/thumb/.../220px-foo.png. We replace the 220px
  // segment with 800px to request a larger thumbnail; CSS scales it to
  // fit the card. If the URL doesn't match the thumb pattern (e.g. SVG,
  // or non-thumbnailed file), we just use the original src.
  var imageHtml = '';
  if (imageImg) {
    var bigSrc = imageImg.src.replace(/\/(\d+)px-/, '/800px-');
    imageHtml = '<div class="pp-image"><img src="' + bigSrc +
      '" alt="' + escapeHtml(imageImg.alt || '') + '">' +
      (posterArtist
        ? '<p class="pp-poster-credit"><span class="pp-poster-credit-label">Poster art by</span> ' +
            escapeHtml(posterArtist) + '</p>'
        : '') +
      '</div>';
  }

  // Tickets field is an anchor; surface its href as a poster action.
  var actionBits = [];
  if (tickets) {
    var tmp = document.createElement('div'); tmp.innerHTML = tickets;
    var ticketAnchor = tmp.querySelector('a');
    if (ticketAnchor && ticketAnchor.href) {
      actionBits.push('<a href="' + ticketAnchor.href +
        '" target="_blank" rel="noopener">Tickets</a>');
    }
  }
  var actionsHtml = actionBits.length
    ? '<div class="pp-actions">' + actionBits.join('') + '</div>'
    : '';

  var statusHtml = status
    ? '<div class="pp-status is-' + status + '">' + status + '</div>'
    : '';

  var titleHtml = artists || escapeHtml(title.replace(/^Show:\s*/, ''));

  var hero = document.createElement('header');
  hero.className = 'show-poster';
  hero.innerHTML =
    statusHtml +
    kickerHtml +
    '<h1 class="pp-title">' + titleHtml + '</h1>' +
    venueHtml +
    ensembleHtml +
    metaHtml +
    imageHtml +
    actionsHtml;

  content.insertBefore(hero, content.firstChild);
  infobox.classList.add('is-absorbed');
  body.classList.add('has-show-hero');

  // ---------- Full-takeover chrome: corner logo + slim topbar ----------
  // Only fires when the hero was built (Template:Show present). The CSS
  // chrome-hiding rules are gated by .has-show-hero so bare Show: pages
  // keep their wiki sidebar; if you want to make those takeover too,
  // remove the .has-show-hero gating from the CSS file.
  var LOGO_URL = '/images/thumb/8/80/Pickipedia-quarter-transparent.png/400px-Pickipedia-quarter-transparent.png';

  var cornerLogo = document.createElement('a');
  cornerLogo.className = 'pp-corner-logo';
  cornerLogo.href = '/wiki/Main_Page';
  cornerLogo.title = 'PickiPedia home';
  cornerLogo.innerHTML = '<img src="' + LOGO_URL + '" alt="PickiPedia">';
  body.insertBefore(cornerLogo, body.firstChild);

  var topbar = document.createElement('header');
  topbar.className = 'pickipedia-show-topbar';
  topbar.innerHTML =
    '<nav class="pickipedia-show-topbar-left">' +
      '<a href="' + mw.util.getUrl('Category:Shows') + '">← All shows</a>' +
    '</nav>' +
    '<nav class="pickipedia-show-topbar-right">' +
      '<a href="' + mw.util.getUrl(pageName, { action: 'edit' }) + '">edit</a>' +
      '<a href="' + mw.util.getUrl(pageName, { action: 'history' }) + '">history</a>' +
      '<a href="' + mw.util.getUrl('Talk:' + pageName) + '">talk</a>' +
    '</nav>';
  body.insertBefore(topbar, cornerLogo.nextSibling);

  function escapeHtml(s) {
    return String(s == null ? '' : s)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }
}());