Jump to content

MediaWiki:Common.js: Difference between revisions

From Fedoverse Wiki, the free encyclopedia
No edit summary
Tag: Reverted
No edit summary
Tag: Reverted
Line 511: Line 511:
});
});


    document.addEventListener( 'pointermove', function ( e ) {
        if ( e.pointerType !== 'mouse' || reduce.matches ) { return; }
        var el = e.target.closest && e.target.closest( SEL );
        if ( !el ) { return; }
        var r = el.getBoundingClientRect();
        var x = ( e.clientX - r.left ) / r.width - 0.5;  // -0.5 .. 0.5
        var y = ( e.clientY - r.top ) / r.height - 0.5;
        el.classList.add( 'is-hovering' );
        el.style.setProperty( '--ry', ( x * MAX_TILT * 2 ) + 'deg' );
        el.style.setProperty( '--rx', ( -y * MAX_TILT * 2 ) + 'deg' );
    } );
    document.addEventListener( 'pointerout', function ( e ) {
        var el = e.target.closest && e.target.closest( SEL );
        if ( !el || el.contains( e.relatedTarget ) ) { return; }
        el.classList.remove( 'is-hovering' );
        el.style.setProperty( '--rx', '0deg' );
        el.style.setProperty( '--ry', '0deg' );
    } );
    document.addEventListener( 'click', function ( e ) {
        var el = e.target.closest && e.target.closest( SEL );
        if ( !el || reduce.matches ) { return; }
        // restart the animation even on rapid repeat clicks
        el.classList.remove( 'is-pressed' );
        void el.offsetWidth;
        el.classList.add( 'is-pressed' );
        el.addEventListener( 'animationend', function done() {
            el.classList.remove( 'is-pressed' );
            el.removeEventListener( 'animationend', done );
        } );


/* Fedo button: 3D cursor tilt + press animation */
/* Fedo button: 3D cursor tilt + press animation */

Revision as of 21:06, 4 October 2026

/* =========================================================
   1. MINECRAFT SERVER STATUS  (was missing: nothing ever
      replaced "Checking…", so it stayed stuck forever)
   ========================================================= */
mw.hook('wikipage.content').add(function ($content) {
  $content.find('.mc-status').each(function () {
    var box = this;
    if (box.dataset.mcInit === '1') return;
    box.dataset.mcInit = '1';

    var host = box.dataset.host || 'smp.fedoverse.com';
    var stateEl = box.querySelector('.mc-state');
    var playersEl = box.querySelector('.mc-players');
    var iconEl = box.querySelector('.mc-icon');
    var motdEl = box.querySelector('.mc-motd');
    var addrEl = box.querySelector('.mc-addr');
    var mainEl = box.querySelector('.mc-main');

    function getJson(url, ms) {
      var ctrl = window.AbortController ? new AbortController() : null;
      var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, ms || 8000) : null;
      return fetch(url, ctrl ? { signal: ctrl.signal } : undefined)
        .then(function (r) {
          if (!r.ok) throw new Error('HTTP ' + r.status);
          return r.json();
        })
        .then(
          function (v) { clearTimeout(timer); return v; },
          function (e) { clearTimeout(timer); throw e; }
        );
    }

    // Normalise the two APIs into one shape
    function fromMcStatusIo(j) {
      if (!j || typeof j.online !== 'boolean') throw new Error('bad response');
      return {
        online: j.online,
        players: j.players ? j.players.online : 0,
        max: j.players ? j.players.max : 0,
        motd: j.motd && j.motd.clean ? String(j.motd.clean) : '',
        icon: j.icon || ''
      };
    }

    function fromMcSrvStat(j) {
      if (!j || typeof j.online !== 'boolean') throw new Error('bad response');
      return {
        online: j.online,
        players: j.players ? j.players.online : 0,
        max: j.players ? j.players.max : 0,
        motd: j.motd && j.motd.clean ? [].concat(j.motd.clean).join('\n') : '',
        icon: j.icon || ''
      };
    }

    function fetchStatus() {
      return getJson('https://api.mcstatus.io/v2/status/java/' + encodeURIComponent(host))
        .then(fromMcStatusIo)
        .catch(function () {
          return getJson('https://api.mcsrvstat.us/3/' + encodeURIComponent(host))
            .then(fromMcSrvStat);
        });
    }

    function setState(name) {
      box.dataset.state = name;
      box.classList.toggle('is-online', name === 'online');
      box.classList.toggle('is-offline', name === 'offline');
      box.classList.toggle('is-error', name === 'error');
    }

    function show(s) {
      setState(s.online ? 'online' : 'offline');
      if (stateEl) stateEl.textContent = s.online ? 'Online' : 'Offline';
      if (playersEl) playersEl.textContent = s.online ? s.players + ' / ' + s.max : '– / –';

      if (motdEl) {
        motdEl.textContent = '';
        if (s.online) {
          s.motd.split('\n').forEach(function (line) {
            line = line.trim();
            if (line) {
              var d = document.createElement('div');
              d.textContent = line;
              motdEl.appendChild(d);
            }
          });
        }
      }

      if (iconEl) {
        iconEl.textContent = '';
        if (/^data:image\/png;base64,/i.test(s.icon)) {
          var img = document.createElement('img');
          img.alt = '';
          img.src = s.icon;
          iconEl.appendChild(img);
        }
      }
    }

    function showError() {
      setState('error');
      if (stateEl) stateEl.textContent = 'Status unavailable';
      if (playersEl) playersEl.textContent = '– / –';
    }

    function refresh() {
      if (!document.body.contains(box)) return; // page content was replaced
      fetchStatus().then(show, showError);
    }

    // Click / Enter on the address copies the IP
    function copyAddr() {
      if (!navigator.clipboard || !navigator.clipboard.writeText || !addrEl) return;
      navigator.clipboard.writeText(host).then(function () {
        addrEl.textContent = 'Copied!';
        setTimeout(function () { addrEl.textContent = host; }, 1200);
      }, function () { /* clipboard blocked, ignore */ });
    }

    if (mainEl) {
      mainEl.addEventListener('click', copyAddr);
      mainEl.addEventListener('keydown', function (e) {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          copyAddr();
        }
      });
    }

    refresh();
    setInterval(refresh, 60000);
  });
});


