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
Line 1: Line 1:
/* Portal image slider — MediaWiki:Common.js
/**
* BattlestarWiki — Portal page JavaScript
* Append to MediaWiki:Common.js (after the main page JS block)
  *
  *
  * 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 — random images from the portal's image category,
  * server-side; the JS fetches the full ImageList sub-page via the API
*      seeded daily so all visitors see the same sequence each day.
  * and pre-loads remaining images for the slider.
  *     Reads filenames from Portal:NAME/ImageList (one File: per line).
*      Falls back to querying the category directly if no list exists.
*  2. Newest article — filtered to the portal's assigned category,
  *     using the same logevents API call as the main page's newest widget.
*  3. Stats bar — live article count from the portal's category via
  *     action=query&prop=categoryinfo. Manual counts (episodes, seasons,
*      etc.) are static in the wikitext and don't need JS.
  *
  *
  * Falls back gracefully: if JS is disabled, the first random image
  * Reuses the apiGet() / apiGetFrom() / dailySeed() / esc() helpers
  * is still shown (rendered server-side by Lua).
  * already defined in the main page JS block above — do not redefine them.
  */
  */
( function ( mw, $ ) {
 
( function () {
     'use strict';
     'use strict';


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


    /* ── Bootstrap: run after DOM ready ────────────────── */
     mw.hook( 'wikipage.content' ).add( function () {
     mw.hook( 'wikipage.content' ).add( function ( $content ) {
         var page = document.querySelector( '.portal-page' );
         $content.find( '.portal-slider-wrapper' ).each( function () {
        if ( !page ) return;
            initSlider( $( this ) );
 
         } );
        initPortalSlider( page );
        initPortalNewest( page );
         initPortalStats( page );
     } );
     } );


     /* ── Per-slider initialisation ─────────────────────── */
     /* ════════════════════════════════════════════════════════════════
    function initSlider( $wrapper ) {
      1. IMAGE CAROUSEL
        var $nav      = $wrapper.find( '.portal-slider-nav' );
      Reads Portal:NAME/ImageList for a curated file list, falls back
        var category  = $nav.data( 'category' );  // set by template
      to querying the category directly. Images are fetched via the
        var $prevBtn  = $nav.find( '.portal-slider-prev' );
      media wiki's imageinfo API for thumbnail URLs.
        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 initPortalSlider( page ) {
        // as a plain <a><img></a> block (from wikitext [[File:...]]).
         var wrapper = page.querySelector( '.portal-slider-wrapper' );
         var $firstImg = $wrapper.find( 'a.image, img' ).first();
        if ( !wrapper ) return;


         var slides  = [];   // { src, caption, pageTitle }
         var nav      = wrapper.querySelector( '.portal-slider-nav' );
         var current = 0;
         var category = nav ? nav.dataset.category : '';
         var timer  = null;
         var listPage = nav ? nav.dataset.listpage  : '';
         var $display;       // the <img> we'll swap
 
        if ( !listPage && !category ) return;
 
         var seed = dailySeed();
 
        /* Step 1: get file list — prefer the curated /ImageList sub-page */
        getFileList( listPage, category )
            .then( function ( files ) {
                if ( !files.length ) throw new Error( 'No files found' );
 
                /* Daily-seeded shuffle so order is stable all day */
                files = seededShuffle( files, seed );
                /* Cap at 12 slides — enough for a day's rotation */
                files = files.slice( 0, 12 );


        /* ── Step 1: wrap the server-rendered image ──── */
                /* Step 2: fetch thumbnail URLs from the media wiki */
        if ( $firstImg.length ) {
                return fetchThumbnails( files );
             // Build a display container around it
             } )
             $display = $( '<img>' )
             .then( function ( slides ) {
                 .addClass( 'portal-slider-image' )
                 if ( !slides.length ) throw new Error( 'No thumbnails' );
                 .attr( 'alt', '' )
                 buildCarousel( wrapper, nav, slides );
                .css( { width: '100%', borderRadius: '6px',
            } )
                        maxHeight: '260px', objectFit: 'cover' } );
            .catch( function () {
                /* Silent fail — the server-rendered first image (from Lua)
                  remains visible; nav buttons just don't appear */
                if ( nav ) nav.style.display = 'none';
            } );
    }


             var $caption = $( '<div>' ).addClass( 'portal-slider-caption' );
    /**
            var $frame  = $( '<div>' ).addClass( 'portal-slider-frame' )
    * Fetch the curated file list from the /ImageList sub-page.
                 .append( $display, $caption );
    * Falls back to a category member query if the page is missing.
    */
    function getFileList( listPage, category ) {
        if ( listPage ) {
             return apiGet( {
                action:  'query',
                titles:  listPage,
                prop:    'revisions',
                rvprop:  'content',
                rvslots: 'main',
                formatversion: '2'
            } ).then( function ( data ) {
                var pages = data.query && data.query.pages;
                if ( !pages || !pages[0] || pages[0].missing ) {
                    /* Page doesn't exist yet — fall back to category query */
                    return category ? getCategoryFiles( category ) : [];
                }
                 var content = pages[0].revisions &&
                              pages[0].revisions[0] &&
                              pages[0].revisions[0].slots &&
                              pages[0].revisions[0].slots.main &&
                              pages[0].revisions[0].slots.main.content || '';


            // Replace whatever MW rendered with our controlled frame
                var files = [];
            $firstImg.closest( 'a.image, p' ).first().replaceWith( $frame );
                content.split( '\n' ).forEach( function ( line ) {
        } else {
                    line = line.replace( /<!--[\s\S]*?-->/g, '' ).trim();
            // Placeholder already in DOM from Lua; just ensure nav shows
                    if ( !line ) return;
            $prevBtn.hide();
                    /* Format: File:Name.jpg | optional caption */
            $nextBtn.hide();
                    var parts = line.split( '|' );
             return;
                    var fname = parts[0].trim().replace( /^[Ff]ile:/, '' );
                    var caption = parts[1] ? parts[1].trim() : '';
                    if ( fname ) files.push( { name: 'File:' + fname, caption: caption } );
                } );
                return files.length ? files : ( category ? getCategoryFiles( category ) : [] );
             } );
         }
         }
        return category ? getCategoryFiles( category ) : Promise.resolve( [] );
    }


        /* ── Step 2: fetch ImageList sub-page ────────── */
    /** Fetch file members of a category from the media wiki */
         if ( !category ) return;
    function getCategoryFiles( category ) {
         return apiGetFrom( MEDIA_API, {
            action:  'query',
            list:    'categorymembers',
            cmtitle:  'Category:' + category,
            cmtype:  'file',
            cmlimit:  '50'
        } ).then( function ( data ) {
            return ( data.query.categorymembers || [] ).map( function ( m ) {
                return { name: m.title, caption: '' };
            } );
        } );
    }


         var listPage = mw.config.get( 'wgPageName' ) + '/ImageList';
    /** Fetch thumbnail URLs for a list of file objects via imageinfo */
    function fetchThumbnails( files ) {
        /* MW API accepts up to 50 titles per call */
         var titles = files.map( function ( f ) { return f.name; } ).join( '|' );
        var captionMap = {};
        files.forEach( function ( f ) { captionMap[ f.name ] = f.caption; } );


         mw.api = mw.api || new mw.Api();
         return apiGetFrom( MEDIA_API, {
        ( new mw.Api() ).get( {
             action:     'query',
             action: 'query',
             titles:     titles,
             titles: listPage,
             prop:       'imageinfo',
             prop:   'revisions',
             iiprop:     'url|extmetadata',
             rvprop: 'content',
             iiurlwidth: '800',
             rvslots: 'main',
             formatversion: '2'
             formatversion: 2
         } ).then( function ( data ) {
         } ).then( function ( data ) {
             var pages = data.query && data.query.pages;
             var result = [];
            if ( !pages || !pages[0] ) return;
            ( data.query.pages || [] ).forEach( function ( p ) {
                var ii = p.imageinfo && p.imageinfo[0];
                if ( !ii || !ii.thumburl ) return;
                /* Caption priority: curated list caption → image description metadata */
                var caption = captionMap[ p.title ] || '';
                if ( !caption && ii.extmetadata && ii.extmetadata.ImageDescription ) {
                    caption = ii.extmetadata.ImageDescription.value.replace( /<[^>]+>/g, '' );
                }
                if ( !caption ) caption = p.title.replace( /^File:/, '' ).replace( /\.[^.]+$/, '' );
                result.push( { thumb: ii.thumburl, caption: caption, title: p.title } );
            } );
            return result;
        } );
    }
 
    /** Build and wire the carousel DOM */
    function buildCarousel( wrapper, nav, slides ) {
        var current = 0;
        var total  = slides.length;
        var timer;


            var page = pages[0];
        /* Build the main image element — reuse the one Lua rendered if present,
             if ( page.missing ) {
          otherwise create it so there's no flash on first load */
                // No ImageList — fall back to random category query
        var img = wrapper.querySelector( 'img.portal-slider-image' );
                fetchRandomFromCategory( category );
        if ( !img ) {
                return;
             img = document.createElement( 'img' );
             }
            img.className = 'portal-slider-image';
            img.style.cssText = 'display:block;width:100%;border-radius:6px;max-height:260px;object-fit:cover';
            /* Insert before the nav */
            wrapper.insertBefore( img, nav );
        }
 
        /* Caption element */
        var caption = wrapper.querySelector( '.portal-slider-caption' );
        if ( !caption ) {
            caption = document.createElement( 'div' );
            caption.className = 'portal-slider-caption';
            caption.style.cssText = 'font-size:0.78em;color:var(--color-subtle,#888);margin-top:4px;min-height:1.2em';
             wrapper.insertBefore( caption, nav );
        }


            var content = page.revisions &&
        /* Dots */
                          page.revisions[0] &&
        var dotsEl = nav.querySelector( '.portal-slider-dots' );
                          page.revisions[0].slots &&
        var counter = nav.querySelector( '.portal-slider-counter' );
                          page.revisions[0].slots.main &&
        var prevBtn = nav.querySelector( '.portal-slider-prev' );
                          page.revisions[0].slots.main.content;
        var nextBtn = nav.querySelector( '.portal-slider-next' );
            if ( !content ) return;


             var fileNames = [];
        if ( dotsEl ) {
             content.split( '\n' ).forEach( function ( line ) {
             dotsEl.innerHTML = '';
                 var trimmed = line.replace( /^\s+|\s+$/g, '' );
             slides.forEach( function ( _, i ) {
                 if ( trimmed ) {
                 var dot = document.createElement( 'span' );
                    // Strip leading "File:" if present
                 dot.className = 'portal-slider-dot' + ( i === 0 ? ' is-active' : '' );
                    var fname = trimmed.replace( /^[Ff]ile:/, '' )
                dot.addEventListener( 'click', function () { goTo( i ); } );
                                      .split( '|' )[0]
                dotsEl.appendChild( dot );
                                      .replace( /^\s+|\s+$/g, '' );
                    if ( fname ) fileNames.push( 'File:' + fname );
                }
             } );
             } );
        }


             if ( fileNames.length === 0 ) return;
        function goTo( n ) {
             current = ( n + total ) % total;
            var s = slides[ current ];
            img.src = s.thumb;
            img.alt = s.caption;
            caption.textContent = s.caption;


             // Shuffle using a date-seeded PRNG so order is stable all day
             if ( dotsEl ) {
             fileNames = seededShuffle( fileNames );
                dotsEl.querySelectorAll( '.portal-slider-dot' ).forEach( function ( d, i ) {
             fetchThumbnails( fileNames );
                    d.classList.toggle( 'is-active', i === current );
                } );
            }
             if ( counter ) counter.textContent = ( current + 1 ) + ' / ' + total;
             resetTimer();
        }


         } ).catch( function () {
         function resetTimer() {
             // Silent fail — first image still visible
             clearInterval( timer );
        } );
            timer = setInterval( function () { goTo( current + 1 ); }, 5000 );
        }


         /* ── Fetch thumbnails for a list of file titles ─ */
         if ( prevBtn ) prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
        function fetchThumbnails( fileNames ) {
        if ( nextBtn ) nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );
            ( new mw.Api() ).get( {
                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 ) {
        wrapper.addEventListener( 'mouseenter', function () { clearInterval( timer ); } );
                    var ii = page.imageinfo && page.imageinfo[0];
        wrapper.addEventListener( 'mouseleave', resetTimer );
                    if ( !ii || !ii.thumburl ) return;
                    var caption = '';
                    if ( ii.extmetadata ) {
                        caption = ( ii.extmetadata.ImageDescription &&
                                    ii.extmetadata.ImageDescription.value ) || '';
                        // Strip HTML tags
                        caption = caption.replace( /<[^>]+>/g, '' );
                    }
                    slides.push( {
                        src:      ii.thumburl,
                        caption:  caption || page.title.replace( /^File:/, '' )
                                                        .replace( /\.[^.]+$/, '' ),
                        pageTitle: page.title
                    } );
                } );


                if ( slides.length === 0 ) return;
        /* Start on slide 0 */
        goTo( 0 );
    }


                // Set the first slide immediately
    /* ════════════════════════════════════════════════════════════════
                showSlide( 0 );
      2. NEWEST ARTICLE (portal-scoped)
                buildDots();
      Same logevents call as the main page newest widget, but filtered
                startAutoplay();
      to the portal's category via a follow-up categorymembers check.
      ════════════════════════════════════════════════════════════════ */


                $prevBtn.on( 'click', function () { advance( -1 ); } );
    function initPortalNewest( page ) {
                $nextBtn.on( 'click', function () { advance(  1 ); } );
        var inner = page.querySelector( '.portal-newest-inner' );
                $wrapper.on( 'mouseenter', stopAutoplay )
        if ( !inner ) return;
                        .on( 'mouseleave', startAutoplay );
            } );
        }


         /* ── Fetch random images directly from category ─ */
         var category = inner.dataset.category || '';
        function fetchRandomFromCategory( cat ) {
        if ( !category ) return;
            ( new mw.Api() ).get( {
                action:    'query',
                list:      'categorymembers',
                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; } );
        /* Fetch the 30 most recently created mainspace pages */
                fileNames = seededShuffle( fileNames );
        apiGet( {
                 fetchThumbnails( fileNames );
            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' );


        /* ── Slide display ──────────────────────────────── */
            /* Check which of these titles are in the portal's category */
        function showSlide( idx ) {
            var titles = entries.map( function ( e ) { return e.title; } );
            current = ( idx + slides.length ) % slides.length;
            /* Build a timestamp map for quick lookup */
             var slide = slides[current];
             var tsMap = {};
            entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } );


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


            $dots.find( '.portal-slider-dot' ).removeClass( 'is-active' )
                /* If none match in the first 20, fall back to the single most recent */
                .eq( current ).addClass( 'is-active' );
                var title = match ? match.title : entries[0].title;
                var ts    = tsMap[ title ];


             $counter.text( ( current + 1 ) + ' / ' + slides.length );
                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 advance( dir ) {
    function renderPortalNewest( inner, page, ts ) {
            stopAutoplay();
        var title  = page.title || '';
            showSlide( current + dir );
        var url    = 'https://en.battlestarwiki.org/wiki/' + encodeURIComponent( title.replace( / /g, '_' ) );
            startAutoplay();
        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' } ) : '';


         /* ── Dots ───────────────────────────────────────── */
         var thumbHtml = thumb
        function buildDots() {
            ? '<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">'
            $dots.empty();
            : '';
            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 ──────────────────────────────────── */
         inner.innerHTML =
        function startAutoplay() {
            '<div style="display:flex;gap:10px;align-items:flex-start">' +
            stopAutoplay();
            thumbHtml +
             timer = setInterval( function () { advance( 1 ); }, SLIDE_INTERVAL_MS );
            '<div>' +
        }
            '<div class="portal-newest-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' +
        function stopAutoplay() {
             ( date ? '<div class="portal-newest-meta">Created ' + esc( date ) + '</div>' : '' ) +
             if ( timer ) { clearInterval( timer ); timer = null; }
            '<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>';
     }
     }


     /* ── 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 countEl = page.querySelector( '.portal-stat-count[data-category]' );
             state ^= state << 13;
        if ( !countEl ) return;
             state ^= state >> 17;
 
             state ^= state << 5;
        var category = countEl.dataset.category;
            return ( state >>> 0 ) / 0xFFFFFFFF;
 
         }
        apiGet( {
            action:  'query',
            titles:  'Category:' + category,
            prop:    'categoryinfo'
         } ).then( function ( data ) {
             var pages = Object.values( data.query.pages || {} );
             if ( !pages[0] || !pages[0].categoryinfo ) return;
             var n = pages[0].categoryinfo.pages || 0;
             countEl.textContent = n.toLocaleString();
        } ).catch( function () {
            countEl.textContent = '\u2014';
         } );
    }


    /* ── 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 384:
     }
     }


}( mediaWiki, jQuery ) );
}() );

Revision as of 20:34, 13 April 2026

/**
 * BattlestarWiki — Portal page JavaScript
 * Append to MediaWiki:Common.js (after the main page JS block)
 *
 * Handles per-portal widgets on any .portal-page:
 *   1. Image carousel — random images from the portal's image category,
 *      seeded daily so all visitors see the same sequence each day.
 *      Reads filenames from Portal:NAME/ImageList (one File: per line).
 *      Falls back to querying the category directly if no list exists.
 *   2. Newest article — filtered to the portal's assigned category,
 *      using the same logevents API call as the main page's newest widget.
 *   3. Stats bar — live article count from the portal's category via
 *      action=query&prop=categoryinfo. Manual counts (episodes, seasons,
 *      etc.) are static in the wikitext and don't need JS.
 *
 * Reuses the apiGet() / apiGetFrom() / dailySeed() / esc() helpers
 * already defined in the main page JS block above — do not redefine them.
 */

( function () {
    'use strict';

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

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

        initPortalSlider( page );
        initPortalNewest( page );
        initPortalStats( 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 wrapper = page.querySelector( '.portal-slider-wrapper' );
        if ( !wrapper ) return;

        var nav      = wrapper.querySelector( '.portal-slider-nav' );
        var category = nav ? nav.dataset.category : '';
        var listPage = nav ? nav.dataset.listpage  : '';

        if ( !listPage && !category ) return;

        var seed = dailySeed();

        /* Step 1: get file list — prefer the curated /ImageList sub-page */
        getFileList( listPage, category )
            .then( function ( files ) {
                if ( !files.length ) throw new Error( 'No files found' );

                /* Daily-seeded shuffle so order is stable all day */
                files = seededShuffle( files, seed );
                /* Cap at 12 slides — enough for a day's rotation */
                files = files.slice( 0, 12 );

                /* Step 2: fetch thumbnail URLs from the media wiki */
                return fetchThumbnails( files );
            } )
            .then( function ( slides ) {
                if ( !slides.length ) throw new Error( 'No thumbnails' );
                buildCarousel( wrapper, nav, slides );
            } )
            .catch( function () {
                /* Silent fail — the server-rendered first image (from Lua)
                   remains visible; nav buttons just don't appear */
                if ( nav ) nav.style.display = 'none';
            } );
    }

    /**
     * Fetch the curated file list from the /ImageList sub-page.
     * Falls back to a category member query if the page is missing.
     */
    function getFileList( listPage, category ) {
        if ( listPage ) {
            return apiGet( {
                action:  'query',
                titles:  listPage,
                prop:    'revisions',
                rvprop:  'content',
                rvslots: 'main',
                formatversion: '2'
            } ).then( function ( data ) {
                var pages = data.query && data.query.pages;
                if ( !pages || !pages[0] || pages[0].missing ) {
                    /* Page doesn't exist yet — fall back to category query */
                    return category ? getCategoryFiles( category ) : [];
                }
                var content = pages[0].revisions &&
                              pages[0].revisions[0] &&
                              pages[0].revisions[0].slots &&
                              pages[0].revisions[0].slots.main &&
                              pages[0].revisions[0].slots.main.content || '';

                var files = [];
                content.split( '\n' ).forEach( function ( line ) {
                    line = line.replace( /<!--[\s\S]*?-->/g, '' ).trim();
                    if ( !line ) return;
                    /* Format: File:Name.jpg | optional caption */
                    var parts = line.split( '|' );
                    var fname = parts[0].trim().replace( /^[Ff]ile:/, '' );
                    var caption = parts[1] ? parts[1].trim() : '';
                    if ( fname ) files.push( { name: 'File:' + fname, caption: caption } );
                } );
                return files.length ? files : ( category ? getCategoryFiles( category ) : [] );
            } );
        }
        return category ? getCategoryFiles( category ) : Promise.resolve( [] );
    }

    /** Fetch file members of a category from the media wiki */
    function getCategoryFiles( category ) {
        return apiGetFrom( MEDIA_API, {
            action:   'query',
            list:     'categorymembers',
            cmtitle:  'Category:' + category,
            cmtype:   'file',
            cmlimit:  '50'
        } ).then( function ( data ) {
            return ( data.query.categorymembers || [] ).map( function ( m ) {
                return { name: m.title, caption: '' };
            } );
        } );
    }

    /** Fetch thumbnail URLs for a list of file objects via imageinfo */
    function fetchThumbnails( files ) {
        /* MW API accepts up to 50 titles per call */
        var titles = files.map( function ( f ) { return f.name; } ).join( '|' );
        var captionMap = {};
        files.forEach( function ( f ) { captionMap[ f.name ] = f.caption; } );

        return apiGetFrom( MEDIA_API, {
            action:     'query',
            titles:     titles,
            prop:       'imageinfo',
            iiprop:     'url|extmetadata',
            iiurlwidth: '800',
            formatversion: '2'
        } ).then( function ( data ) {
            var result = [];
            ( data.query.pages || [] ).forEach( function ( p ) {
                var ii = p.imageinfo && p.imageinfo[0];
                if ( !ii || !ii.thumburl ) return;
                /* Caption priority: curated list caption → image description metadata */
                var caption = captionMap[ p.title ] || '';
                if ( !caption && ii.extmetadata && ii.extmetadata.ImageDescription ) {
                    caption = ii.extmetadata.ImageDescription.value.replace( /<[^>]+>/g, '' );
                }
                if ( !caption ) caption = p.title.replace( /^File:/, '' ).replace( /\.[^.]+$/, '' );
                result.push( { thumb: ii.thumburl, caption: caption, title: p.title } );
            } );
            return result;
        } );
    }

    /** Build and wire the carousel DOM */
    function buildCarousel( wrapper, nav, slides ) {
        var current = 0;
        var total   = slides.length;
        var timer;

        /* Build the main image element — reuse the one Lua rendered if present,
           otherwise create it so there's no flash on first load */
        var img = wrapper.querySelector( 'img.portal-slider-image' );
        if ( !img ) {
            img = document.createElement( 'img' );
            img.className = 'portal-slider-image';
            img.style.cssText = 'display:block;width:100%;border-radius:6px;max-height:260px;object-fit:cover';
            /* Insert before the nav */
            wrapper.insertBefore( img, nav );
        }

        /* Caption element */
        var caption = wrapper.querySelector( '.portal-slider-caption' );
        if ( !caption ) {
            caption = document.createElement( 'div' );
            caption.className = 'portal-slider-caption';
            caption.style.cssText = 'font-size:0.78em;color:var(--color-subtle,#888);margin-top:4px;min-height:1.2em';
            wrapper.insertBefore( caption, nav );
        }

        /* Dots */
        var dotsEl = nav.querySelector( '.portal-slider-dots' );
        var counter = nav.querySelector( '.portal-slider-counter' );
        var prevBtn = nav.querySelector( '.portal-slider-prev' );
        var nextBtn = nav.querySelector( '.portal-slider-next' );

        if ( dotsEl ) {
            dotsEl.innerHTML = '';
            slides.forEach( function ( _, i ) {
                var dot = document.createElement( 'span' );
                dot.className = 'portal-slider-dot' + ( i === 0 ? ' is-active' : '' );
                dot.addEventListener( 'click', function () { goTo( i ); } );
                dotsEl.appendChild( dot );
            } );
        }

        function goTo( n ) {
            current = ( n + total ) % total;
            var s = slides[ current ];
            img.src = s.thumb;
            img.alt = s.caption;
            caption.textContent = s.caption;

            if ( dotsEl ) {
                dotsEl.querySelectorAll( '.portal-slider-dot' ).forEach( function ( d, i ) {
                    d.classList.toggle( 'is-active', i === current );
                } );
            }
            if ( counter ) counter.textContent = ( current + 1 ) + ' / ' + total;
            resetTimer();
        }

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

        if ( prevBtn ) prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
        if ( nextBtn ) nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );

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

        /* Start on slide 0 */
        goTo( 0 );
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

        var category = countEl.dataset.category;

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

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

}() );