Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

MediaWiki:Common.portal.js

MediaWiki interface page

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* Portal image slider — MediaWiki:Common.js
 *
 * Wires up any .portal-slider-wrapper on the page.
 * The Lua module (Module:Portal|randomImage) renders the first image
 * server-side; the JS fetches the full ImageList sub-page via the API
 * and pre-loads remaining images for the slider.
 *
 * Falls back gracefully: if JS is disabled, the first random image
 * is still shown (rendered server-side by Lua).
 */
( function ( mw, $ ) {
    'use strict';

    /* ── Configuration ─────────────────────────────────── */
    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 ( $content ) {
        $content.find( '.portal-slider-wrapper' ).each( function () {
            initSlider( $( this ) );
        } );
    } );

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

        // The server already rendered the first image inside $wrapper
        // as a plain <a><img></a> block (from wikitext [[File:...]]).
        var $firstImg = $wrapper.find( 'a.image, img' ).first();

        var slides  = [];   // { src, caption, pageTitle }
        var current = 0;
        var timer   = null;
        var $display;       // the <img> we'll swap

        /* ── Step 1: wrap the server-rendered image ──── */
        if ( $firstImg.length ) {
            // Build a display container around it
            $display = $( '<img>' )
                .addClass( 'portal-slider-image' )
                .attr( 'alt', '' )
                .css( { width: '100%', borderRadius: '6px',
                        maxHeight: '260px', objectFit: 'cover' } );

            var $caption = $( '<div>' ).addClass( 'portal-slider-caption' );
            var $frame   = $( '<div>' ).addClass( 'portal-slider-frame' )
                .append( $display, $caption );

            // Replace whatever MW rendered with our controlled frame
            $firstImg.closest( 'a.image, p' ).first().replaceWith( $frame );
        } else {
            // Placeholder already in DOM from Lua; just ensure nav shows
            $prevBtn.hide();
            $nextBtn.hide();
            return;
        }

        /* ── Step 2: fetch ImageList sub-page ────────── */
        if ( !category ) return;

        var listPage = mw.config.get( 'wgPageName' ) + '/ImageList';

        mw.api = mw.api || new mw.Api();
        ( new mw.Api() ).get( {
            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] ) return;

            var page = pages[0];
            if ( page.missing ) {
                // No ImageList — fall back to random category query
                fetchRandomFromCategory( category );
                return;
            }

            var content = page.revisions &&
                          page.revisions[0] &&
                          page.revisions[0].slots &&
                          page.revisions[0].slots.main &&
                          page.revisions[0].slots.main.content;
            if ( !content ) return;

            var fileNames = [];
            content.split( '\n' ).forEach( function ( line ) {
                var trimmed = line.replace( /^\s+|\s+$/g, '' );
                if ( trimmed ) {
                    // Strip leading "File:" if present
                    var fname = trimmed.replace( /^[Ff]ile:/, '' )
                                      .split( '|' )[0]
                                      .replace( /^\s+|\s+$/g, '' );
                    if ( fname ) fileNames.push( 'File:' + fname );
                }
            } );

            if ( fileNames.length === 0 ) return;

            // Shuffle using a date-seeded PRNG so order is stable all day
            fileNames = seededShuffle( fileNames );
            fetchThumbnails( fileNames );

        } ).catch( function () {
            // Silent fail — first image still visible
        } );

        /* ── Fetch thumbnails for a list of file titles ─ */
        function fetchThumbnails( fileNames ) {
            ( 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 ) {
                    var ii = page.imageinfo && page.imageinfo[0];
                    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;

                // Set the first slide immediately
                showSlide( 0 );
                buildDots();
                startAutoplay();

                $prevBtn.on( 'click', function () { advance( -1 ); } );
                $nextBtn.on( 'click', function () { advance(  1 ); } );
                $wrapper.on( 'mouseenter', stopAutoplay )
                        .on( 'mouseleave', startAutoplay );
            } );
        }

        /* ── Fetch random images directly from category ─ */
        function fetchRandomFromCategory( cat ) {
            ( 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; } );
                fileNames = seededShuffle( fileNames );
                fetchThumbnails( fileNames );
            } );
        }

        /* ── Slide display ──────────────────────────────── */
        function showSlide( idx ) {
            current = ( idx + slides.length ) % slides.length;
            var slide = slides[current];

            $display.attr( 'src', slide.src ).attr( 'alt', slide.caption );
            $wrapper.find( '.portal-slider-caption' )
                    .text( slide.caption );

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

            $counter.text( ( current + 1 ) + ' / ' + slides.length );
        }

        function advance( dir ) {
            stopAutoplay();
            showSlide( current + dir );
            startAutoplay();
        }

        /* ── Dots ───────────────────────────────────────── */
        function buildDots() {
            $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 ──────────────────────────────────── */
        function startAutoplay() {
            stopAutoplay();
            timer = setInterval( function () { advance( 1 ); }, SLIDE_INTERVAL_MS );
        }
        function stopAutoplay() {
            if ( timer ) { clearInterval( timer ); timer = null; }
        }
    }

    /* ── Seeded shuffle (date-based, stable all day) ──── */
    function seededShuffle( arr ) {
        var date   = new Date();
        var seed   = date.getFullYear() * 10000 +
                     ( date.getMonth() + 1 ) * 100 +
                     date.getDate();
        var state  = seed;

        function rand() {
            // Xorshift32
            state ^= state << 13;
            state ^= state >> 17;
            state ^= state << 5;
            return ( state >>> 0 ) / 0xFFFFFFFF;
        }

        var a = arr.slice();
        for ( var i = a.length - 1; i > 0; i-- ) {
            var j = Math.floor( rand() * ( i + 1 ) );
            var t = a[i]; a[i] = a[j]; a[j] = t;
        }
        return a;
    }

}( mediaWiki, jQuery ) );