/* =========================================================
   2. PODCAST DECK
   Changes: runs inside mw.hook (DOM guaranteed), tries several
   sources in order, falls through when a source returns an
   empty list, and shows a link instead of staying blank.
   ========================================================= */
mw.hook('wikipage.content').add(function ($content) {
  var root = $content.find('#pc-podcast')[0];
  if (!root || root.dataset.pcInit === '1') return;
  root.dataset.pcInit = '1';

  var feed = root.dataset.feed, page = root.dataset.page;
  var deck = root.querySelector('.pc-deck'), nav = root.querySelector('.pc-nav');
  var cards = [], dots = [], active = 0;

  function el(tag, cls, text) {
    var e = document.createElement(tag);
    if (cls) e.className = cls;
    if (text) e.textContent = text;
    return e;
  }

  function safeUrl(u) { return /^https?:\/\//i.test(u || '') ? u : ''; }
  function txt(node, tag) { var n = node.getElementsByTagName(tag)[0]; return n ? n.textContent.trim() : ''; }
  function plain(html) {
    return new DOMParser().parseFromString(html || '', 'text/html').body.textContent.replace(/\s+/g, ' ').trim();
  }
  function parseDate(s) {
    var d = new Date(s);
    if (isNaN(d)) d = new Date(String(s).replace(' ', 'T') + 'Z');
    return d;
  }
  function fmtDate(s) {
    var d = parseDate(s);
    return isNaN(d) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
  }
  function fmtDur(v) {
    if (!v) return '';
    if (v.indexOf(':') > -1) return v.replace(/^00:/, '').replace(/^0(\d:)/, '$1');
    var s = parseInt(v, 10);
    if (!s) return '';
    var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
    return (h ? h + ':' + ('0' + m).slice(-2) : m) + ':' + ('0' + sec).slice(-2);
  }

  function fromXml(xml) {
    var doc = new DOMParser().parseFromString(xml, 'application/xml');
    if (doc.getElementsByTagName('parsererror').length) throw new Error('bad xml');
    var chImg = doc.getElementsByTagName('itunes:image')[0];
    var chCover = chImg ? chImg.getAttribute('href') : '';

    return Array.prototype.map.call(doc.getElementsByTagName('item'), function (it) {
      var img = it.getElementsByTagName('itunes:image')[0];
      var s = txt(it, 'itunes:season'), e = txt(it, 'itunes:episode');

      return {
        title: txt(it, 'title'),
        link: txt(it, 'link'),
        date: txt(it, 'pubDate'),
        dur: fmtDur(txt(it, 'itunes:duration')),
        tag: e ? (s ? 'S' + s + ' · ' : '') + 'E' + e : '',
        desc: plain(txt(it, 'description')),
        cover: (img && img.getAttribute('href')) || chCover
      };
    });
  }

  function fromJson(j) {
    var chCover = j.feed && j.feed.image;
    return (j.items || []).map(function (it) {
      return {
        title: it.title,
        link: it.link,
        date: it.pubDate,
        dur: '',
        tag: '',
        desc: plain(it.description),
        cover: it.thumbnail || chCover
      };
    });
  }

  function okText(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }
  function okJson(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }

  function loadEpisodes() {
    var enc = encodeURIComponent(feed);
    var sources = [
      function () { return fetch(feed).then(okText).then(fromXml); },
      function () { return fetch('https://api.allorigins.win/raw?url=' + enc).then(okText).then(fromXml); },
      function () { return fetch('https://api.rss2json.com/v1/api.json?rss_url=' + enc).then(okJson).then(fromJson); },
      function () { return fetch('https://corsproxy.io/?url=' + enc).then(okText).then(fromXml); }
    ];

    // Try each source in turn; an error OR an empty list moves on to the next
    return sources.reduce(function (p, src) {
      return p.catch(function () {
        return src().then(function (list) {
          list = list.filter(function (e) { return e.title; });
          if (!list.length) throw new Error('empty');
          return list;
        });
      });
    }, Promise.reject(new Error('start')));
  }

  function buildCard(ep) {
    var c = el('article', 'pc-card');
    var cover = el('div', 'pc-cover');

    if (safeUrl(ep.cover)) {
      var img = el('img');
      img.alt = '';
      img.loading = 'lazy';
      img.onerror = function () { cover.textContent = '🎙️'; };
      img.src = ep.cover;
      cover.appendChild(img);
    } else {
      cover.textContent = '🎙️';
    }

    var body = el('div', 'pc-body');
    var meta = el('div', 'pc-meta');

    if (ep.tag) meta.appendChild(el('span', 'pc-pill pc-pill-strong', ep.tag));
    var ds = fmtDate(ep.date);
    if (ds) meta.appendChild(el('span', 'pc-pill', ds));
    if (ep.dur) meta.appendChild(el('span', 'pc-pill', ep.dur));
    if (meta.children.length) body.appendChild(meta);

    var href = safeUrl(ep.link) || safeUrl(page);
    var title = el('div', 'pc-title');
    title.setAttribute('role', 'heading');
    title.setAttribute('aria-level', '3');

    var a = el('a', '', ep.title || 'Untitled episode');
    if (href) {
      a.href = href;
      a.target = '_blank';
      a.rel = 'noopener';
    }
    title.appendChild(a);
    body.appendChild(title);
    body.appendChild(el('p', 'pc-desc', ep.desc));

    c.appendChild(cover);
    c.appendChild(body);

    if (href) {
      var o = el('div', 'mc-open');
      var oa = el('a');
      oa.href = href;
      oa.target = '_blank';
      oa.rel = 'noopener';
      oa.title = 'Open episode';
      oa.setAttribute('aria-label', 'Open episode');
      oa.appendChild(el('span', 'mc-open-icon'));
      o.appendChild(oa);
      c.appendChild(o);
    }

    return c;
  }

  function layout() {
    var n = cards.length;
    if (!n) return;

    cards.forEach(function (c, i) {
      var pos = (i - active + n) % n;
      c.dataset.pos = pos;
      c.inert = pos !== 0;
      c.setAttribute('aria-hidden', pos ? 'true' : 'false');
    });

    dots.forEach(function (d, i) {
      d.setAttribute('aria-current', i === active ? 'true' : 'false');
    });
  }

  function go(delta) {
    active = (active + delta + cards.length) % cards.length;
    layout();
  }

  function arrow(label, path, delta) {
    var b = el('button', 'pc-btn');
    b.type = 'button';
    b.setAttribute('aria-label', label);
    b.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="' + path + '"/></svg>';
    b.addEventListener('click', function () { go(delta); });
    return b;
  }

  function render(list) {
    list = list
      .sort(function (a, b) { return parseDate(b.date) - parseDate(a.date); })
      .slice(0, 3);

    deck.innerHTML = '';
    nav.innerHTML = '';
    cards = [];
    dots = [];
    active = 0;

    list.forEach(function (ep) {
      var card = buildCard(ep);
      cards.push(card);
      deck.appendChild(card);
    });

    for (var k = cards.length; k < 3; k++) {
      var g = el('div', 'pc-card pc-ghost');
      g.dataset.pos = k;
      g.setAttribute('aria-hidden', 'true');
      deck.appendChild(g);
    }

    root.classList.add('is-loaded');

    if (cards.length > 1) {
      var wrap = el('div', 'pc-dots');

      cards.forEach(function (_, i) {
        var d = el('button', 'pc-dot');
        d.type = 'button';
        d.setAttribute('aria-label', 'Episode ' + (i + 1));
        d.addEventListener('click', function () {
          active = i;
          layout();
        });
        dots.push(d);
        wrap.appendChild(d);
      });

      nav.appendChild(arrow('Previous episode', 'm15 18-6-6 6-6', -1));
      nav.appendChild(wrap);
      nav.appendChild(arrow('Next episode', 'm9 18 6-6-6-6', 1));

      var sx = null;
      deck.addEventListener('pointerdown', function (e) { sx = e.clientX; });
      deck.addEventListener('pointerup', function (e) {
        if (sx === null) return;
        var dx = e.clientX - sx;
        sx = null;
        if (Math.abs(dx) > 45) go(dx < 0 ? 1 : -1);
      });
    }

    layout();
  }

  function renderError() {
    root.classList.add('is-error');
    deck.innerHTML = '';
    var p = el('p', 'pc-error', 'Couldn\u2019t load episodes right now. ');
    var href = safeUrl(page);
    if (href) {
      var a = el('a', '', 'Listen on RSS.com');
      a.href = href;
      a.target = '_blank';
      a.rel = 'noopener';
      p.appendChild(a);
    }
    deck.appendChild(p);
  }

  loadEpisodes().then(render, renderError);
});


/* =========================================================
   3. NATIVE AUDIO PLAYER (unchanged)
   ========================================================= */
mw.hook('wikipage.content').add(function ($content) {
    $content.find('.infobox-native-audio').each(function () {
        if (this.dataset.audioInitialized === '1') {
            return;
        }

        const src = this.dataset.src;

        if (!src) {
            return;
        }

        const audio = document.createElement('audio');
        audio.preload = 'metadata';
        audio.src = src;

        const player = document.createElement('div');
        player.className = 'custom-audio-player';

        player.innerHTML = `
            <button class="audio-play" type="button" aria-label="Play">▶</button>

            <div class="audio-progress-container">
                <input class="audio-progress" type="range" min="0" max="100" value="0">
            </div>

            <span class="audio-time">0:00 / 0:00</span>

            <button class="audio-volume" type="button" aria-label="Mute">🔊</button>
        `;

        const playButton = player.querySelector('.audio-play');
        const progress = player.querySelector('.audio-progress');
        const time = player.querySelector('.audio-time');
        const volumeButton = player.querySelector('.audio-volume');

        function formatTime(seconds) {
            if (!isFinite(seconds)) {
                return '0:00';
            }

            const minutes = Math.floor(seconds / 60);
            const secs = Math.floor(seconds % 60)
                .toString()
                .padStart(2, '0');

            return `${minutes}:${secs}`;
        }

        function updateTime() {
            progress.value = audio.duration
                ? (audio.currentTime / audio.duration) * 100
                : 0;

            time.textContent =
                `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
        }

        playButton.addEventListener('click', function () {
            if (audio.paused) {
                audio.play();
            } else {
                audio.pause();
            }
        });

        audio.addEventListener('play', function () {
            playButton.textContent = '❚❚';
            playButton.setAttribute('aria-label', 'Pause');
        });

        audio.addEventListener('pause', function () {
            playButton.textContent = '▶';
            playButton.setAttribute('aria-label', 'Play');
        });

        audio.addEventListener('timeupdate', updateTime);
        audio.addEventListener('loadedmetadata', updateTime);

        progress.addEventListener('input', function () {
            if (audio.duration) {
                audio.currentTime =
                    (progress.value / 100) * audio.duration;
            }
        });

        volumeButton.addEventListener('click', function () {
            audio.muted = !audio.muted;
            volumeButton.textContent = audio.muted ? '🔇' : '🔊';
        });

        audio.addEventListener('ended', function () {
            playButton.textContent = '▶';
            progress.value = 0;
        });

        this.replaceChildren(audio, player);
        this.dataset.audioInitialized = '1';
    });
});

    document.addEventListener( 'pointermove', function ( e ) {
        if ( e.pointerType !== 'mouse' || reduce.matches ) { return; }
        var el = e.target.closest && e.target.closest( SEL );
        if ( !el ) { return; }
        var r = el.getBoundingClientRect();
        var x = ( e.clientX - r.left ) / r.width - 0.5;   // -0.5 .. 0.5
        var y = ( e.clientY - r.top ) / r.height - 0.5;
        el.classList.add( 'is-hovering' );
        el.style.setProperty( '--ry', ( x * MAX_TILT * 2 ) + 'deg' );
        el.style.setProperty( '--rx', ( -y * MAX_TILT * 2 ) + 'deg' );
    } );

    document.addEventListener( 'pointerout', function ( e ) {
        var el = e.target.closest && e.target.closest( SEL );
        if ( !el || el.contains( e.relatedTarget ) ) { return; }
        el.classList.remove( 'is-hovering' );
        el.style.setProperty( '--rx', '0deg' );
        el.style.setProperty( '--ry', '0deg' );
    } );

    document.addEventListener( 'click', function ( e ) {
        var el = e.target.closest && e.target.closest( SEL );
        if ( !el || reduce.matches ) { return; }

        // restart the animation even on rapid repeat clicks
        el.classList.remove( 'is-pressed' );
        void el.offsetWidth;
        el.classList.add( 'is-pressed' );
        el.addEventListener( 'animationend', function done() {
            el.classList.remove( 'is-pressed' );
            el.removeEventListener( 'animationend', done );
        } );

/* Fedo button: 3D cursor tilt + press animation */
( function () {
    var SEL = '.fedo-btn.your-button-class';
    var MAX_TILT = 14; // degrees at the very edge
    var HOLD_MS = 450; // wait for the press animation before following the link
    var reduce = window.matchMedia( '(prefers-reduced-motion: reduce)' );

        // let the animation play before following the link
        var a = el.closest( 'a' );
        if ( !a || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || a.target === '_blank' ) {
            return;
        }
        e.preventDefault();
        setTimeout( function () { window.location.href = a.href; }, HOLD_MS );
    } );
}() );