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, crossOrigin ) {
        params.format = 'json';
        if ( crossOrigin ) { 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();
            } );
    }

    /* Same-origin: use relative URL, no origin param needed */
    function apiGet( params ) {
        return apiGetFrom( '/w/api.php', params, false );
    }

    /* Cross-origin: use absolute URL + origin=* for CORS */
    function apiGetCross( params ) {
        return apiGetFrom( MEDIA_API, params, true );
    }

    /* ── 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 );
        initEpisodeGuideSliders( 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;

        /* Random seed per page load — different slides every visit */
        var seed = Math.floor( Math.random() * 2147483647 ) + 1;

        /* 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 || [] )
                .filter( function ( m ) {
                    /* Exclude subpages (slash in title) and redirects */
                    return m.title && m.title.indexOf( '/' ) === -1;
                } );
            if ( !members.length ) throw new Error( 'empty category' );

            /* Random 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 + extract + categories for each article */
            return apiGet( {
                action:          'query',
                titles:          titles,
                prop:            'pageimages|extracts|categories',
                piprop:          'thumbnail',
                pithumbsize:     '1200',
                pilimit:         '20',
                exintro:         '1',
                exchars:         '200',
                exsectionformat: 'plain',
                cllimit:         '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;
                    var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim();
                    if ( extract.length > 180 ) {
                        extract = extract.replace( /\s\S+$/, '' ) + '…';
                    }
                    /* Derive badge label from article categories */
                    var badge = portalSlideBadge( p.categories || [] );
                    slides.push( {
                        thumb:   p.thumbnail.source,
                        caption: extract || p.title,
                        title:   p.title,
                        link:    p.title,
                        badge:   badge
                    } );
                } );
                return slides;
            } );
        } ).then( function ( slides ) {
            if ( !slides.length ) throw new Error( 'No slides with images' );
            buildCarousel( hero, slides );
        } ).catch( function () { /* silent — hero stays dark */ } );
    }

    /** Map article categories to a human-readable slide badge label.
     *  Priority order matters — more specific checks first. */
    function portalSlideBadge( cats ) {
        var names = cats.map( function ( c ) {
            return c.title.replace( /^Category:/, '' ).toLowerCase();
        } );

        /* Exact/prefix matches in priority order */
        var checks = [
            /* Episodes first — most specific */
            [ 'episode guide',         'Episode'      ],
            [ 'episodes by',           'Episode'      ],
            [ 'miniseries',            'Episode'      ],

            /* Ships / Craft */
            [ 'colonial craft',        'Ship'         ],
            [ 'cylon craft',           'Ship'         ],
            [ 'ships (',               'Ship'         ],
            [ 'battlestars (',         'Ship'         ],
            [ 'vipers (',              'Ship'         ],
            [ 'raptors',               'Ship'         ],

            /* Characters */
            [ 'main characters',       'Character'    ],
            [ 'recurring guest',       'Character'    ],
            [ 'one-shot characters',   'Character'    ],
            [ 'deceased characters',   'Character'    ],
            [ 'mentioned-only characters', 'Character' ],
            [ 'characters (',          'Character'    ],

            /* Cylons (before military) */
            [ 'cylons (',              'Cylon'        ],
            [ 'cylon military',        'Cylon'        ],
            [ 'cylon religion',        'Cylon'        ],

            /* Military */
            [ 'colonial military',     'Military'     ],
            [ 'colonial warriors',     'Military'     ],
            [ 'gaeta mutineers',       'Military'     ],
            [ 'fighter squadrons',     'Military'     ],

            /* Places / Locations */
            [ 'planets (',             'Planet'       ],
            [ 'moons (',               'Moon'         ],
            [ 'places (',              'Location'     ],
            [ 'galactica areas',       'Location'     ],
            [ 'places on ',            'Location'     ],
            [ 'basestar areas',        'Location'     ],
            [ 'twelve colonies',       'Location'     ],

            /* Society / Culture */
            [ 'colonial religion',     'Religion'     ],
            [ 'colonial society',      'Society'      ],
            [ 'colonial government',   'Government'   ],
            [ 'colonial history',      'History'      ],
            [ 'organizations (',       'Organization' ],
            [ "ha'la'tha",              'Organization' ],
            [ 'soldiers of the one',   'Organization' ],

            /* Technology */
            [ 'technology (',          'Technology'   ],
            [ 'weapons (',             'Weapon'       ],
            [ 'drugs (',               'Technology'   ],

            /* Production / Real world */
            [ 'cast (',                'Cast'         ],
            [ 'crew (',                'Crew'         ],
            [ 'directors (',           'Director'     ],
            [ 'writers (',             'Writer'       ],
            [ 'producers (',           'Producer'     ],

            /* Comics / Books */
            [ 'dynamite comics',       'Comic'        ],
            [ 'comics (',              'Comic'        ],
            [ 'novels',                'Novel'        ],
            [ 'books (',               'Book'         ],

            /* Terminology */
            [ 'terminology (',         'Term'         ],
            [ 'descriptive terms',     'Term'         ]
        ];

        for ( var i = 0; i < checks.length; i++ ) {
            for ( var j = 0; j < names.length; j++ ) {
                if ( names[j].indexOf( checks[i][0] ) !== -1 ) {
                    return checks[i][1];
                }
            }
        }
        return '';  /* empty = fall back to seriesBadge in caller */
    }

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

        var cat        = ( hero.dataset.category || '' ).toUpperCase();
        var seriesBadge = cat || 'BSG';

        /* Rebuild hero using bsw-slide pattern — same as Main Page */
        var slideHTML = '';
        slides.forEach( function ( s, i ) {
            var badge = s.badge || seriesBadge;
            slideHTML +=
                '<div class="portal-slide' + ( i === 0 ? ' portal-active' : '' ) + '">' +
                '<div class="portal-slide-bg">' +
                ( s.thumb ? '<img src="' + esc( s.thumb ) + '" alt="" class="portal-slide-bg-blur" aria-hidden="true"><img src="' + esc( s.thumb ) + '" alt="" class="portal-slide-bg-img">' : '' ) +
                '</div>' +
                '<div class="portal-slide-overlay"></div>' +
                '<div class="portal-slide-content">' +
                ( badge ? '<div class="portal-slide-badge"><span class="portal-slide-badge-dot"></span><span>' + esc( badge ) + '</span></div>' : '' ) +
                '<div class="portal-slide-title"><a href="/' + encodeURIComponent( ( s.link || s.title ).replace( / /g, '_' ) ) + '">' + esc( s.title ) + '</a></div>' +
                ( s.caption && s.caption !== s.title ? '<div class="portal-slide-desc">' + esc( s.caption ) + '</div>' : '' ) +
                '</div>' +
                '</div>';
        } );

        /* Dots + nav — top-right like Main Page */
        slideHTML +=
            '<div class="portal-hero-dots">' +
            slides.map( function ( _, i ) {
                return '<span class="portal-hero-dot' + ( i === 0 ? ' is-active' : '' ) + '" role="button" tabindex="0"></span>';
            } ).join( '' ) +
            '</div>' +
            '<div class="portal-hero-nav">' +
            '<span class="portal-hero-btn" role="button" tabindex="0" id="portal-hero-prev">‹</span>' +
            '<span class="portal-hero-btn" role="button" tabindex="0" id="portal-hero-next">›</span>' +
            '</div>';

        hero.innerHTML = slideHTML;

        var slideEls = hero.querySelectorAll( '.portal-slide' );
        var dotsEl   = hero.querySelector( '.portal-hero-dots' );
        var prevBtn  = hero.querySelector( '#portal-hero-prev' );
        var nextBtn  = hero.querySelector( '#portal-hero-next' );

        function goTo( n ) {
            slideEls[ current ].classList.remove( 'portal-active' );
            dotsEl.querySelectorAll( '.portal-hero-dot' )[ current ].classList.remove( 'is-active' );
            current = ( n + total ) % total;
            slideEls[ current ].classList.add( 'portal-active' );
            dotsEl.querySelectorAll( '.portal-hero-dot' )[ current ].classList.add( 'is-active' );
            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' );

        var category = inner.dataset.category || '';

        inner.innerHTML = '<span style="color:#aaa;font-size:0.8rem">Loading\u2026 (cat: ' + category + ')</span>';

        apiGet( {
            action:        'query',
            list:          'recentchanges',
            rctype:        'new',
            rcnamespace:   '0',
            rclimit:       '50',
            rcprop:        'title|timestamp',
            formatversion: '2'
        } ).then( function ( data ) {
            var rc = ( ( data.query || {} ).recentchanges || [] );

            var entries = rc.filter( function ( e ) {
                return e.title && e.title.indexOf( '/' ) === -1;
            } );

            var titles = entries.map( function ( e ) { return e.title; } );
            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:       '5',
                formatversion: '2'
            } ).then( function ( d ) {
                var pages = ( d.query || {} ).pages || [];
                if ( !Array.isArray( pages ) ) { pages = Object.values( pages ); }

                var matched = pages.filter( function ( p ) {
                    return p.categories && p.categories.length > 0;
                } ).sort( function ( a, b ) {
                    return titles.indexOf( a.title ) - titles.indexOf( b.title );
                } );

                var title = matched.length ? matched[0].title : entries[0].title;
                var ts    = tsMap[ title ];


                return apiGet( {
                    action:          'query',
                    titles:          title,
                    prop:            'extracts|pageimages',
                    exintro:         '1',
                    exchars:         '400',
                    exsectionformat: 'plain',
                    piprop:          'thumbnail',
                    pithumbsize:     '300',
                    formatversion:   '2'
                } ).then( function ( d2 ) {
                    var pages2 = ( d2.query || {} ).pages || [];
                    if ( !Array.isArray( pages2 ) ) { pages2 = Object.values( pages2 ); }
                    return { page: pages2[0] || {}, ts: ts };
                } );
            } );
        } ).then( function ( result ) {
            renderPortalNewest( inner, result.page, result.ts );
        } ).catch( function ( err ) {
            console.warn( '[Portal] initPortalNewest error:', err );
        } );
    }

    function renderPortalNewest( inner, page, ts ) {
        var title   = page.title || '';
        var url     = '/' + 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' } ) : '';

        /* Use bsw-fa-thumb matching Main Page newest article pattern */
        var thumbHtml = thumb
            ? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb">'
            : '';

        inner.innerHTML =
            thumbHtml +
            '<div class="bsw-fa-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' +
            ( date ? '<div class="bsw-newest-date">Created ' + esc( date ) + '</div>' : '' ) +
            '<div class="bsw-fa-extract">' + esc( extract ) + '</div>' +
            '<div style="margin-top:0.375rem"><a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link)">Read more \u2192</a></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 countEls = page.querySelectorAll( '.portal-stat-count[data-category]' );
        if ( !countEls.length ) return;

        /* Batch all stat categories into a single categoryinfo query */
        var titles = countEls.length
            ? Array.prototype.slice.call( countEls ).map( function ( el ) {
                return 'Category:' + el.dataset.category;
            } )
            : [];

        apiGet( {
            action:        'query',
            titles:        titles.join( '|' ),
            prop:          'categoryinfo',
            formatversion: '2'
        } ).then( function ( data ) {
            var pageMap = {};
            ( data.query.pages || [] ).forEach( function ( p ) {
                pageMap[ p.title ] = p;
            } );
            countEls.forEach( function ( el ) {
                var key = 'Category:' + el.dataset.category;
                var p   = pageMap[ key ];
                if ( p && p.categoryinfo ) {
                    el.textContent = ( p.categoryinfo.pages || 0 ).toLocaleString();
                } else {
                    el.textContent = '\u2014';
                }
            } );
        } ).catch( function () {
            countEls.forEach( function ( el ) { el.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;
    }

}() );

    /* ════════════════════════════════════════════════════════════════
       6. EPISODE GUIDE SLIDERS
       Each .portal-epguide-slider[data-titles] gets a mini carousel
       showing episode screencaps fetched from the media wiki.
       ════════════════════════════════════════════════════════════════ */

    var EPISODE_SEASONS = {
        'Miniseries': [
            'Miniseries, Night 1', 'Miniseries, Night 2'
        ],
        'Season 1 (RDM)': [
            '33', 'Water', 'Bastille Day', 'Act of Contrition',
            "You Can't Go Home Again", 'Litmus', 'Six Degrees of Separation',
            'Flesh and Bone', 'Tigh Me Up, Tigh Me Down', 'The Hand of God (RDM)',
            'Colonial Day', "Kobol's Last Gleaming, Part I", "Kobol's Last Gleaming, Part II"
        ],
        'Season 2 (RDM)': [
            'Scattered', 'Valley of Darkness', 'Fragged', 'Resistance (episode)',
            'The Farm', 'Home, Part I', 'Home, Part II', 'Final Cut',
            'Flight of the Phoenix', 'Pegasus (episode)',
            'Resurrection Ship, Part I', 'Resurrection Ship, Part II',
            'Epiphanies', 'Black Market', 'Scar', 'Sacrifice',
            "The Captain's Hand", 'Downloaded',
            'Lay Down Your Burdens, Part I', 'Lay Down Your Burdens, Part II'
        ],
        'Season 3 (RDM)': [
            'Occupation', 'Precipice', 'Exodus, Part I', 'Exodus, Part II',
            'Collaborators', 'Torn', 'A Measure of Salvation', 'Hero',
            'Unfinished Business', 'The Passage', 'The Eye of Jupiter', 'Rapture',
            'Taking a Break From All Your Worries', 'The Woman King',
            'A Day in the Life', 'Dirty Hands', 'Maelstrom',
            'The Son Also Rises', 'Crossroads, Part I', 'Crossroads, Part II'
        ],
        'Season 4 (RDM)': [
            'He That Believeth in Me', 'Six of One', 'The Ties That Bind',
            'Escape Velocity', 'The Road Less Traveled', 'Faith',
            "Guess What's Coming to Dinner?", 'Sine Qua Non', 'The Hub',
            'Revelations', 'Sometimes a Great Notion',
            'A Disquiet Follows My Soul', 'The Oath', 'Blood on the Scales',
            'No Exit', 'Deadlock', 'Someone to Watch Over Me',
            'Islanded in a Stream of Stars', 'Daybreak, Part I', 'Daybreak, Part II'
        ],
        'Webisodes (RDM)': [
            'Razor Flashbacks, Episode 1', 'Razor Flashbacks, Episode 2',
            'Razor Flashbacks, Episode 3',
            'The Resistance, Episode 1', 'The Resistance, Episode 2',
            'The Resistance, Episode 3', 'The Resistance, Episode 4',
            'The Resistance, Episode 5', 'The Resistance, Episode 6',
            'The Resistance, Episode 7', 'The Resistance, Episode 8',
            'The Resistance, Episode 9', 'The Resistance, Episode 10',
            'The Face of the Enemy'
        ],
        'DVD Movies (RDM)': [
            'Razor', 'The Plan', 'Battlestar Galactica (Miniseries)'
        ]
    };

    function initEpisodeGuideSliders( page ) {
        var sliders = page.querySelectorAll( '.portal-epguide-slider[data-category]' );
        if ( !sliders.length ) return;

        sliders.forEach( function ( el ) {
            var cat    = el.dataset.category;
            var guide  = el.dataset.guide || '';
            var titles = EPISODE_SEASONS[ cat ];
            if ( !titles || !titles.length ) {
                el.innerHTML = '<span style="font-size:0.8em;color:var(--color-base--subtle)">No episodes found.</span>';
                return;
            }

            /* Show loading state */
            el.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 0;color:var(--color-base--subtle);font-size:0.8em"><div class="portal-spinner"></div>Loading episodes\u2026</div>';

            /* Fetch pageimages + extracts for all episodes in this season */
            apiGet( {
                action:        'query',
                titles:        titles.slice( 0, 20 ).join( '|' ),
                prop:          'pageimages|extracts',
                piprop:        'thumbnail',
                pithumbsize:   '600',
                pilimit:       '20',
                exintro:       '1',
                exchars:       '120',
                exsectionformat: 'plain',
                formatversion: '2'
            } ).then( function ( data ) {
                /* Build a map keyed by title for easy lookup */
                var pageMap = {};
                ( data.query.pages || [] ).forEach( function ( p ) {
                    pageMap[ p.title ] = p;
                } );

                /* Build slides in episode order */
                var slides = titles.map( function ( t ) {
                    var p       = pageMap[ t ] || {};
                    var thumb   = p.thumbnail ? p.thumbnail.source : '';
                    var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim();
                    if ( extract.length > 100 ) extract = extract.slice( 0, 100 ).replace( /\s\S+$/, '' ) + '\u2026';
                    return { title: t, thumb: thumb, extract: extract };
                } ).filter( function ( s ) { return s.thumb; } );

                if ( !slides.length ) {
                    el.innerHTML = '<span style="font-size:0.8em;color:var(--color-base--subtle)">No episode images available.</span>';
                    return;
                }

                buildEpSlider( el, slides, guide );
            } ).catch( function () {
                el.innerHTML = '<span style="font-size:0.8em;color:var(--color-base--subtle)">Could not load episodes.</span>';
            } );
        } );
    }

    function buildEpSlider( el, slides, guideUrl ) {
        var current = 0;
        var total   = slides.length;
        var timer;

        function render() {
            var s = slides[ current ];
            var dotsHtml = slides.map( function ( _, i ) {
                return '<span class="portal-ep-dot' + ( i === current ? ' is-active' : '' ) + '" role="button" tabindex="0" data-i="' + i + '"></span>';
            } ).join( '' );

            el.innerHTML =
                '<div class="portal-ep-slide">' +
                ( s.thumb
                    ? '<a href="/' + encodeURIComponent( s.title.replace( / /g, '_' ) ) + '" class="portal-ep-thumb-wrap">' +
                      '<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" class="portal-ep-thumb"></a>'
                    : '' ) +
                '<div class="portal-ep-info">' +
                '<div class="portal-ep-title"><a href="/' + encodeURIComponent( s.title.replace( / /g, '_' ) ) + '">' + esc( s.title ) + '</a></div>' +
                ( s.extract ? '<div class="portal-ep-extract">' + esc( s.extract ) + '</div>' : '' ) +
                '</div>' +
                '</div>' +
                '<div class="portal-ep-nav">' +
                '<span class="portal-ep-btn portal-ep-prev">\u2039</span>' +
                '<div class="portal-ep-dots">' + dotsHtml + '</div>' +
                '<span class="portal-ep-count">' + ( current + 1 ) + ' / ' + total + '</span>' +
                '<span class="portal-ep-btn portal-ep-next">\u203a</span>' +
                '</div>';

            /* Wire nav */
            el.querySelector( '.portal-ep-prev' ).addEventListener( 'click', function () { goTo( current - 1 ); } );
            el.querySelector( '.portal-ep-next' ).addEventListener( 'click', function () { goTo( current + 1 ); } );
            el.querySelectorAll( '.portal-ep-dot' ).forEach( function ( d ) {
                d.addEventListener( 'click', function () { goTo( +d.dataset.i ); } );
            } );
        }

        function goTo( n ) {
            current = ( n + total ) % total;
            clearInterval( timer );
            render();
            timer = setInterval( function () { goTo( current + 1 ); }, 7000 );
        }

        render();
        timer = setInterval( function () { goTo( current + 1 ); }, 7000 );
    }