MediaWiki:Common.js: Difference between revisions

From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search
No edit summary
No edit summary
 
(One intermediate revision by the same user not shown)
Line 1: Line 1:
/**
/**
* Blue Railroad Submission - Date to Block Height Converter
* What used to be here now ships with the PickiPediaReleases extension.
* Adds a datepicker that converts dates to Ethereum block heights
* Also shows time ago for news templates
*/
 
(function() {
    'use strict';
   
    // Only run on the Blue Railroad Submission form
    if (!document.querySelector('input[name="Blue Railroad Submission[block_height]"]')) {
        return;
    }
   
    // Reference point: known block and timestamp
    // Post-merge average block time is ~12.12 seconds
    const AVG_BLOCK_TIME = 12.12;
   
    // Get current block from footer (format: "24,328,442")
    function getCurrentBlockFromFooter() {
        const footerLink = document.querySelector('a[href*="etherscan.io/block/"]');
        if (footerLink) {
            const match = footerLink.href.match(/block\/(\d+)/);
            if (match) return parseInt(match[1]);
        }
        return null;
    }
   
    // Calculate block height from date
    function dateToBlockHeight(targetDate, refBlock, refTimestamp) {
        const targetTimestamp = targetDate.getTime() / 1000;
        const secondsDiff = refTimestamp - targetTimestamp;
        const blocksDiff = Math.round(secondsDiff / AVG_BLOCK_TIME);
        return refBlock - blocksDiff;
    }
   
    // Add the datepicker UI
    function addDatePicker() {
        const blockInput = document.querySelector('input[name="Blue Railroad Submission[block_height]"]');
        if (!blockInput) return;
       
        const container = document.createElement('div');
        container.style.marginTop = '8px';
        container.innerHTML = `
            <label style="display: block; margin-bottom: 4px; font-size: 0.9em;">
                Or pick a date/time:
            </label>
            <input type="datetime-local" id="br-datepicker" style="padding: 4px; margin-right: 8px;">
            <button type="button" id="br-convert-btn" style="padding: 4px 12px; cursor: pointer;">
                Convert to Block
            </button>
            <span id="br-status" style="margin-left: 8px; font-size: 0.9em; color: #666;"></span>
        `;
       
        blockInput.parentNode.appendChild(container);
       
        const datePicker = document.getElementById('br-datepicker');
        const convertBtn = document.getElementById('br-convert-btn');
        const status = document.getElementById('br-status');
       
        // Set default to now
        const now = new Date();
        datePicker.value = now.toISOString().slice(0, 16);
       
        convertBtn.addEventListener('click', function() {
            const selectedDate = new Date(datePicker.value);
            if (isNaN(selectedDate.getTime())) {
                status.textContent = 'Invalid date';
                status.style.color = 'red';
                return;
            }
           
            const currentBlock = getCurrentBlockFromFooter();
            if (!currentBlock) {
                status.textContent = 'Could not find reference block';
                status.style.color = 'red';
                return;
            }
           
            const currentTimestamp = Date.now() / 1000;
            const estimatedBlock = dateToBlockHeight(selectedDate, currentBlock, currentTimestamp);
           
            if (estimatedBlock > currentBlock) {
                status.textContent = 'Date is in the future!';
                status.style.color = 'orange';
            } else {
                status.textContent = '~estimated';
                status.style.color = 'green';
            }
           
            blockInput.value = estimatedBlock;
        });
    }
   
    // Run when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', addDatePicker);
    } else {
        addDatePicker();
    }
})();
 
