Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

MediaWiki:Common.portal.js

MediaWiki interface page

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * BattlestarWiki — Portal page JavaScript
 * MediaWiki:Common.portal.js  (or append to MediaWiki:Common.js)
 *
 * Handles per-portal widgets on any .portal-page:
 *   1. Image carousel
 *   2. Newest article (portal-scoped)
 *   3. Stats bar (live article count)
 *
 * Self-contained — does not depend on helpers from the main page JS.
 */

( function () {
    'use strict';

    /* ── API endpoints ──────────────────────────────────────────────── */

    var API       = 'https://en.battlestarwiki.org/w/api.php';
    var MEDIA_API = 'https://media.battlestarwiki.org/w/api.php';

    /* ── Shared helpers ─────────────────────────────────────────────── */

    function dailySeed() {
        var now = new Date();
        return Math.floor( Date.UTC(
            now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()
        ) / 86400000 );
    }

    function esc( s ) {
        return String( s || '' )
            .replace( /&/g, '&amp;' ).replace( /</g, '&lt;' )
            .replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
    }

    function apiGetFrom( baseUrl, params ) {
        params.format = 'json';
        params.origin = '*';
        var qs = Object.keys( params )
            .map( function ( k ) { return encodeURIComponent( k ) + '=' + encodeURIComponent( params[ k ] ); } )
            .join( '&' );
        return fetch( baseUrl + '?' + qs, { headers: { Accept: 'application/json' } } )
            .then( function ( r ) {
                if ( !r.ok ) throw new Error( 'HTTP ' + r.status );
                return r.json();
            } );
    }

    function apiGet( params ) {
        return apiGetFrom( API, params );
    }

    /* ── Portal entry point ───────────────────────────────────────── */

    mw.hook( 'wikipage.content' ).add( function () {
        var page = document.querySelector( '.portal-page' );
        if ( !page ) return;

        initPortalSlider( page );
        initPortalNewest( page );
        initPortalStats( page );
        initPortalOrphans( page );
        initPortalFeatured( page );
    } );

    /* ════════════════════════════════════════════════════════════════
       1. IMAGE CAROUSEL
       Reads Portal:NAME/ImageList for a curated file list, falls back
       to querying the category directly. Images are fetched via the
       media wiki's imageinfo API for thumbnail URLs.
       ════════════════════════════════════════════════════════════════ */

    function initPortalSlider( page ) {
        var hero = page.querySelector( '.portal-hero' );
        if ( !hero ) return;

        var category = hero.dataset.category || '';
        if ( !category ) return;

        var seed = dailySeed();

        /* Query articles from the portal category on en.battlestarwiki.org.
           Mirror the Main Page bsw-hero pattern: article → pageimage → slide. */
        apiGet( {
            action:      'query',
            list:        'categorymembers',
            cmtitle:     'Category:' + category,
            cmtype:      'page',
            cmnamespace: '0',
            cmlimit:     '200'
        } ).then( function ( data ) {
            var members = ( data.query.categorymembers || [] );
            if ( !members.length ) throw new Error( 'empty category' );

            /* Daily-seeded shuffle, cap at 20 candidates to fetch images for */
            members = seededShuffle( members, seed ).slice( 0, 20 );
            var titles = members.map( function ( m ) { return m.title; } ).join( '|' );

            /* Fetch pageimage + description for each article */
            return apiGet( {
                action:      'query',
                titles:      titles,
                prop:        'pageimages|description',
                piprop:      'thumbnail',
                pithumbsize: '1200',
                pilimit:     '20',
                formatversion: '2'
            } ).then( function ( d ) {
                var slides = [];
                ( d.query.pages || [] ).forEach( function ( p ) {
                    /* Skip articles with no image */
                    if ( !p.thumbnail || !p.thumbnail.source ) return;
                    slides.push( {
                        thumb:   p.thumbnail.source,
                        caption: p.description || p.title,
                        title:   p.title,
                        link:    p.title
                    } );
                } );
                return slides;
            } );
        } ).then( function ( slides ) {
            if ( !slides.length ) throw new Error( 'No slides with images' );
            buildCarousel( hero, slides );
        } ).catch( function () { /* silent — hero stays dark */ } );
    }

    /** Build and wire the carousel DOM — bsw-hero style */
    function buildCarousel( hero, slides ) {
        var current = 0;
        var total   = slides.length;
        var timer;

        var bg      = hero.querySelector( '.portal-hero-bg' );
        var caption = hero.querySelector( '.portal-hero-caption' );
        var dotsEl  = hero.querySelector( '.portal-hero-dots' );
        var prevBtn = hero.querySelector( '.portal-hero-prev' );
        var nextBtn = hero.querySelector( '.portal-hero-next' );

        /* Clickable overlay covering the image area — sits above bg/overlay,
           below nav and footer so those remain independently clickable */
        var link = hero.querySelector( '.portal-hero-link' );
        if ( !link ) {
            link = document.createElement( 'a' );
            link.className = 'portal-hero-link';
            /* Insert after overlay, before content */
            var content = hero.querySelector( '.portal-hero-content' );
            hero.insertBefore( link, content );
        }

        /* Build dot indicators */
        if ( dotsEl ) {
            dotsEl.innerHTML = '';
            slides.forEach( function ( _, i ) {
                var dot = document.createElement( 'span' );
                dot.className = 'portal-hero-dot' + ( i === 0 ? ' is-active' : '' );
                dot.setAttribute( 'role', 'button' );
                dot.setAttribute( 'tabindex', '0' );
                dot.addEventListener( 'click', function () { goTo( i ); } );
                dotsEl.appendChild( dot );
            } );
        }

        function goTo( n ) {
            current = ( n + total ) % total;
            var s = slides[ current ];

            /* Set background image on the bg div — same as bsw-slide-bg */
            if ( bg ) {
                bg.style.backgroundImage = 'url(' + s.thumb + ')';
            }
            if ( caption ) {
                caption.textContent = s.caption;
            }
            /* Update the clickable overlay link */
            if ( link ) {
                if ( s.link ) {
                    link.href = '/' + encodeURIComponent( s.link.replace( / /g, '_' ) );
                    link.title = s.link;
                    link.style.display = 'block';
                } else {
                    /* No article link for this slide — disable the overlay */
                    link.removeAttribute( 'href' );
                    link.style.display = 'none';
                }
            }

            if ( dotsEl ) {
                dotsEl.querySelectorAll( '.portal-hero-dot' ).forEach( function ( d, i ) {
                    d.classList.toggle( 'is-active', i === current );
                } );
            }
            resetTimer();
        }

        function resetTimer() {
            clearInterval( timer );
            timer = setInterval( function () { goTo( current + 1 ); }, 6000 );
        }

        if ( prevBtn ) {
            prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
            prevBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current - 1 ); }
            } );
        }
        if ( nextBtn ) {
            nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );
            nextBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current + 1 ); }
            } );
        }

        hero.addEventListener( 'mouseenter', function () { clearInterval( timer ); } );
        hero.addEventListener( 'mouseleave', resetTimer );

        /* Touch/swipe support */
        var touchX = null;
        hero.addEventListener( 'touchstart', function ( e ) {
            touchX = e.touches[0].clientX;
        }, { passive: true } );
        hero.addEventListener( 'touchend', function ( e ) {
            if ( touchX === null ) return;
            var dx = e.changedTouches[0].clientX - touchX;
            if ( Math.abs( dx ) > 40 ) goTo( dx < 0 ? current + 1 : current - 1 );
            touchX = null;
        }, { passive: true } );

        goTo( 0 );
    }


    /* ════════════════════════════════════════════════════════════════
       2. NEWEST ARTICLE (portal-scoped)
       Same logevents call as the main page newest widget, but filtered
       to the portal's category via a follow-up categorymembers check.
       ════════════════════════════════════════════════════════════════ */

    function initPortalNewest( page ) {
        var inner = page.querySelector( '.portal-newest-inner' );
        if ( !inner ) return;

        var category = inner.dataset.category || '';
        if ( !category ) return;

        /* Fetch the 30 most recently created mainspace pages */
        apiGet( {
            action:      'query',
            list:        'logevents',
            letype:      'create',
            lenamespace: '0',
            lelimit:     '30',
            leprop:      'title|timestamp'
        } ).then( function ( data ) {
            var entries = ( data.query.logevents || [] ).filter( function ( e ) {
                return e.title.indexOf( '/' ) === -1 && e.title !== 'Main Page';
            } );
            if ( !entries.length ) throw new Error( 'No entries' );

            /* Check which of these titles are in the portal's category */
            var titles = entries.map( function ( e ) { return e.title; } );
            /* Build a timestamp map for quick lookup */
            var tsMap = {};
            entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } );

            return apiGet( {
                action:      'query',
                titles:      titles.slice( 0, 20 ).join( '|' ),
                prop:        'categories',
                clcategories: 'Category:' + category,
                cllimit:     '1'
            } ).then( function ( d ) {
                /* Find pages that ARE in the category (have a categories array) */
                var pages = Object.values( d.query.pages || {} );
                var match = pages.find( function ( p ) {
                    return p.categories && p.categories.length > 0;
                } );

                /* If none match in the first 20, fall back to the single most recent */
                var title = match ? match.title : entries[0].title;
                var ts    = tsMap[ title ];

                return apiGet( {
                    action:  'query',
                    titles:  title,
                    prop:    'extracts|pageimages|pageprops',
                    exintro: '1',
                    exchars: '400',
                    exsectionformat: 'plain',
                    piprop:  'thumbnail',
                    pithumbsize: '200',
                    ppprop:  'disambiguation'
                } ).then( function ( d2 ) {
                    var p = Object.values( d2.query.pages || {} )[0] || {};
                    /* Skip disambiguation, try plain most-recent as fallback */
                    if ( p.pageprops && p.pageprops.disambiguation !== undefined ) {
                        title = entries[0].title;
                        ts    = entries[0].timestamp;
                        return apiGet( {
                            action: 'query', titles: title,
                            prop: 'extracts|pageimages', exintro: '1', exchars: '400',
                            exsectionformat: 'plain', piprop: 'thumbnail', pithumbsize: '200'
                        } ).then( function ( d3 ) {
                            return { page: Object.values( d3.query.pages || {} )[0] || {}, ts: ts };
                        } );
                    }
                    return { page: p, ts: ts };
                } );
            } );
        } ).then( function ( result ) {
            renderPortalNewest( inner, result.page, result.ts );
        } ).catch( function () {
            inner.innerHTML = '<div style="font-size:0.8125rem;color:var(--color-subtle,#888)">Could not load newest article.</div>';
        } );
    }

    function renderPortalNewest( inner, page, ts ) {
        var title   = page.title || '';
        var url     = 'https://en.battlestarwiki.org/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) );
        var thumb   = page.thumbnail ? page.thumbnail.source : '';
        var extract = ( page.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 300 );
        if ( extract.length === 300 ) extract = extract.replace( /\s\S+$/, '' ) + '\u2026';
        var date = ts ? new Date( ts ).toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } ) : '';

        var thumbHtml = thumb
            ? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb" style="width:70px;height:52px;object-fit:cover;border-radius:4px;flex-shrink:0">'
            : '';

        inner.innerHTML =
            '<div style="display:flex;gap:10px;align-items:flex-start">' +
            thumbHtml +
            '<div>' +
            '<div class="portal-newest-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' +
            ( date ? '<div class="portal-newest-meta">Created ' + esc( date ) + '</div>' : '' ) +
            '<div style="font-size:0.8em;color:var(--color-base--subtle,#666);margin-top:3px;line-height:1.5">' + esc( extract ) + '</div>' +
            '<div style="margin-top:4px"><a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link,#3366cc)">Read more \u2192</a></div>' +
            '</div>' +
            '</div>';
    }

    /* ════════════════════════════════════════════════════════════════
       3. STATS BAR — live article count
       The stat_count span with data-category gets its number filled
       in from categoryinfo. All other stats are static wikitext.
       ════════════════════════════════════════════════════════════════ */

    function initPortalStats( page ) {
        var countEl = page.querySelector( '.portal-stat-count[data-category]' );
        if ( !countEl ) return;

        var category = countEl.dataset.category;

        apiGet( {
            action:  'query',
            titles:  'Category:' + category,
            prop:    'categoryinfo'
        } ).then( function ( data ) {
            var pages = Object.values( data.query.pages || {} );
            if ( !pages[0] || !pages[0].categoryinfo ) return;
            var n = pages[0].categoryinfo.pages || 0;
            countEl.textContent = n.toLocaleString();
        } ).catch( function () {
            countEl.textContent = '\u2014';
        } );
    }

    /* ════════════════════════════════════════════════════════════════
       4. ORPHANED ARTICLES — dynamic, filtered to portal category
       Uses the MW API to find articles with no incoming links that
       are also members of the portal's stat_category.
       ════════════════════════════════════════════════════════════════ */

    function initPortalOrphans( page ) {
        var widget = page.querySelector( '#portal-widget-orphans[data-category]' );
        if ( !widget ) return;

        var inner    = widget.querySelector( '.portal-orphans-inner' );
        var category = widget.dataset.category;
        if ( !inner || !category ) return;

        inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';

        /* Fetch global lonely pages, then filter to those in this portal's category.
           list=querypage is available to all users; qplimit caps at 10 for non-sysops
           but we fetch 500 and let the server cap it — more matches for the category. */
        apiGet( {
            action:   'query',
            list:     'querypage',
            qppage:   'Lonelypages',
            qplimit:  '500'
        } ).then( function ( data ) {
            var lonely = ( ( data.query.querypage || {} ).results || [] )
                .map( function ( r ) { return r.title; } );
            if ( !lonely.length ) return [];

            /* Cross-reference against the portal's category in batches of 50 */
            var batch = lonely.slice( 0, 50 ).join( '|' );
            return apiGet( {
                action:  'query',
                titles:  batch,
                prop:    'categories',
                clcategories: 'Category:' + category,
                cllimit: '1',
                formatversion: '2'
            } ).then( function ( d ) {
                var orphans = [];
                ( d.query.pages || [] ).forEach( function ( p ) {
                    if ( p.missing ) return;
                    /* categories present = this page IS in the portal category */
                    if ( p.categories && p.categories.length > 0 ) {
                        orphans.push( p.title );
                    }
                } );
                return orphans;
            } );
        } ).then( function ( orphans ) {
            if ( !orphans.length ) {
                inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">No orphaned articles found.</span>';
                return;
            }
            inner.innerHTML = orphans.map( function ( t ) {
                var url = '/' + encodeURIComponent( t.replace( / /g, '_' ) );
                return '<a href="' + esc( url ) + '" style="display:block;font-size:0.85em;padding:2px 0">' + esc( t ) + '</a>';
            } ).join( '' );
        } ).catch( function () {
            inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load orphaned articles.</span>';
        } );
    }

    /* ════════════════════════════════════════════════════════════════
       5. FEATURED EPISODE SLIDER
       Reads a pipe-separated list of episode titles from the widget's
       data-episodes attribute (one per season), fetches extract +
       thumbnail for each, and builds a carousel.
       ════════════════════════════════════════════════════════════════ */

    function initPortalFeatured( page ) {
        var widget = page.querySelector( '#portal-widget-featured[data-episodes]' );
        if ( !widget ) return;

        var body    = widget.querySelector( '.portal-featured-body' );
        var dotsEl  = widget.querySelector( '.portal-featured-dots' );
        var prevBtn = widget.querySelector( '.portal-featured-prev' );
        var nextBtn = widget.querySelector( '.portal-featured-next' );

        var episodes = ( widget.dataset.episodes || '' ).split( '|' ).map( function ( s ) { return s.trim(); } ).filter( Boolean );
        if ( !episodes.length || !body ) return;

        body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';

        apiGet( {
            action:  'query',
            titles:  episodes.join( '|' ),
            prop:    'extracts|pageimages',
            exintro: '1',
            exchars: '500',
            exsectionformat: 'plain',
            piprop:  'thumbnail',
            pithumbsize: '400',
            formatversion: '2'
        } ).then( function ( data ) {
            var pageMap = {};
            ( data.query.pages || [] ).forEach( function ( p ) { pageMap[ p.title ] = p; } );

            /* Preserve the requested episode order */
            var slides = episodes.map( function ( t ) {
                var p = pageMap[ t ] || {};
                return {
                    title:   t,
                    extract: ( p.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 400 ),
                    thumb:   p.thumbnail ? p.thumbnail.source : '',
                    url:     '/' + encodeURIComponent( t.replace( / /g, '_' ) )
                };
            } );

            buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides );
        } ).catch( function () {
            body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load featured episodes.</span>';
        } );
    }

    function buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides ) {
        var current = 0;
        var total   = slides.length;

        function render( n ) {
            current = ( n + total ) % total;
            var s = slides[ current ];

            var thumbHtml = s.thumb
                ? '<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--border-radius-medium,6px);display:block;margin-bottom:10px">'
                : '';

            var extract = s.extract;
            if ( extract.length >= 400 ) extract = extract.replace( /\s\S+$/, '' ) + '…';

            body.innerHTML =
                '<a href="' + esc( s.url ) + '" style="text-decoration:none;display:block">' + thumbHtml + '</a>' +
                '<div style="font-weight:600;margin-bottom:4px"><a href="' + esc( s.url ) + '">' + esc( s.title ) + '</a></div>' +
                '<div style="font-size:0.825em;color:var(--color-base--subtle,#888);line-height:1.5">' + esc( extract ) + '</div>' +
                '<div style="margin-top:6px"><a href="' + esc( s.url ) + '" style="font-size:0.75rem">Read more →</a></div>';

            if ( dotsEl ) {
                dotsEl.querySelectorAll( '.portal-featured-dot' ).forEach( function ( d, i ) {
                    d.classList.toggle( 'is-active', i === current );
                } );
            }
        }

        /* Build dots */
        if ( dotsEl ) {
            dotsEl.innerHTML = '';
            slides.forEach( function ( _, i ) {
                var dot = document.createElement( 'span' );
                dot.className = 'portal-featured-dot' + ( i === 0 ? ' is-active' : '' );
                dot.setAttribute( 'role', 'button' );
                dot.setAttribute( 'tabindex', '0' );
                dot.title = 'Season ' + ( i + 1 );
                dot.addEventListener( 'click', function () { render( i ); } );
                dotsEl.appendChild( dot );
            } );
        }

        if ( prevBtn ) {
            prevBtn.addEventListener( 'click', function () { render( current - 1 ); } );
            prevBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current - 1 ); }
            } );
        }
        if ( nextBtn ) {
            nextBtn.addEventListener( 'click', function () { render( current + 1 ); } );
            nextBtn.addEventListener( 'keydown', function ( e ) {
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current + 1 ); }
            } );
        }

        render( 0 );
    }

        /* ── Seeded shuffle (stable within a calendar day) ────────────── */
    function seededShuffle( arr, seed ) {
        var a = arr.slice();
        var s = seed;
        for ( var i = a.length - 1; i > 0; i-- ) {
            /* Xorshift32 */
            s ^= s << 13;
            s ^= s >> 17;
            s ^= s << 5;
            var j = ( ( s >>> 0 ) % ( i + 1 ) );
            var t = a[i]; a[i] = a[j]; a[j] = t;
        }
        return a;
    }

}() );