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)
No edit summary
Joe Beaudoin Jr. (talk | contribs)
No edit summary
 
(3 intermediate revisions by the same user not shown)
Line 104: Line 104:
             var titles = members.map( function ( m ) { return m.title; } ).join( '|' );
             var titles = members.map( function ( m ) { return m.title; } ).join( '|' );


             /* Fetch pageimage + extract for each article — mirrors Main Page bsw-hero */
             /* Fetch pageimage + extract + categories for each article */
             return apiGet( {
             return apiGet( {
                 action:          'query',
                 action:          'query',
                 titles:          titles,
                 titles:          titles,
                 prop:            'pageimages|extracts',
                 prop:            'pageimages|extracts|categories',
                 piprop:          'thumbnail',
                 piprop:          'thumbnail',
                 pithumbsize:    '1200',
                 pithumbsize:    '1200',
Line 115: Line 115:
                 exchars:        '200',
                 exchars:        '200',
                 exsectionformat: 'plain',
                 exsectionformat: 'plain',
                cllimit:        '20',
                 formatversion:  '2'
                 formatversion:  '2'
             } ).then( function ( d ) {
             } ).then( function ( d ) {
Line 125: Line 126:
                         extract = extract.replace( /\s\S+$/, '' ) + '…';
                         extract = extract.replace( /\s\S+$/, '' ) + '…';
                     }
                     }
                    /* Derive badge label from article categories */
                    var badge = portalSlideBadge( p.categories || [] );
                     slides.push( {
                     slides.push( {
                         thumb:  p.thumbnail.source,
                         thumb:  p.thumbnail.source,
                         caption: extract || p.title,
                         caption: extract || p.title,
                         title:  p.title,
                         title:  p.title,
                         link:    p.title
                         link:    p.title,
                        badge:  badge
                     } );
                     } );
                 } );
                 } );
Line 140: Line 144:
     }
     }


     /** Build and wire the carousel DOM — bsw-hero style */
     /** 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 ) {
     function buildCarousel( hero, slides ) {
         var current = 0;
         var current = 0;
Line 190: Line 286:
             if ( caption ) {
             if ( caption ) {
                 caption.innerHTML =
                 caption.innerHTML =
                     '<div class="portal-hero-badge">✶ ' + esc( seriesBadge ) + '</div>' +
                     ( ( s.badge || seriesBadge ) ? '<div class="portal-hero-badge">✶ ' + esc( s.badge || seriesBadge ) + '</div>' : '' ) +
                     '<div class="portal-hero-title">' + esc( s.title ) + '</div>' +
                     '<div class="portal-hero-title">' + esc( s.title ) + '</div>' +
                     ( s.caption && s.caption !== s.title
                     ( s.caption && s.caption !== s.title
Line 286: Line 382:
             var tsMap = {};
             var tsMap = {};
             entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } );
             entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } );
            /* Check multiple RDM categories so articles in subcats are matched */
            var catCandidates = [
                'Category:' + category,
                'Category:Characters (RDM)',
                'Category:Characters (TRS)',
                'Category:Episode Guide (RDM)',
                'Category:Colonial Military (RDM)',
                'Category:Colonial Craft (RDM)',
                'Category:Ships (RDM)',
                'Category:Places (RDM)',
                'Category:Technology (RDM)',
                'Category:Terminology (RDM)',
                'Category:Colonial Society (RDM)',
                'Category:Colonial History (RDM)',
                'Category:Cylons (RDM)'
            ];


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


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


         apiGet( {
         apiGet( {
             action: 'query',
             action:       'query',
             titles: titles.join( '|' ),
             titles:       titles.join( '|' ),
             prop:   'categoryinfo',
             prop:         'categoryinfo',
             formatversion: '2'
             formatversion: '2'
         } ).then( function ( data ) {
         } ).then( function ( data ) {
Line 388: Line 502:
                 pageMap[ p.title ] = p;
                 pageMap[ p.title ] = p;
             } );
             } );
             countEls.forEach( function ( el ) {
             countEls.forEach( function ( el ) {
                 var key = 'Category:' + el.dataset.category;
                 var key = 'Category:' + el.dataset.category;

Latest revision as of 02:55, 14 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 );
    }

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

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

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

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

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

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

        /* 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 || [] );
            if ( !members.length ) throw new Error( 'empty category' );

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

            /* Fetch pageimage + 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;

        /* Derive portal series label from data-category for the slide badge */
        var cat = ( hero.dataset.category || '' ).toUpperCase();
        var seriesBadge = cat || 'BSG';

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

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

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

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

            /* Set background image on the bg div — same as bsw-slide-bg */
            if ( bg ) {
                bg.style.backgroundImage = 'url(' + s.thumb + ')';
            }
            if ( caption ) {
                caption.innerHTML =
                    ( ( s.badge || seriesBadge ) ? '<div class="portal-hero-badge">✶ ' + esc( s.badge || seriesBadge ) + '</div>' : '' ) +
                    '<div class="portal-hero-title">' + esc( s.title ) + '</div>' +
                    ( s.caption && s.caption !== s.title
                        ? '<div class="portal-hero-extract">' + esc( s.caption ) + '</div>'
                        : '' );
            }
            /* Update the clickable overlay link */
            if ( link ) {
                if ( s.link ) {
                    link.href = '/' + encodeURIComponent( s.link.replace( / /g, '_' ) );
                    link.title = s.link;
                    link.style.display = 'block';
                } else {
                    /* No article link for this slide — disable the overlay */
                    link.removeAttribute( 'href' );
                    link.style.display = 'none';
                }
            }

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

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

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

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

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

        goTo( 0 );
    }


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

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

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

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

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

            /* Check multiple RDM categories so articles in subcats are matched */
            var catCandidates = [
                'Category:' + category,
                'Category:Characters (RDM)',
                'Category:Characters (TRS)',
                'Category:Episode Guide (RDM)',
                'Category:Colonial Military (RDM)',
                'Category:Colonial Craft (RDM)',
                'Category:Ships (RDM)',
                'Category:Places (RDM)',
                'Category:Technology (RDM)',
                'Category:Terminology (RDM)',
                'Category:Colonial Society (RDM)',
                'Category:Colonial History (RDM)',
                'Category:Cylons (RDM)'
            ];

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

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

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

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

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

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

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

    function initPortalStats( page ) {
        var 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;
    }

}() );