/**
* TimeAgo — shows "X blocks ago" for transcluded templates.
  *
  *
  * Usage in wikitext:
  *  the HLS video player  -> ext.pickipediaReleases.videoPlayer.js    (#124)
*   <span class="timeago" data-lastmod-page="Template:NewsShorts"></span>
*  Blue Railroad date picker -> ext.pickipediaReleases.blueRailroadDate.js (#131)
*
*  "X blocks ago"         -> ext.pickipediaReleases.blocksAgo.js      (#131)
* Fetches the last revision timestamp from the API and renders
*  Instrument form checks -> ext.pickipediaReleases.instrumentForm.js (#131)
* a block-based relative time string using Ethereum block heights.
*/
(function() {
    'use strict';
 
    // Post-merge average block time is ~12.12 seconds
    var AVG_BLOCK_TIME = 12.12;
 
    var spans = document.querySelectorAll('.timeago[data-lastmod-page]');
    if (!spans.length) return;
 
    // Format number with commas (e.g., 1234567 -> "1,234,567")
    function formatNumber(num) {
        return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
    }
 
    // Collect unique page titles
    var titles = [];
    spans.forEach(function(span) {
        var t = span.getAttribute('data-lastmod-page');
         if (t && titles.indexOf(t) === -1) titles.push(t);
    });
 
    // Batch query the API (up to 50 titles per request)
    var api = mw.config.get('wgScriptPath') + '/api.php';
    var url = api + '?action=query&prop=revisions&rvprop=timestamp&format=json&titles=' +
        encodeURIComponent(titles.join('|'));
 
    fetch(url).then(function(r) { return r.json(); }).then(function(data) {
        var pages = data.query && data.query.pages || {};
        var timestamps = {};
 
        Object.keys(pages).forEach(function(id) {
            var page = pages[id];
            if (page.revisions && page.revisions[0]) {
                timestamps[page.title] = new Date(page.revisions[0].timestamp);
            }
        });
 
        spans.forEach(function(span) {
            var title = span.getAttribute('data-lastmod-page');
            var ts = timestamps[title];
            if (!ts) {
                span.textContent = '';
                return;
            }
            span.textContent = formatBlocksAgo(ts);
            span.title = ts.toLocaleString();
        });
    });
 
    function formatBlocksAgo(date) {
        var secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
        var blocksAgo = Math.round(secondsAgo / AVG_BLOCK_TIME);
 
        if (blocksAgo < 1) {
            return 'this block';
        } else if (blocksAgo === 1) {
            return '1 block ago';
        } else {
            return formatNumber(blocksAgo) + ' blocks ago';
        }
    }
})();
 
/**
* HLS Video Player - Initializes HLS.js for IPFS-hosted videos
  *
  *
  * Usage in wikitext (via Template:HLSVideo):
  * All four load on every page from the extension's BeforePageDisplay hook,
  *   <div class="hls-video-player" data-cid="QmXet6..."></div>
* exactly as they did from here, and each does nothing unless the form or
* element it cares about is on the page being shown. Changing any of them is
* a pull request now: reviewed, syntax-checked by CI, deployed by Jenkins —
* rather than several hundred lines pasted into this box, which is how the
  * whole of this file went down for 23 minutes on 15 September 2026.
  *
  *
  * The gadget loads hls.js and initializes players for any element
  * What remains below is the user-subpage zine styling. It stays because it is
  * with the hls-video-player class and a data-cid attribute.
* bound to classes defined in MediaWiki:Common.css, and moving the script
  * without its stylesheet would split one feature across two homes. It goes
* next, with the CSS, after which this page can be empty.
  *
  *
  * Supports both HLS streams (CID/master.m3u8) and raw video files.
  * See cryptograss/pickipedia#124 and #131.
* Tries HLS first; if the manifest 404s, falls back to direct playback.
  */
  */
