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: Difference between revisions

MediaWiki interface page
Joe Beaudoin Jr. (talk | contribs)
 
Joe Beaudoin Jr. (talk | contribs)
No edit summary
 
(32 intermediate revisions by the same user not shown)
Line 1: Line 1:
/* Portal image slider — MediaWiki:Common.js
/**
* BattlestarWiki — Portal page JavaScript
* MediaWiki:Common.portal.js  (or append to MediaWiki:Common.js)
  *
  *
  * Wires up any .portal-slider-wrapper on the page.
  * Handles per-portal widgets on any .portal-page:
  * The Lua module (Module:Portal|randomImage) renders the first image
  *   1. Image carousel
  * server-side; the JS fetches the full ImageList sub-page via the API
  *   2. Newest article (portal-scoped)
  * and pre-loads remaining images for the slider.
  *   3. Stats bar (live article count)
  *
  *
  * Falls back gracefully: if JS is disabled, the first random image
  * Self-contained — does not depend on helpers from the main page JS.
* is still shown (rendered server-side by Lua).
  */
  */
( function ( mw, $ ) {
 
( function () {
     'use strict';
     'use strict';


     /* ── Configuration ─────────────────────────────────── */
     /* ── API endpoints ──────────────────────────────────────────────── */
    var SLIDE_INTERVAL_MS = 5000;  // auto-advance interval
    var IMAGE_WIDTH_PX    = 600;    // thumbnail width requested from API


     /* ── Bootstrap: run after DOM ready ────────────────── */
     var API      = 'https://en.battlestarwiki.org/w/api.php';
     mw.hook( 'wikipage.content' ).add( function ( $content ) {
     var MEDIA_API = 'https://media.battlestarwiki.org/w/api.php';
        $content.find( '.portal-slider-wrapper' ).each( function () {
            initSlider( $( this ) );
        } );
    } );


     /* ── Per-slider initialisation ─────────────────────── */
     /* ── Shared helpers ─────────────────────────────────────────────── */
    function initSlider( $wrapper ) {
        var $nav      = $wrapper.find( '.portal-slider-nav' );
        var category  = $nav.data( 'category' );  // set by template
        var $prevBtn  = $nav.find( '.portal-slider-prev' );
        var $nextBtn  = $nav.find( '.portal-slider-next' );
        var $dots    = $nav.find( '.portal-slider-dots' );
        var $counter  = $nav.find( '.portal-slider-counter' );


        // The server already rendered the first image inside $wrapper
    function dailySeed() {
         // as a plain <a><img></a> block (from wikitext [[File:...]]).
         var now = new Date();
         var $firstImg = $wrapper.find( 'a.image, img' ).first();
         return Math.floor( Date.UTC(
            now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()
        ) / 86400000 );
    }


         var slides  = [];   // { src, caption, pageTitle }
    function esc( s ) {
        var current = 0;
         return String( s || '' )
        var timer  = null;
            .replace( /&/g, '&amp;' ).replace( /</g, '&lt;' )
        var $display;      // the <img> we'll swap
            .replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
    }


         /* ── Step 1: wrap the server-rendered image ──── */
    function apiGetFrom( baseUrl, params, crossOrigin ) {
         if ( $firstImg.length ) {
         params.format = 'json';
            // Build a display container around it
         if ( crossOrigin ) { params.origin = '*'; }
             $display = $( '<img>' )
        var qs = Object.keys( params )
                .addClass( 'portal-slider-image' )
             .map( function ( k ) { return encodeURIComponent( k ) + '=' + encodeURIComponent( params[ k ] ); } )
                .attr( 'alt', '' )
            .join( '&' );
                 .css( { width: '100%', borderRadius: '6px',
        return fetch( baseUrl + '?' + qs, { headers: { Accept: 'application/json' } } )
                        maxHeight: '260px', objectFit: 'cover' } );
            .then( function ( r ) {
                 if ( !r.ok ) throw new Error( 'HTTP ' + r.status );
                return r.json();
            } );
    }


            var $caption = $( '<div>' ).addClass( 'portal-slider-caption' );
    /* Same-origin: use relative URL, no origin param needed */
            var $frame  = $( '<div>' ).addClass( 'portal-slider-frame' )
    function apiGet( params ) {
                .append( $display, $caption );
        return apiGetFrom( '/w/api.php', params, false );
    }


            // Replace whatever MW rendered with our controlled frame
    /* Cross-origin: use absolute URL + origin=* for CORS */
            $firstImg.closest( 'a.image, p' ).first().replaceWith( $frame );
    function apiGetCross( params ) {
         } else {
         return apiGetFrom( MEDIA_API, params, true );
            // Placeholder already in DOM from Lua; just ensure nav shows
    }
            $prevBtn.hide();
            $nextBtn.hide();
            return;
        }


         /* ── Step 2: fetch ImageList sub-page ────────── */
    var EPISODE_SEASONS = {
         if ( !category ) return;
        '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', 'Razor Flashbacks, Episode 4',
            'Razor Flashbacks, Episode 5', 'Razor Flashbacks, Episode 6',
            'Razor Flashbacks, Episode 7',
            '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)'
        ],


         var listPage = mw.config.get( 'wgPageName' ) + '/ImageList';
         /* ── Original Series (TOS) ── */
        'Season 1 (TOS)': [
            'Saga of a Star World', 'Lost Planet of the Gods, Part I',
            'Lost Planet of the Gods, Part II', 'The Lost Warrior',
            'The Long Patrol', 'The Gun on Ice Planet Zero, Part I',
            'The Gun on Ice Planet Zero, Part II', 'The Magnificent Warriors',
            'The Young Lords', 'The Living Legend, Part I',
            'The Living Legend, Part II', 'Fire in Space',
            'War of the Gods, Part I', 'War of the Gods, Part II',
            'The Man with Nine Lives', 'Murder on the Rising Star',
            'Greetings from Earth', "Baltar's Escape",
            'Experiment in Terra', 'Take the Celestra',
            'The Hand of God (TOS)'
        ],
        'Season 1 (1980)': [
            'Galactica Discovers Earth, Part I', 'Galactica Discovers Earth, Part II',
            'Galactica Discovers Earth, Part III', 'The Super Scouts, Part I',
            'The Super Scouts, Part II', 'Spaceball',
            'The Night the Cylons Landed, Part I', 'The Night the Cylons Landed, Part II',
            'Space Croppers', 'The Return of Starbuck'
        ]
    };


        mw.api = mw.api || new mw.Api();
    function initEpisodeGuideSliders( page ) {
         ( new mw.Api() ).get( {
         var sliders = page.querySelectorAll( '.portal-epguide-slider[data-category]' );
            action:  'query',
        if ( !sliders.length ) return;
            titles:  listPage,
            prop:    'revisions',
            rvprop:  'content',
            rvslots: 'main',
            formatversion: 2
        } ).then( function ( data ) {
            var pages = data.query && data.query.pages;
            if ( !pages || !pages[0] ) return;


             var page = pages[0];
        sliders.forEach( function ( el ) {
             if ( page.missing ) {
            var cat    = el.dataset.category;
                 // No ImageList — fall back to random category query
             var guide  = el.dataset.guide || '';
                fetchRandomFromCategory( category );
            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;
                 return;
             }
             }


             var content = page.revisions &&
             /* Show loading state */
                          page.revisions[0] &&
            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>';
                          page.revisions[0].slots &&
 
                          page.revisions[0].slots.main &&
            /* Fetch pageimages + extracts for all episodes in this season */
                          page.revisions[0].slots.main.content;
            apiGet( {
            if ( !content ) return;
                action:        'query',
                titles:        titles.slice( 0, 20 ).join( '|' ),
                redirects:    '1',
                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;
                } );


            var fileNames = [];
                /* Build slides in episode order */
            content.split( '\n' ).forEach( function ( line ) {
                var slides = titles.map( function ( t ) {
                var trimmed = line.replace( /^\s+|\s+$/g, '' );
                    var p      = pageMap[ t ] || {};
                if ( trimmed ) {
                     var thumb  = p.thumbnail ? p.thumbnail.source : '';
                     // Strip leading "File:" if present
                     var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim();
                     var fname = trimmed.replace( /^[Ff]ile:/, '' )
                    if ( extract.length > 100 ) extract = extract.slice( 0, 100 ).replace( /\s\S+$/, '' ) + '\u2026';
                                      .split( '|' )[0]
                     return { title: t, thumb: thumb, extract: extract };
                                      .replace( /^\s+|\s+$/g, '' );
                } ).filter( function ( s ) { return s.thumb; } );
                     if ( fname ) fileNames.push( 'File:' + fname );
 
                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>';
             } );
             } );
        } );
    }


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


             // Shuffle using a date-seeded PRNG so order is stable all day
        function render() {
             fileNames = seededShuffle( fileNames );
             var s = slides[ current ];
             fetchThumbnails( fileNames );
             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( '' );


         } ).catch( function () {
            el.innerHTML =
             // Silent fail first image still visible
                '<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 );
    }
 
 
    /* ── 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 );
        initCharSeriesTabs( page );
    } );
 
    /* Episode guide page — not wrapped in .portal-page */
    mw.hook( 'wikipage.content' ).add( function () {
        if ( document.querySelector( '.portal-epguide' ) ) {
             initEpisodeGuideSliders( document );
        }
    } );
 
    /* ════════════════════════════════════════════════════════════════
      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' );


        /* ── Fetch thumbnails for a list of file titles ─ */
            /* Random shuffle, cap at 20 candidates to fetch images for */
        function fetchThumbnails( fileNames ) {
             members = seededShuffle( members, seed ).slice( 0, 20 );
             ( new mw.Api() ).get( {
             var titles = members.map( function ( m ) { return m.title; } ).join( '|' );
                action:  'query',
                titles:  fileNames.slice( 0, 20 ).join( '|' ),
                prop:    'imageinfo',
                iiprop:  'url|extmetadata',
                iiurlwidth: IMAGE_WIDTH_PX,
                formatversion: 2
             } ).then( function ( data ) {
                var pages = data.query && data.query.pages;
                if ( !pages ) return;


                 pages.forEach( function ( page ) {
            /* Fetch pageimage + extract + categories for each article */
                    var ii = page.imageinfo && page.imageinfo[0];
            return apiGet( {
                     if ( !ii || !ii.thumburl ) return;
                action:          'query',
                     var caption = '';
                titles:          titles,
                     if ( ii.extmetadata ) {
                prop:            'pageimages|extracts|categories',
                         caption = ( ii.extmetadata.ImageDescription &&
                piprop:          'thumbnail',
                                    ii.extmetadata.ImageDescription.value ) || '';
                pithumbsize:    '1200',
                        // Strip HTML tags
                pilimit:        '20',
                        caption = caption.replace( /<[^>]+>/g, '' );
                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( {
                     slides.push( {
                         src:       ii.thumburl,
                         thumb:   p.thumbnail.source,
                         caption:   caption || page.title.replace( /^File:/, '' )
                         caption: extract || p.title,
                                                        .replace( /\.[^.]+$/, '' ),
                        title:  p.title,
                         pageTitle: page.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 */ } );
    }


                if ( slides.length === 0 ) return;
    /** 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();
        } );


                // Set the first slide immediately
        /* Exact/prefix matches in priority order */
                showSlide( 0 );
        var checks = [
                buildDots();
            /* Episodes first — most specific */
                startAutoplay();
            [ 'episode guide',        'Episode'      ],
            [ 'episodes by',          'Episode'      ],
            [ 'miniseries',            'Episode'      ],


                $prevBtn.on( 'click', function () { advance( -1 ); } );
            /* Ships / Craft */
                $nextBtn.on( 'click', function () { advance( 1 ); } );
            [ 'colonial craft',        'Ship'        ],
                $wrapper.on( 'mouseenter', stopAutoplay )
            [ 'cylon craft',          'Ship'        ],
                        .on( 'mouseleave', startAutoplay );
            [ '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 */
    }


         /* ── Fetch random images directly from category ─ */
         /** Build and wire the carousel DOM — bsw-hero style */
        function fetchRandomFromCategory( cat ) {
    function buildCarousel( hero, slides ) {
            ( new mw.Api() ).get( {
        var current = 0;
                action:    'query',
        var total  = slides.length;
                list:      'categorymembers',
        var timer;
                cmtitle:    'Category:' + cat,
                cmtype:    'file',
                cmlimit:    20,
                cmsort:    'sortkey',
                formatversion: 2
            } ).then( function ( data ) {
                var members = data.query && data.query.categorymembers;
                if ( !members || members.length === 0 ) return;


                var fileNames = members.map( function ( m ) { return m.title; } );
        var cat        = ( hero.dataset.category || '' ).toUpperCase();
                fileNames = seededShuffle( fileNames );
        var seriesBadge = cat || 'BSG';
                fetchThumbnails( fileNames );
            } );
        }


         /* ── Slide display ──────────────────────────────── */
         /* Rebuild hero using bsw-slide pattern — same as Main Page */
         function showSlide( idx ) {
         var slideHTML = '';
             current = ( idx + slides.length ) % slides.length;
        slides.forEach( function ( s, i ) {
            var slide = slides[current];
             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>';
        } );


             $display.attr( 'src', slide.src ).attr( 'alt', slide.caption );
        /* Dots + nav — top-right like Main Page */
             $wrapper.find( '.portal-slider-caption' )
        slideHTML +=
                    .text( slide.caption );
             '<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>';


            $dots.find( '.portal-slider-dot' ).removeClass( 'is-active' )
        hero.innerHTML = slideHTML;
                .eq( current ).addClass( 'is-active' );


            $counter.text( ( current + 1 ) + ' / ' + slides.length );
        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 advance( dir ) {
         function goTo( n ) {
             stopAutoplay();
             slideEls[ current ].classList.remove( 'portal-active' );
             showSlide( current + dir );
             dotsEl.querySelectorAll( '.portal-hero-dot' )[ current ].classList.remove( 'is-active' );
             startAutoplay();
            current = ( n + total ) % total;
            slideEls[ current ].classList.add( 'portal-active' );
            dotsEl.querySelectorAll( '.portal-hero-dot' )[ current ].classList.add( 'is-active' );
             resetTimer();
         }
         }


        /* ── Dots ───────────────────────────────────────── */
         function resetTimer() {
         function buildDots() {
             clearInterval( timer );
             $dots.empty();
             timer = setInterval( function () { goTo( current + 1 ); }, 6000 );
             for ( var i = 0; i < slides.length; i++ ) {
                $( '<span>' ).addClass( 'portal-slider-dot' )
                    .toggleClass( 'is-active', i === 0 )
                    .data( 'idx', i )
                    .on( 'click', function () { showSlide( $( this ).data( 'idx' ) ); } )
                    .appendTo( $dots );
            }
         }
         }


         /* ── Auto-play ──────────────────────────────────── */
         if ( prevBtn ) {
        function startAutoplay() {
             prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
             stopAutoplay();
             prevBtn.addEventListener( 'keydown', function ( e ) {
             timer = setInterval( function () { advance( 1 ); }, SLIDE_INTERVAL_MS );
                if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current - 1 ); }
            } );
         }
         }
         function stopAutoplay() {
         if ( nextBtn ) {
             if ( timer ) { clearInterval( timer ); timer = null; }
            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>';
     }
     }


     /* ── Seeded shuffle (date-based, stable all day) ──── */
     /* ════════════════════════════════════════════════════════════════
    function seededShuffle( arr ) {
      3. STATS BAR — live article count
        var date  = new Date();
      The stat_count span with data-category gets its number filled
        var seed  = date.getFullYear() * 10000 +
      in from categoryinfo. All other stats are static wikitext.
                    ( date.getMonth() + 1 ) * 100 +
      ════════════════════════════════════════════════════════════════ */
                    date.getDate();
        var state  = seed;


         function rand() {
    function initPortalStats( page ) {
             // Xorshift32
        var countEls = page.querySelectorAll( '.portal-stat-count[data-category]' );
             state ^= state << 13;
        if ( !countEls.length ) return;
             state ^= state >> 17;
 
             state ^= state << 5;
        /* Batch all stat categories into a single categoryinfo query */
             return ( state >>> 0 ) / 0xFFFFFFFF;
        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( '|' ),
            redirects: '1',
            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 extract = s.extract;
            if ( extract.length >= 400 ) extract = extract.replace( /\s\S+$/, '' ) + '…';
 
            var thumbHtml = s.thumb
                ? '<a href="' + esc( s.url ) + '" class="portal-featured-thumb-wrap">' +
                  '<div class="portal-slide-bg">' +
                  '<img src="' + esc( s.thumb ) + '" alt="" class="portal-slide-bg-blur" aria-hidden="true">' +
                  '<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" class="portal-featured-img">' +
                  '</div>' +
                  '</a>'
                : '';
 
            body.innerHTML =
                thumbHtml +
                '<div class="portal-featured-info">' +
                '<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>' +
                '</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 a = arr.slice();
        var s = seed;
         for ( var i = a.length - 1; i > 0; i-- ) {
         for ( var i = a.length - 1; i > 0; i-- ) {
             var j = Math.floor( rand() * ( i + 1 ) );
            /* 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;
             var t = a[i]; a[i] = a[j]; a[j] = t;
         }
         }
Line 248: Line 858:
     }
     }


}( mediaWiki, jQuery ) );
 
/* ════════════════════════════════════════════════════════════════
      6. EPISODE GUIDE SLIDERS
      Each .portal-epguide-slider[data-titles] gets a mini carousel
      showing episode screencaps fetched from the media wiki.
      ════════════════════════════════════════════════════════════════ */
 
    /* ════════════════════════════════════════════════════════════════
      7. CHARACTER SERIES TABS
      ════════════════════════════════════════════════════════════════ */
 
    function initCharSeriesTabs( page ) {
        var containers = page.querySelectorAll( '.portal-char-series' );
        if ( !containers.length ) return;
 
        containers.forEach( function ( container ) {
            var tabs  = container.querySelectorAll( '.portal-char-series-tab' );
            var panels = container.querySelectorAll( '.portal-char-series-panel' );
 
            tabs.forEach( function ( tab ) {
                tab.addEventListener( 'click', function () {
                    var series = tab.dataset.series;
 
                    tabs.forEach( function ( t ) { t.classList.remove( 'is-active' ); } );
                    panels.forEach( function ( p ) { p.classList.remove( 'is-active' ); } );
 
                    tab.classList.add( 'is-active' );
                    var panel = container.querySelector( '.portal-char-series-panel[data-series="' + series + '"]' );
                    if ( panel ) panel.classList.add( 'is-active' );
                } );
            } );
        } );
    }
 
 
}() );

Latest revision as of 02:50, 18 April 2026

/**
 * 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 );
    }

    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', 'Razor Flashbacks, Episode 4',
            'Razor Flashbacks, Episode 5', 'Razor Flashbacks, Episode 6',
            'Razor Flashbacks, Episode 7',
            '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)'
        ],

        /* ── Original Series (TOS) ── */
        'Season 1 (TOS)': [
            'Saga of a Star World', 'Lost Planet of the Gods, Part I',
            'Lost Planet of the Gods, Part II', 'The Lost Warrior',
            'The Long Patrol', 'The Gun on Ice Planet Zero, Part I',
            'The Gun on Ice Planet Zero, Part II', 'The Magnificent Warriors',
            'The Young Lords', 'The Living Legend, Part I',
            'The Living Legend, Part II', 'Fire in Space',
            'War of the Gods, Part I', 'War of the Gods, Part II',
            'The Man with Nine Lives', 'Murder on the Rising Star',
            'Greetings from Earth', "Baltar's Escape",
            'Experiment in Terra', 'Take the Celestra',
            'The Hand of God (TOS)'
        ],
        'Season 1 (1980)': [
            'Galactica Discovers Earth, Part I', 'Galactica Discovers Earth, Part II',
            'Galactica Discovers Earth, Part III', 'The Super Scouts, Part I',
            'The Super Scouts, Part II', 'Spaceball',
            'The Night the Cylons Landed, Part I', 'The Night the Cylons Landed, Part II',
            'Space Croppers', 'The Return of Starbuck'
        ]
    };

    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( '|' ),
                redirects:     '1',
                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 );
    }


    /* ── 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 );
        initCharSeriesTabs( page );
    } );

    /* Episode guide page — not wrapped in .portal-page */
    mw.hook( 'wikipage.content' ).add( function () {
        if ( document.querySelector( '.portal-epguide' ) ) {
            initEpisodeGuideSliders( document );
        }
    } );

    /* ════════════════════════════════════════════════════════════════
       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( '|' ),
            redirects: '1',
            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 extract = s.extract;
            if ( extract.length >= 400 ) extract = extract.replace( /\s\S+$/, '' ) + '…';

            var thumbHtml = s.thumb
                ? '<a href="' + esc( s.url ) + '" class="portal-featured-thumb-wrap">' +
                  '<div class="portal-slide-bg">' +
                  '<img src="' + esc( s.thumb ) + '" alt="" class="portal-slide-bg-blur" aria-hidden="true">' +
                  '<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" class="portal-featured-img">' +
                  '</div>' +
                  '</a>'
                : '';

            body.innerHTML =
                thumbHtml +
                '<div class="portal-featured-info">' +
                '<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>' +
                '</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.
       ════════════════════════════════════════════════════════════════ */

    /* ════════════════════════════════════════════════════════════════
       7. CHARACTER SERIES TABS
       ════════════════════════════════════════════════════════════════ */

    function initCharSeriesTabs( page ) {
        var containers = page.querySelectorAll( '.portal-char-series' );
        if ( !containers.length ) return;

        containers.forEach( function ( container ) {
            var tabs   = container.querySelectorAll( '.portal-char-series-tab' );
            var panels = container.querySelectorAll( '.portal-char-series-panel' );

            tabs.forEach( function ( tab ) {
                tab.addEventListener( 'click', function () {
                    var series = tab.dataset.series;

                    tabs.forEach( function ( t ) { t.classList.remove( 'is-active' ); } );
                    panels.forEach( function ( p ) { p.classList.remove( 'is-active' ); } );

                    tab.classList.add( 'is-active' );
                    var panel = container.querySelector( '.portal-char-series-panel[data-series="' + series + '"]' );
                    if ( panel ) panel.classList.add( 'is-active' );
                } );
            } );
        } );
    }


}() );