|
|
| (One intermediate revision by the same user not shown) |
| Line 1: |
Line 1: |
| /**
| | <proposed by="Magent"> |
| * Blue Railroad Submission - Date to Block Height Converter
| | '''Superseded.''' The quality selector this page carried shipped with the PickiPediaReleases extension instead, in [https://github.com/cryptograss/pickipedia/pull/124 pickipedia#124], so it no longer needs pasting. What is left to paste is [[User:Magent/Common.js without the player]] — the same Common.js with the player block removed, now that the player ships in code. |
| * Adds a datepicker that converts dates to Ethereum block heights
| | </proposed> |
| * 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:
| |
| * <span class="timeago" data-lastmod-page="Template:NewsShorts"></span>
| |
| *
| |
| * Fetches the last revision timestamp from the API and renders
| |
| * 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):
| |
| * <div class="hls-video-player" data-cid="QmXet6..."></div>
| |
| *
| |
| * The gadget loads hls.js and initializes players for any element
| |
| * with the hls-video-player class and a data-cid attribute.
| |
| *
| |
| * Supports both HLS streams (CID/master.m3u8) and raw video files.
| |
| * 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 !== '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)
| |
| ? Math.min(seconds, video.duration)
| |
| : 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 ? parseTimecode(m[1]) : 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) !== '';
| |
| } 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 !== '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 !== '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 ? make.value.trim() : '';
| |
| var modelVal = model ? model.value.trim() : '';
| |
| var serialVal = serial ? serial.value.trim() : '';
| |
| | |
| var hasNickname = nickVal !== '';
| |
| var hasMakerComplete = makeVal !== '' && modelVal !== '' && serialVal !== '';
| |
| | |
| if (!hasNickname && !hasMakerComplete) {
| |
| args.numErrors += 1;
| |
| if (makeVal !== '' || modelVal !== '' || serialVal !== '') {
| |
| 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;
| |
| }
| |
| });
| |
| })();
| |
| | |
| /* ============================================================
| |
| * 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, '&')
| |
| .replace(/</g, '<')
| |
| .replace(/>/g, '>')
| |
| .replace(/"/g, '"');
| |
| }
| |
| }());
| |