(function() {
    'use strict';
    var IPFS_GATEWAY = 'https://ipfs.delivery-kid.cryptograss.live/ipfs';
    var HLS_JS_URL = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
    var hlsLoadPromise = null;
    // CIDv1 (bafy...) is Base32 lowercase, but MediaWiki capitalizes page titles.
    // CIDv0 (Qm...) is Base58 case-sensitive — must not be lowercased.
    function normalizeCid(cid) {
        if (cid && cid.substring(0, 4) === 'Bafy') {
            return cid.toLowerCase();
        }
        return cid;
    }
    function loadHls() {
        if (typeof Hls&#32;!== 'undefined') {
            return Promise.resolve();
        }
        if (hlsLoadPromise) {
            return hlsLoadPromise;
        }
        hlsLoadPromise = new Promise(function(resolve) {
            var script = document.createElement('script');
            script.src = HLS_JS_URL;
            script.onload = resolve;
            document.head.appendChild(script);
        });
        return hlsLoadPromise;
    }
// Accepts raw seconds (872), "14:32", or "1:02:03". Anything unparseable
    // means "start at the beginning" rather than throwing — a typo in a
    // citation should still play the video.
    function parseTimecode(raw) {
        if (!raw) { return 0; }
        var parts = String(raw).trim().split(':');
        for (var i = 0; i < parts.length; i++) {
            if (parts[i] === '' || isNaN(Number(parts[i]))) { return 0; }
        }
        return parts.reduce(function (acc, p) { return acc * 60 + Number(p); }, 0);
    }
    // Seek once the browser knows how long the video is.
    //
    // All three playback paths below — Safari's native HLS, hls.js, and the
    // direct-file fallback — end up attached to a real <video>, so hanging one
    // listener on the element covers every case. Seeking earlier than
    // loadedmetadata is unreliable: with hls.js the manifest may not be parsed
    // yet and the seek is silently dropped.
    function seekWhenReady(video, seconds) {
        if (!seconds) { return; }
        video.addEventListener('loadedmetadata', function onMeta() {
            video.removeEventListener('loadedmetadata', onMeta);
            // A citation pointing past the end should land on the last frame
            // rather than being ignored outright.
            video.currentTime = isFinite(video.duration)
              &#32;? Math.min(seconds, video.duration)
              &#32;: seconds;
        });
    }
    // A #t= fragment in the URL seeks the player too, which is what makes a
    // {{Src|video|...|t=14:32}} citation land the reader on the assertion
    // rather than at 0:00 of a forty-minute set.
    function timecodeFromHash() {
        var m = /[#&]t=([0-9:]+)/.exec(window.location.hash || '');
        return m&#32;? parseTimecode(m[1])&#32;: 0;
    }
    function initPlayers() {
        var containers = document.querySelectorAll('.hls-video-player[data-cid]:not([data-initialized])');
        if (!containers.length) return;
        // Mark ALL containers immediately to prevent race conditions
        containers.forEach(function(c) {
            c.setAttribute('data-initialized', 'true');
        });
        // Then load hls.js and initialize
        loadHls().then(function() {
            containers.forEach(initPlayer);
        });
    }
    function createVideoElement(width, maxWidth) {
        var video = document.createElement('video');
        video.controls = true;
        video.playsInline = true;
        video.style.width = width;
        video.style.maxWidth = maxWidth;
        video.style.backgroundColor = '#000';
        video.style.display = 'block';
        return video;
    }
    // Can this browser actually decode AV1? Apple ships no software AV1
    // decoder, so Safari plays it only on M3/A17-and-later hardware; Chrome
    // and Firefox bundle dav1d and manage it anywhere. Everything published
    // through delivery-kid since Sept 2026 is AV1, so "can't play" is far
    // more often a decoder gap than a missing file.
    function canDecodeAV1() {
        var type = 'video/mp4; codecs="av01.0.05M.08"';
        try {
            if (window.MediaSource && window.MediaSource.isTypeSupported) {
                return window.MediaSource.isTypeSupported(type);
            }
            return document.createElement('video').canPlayType(type)&#32;!== '';
        } catch (e) {
            return false;
        }
    }
    // One honest failure message. The old text blamed IPFS unconditionally,
    // which sent people debugging storage for what was usually a codec
    // problem — and said "may not be available" about content that was
    // demonstrably being served.
    function showPlaybackError(container, cid) {
        var url = IPFS_GATEWAY + '/' + normalizeCid(cid) + '/master.m3u8';
        var html;
        if (!canDecodeAV1()) {
            html = '<p style="padding:1em; border:1px solid #ac6600; background:#fef6e7;">'
                + '<strong>This browser cannot play this video.</strong><br>'
                + 'It is encoded in AV1 — a royalty-free format, which is why we use it. '
                + 'Apple ships no software AV1 decoder, so Safari plays it only on M3 or newer Macs '
                + 'and iPhone 15 Pro or newer.<br><br>'
                + '<strong>Firefox and Chrome play it on any desktop</strong>, including older Intel Macs. '
                + 'Or download it and play it in VLC.<br>'
                + '<a href="' + url + '" rel="nofollow">' + url + '</a>'
                + '</p>';
        } else {
            html = '<p style="padding:1em; border:1px solid #b32424; background:#fee;">'
                + '<strong>Could not load this video.</strong><br>'
                + 'Your browser can decode AV1, so this is more likely a fetch or gateway problem '
                + 'than a codec one. Try the stream directly — if this link works, the content is '
                + 'present and the fault is in playback rather than availability:<br>'
                + '<a href="' + url + '" rel="nofollow">' + url + '</a>'
                + '</p>';
        }
        container.innerHTML = html;
    }
function fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds) {
        // CID is a raw video file, not an HLS stream — play directly
        var directUrl = IPFS_GATEWAY + '/' + normalizeCid(cid);
        container.innerHTML = '';
        var video = createVideoElement(width, maxWidth);
        video.src = directUrl;
        video.addEventListener('error', function() {
            showPlaybackError(container, cid);
        });
        seekWhenReady(video, startSeconds);
        container.appendChild(video);
    }
function initPlayer(container) {
        var cid = container.getAttribute('data-cid');
        var normalCid = normalizeCid(cid);
        var width = container.getAttribute('data-width') || '100%';
        var maxWidth = container.getAttribute('data-max-width') || '800px';
        // data-start wins over the URL fragment: an embed that explicitly asks
        // for a moment means it, whereas the fragment is a property of how the
        // reader arrived and should not override the page's own intent.
        var startSeconds = parseTimecode(container.getAttribute('data-start'))
            || timecodeFromHash();
        var hlsSrc = IPFS_GATEWAY + '/' + normalCid + '/master.m3u8';
        var video = createVideoElement(width, maxWidth);
        // A still from the video, pinned alongside it under the same CID, so
        // the player shows the thing rather than a black rectangle before
        // anyone presses play. It is a JPEG, which means it also renders on
        // browsers that cannot decode the AV1 behind it — a Safari user
        // without hardware AV1 at least sees what they are being offered.
        //
        // Releases pinned before posters existed simply have no poster.jpg.
        // A video element whose poster 404s falls back to its normal empty
        // state rather than showing a broken image, so this is safe to set
        // unconditionally.
        video.poster = IPFS_GATEWAY + '/' + normalCid + '/poster.jpg';
        seekWhenReady(video, startSeconds);
        container.appendChild(video);
        if (video.canPlayType('application/vnd.apple.mpegurl')) {
            // Safari native HLS — try HLS first, fall back on error.
            // If the browser has no AV1 decoder there is no point chasing the
            // CID as a raw file: it is an HLS directory, that request fails
            // too, and the reader is told the content is missing when the
            // real answer is "this browser cannot decode it".
            video.src = hlsSrc;
            video.addEventListener('error', function() {
                if (!canDecodeAV1()) {
                    showPlaybackError(container, cid);
                    return;
                }
                fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds);
            });
        } else if (typeof Hls&#32;!== 'undefined' && Hls.isSupported()) {
            var hls = new Hls();
            hls.loadSource(hlsSrc);
            hls.attachMedia(video);
            hls.on(Hls.Events.ERROR, function(event, data) {
                if (data.fatal) {
                    hls.destroy();
                    // A media-type fatal on a browser without AV1 is a decoder
                    // gap, not a missing playlist. Say so rather than falling
                    // through to a direct-video attempt that cannot succeed.
                    var mediaFatal = data.type === Hls.ErrorTypes.MEDIA_ERROR;
                    if (mediaFatal && !canDecodeAV1()) {
                        showPlaybackError(container, cid);
                        return;
                    }
                    // Otherwise the usual cause is that this CID is a plain
                    // video file rather than an HLS directory.
                    fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds);
                }
            });
        } else {
            // No HLS support — try direct video
            fallbackToDirectVideo(container, cid, width, maxWidth, startSeconds);
        }
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initPlayers);
    } else {
        initPlayers();
    }
    if (typeof mw&#32;!== 'undefined' && mw.hook) {
        mw.hook('wikipage.content').add(function($content) {
            initPlayers();
        });
    }
})();
/**
* Instrument form — validation + page name computation (v24772700)
*/
(function() {
    'use strict';
    console.log('PickiPedia Common.js instrument module v24772700 loaded');
    mw.hook('pf.formValidation').add(function(args) {
        var nickname = document.querySelector('input[name="Instrument[nickname]"]');
        if (!nickname) return;
        var make = document.querySelector('input[name="Instrument[make]"]');
        var model = document.querySelector('input[name="Instrument[model]"]');
        var serial = document.querySelector('input[name="Instrument[serial]"]');
        var nickVal = nickname.value.trim();
        var makeVal = make&#32;? make.value.trim()&#32;: '';
        var modelVal = model&#32;? model.value.trim()&#32;: '';
        var serialVal = serial&#32;? serial.value.trim()&#32;: '';
        var hasNickname = nickVal&#32;!== '';
        var hasMakerComplete = makeVal&#32;!== '' && modelVal&#32;!== '' && serialVal&#32;!== '';
        if (!hasNickname && !hasMakerComplete) {
            args.numErrors += 1;
            if (makeVal&#32;!== '' || modelVal&#32;!== '' || serialVal&#32;!== '') {
                alert('Maker identification requires all three: make, model, and serial number. Or provide a nickname instead.');
            } else {
                alert('Please provide either a nickname or maker identification (make, model, and serial number).');
            }
            return;
        }
        // If no nickname, fill it from make/model/serial for page naming
        if (!hasNickname) {
            nickname.value = makeVal + ' ' + modelVal + ' ' + serialVal;
        }
    });
})();


