Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.
       🚧 True to our name, we’re still a work in progress. 🚧
   
       You’re welcome to explore, but account registration is currently invite-only as we finalize the setup. 
       Join our forum or follow Mastodon for updates. 
       Full Wiki launch coming soon!
   

MediaWiki:Gadget-projects.js: Difference between revisions

MediaWiki interface page
m Removed protection from "MediaWiki:Gadget-projects.js"
 
No edit summary
Line 1: Line 1:
/* =============================================================================
/* =============================================================================
   Gadget-projects.js
   Gadget-projects.js v2.0
   Handles: tag rendering, filter bar, contributor count + last edited.
   Unified project hub — API-powered search, tag filtering, and pagination.
   ============================================================================= */
   ============================================================================= */
( function () {
( function () {
     var pageName = mw.config.get( 'wgPageName' ) || '';
 
    if ( !/^Projects[:/][^/]+$/.test( pageName ) ) return;
     if ( mw.config.get( 'wgPageName' ) !== 'Projects' ) { return; }


     $( function () {
     $( function () {
        var $grid      = $( '.project-hub-grid' );
        var $filterBar = $( '.project-hub-filters' );
        if ( !$grid.length || !$filterBar.length ) return;


         var $cards   = $grid.find( '.project-card-wrap' );
         var PAGE_SIZE = 24;
         var total   = $cards.length;
        var state = {
         var hubSlug  = pageName.replace( /^Projects[:/]/, '' );
            search : '',
            tag    : '',
            sort  : 'newest',
            offset : 0,
            done  : false,
            busy  : false
        };
 
        var $wrapper = $( '.project-hub-wrapper' );
        if ( !$wrapper.length ) { return; }
 
        var $filters = $wrapper.find( '.project-hub-filters' );
        var $cloud   = $wrapper.find( '.project-hub-tagcloud' );
        var $grid   = $wrapper.find( '.project-hub-grid' );
         var $more   = $wrapper.find( '.project-hub-loadmore' );
         var $count  = $( '<span>' ).addClass( 'project-hub-count' );


         // --- Tag spans -------------------------------------------------------
         // ── Build filter controls ────────────────────────────────────────────
         $( '.project-card-tags[data-tags]' ).each( function () {
         var $search = $( '<input>' ).attr( {
             var $el = $( this );
             type        : 'text',
             $el.attr( 'data-tags' ).trim().split( /[\s,]+/ ).forEach( function ( tag ) {
             placeholder : 'Search projects, tags, creators…',
                if ( tag ) $el.append( $( '<span>' ).addClass( 'project-card-tag' ).text( tag ) );
            'class'     : 'project-hub-search'
            } );
         } );
         } );
 
         var $sort = $( '<select>' ).addClass( 'project-hub-select' ).append(
        // --- Filter bar (options populated from Cargo) -----------------------
            $( '<option>' ).val( 'newest' ).text( 'Newest first' ),
         var $search  = $( '<input>' ).attr( { type: 'text', placeholder: 'Search projects, tags, creators...', 'class': 'project-hub-search' } );
             $( '<option>' ).val( 'alpha' ).text( 'A – Z' )
        var $status  = $( '<select>' ).addClass( 'project-hub-select' ).append( $( '<option>' ).val( '' ).text( 'All statuses' ) );
        var $license = $( '<select>' ).addClass( 'project-hub-select' ).append( $( '<option>' ).val( '' ).text( 'All licenses' ) );
        var $sort    = $( '<select>' ).addClass( 'project-hub-select' ).append(
             $( '<option>' ).val( 'alpha' ).text( 'A – Z' ),
            $( '<option>' ).val( 'edited' ).text( 'Recently edited' ),
            $( '<option>' ).val( 'contributors' ).text( 'Most contributors' )
         );
         );
         var $count = $( '<span>' ).addClass( 'project-hub-count' )
         $filters.append( $search, $sort, $count );
            .text( total + ' project' + ( total === 1 ? '' : 's' ) );


         $filterBar.append( $search, $status, $license, $sort, $count );
        // ── Wire events ──────────────────────────────────────────────────────
        var debounce;
         $search.on( 'input', function () {
            clearTimeout( debounce );
            debounce = setTimeout( function () {
                state.search = $search.val().trim();
                resetAndLoad();
            }, 350 );
        } );


         // Populate status + license options from Cargo
         $sort.on( 'change', function () {
        var apiBase = mw.util.wikiScript( 'api' );
            state.sort = $sort.val();
            resetAndLoad();
        } );


         $.getJSON( apiBase, {
         $more.on( 'click', function () {
            action  : 'cargoquery',
             if ( !state.busy ) { fetchPage(); }
            tables  : 'Projects',
            fields  : 'status',
            where    : 'categories HOLDS \'' + hubSlug + '\' AND status IS NOT NULL AND status != \'\'',
            group_by : 'status',
            order_by : 'status',
            limit    : 20,
            format  : 'json'
        } ).done( function ( data ) {
             ( data.cargoquery || [] ).forEach( function ( row ) {
                var v = row.title && row.title.status;
                if ( v ) $status.append( $( '<option>' ).val( v.toLowerCase() ).text( v ) );
            } );
         } );
         } );


         $.getJSON( apiBase, {
        // Tag click — works for both cloud tags and card tags (delegated)
            action  : 'cargoquery',
         $( document ).on( 'click', '.js-tag-filter', function ( e ) {
            tables  : 'Projects',
             e.stopPropagation();
             fields  : 'license',
             var tag = $( this ).data( 'tag' );
             where    : 'categories HOLDS \'' + hubSlug + '\' AND license IS NOT NULL AND license != \'\'',
             if ( state.tag === tag ) {
             group_by : 'license',
                state.tag = '';
            order_by : 'license',
                $( '.js-tag-filter' ).removeClass( 'is-active' );
            limit    : 20,
             } else {
            format  : 'json'
                 state.tag = tag;
        } ).done( function ( data ) {
                 $( '.js-tag-filter' ).removeClass( 'is-active' );
             ( data.cargoquery || [] ).forEach( function ( row ) {
                $( this ).addClass( 'is-active' );
                 var v = row.title && row.title.license;
            }
                 if ( v ) $license.append( $( '<option>' ).val( v.toLowerCase() ).text( v ) );
            state.search = '';
             } );
            $search.val( '' );
             resetAndLoad();
         } );
         } );


         // --- Filter + sort ---------------------------------------------------
         // ── Initial load ─────────────────────────────────────────────────────
         function applyFilters() {
         loadTagCloud();
            var q = $search.val().toLowerCase().trim();
        loadHubMeta();
            var s = $status.val();
        fetchPage();
            var l = $license.val();
            var visible = 0;


             $cards.each( function () {
        // ── Core helpers ─────────────────────────────────────────────────────
                var $c = $( this );
        function resetAndLoad() {
                 var hay = [ $c.data('name'), $c.data('tags'), $c.data('creator'), $c.data('extra') ]
            state.offset = 0;
                    .join( ' ' ).toLowerCase();
            state.done  = false;
                var show = ( !q || hay.indexOf(q) !== -1 ) &&
            $grid.empty();
                          ( !s || ( $c.data('status') || '' ).toLowerCase() === s ) &&
             $more.hide();
                          ( !l || ( $c.data('license') || '' ).toLowerCase() === l );
            fetchPage();
                $c.toggle( show );
        }
                 if ( show ) visible++;
 
        function buildWhere() {
            var parts = [];
            if ( state.search ) {
                 var q = state.search.replace( /'/g, "''" );
                parts.push(
                    "( name LIKE '%" + q + "%'" +
                    " OR tags LIKE '%" + q + "%'" +
                    " OR license LIKE '%" + q + "%'" +
                    " OR creator LIKE '%" + q + "%' )"
                );
            }
            if ( state.tag ) {
                parts.push( "tags HOLDS '" + state.tag.replace( /'/g, "''" ) + "'" );
            }
            return parts.join( ' AND ' );
        }
 
        function orderBy() {
            return state.sort === 'alpha' ? 'name ASC' : '_pageID DESC';
        }
 
        // ── Data fetching ────────────────────────────────────────────────────
        function fetchPage() {
            if ( state.busy || state.done ) { return; }
            state.busy = true;
            $more.text( 'Loading…' ).show();
 
            var params = {
                action  : 'cargoquery',
                tables  : 'Projects',
                fields  : '_pageName=pagename,name,status,license,creator,' +
                          'description,image,tags,' +
                          'field1label,field1value,field2label,field2value,' +
                          'field3label,field3value,field4label,field4value',
                order_by : orderBy(),
                limit    : PAGE_SIZE,
                offset  : state.offset,
                format  : 'json'
            };
            var w = buildWhere();
            if ( w ) { params.where = w; }
 
            // Fetch total count on every first-page load
            if ( state.offset === 0 ) { fetchTotal( w ); }
 
            $.getJSON( mw.util.wikiScript( 'api' ), params )
                .done( function ( data ) {
                    var rows      = ( data.cargoquery || [] ).map( function ( r ) { return r.title; } );
                    var prevOff  = state.offset;
                    var cardBatch = [];
 
                    rows.forEach( function ( row ) {
                        var $c = buildCard( row );
                        $grid.append( $c );
                        cardBatch.push( { $el: $c, page: row.pagename } );
                    } );
 
                    enrichCards( cardBatch );
 
                    state.offset += rows.length;
                    state.done    = ( rows.length < PAGE_SIZE );
                    state.busy    = false;
 
                    $more.text( 'Load more projects' ).toggle( !state.done );
 
                    if ( rows.length === 0 && prevOff === 0 ) {
                        $grid.html( '<div class="project-hub-empty">No projects found.</div>' );
                    }
                } )
                .fail( function () {
                    state.busy = false;
                    $more.text( 'Load more projects' ).toggle( !state.done );
                } );
        }
 
        function fetchTotal( where ) {
            var p = {
                action : 'cargoquery',
                tables : 'Projects',
                fields : 'COUNT(*)=n',
                limit  : 1,
                format : 'json'
            };
            if ( where ) { p.where = where; }
            $.getJSON( mw.util.wikiScript( 'api' ), p ).done( function ( data ) {
                var row = ( data.cargoquery || [] )[0];
                 var n  = row ? ( parseInt( row.title.n, 10 ) || 0 ) : 0;
                $count.text( n + ' project' + ( n === 1 ? '' : 's' ) );
             } );
             } );
        }


             $count.text( ( q||s||l ? visible + ' / ' : '' ) + total + ' project' + ( total===1 ? '' : 's' ) );
        function loadTagCloud() {
            $( '.project-hub-empty' ).toggle( visible === 0 );
             $.getJSON( mw.util.wikiScript( 'api' ), {
                action : 'cargoquery',
                tables : 'Projects',
                fields : 'tags',
                limit  : 500,
                format : 'json'
            } ).done( function ( data ) {
                var freq = {};
                ( data.cargoquery || [] ).forEach( function ( r ) {
                    ( r.title.tags || '' ).split( ',' ).forEach( function ( t ) {
                        t = t.trim();
                        if ( t ) { freq[ t ] = ( freq[ t ] || 0 ) + 1; }
                    } );
                } );
                var top = Object.keys( freq )
                    .sort( function ( a, b ) { return freq[ b ] - freq[ a ]; } )
                    .slice( 0, 25 );
                if ( !top.length ) { return; }
                top.forEach( function ( tag ) {
                    $cloud.append(
                        $( '<span>' )
                            .addClass( 'js-tag-filter' )
                            .data( 'tag', tag )
                            .text( tag )
                    );
                } );
                $cloud.show();
            } );
        }


            var sortVal = $sort.val();
        function loadHubMeta() {
             $cards.filter( ':visible' ).toArray().sort( function ( a, b ) {
             mw.loader.using( 'mediawiki.api' ).then( function () {
                 if ( sortVal === 'alpha'        ) return ( $( a ).data('name') || '' ).localeCompare( $( b ).data('name') || '' );
                 new mw.Api().get( {
                if ( sortVal === 'edited'       ) return ( $( b ).data('timestamp') || '' ).localeCompare( $( a ).data('timestamp') || '' );
                    action  : 'query',
                if ( sortVal === 'contributors' ) return ( +$( b ).data('contributor-count') || 0 ) - ( +$( a ).data('contributor-count') || 0 );
                    list    : 'categorymembers',
                 return 0;
                    cmtitle : 'Category:Projects',
             } ).forEach( function ( el ) { $grid.append( el ); } );
                    cmsort  : 'timestamp',
                    cmdir  : 'desc',
                    cmlimit : 1,
                    cmtype  : 'page',
                    cmprop  : 'ids|title|timestamp',
                    format  : 'json'
                } ).done( function ( data ) {
                    var members = data.query && data.query.categorymembers;
                    if ( !members || !members.length ) { return; }
                    var name = members[ 0 ].title.replace( /^.*\//, '' );
                    var date = members[ 0 ].timestamp
                        ? new Date( members[ 0 ].timestamp ).toLocaleDateString(
                            'en-GB', { day: 'numeric', month: 'short', year: 'numeric' }
                          )
                        : '—';
                    var str = name + ' — ' + date;
                    $( '#hub-meta-last-submission' ).text( str );
                    $( '#hub-meta-last-edit' ).text( str );
                 } );
             } );
         }
         }


         $search.on( 'input', applyFilters );
         // ── Card rendering ───────────────────────────────────────────────────
         $status.on( 'change', applyFilters );
        function fileUrl( filename ) {
        $license.on( 'change', applyFilters );
            if ( !filename ) { return ''; }
        $sort.on( 'change', applyFilters );
            return mw.config.get( 'wgServer' ) +
                '/wiki/Special:FilePath/' + encodeURIComponent( filename );
         }
 
        function esc( s ) { return mw.html.escape( s || '' ); }
 
        function buildCard( row ) {
            var page = row.pagename || '';
            var url  = mw.util.getUrl( page );
            var name = row.name || page.replace( /^.*\//, '' );
 
            var imgInner = row.image
                ? '<img src="' + esc( fileUrl( row.image ) ) + '" alt="' + esc( name ) + '" loading="lazy">'
                : '<div class="project-card-image-fallback"></div>';
 
            var badges = '';
            if ( row.status ) {
                badges += '<span class="project-badge-status project-badge-status--' +
                    esc( row.status.toLowerCase() ) + '">' + esc( row.status ) + '</span>';
            }
            if ( row.license ) {
                badges += '<span class="project-badge-license">' + esc( row.license ) + '</span>';
            }
 
            var tagSpans = '';
            if ( row.tags ) {
                row.tags.split( ',' ).forEach( function ( t ) {
                    t = t.trim();
                    if ( t ) {
                        tagSpans += '<span class="project-card-tag js-tag-filter" data-tag="' +
                            esc( t ) + '">' + esc( t ) + '</span>';
                    }
                } );
            }


        // --- API calls -------------------------------------------------------
            var extras = '';
        var pageNames = $cards.map( function () {
            for ( var i = 1; i <= 4; i++ ) {
            return decodeURIComponent( $( this ).data('page') || '' ).replace( /_/g, ' ' ) || null;
                var lbl = row[ 'field' + i + 'label' ];
        } ).get().filter( Boolean );
                var val = row[ 'field' + i + 'value' ];
                if ( lbl ) {
                    extras += '<div class="project-card-extra">' +
                        esc( lbl ) + ': <strong>' + esc( val ) + '</strong></div>';
                }
            }


        mw.loader.using( 'mediawiki.api' ).then( function () {
            var html = [
                '<div class="project-card-wrap">',
                  '<div class="project-card">',
                    '<div class="project-card-image">', imgInner, '</div>',
                    '<div class="project-card-body">',
                      '<div class="project-card-title">',
                        '<a href="', url, '">', esc( name ), '</a>',
                      '</div>',
                      '<div class="project-card-badges">', badges, '</div>',
                      row.creator
                        ? '<div class="project-card-creator"><span class="project-card-label">By</span> ' +
                          esc( row.creator ) + '</div>'
                        : '',
                      extras,
                      tagSpans
                        ? '<div class="project-card-tags">' + tagSpans + '</div>'
                        : '',
                      '<div class="project-card-footer">',
                        '<span class="project-card-meta-item project-card-contributors">—</span>',
                        '<span class="project-card-meta-item project-card-updated">—</span>',
                      '</div>',
                    '</div>',
                    row.description
                      ? '<div class="project-card-description">' + esc( row.description ) + '</div>'
                      : '',
                  '</div>',
                '</div>'
            ].join( '' );
 
            return $( html );
        }
 
        // ── Card metadata enrichment (batched API calls) ─────────────────────
        function enrichCards( batch ) {
            if ( !batch.length ) { return; }
             var api = new mw.Api();
             var api = new mw.Api();
 
             for ( var i = 0; i < batch.length; i += 20 ) {
            // Card enrichment: contributor count + last edited
                ( function ( chunk ) {
             if ( pageNames.length ) {
                    api.get( {
                for ( var i = 0; i < pageNames.length; i += 20 ) {
                        action  : 'query',
                    ( function ( batch ) {
                        titles  : chunk.map( function ( c ) { return c.page; } ).join( '|' ),
                        api.get( {
                        prop    : 'revisions|contributors',
                            action  : 'query',
                        rvprop  : 'timestamp',
                            titles  : batch.join( '|' ),
                        rvlimit : 1,
                            prop    : 'revisions|contributors',
                        pclimit : 20,
                            rvprop  : 'timestamp',
                        format  : 'json'
                            rvlimit : 1,
                    } ).done( function ( data ) {
                            pclimit : 20,
                        if ( !data.query || !data.query.pages ) { return; }
                            format  : 'json'
                        $.each( data.query.pages, function ( _, page ) {
                        } ).done( function ( data ) {
                            if ( page.missing !== undefined ) { return; }
                            if ( !data || !data.query || !data.query.pages ) return;
                            var match = chunk.filter( function ( c ) { return c.page === page.title; } );
                            $.each( data.query.pages, function ( _, page ) {
                            if ( !match.length ) { return; }
                                if ( page.missing !== undefined ) return;
                            var $el  = match[ 0 ].$el;
                                var ts = page.revisions && page.revisions[0] && page.revisions[0].timestamp || '';
                            var ts   = page.revisions && page.revisions[ 0 ] &&
                                var n = ( page.contributors ? page.contributors.length : 0 )
                                      page.revisions[ 0 ].timestamp || '';
                                       + ( page.anoncontributors || 0 );
                            var n   = ( page.contributors ? page.contributors.length : 0 ) +
                                var $c = $cards.filter( function () {
                                       ( page.anoncontributors || 0 );
                                    return decodeURIComponent( $( this ).data('page') || '' ).replace( /_/g, ' ' ) === page.title;
                            var d    = ts
                                } );
                                 ? new Date( ts ).toLocaleDateString(
                                if ( !$c.length ) return;
                                    'en-GB', { year: 'numeric', month: 'short' }
                                $c.data( 'timestamp', ts ).data( 'contributor-count', n );
                                  )
                                 var d = ts ? new Date( ts ).toLocaleDateString( 'en-GB', { year: 'numeric', month: 'short' } ) : '—';
                                : '—';
                                $c.find( '.project-card-contributors' ).text( n + ( n === 1 ? ' contributor' : ' contributors' ) ).addClass( 'loaded' );
                            $el.find( '.project-card-contributors' )
                                $c.find( '.project-card-updated' ).text( 'Edited ' + d ).addClass( 'loaded' );
                              .text( n + ( n === 1 ? ' contributor' : ' contributors' ) )
                            } );
                              .addClass( 'loaded' );
                            $el.find( '.project-card-updated' )
                              .text( 'Edited ' + d )
                              .addClass( 'loaded' );
                         } );
                         } );
                     }( pageNames.slice( i, i + 20 ) ) );
                     } );
                }
                } )( batch.slice( i, i + 20 ) );
             }
             }
        }


            // Hub header: most recently touched page in this hub's category
    } ); // end document.ready
            api.get( {
                action  : 'query',
                list    : 'categorymembers',
                cmtitle : 'Category:Projects/' + hubSlug,
                cmsort  : 'timestamp',
                cmdir  : 'desc',
                cmlimit : 1,
                cmtype  : 'page',
                cmprop  : 'ids|title|timestamp',
                format  : 'json'
            } ).done( function ( data ) {
                var members = data.query && data.query.categorymembers;
                if ( !members || !members.length ) return;
                var name = members[0].title.replace( /^.*\//, '' );
                var date = members[0].timestamp
                    ? new Date( members[0].timestamp ).toLocaleDateString( 'en-GB', { day: 'numeric', month: 'short', year: 'numeric' } )
                    : '—';
                var str = name + ' — ' + date;
                $( '#hub-meta-last-submission' ).text( str );
                $( '#hub-meta-last-edit' ).text( str );
            } );
        } );


    } );
} () );
}() );

Revision as of 00:41, 2 April 2026

/* =============================================================================
   Gadget-projects.js  v2.0
   Unified project hub — API-powered search, tag filtering, and pagination.
   ============================================================================= */
( function () {

    if ( mw.config.get( 'wgPageName' ) !== 'Projects' ) { return; }

    $( function () {

        var PAGE_SIZE = 24;
        var state = {
            search : '',
            tag    : '',
            sort   : 'newest',
            offset : 0,
            done   : false,
            busy   : false
        };

        var $wrapper = $( '.project-hub-wrapper' );
        if ( !$wrapper.length ) { return; }

        var $filters = $wrapper.find( '.project-hub-filters' );
        var $cloud   = $wrapper.find( '.project-hub-tagcloud' );
        var $grid    = $wrapper.find( '.project-hub-grid' );
        var $more    = $wrapper.find( '.project-hub-loadmore' );
        var $count   = $( '<span>' ).addClass( 'project-hub-count' );

        // ── Build filter controls ────────────────────────────────────────────
        var $search = $( '<input>' ).attr( {
            type        : 'text',
            placeholder : 'Search projects, tags, creators…',
            'class'     : 'project-hub-search'
        } );
        var $sort = $( '<select>' ).addClass( 'project-hub-select' ).append(
            $( '<option>' ).val( 'newest' ).text( 'Newest first' ),
            $( '<option>' ).val( 'alpha'  ).text( 'A – Z' )
        );
        $filters.append( $search, $sort, $count );

        // ── Wire events ──────────────────────────────────────────────────────
        var debounce;
        $search.on( 'input', function () {
            clearTimeout( debounce );
            debounce = setTimeout( function () {
                state.search = $search.val().trim();
                resetAndLoad();
            }, 350 );
        } );

        $sort.on( 'change', function () {
            state.sort = $sort.val();
            resetAndLoad();
        } );

        $more.on( 'click', function () {
            if ( !state.busy ) { fetchPage(); }
        } );

        // Tag click — works for both cloud tags and card tags (delegated)
        $( document ).on( 'click', '.js-tag-filter', function ( e ) {
            e.stopPropagation();
            var tag = $( this ).data( 'tag' );
            if ( state.tag === tag ) {
                state.tag = '';
                $( '.js-tag-filter' ).removeClass( 'is-active' );
            } else {
                state.tag = tag;
                $( '.js-tag-filter' ).removeClass( 'is-active' );
                $( this ).addClass( 'is-active' );
            }
            state.search = '';
            $search.val( '' );
            resetAndLoad();
        } );

        // ── Initial load ─────────────────────────────────────────────────────
        loadTagCloud();
        loadHubMeta();
        fetchPage();

        // ── Core helpers ─────────────────────────────────────────────────────
        function resetAndLoad() {
            state.offset = 0;
            state.done   = false;
            $grid.empty();
            $more.hide();
            fetchPage();
        }

        function buildWhere() {
            var parts = [];
            if ( state.search ) {
                var q = state.search.replace( /'/g, "''" );
                parts.push(
                    "( name LIKE '%" + q + "%'" +
                    " OR tags LIKE '%" + q + "%'" +
                    " OR license LIKE '%" + q + "%'" +
                    " OR creator LIKE '%" + q + "%' )"
                );
            }
            if ( state.tag ) {
                parts.push( "tags HOLDS '" + state.tag.replace( /'/g, "''" ) + "'" );
            }
            return parts.join( ' AND ' );
        }

        function orderBy() {
            return state.sort === 'alpha' ? 'name ASC' : '_pageID DESC';
        }

        // ── Data fetching ────────────────────────────────────────────────────
        function fetchPage() {
            if ( state.busy || state.done ) { return; }
            state.busy = true;
            $more.text( 'Loading…' ).show();

            var params = {
                action   : 'cargoquery',
                tables   : 'Projects',
                fields   : '_pageName=pagename,name,status,license,creator,' +
                           'description,image,tags,' +
                           'field1label,field1value,field2label,field2value,' +
                           'field3label,field3value,field4label,field4value',
                order_by : orderBy(),
                limit    : PAGE_SIZE,
                offset   : state.offset,
                format   : 'json'
            };
            var w = buildWhere();
            if ( w ) { params.where = w; }

            // Fetch total count on every first-page load
            if ( state.offset === 0 ) { fetchTotal( w ); }

            $.getJSON( mw.util.wikiScript( 'api' ), params )
                .done( function ( data ) {
                    var rows      = ( data.cargoquery || [] ).map( function ( r ) { return r.title; } );
                    var prevOff   = state.offset;
                    var cardBatch = [];

                    rows.forEach( function ( row ) {
                        var $c = buildCard( row );
                        $grid.append( $c );
                        cardBatch.push( { $el: $c, page: row.pagename } );
                    } );

                    enrichCards( cardBatch );

                    state.offset += rows.length;
                    state.done    = ( rows.length < PAGE_SIZE );
                    state.busy    = false;

                    $more.text( 'Load more projects' ).toggle( !state.done );

                    if ( rows.length === 0 && prevOff === 0 ) {
                        $grid.html( '<div class="project-hub-empty">No projects found.</div>' );
                    }
                } )
                .fail( function () {
                    state.busy = false;
                    $more.text( 'Load more projects' ).toggle( !state.done );
                } );
        }

        function fetchTotal( where ) {
            var p = {
                action : 'cargoquery',
                tables : 'Projects',
                fields : 'COUNT(*)=n',
                limit  : 1,
                format : 'json'
            };
            if ( where ) { p.where = where; }
            $.getJSON( mw.util.wikiScript( 'api' ), p ).done( function ( data ) {
                var row = ( data.cargoquery || [] )[0];
                var n   = row ? ( parseInt( row.title.n, 10 ) || 0 ) : 0;
                $count.text( n + ' project' + ( n === 1 ? '' : 's' ) );
            } );
        }

        function loadTagCloud() {
            $.getJSON( mw.util.wikiScript( 'api' ), {
                action : 'cargoquery',
                tables : 'Projects',
                fields : 'tags',
                limit  : 500,
                format : 'json'
            } ).done( function ( data ) {
                var freq = {};
                ( data.cargoquery || [] ).forEach( function ( r ) {
                    ( r.title.tags || '' ).split( ',' ).forEach( function ( t ) {
                        t = t.trim();
                        if ( t ) { freq[ t ] = ( freq[ t ] || 0 ) + 1; }
                    } );
                } );
                var top = Object.keys( freq )
                    .sort( function ( a, b ) { return freq[ b ] - freq[ a ]; } )
                    .slice( 0, 25 );
                if ( !top.length ) { return; }
                top.forEach( function ( tag ) {
                    $cloud.append(
                        $( '<span>' )
                            .addClass( 'js-tag-filter' )
                            .data( 'tag', tag )
                            .text( tag )
                    );
                } );
                $cloud.show();
            } );
        }

        function loadHubMeta() {
            mw.loader.using( 'mediawiki.api' ).then( function () {
                new mw.Api().get( {
                    action  : 'query',
                    list    : 'categorymembers',
                    cmtitle : 'Category:Projects',
                    cmsort  : 'timestamp',
                    cmdir   : 'desc',
                    cmlimit : 1,
                    cmtype  : 'page',
                    cmprop  : 'ids|title|timestamp',
                    format  : 'json'
                } ).done( function ( data ) {
                    var members = data.query && data.query.categorymembers;
                    if ( !members || !members.length ) { return; }
                    var name = members[ 0 ].title.replace( /^.*\//, '' );
                    var date = members[ 0 ].timestamp
                        ? new Date( members[ 0 ].timestamp ).toLocaleDateString(
                            'en-GB', { day: 'numeric', month: 'short', year: 'numeric' }
                          )
                        : '—';
                    var str = name + ' — ' + date;
                    $( '#hub-meta-last-submission' ).text( str );
                    $( '#hub-meta-last-edit' ).text( str );
                } );
            } );
        }

        // ── Card rendering ───────────────────────────────────────────────────
        function fileUrl( filename ) {
            if ( !filename ) { return ''; }
            return mw.config.get( 'wgServer' ) +
                '/wiki/Special:FilePath/' + encodeURIComponent( filename );
        }

        function esc( s ) { return mw.html.escape( s || '' ); }

        function buildCard( row ) {
            var page = row.pagename || '';
            var url  = mw.util.getUrl( page );
            var name = row.name || page.replace( /^.*\//, '' );

            var imgInner = row.image
                ? '<img src="' + esc( fileUrl( row.image ) ) + '" alt="' + esc( name ) + '" loading="lazy">'
                : '<div class="project-card-image-fallback"></div>';

            var badges = '';
            if ( row.status ) {
                badges += '<span class="project-badge-status project-badge-status--' +
                    esc( row.status.toLowerCase() ) + '">' + esc( row.status ) + '</span>';
            }
            if ( row.license ) {
                badges += '<span class="project-badge-license">' + esc( row.license ) + '</span>';
            }

            var tagSpans = '';
            if ( row.tags ) {
                row.tags.split( ',' ).forEach( function ( t ) {
                    t = t.trim();
                    if ( t ) {
                        tagSpans += '<span class="project-card-tag js-tag-filter" data-tag="' +
                            esc( t ) + '">' + esc( t ) + '</span>';
                    }
                } );
            }

            var extras = '';
            for ( var i = 1; i <= 4; i++ ) {
                var lbl = row[ 'field' + i + 'label' ];
                var val = row[ 'field' + i + 'value' ];
                if ( lbl ) {
                    extras += '<div class="project-card-extra">' +
                        esc( lbl ) + ': <strong>' + esc( val ) + '</strong></div>';
                }
            }

            var html = [
                '<div class="project-card-wrap">',
                  '<div class="project-card">',
                    '<div class="project-card-image">', imgInner, '</div>',
                    '<div class="project-card-body">',
                      '<div class="project-card-title">',
                        '<a href="', url, '">', esc( name ), '</a>',
                      '</div>',
                      '<div class="project-card-badges">', badges, '</div>',
                      row.creator
                        ? '<div class="project-card-creator"><span class="project-card-label">By</span> ' +
                          esc( row.creator ) + '</div>'
                        : '',
                      extras,
                      tagSpans
                        ? '<div class="project-card-tags">' + tagSpans + '</div>'
                        : '',
                      '<div class="project-card-footer">',
                        '<span class="project-card-meta-item project-card-contributors">—</span>',
                        '<span class="project-card-meta-item project-card-updated">—</span>',
                      '</div>',
                    '</div>',
                    row.description
                      ? '<div class="project-card-description">' + esc( row.description ) + '</div>'
                      : '',
                  '</div>',
                '</div>'
            ].join( '' );

            return $( html );
        }

        // ── Card metadata enrichment (batched API calls) ─────────────────────
        function enrichCards( batch ) {
            if ( !batch.length ) { return; }
            var api = new mw.Api();
            for ( var i = 0; i < batch.length; i += 20 ) {
                ( function ( chunk ) {
                    api.get( {
                        action  : 'query',
                        titles  : chunk.map( function ( c ) { return c.page; } ).join( '|' ),
                        prop    : 'revisions|contributors',
                        rvprop  : 'timestamp',
                        rvlimit : 1,
                        pclimit : 20,
                        format  : 'json'
                    } ).done( function ( data ) {
                        if ( !data.query || !data.query.pages ) { return; }
                        $.each( data.query.pages, function ( _, page ) {
                            if ( page.missing !== undefined ) { return; }
                            var match = chunk.filter( function ( c ) { return c.page === page.title; } );
                            if ( !match.length ) { return; }
                            var $el  = match[ 0 ].$el;
                            var ts   = page.revisions && page.revisions[ 0 ] &&
                                       page.revisions[ 0 ].timestamp || '';
                            var n    = ( page.contributors ? page.contributors.length : 0 ) +
                                       ( page.anoncontributors || 0 );
                            var d    = ts
                                ? new Date( ts ).toLocaleDateString(
                                    'en-GB', { year: 'numeric', month: 'short' }
                                  )
                                : '—';
                            $el.find( '.project-card-contributors' )
                               .text( n + ( n === 1 ? ' contributor' : ' contributors' ) )
                               .addClass( 'loaded' );
                            $el.find( '.project-card-updated' )
                               .text( 'Edited ' + d )
                               .addClass( 'loaded' );
                        } );
                    } );
                } )( batch.slice( i, i + 20 ) );
            }
        }

    } ); // end document.ready

} () );