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
Line 225: Line 225:
         }
         }


         if ( prevBtn ) prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
         if ( prevBtn ) {
         if ( nextBtn ) nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );
            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 ); } } );
        }


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

Revision as of 20:48, 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 ); } );
            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 ); } } );
        }

        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;
    }

}() );