/* ============================================================
/* ============================================================
Line 490: Line 47:
   var action  = mw.config.get('wgAction') || 'view';
   var action  = mw.config.get('wgAction') || 'view';


   if (nsNumber&#32;!== 2) return;
   if (nsNumber !== 2) return;
   if (action&#32;!== 'view') return;
   if (action !== 'view') return;
   if (title.indexOf('/') === -1) return;
   if (title.indexOf('/') === -1) return;


Line 509: Line 66:
   }
   }
   var cats = mw.config.get('wgCategories') || [];
   var cats = mw.config.get('wgCategories') || [];
   if (cats.indexOf('Masthead style')&#32;!== -1) variant = 'masthead';
   if (cats.indexOf('Masthead style') !== -1) variant = 'masthead';
   if (cats.indexOf('Letter style')&#32;!== -1) variant = 'letter';
   if (cats.indexOf('Letter style') !== -1) variant = 'letter';
   body.classList.add('pickipedia-variant-' + variant);
   body.classList.add('pickipedia-variant-' + variant);


Line 568: Line 125:
           '<div class="pp-kicker">' + kickerHtml + '</div>' +
           '<div class="pp-kicker">' + kickerHtml + '</div>' +
           '<div class="pp-topbar-rule"></div>' +
           '<div class="pp-topbar-rule"></div>' +
           (lastMod&#32;? '<div class="pp-topbar-vol">' + escapeHtml(lastMod) + '</div>'&#32;: '') +
           (lastMod ? '<div class="pp-topbar-vol">' + escapeHtml(lastMod) + '</div>' : '') +
         '</div>' +
         '</div>' +
         '<div class="pp-titlewrap">' +
         '<div class="pp-titlewrap">' +
           '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
           '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
           (dek&#32;? '<p class="pp-dek">' + dek + '</p>'&#32;: '') +
           (dek ? '<p class="pp-dek">' + dek + '</p>' : '') +
         '</div>';
         '</div>';
     } else {
     } else {
Line 579: Line 136:
         '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
         '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
         '<div class="pp-kicker">' + kickerHtml + '</div>' +
         '<div class="pp-kicker">' + kickerHtml + '</div>' +
         (dek&#32;? '<p class="pp-dek">' + dek + '</p>'&#32;: '') +
         (dek ? '<p class="pp-dek">' + dek + '</p>' : '') +
         (lastMod&#32;? '<div class="pp-meta">Last edited ' + escapeHtml(lastMod) + '</div>'&#32;: '');
         (lastMod ? '<div class="pp-meta">Last edited ' + escapeHtml(lastMod) + '</div>' : '');
     }
     }
     content.parentNode.insertBefore(hero, content);
     content.parentNode.insertBefore(hero, content);
Line 589: Line 146:
   toggle.className = 'pickipedia-mode';
   toggle.className = 'pickipedia-mode';
   toggle.type = 'button';
   toggle.type = 'button';
   toggle.textContent = body.classList.contains('pickipedia-dark')&#32;? '☼ Light'&#32;: '☾ Dark';
   toggle.textContent = body.classList.contains('pickipedia-dark') ? '☼ Light' : '☾ Dark';
   toggle.addEventListener('click', function () {
   toggle.addEventListener('click', function () {
     var dark = body.classList.toggle('pickipedia-dark');
     var dark = body.classList.toggle('pickipedia-dark');
     toggle.textContent = dark&#32;? '☼ Light'&#32;: '☾ Dark';
     toggle.textContent = dark ? '☼ Light' : '☾ Dark';
     try { localStorage.setItem(STORAGE_KEY, dark&#32;? 'dark'&#32;: 'light'); } catch (e) {}
     try { localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) {}
   });
   });
   document.body.appendChild(toggle);
   document.body.appendChild(toggle);


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

Latest revision as of 18:55, 9 October 2026

/**
 * What used to be here now ships with the PickiPediaReleases extension.
 *
 *   the HLS video player   -> ext.pickipediaReleases.videoPlayer.js    (#124)
 *   Blue Railroad date picker -> ext.pickipediaReleases.blueRailroadDate.js (#131)
 *   "X blocks ago"         -> ext.pickipediaReleases.blocksAgo.js      (#131)
 *   Instrument form checks -> ext.pickipediaReleases.instrumentForm.js (#131)
 *
 * All four load on every page from the extension's BeforePageDisplay hook,
 * exactly as they did from here, and each does nothing unless the form or
 * element it cares about is on the page being shown. Changing any of them is
 * a pull request now: reviewed, syntax-checked by CI, deployed by Jenkins —
 * rather than several hundred lines pasted into this box, which is how the
 * whole of this file went down for 23 minutes on 15 September 2026.
 *
 * What remains below is the user-subpage zine styling. It stays because it is
 * bound to classes defined in MediaWiki:Common.css, and moving the script
 * without its stylesheet would split one feature across two homes. It goes
 * next, with the CSS, after which this page can be empty.
 *
 * See cryptograss/pickipedia#124 and #131.
 */

/* ============================================================
 * Pickipedia — User-subpage zine/Substack styling
 *
 * Layout:
 *   - corner logo (absolute-positioned upper-left of viewport,
 *     OUTSIDE all wiki wrappers — does not push content down)
 *   - non-sticky topbar (just nav) at top of body
 *   - hero block with title (top), then USER:Username kicker below,
 *     optional dek + meta
 *   - light/dark mode toggle (persisted in localStorage)
 *
 * Variant from HTML comment marker `pickipedia:variant=letter|masthead`
 * or category membership; default letter.
 * ============================================================ */

(function () {
  'use strict';

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

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

  if (nsNumber !== 2) return;
  if (action !== 'view') return;
  if (title.indexOf('/') === -1) return;

  var slash    = title.indexOf('/');
  var username = title.slice(0, slash).replace(/_/g, ' ');
  var subpage  = title.slice(slash + 1).replace(/_/g, ' ');

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

  var variant = 'letter';
  var content = document.querySelector('.mw-parser-output');
  if (content) {
    var html = content.innerHTML || '';
    var m = html.match(/pickipedia:variant\s*=\s*(letter|masthead)/i);
    if (m) variant = m[1].toLowerCase();
  }
  var cats = mw.config.get('wgCategories') || [];
  if (cats.indexOf('Masthead style') !== -1) variant = 'masthead';
  if (cats.indexOf('Letter style') !== -1) variant = 'letter';
  body.classList.add('pickipedia-variant-' + variant);

  var STORAGE_KEY = 'pickipedia-mode';
  var saved = null;
  try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
  if (saved === 'dark') body.classList.add('pickipedia-dark');

  var LOGO_URL = '/images/thumb/8/80/Pickipedia-quarter-transparent.png/400px-Pickipedia-quarter-transparent.png';

  // ---------- Corner logo: stand-alone body child, absolute-positioned. ----------
  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">';
  document.body.insertBefore(cornerLogo, document.body.firstChild);

  // ---------- Topbar (nav only) ----------
  var topbar = document.createElement('header');
  topbar.className = 'pickipedia-topbar';
  topbar.innerHTML =
    '<nav class="pickipedia-topbar-nav">' +
      '<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>';
  document.body.insertBefore(topbar, cornerLogo.nextSibling);

  // ---------- Hero ----------
  if (content) {
    var dek = '';
    var dekEl = content.querySelector('.pp-dek-source');
    if (dekEl) {
      dek = dekEl.innerHTML;
      dekEl.remove();
    }

    var kickerHtml = '<a href="' + mw.util.getUrl('User:' + username.replace(/ /g, '_')) +
                     '">User:' + escapeHtml(username) + '</a>';
    var kickerEl = content.querySelector('.pp-kicker-source');
    if (kickerEl) {
      kickerHtml = escapeHtml(kickerEl.textContent.trim());
      kickerEl.remove();
    }

    var lastMod = '';
    var lastModEl = document.getElementById('footer-info-lastmod');
    if (lastModEl) lastMod = lastModEl.textContent.replace(/^\s*This page was last (edited|modified) on\s*/i, '').trim();

    var hero = document.createElement('header');
    hero.className = 'pickipedia-hero';

    if (variant === 'masthead') {
      hero.innerHTML =
        '<div class="pp-topbar">' +
          '<div class="pp-kicker">' + kickerHtml + '</div>' +
          '<div class="pp-topbar-rule"></div>' +
          (lastMod ? '<div class="pp-topbar-vol">' + escapeHtml(lastMod) + '</div>' : '') +
        '</div>' +
        '<div class="pp-titlewrap">' +
          '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
          (dek ? '<p class="pp-dek">' + dek + '</p>' : '') +
        '</div>';
    } else {
      // Letter variant: title first, then kicker, then optional dek/meta.
      hero.innerHTML =
        '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
        '<div class="pp-kicker">' + kickerHtml + '</div>' +
        (dek ? '<p class="pp-dek">' + dek + '</p>' : '') +
        (lastMod ? '<div class="pp-meta">Last edited ' + escapeHtml(lastMod) + '</div>' : '');
    }
    content.parentNode.insertBefore(hero, content);
  }

  // ---------- Light/dark toggle ----------
  var toggle = document.createElement('button');
  toggle.className = 'pickipedia-mode';
  toggle.type = 'button';
  toggle.textContent = body.classList.contains('pickipedia-dark') ? '☼ Light' : '☾ Dark';
  toggle.addEventListener('click', function () {
    var dark = body.classList.toggle('pickipedia-dark');
    toggle.textContent = dark ? '☼ Light' : '☾ Dark';
    try { localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) {}
  });
  document.body.appendChild(toggle);

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