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
No edit summary
No edit summary
 
(2 intermediate revisions by the same user not shown)
Line 281: Line 281:


         // ── Card rendering ────────────────────────────────────────────────────
         // ── Card rendering ────────────────────────────────────────────────────
        function parseWikitext( text ) {
            if ( !text ) { return ''; }
            text = String( text );
            // External links: [https://url Display Text]
            text = text.replace( /\[(\S+?)\s+([^\]]+?)\]/g, function ( m, url, display ) {
                if ( !/^https?:\/\//i.test( url ) ) { return mw.html.escape( m ); }
                return '<a href="' + url.replace( /"/g, '%22' ) + '" target="_blank" rel="noopener noreferrer">' +
                    mw.html.escape( display ) + '</a>';
            } );
            // Internal links: [[Page Name|Display]] or [[Page Name]]
            text = text.replace( /\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, function ( m, page, display ) {
                return '<a href="' + mw.util.getUrl( page.trim() ) + '">' +
                    mw.html.escape( ( display || page ).trim() ) + '</a>';
            } );
            // Newlines to line breaks
            text = text.replace( /\n/g, '<br>' );
            return text;
        }
         function esc( s ) { return mw.html.escape( s || '' ); }
         function esc( s ) { return mw.html.escape( s || '' ); }


Line 319: Line 338:
                     extras += '<div class="project-card-extra">' +
                     extras += '<div class="project-card-extra">' +
                         esc( row[ 'field' + i + 'label' ] ) +
                         esc( row[ 'field' + i + 'label' ] ) +
                         ': <strong>' + esc( row[ 'field' + i + 'value' ] ) + '</strong></div>';
                         ': <strong>' + parseWikitext( row[ 'field' + i + 'value' ] ) + '</strong></div>';
                 }
                 }
             }
             }
Line 326: Line 345:
                 '<div class="project-card-wrap">',
                 '<div class="project-card-wrap">',
                   '<div class="project-card">',
                   '<div class="project-card">',
                     '<div class="project-card-image">', img, '</div>',
                     '<div class="project-card-inner">',
                    '<div class="project-card-body">',
                      '<div class="project-card-front">',
                      '<div class="project-card-title"><a href="', url, '">', esc( name ), '</a></div>',
                        '<div class="project-card-image">', img, '</div>',
                      '<div class="project-card-badges">', badges, '</div>',
                        '<div class="project-card-body">',
                      row.creator
                          '<div class="project-card-title"><a href="', url, '">', esc( name ), '</a></div>',
                        ? '<div class="project-card-creator"><span class="project-card-label">By</span> ' +
                          '<div class="project-card-badges">', badges, '</div>',
                          esc( row.creator ) + '</div>'
                          row.creator ? '<div class="project-card-creator"><span class="project-card-label">By</span> ' + esc( row.creator ) + '</div>' : '',
                        : '',
                          extras,
                      extras,
                          tags ? '<div class="project-card-tags">' + tags + '</div>' : '',
                      tags ? '<div class="project-card-tags">' + tags + '</div>' : '',
                          '<div class="project-card-footer">',
                      '<div class="project-card-footer">',
                            '<span class="project-card-meta-item project-card-contributors"></span>',
                        '<span class="project-card-meta-item project-card-contributors">\u2014</span>',
                            '<span class="project-card-meta-item project-card-updated"></span>',
                        '<span class="project-card-meta-item project-card-updated">\u2014</span>',
                            '<button class="js-flip-btn project-card-flip-btn">Read more</button>',
                          '</div>',
                        '</div>',
                      '</div>',
                      '<div class="project-card-back">',
                        '<div class="project-card-back-header">',
                            '<span class="project-card-back-title">About</span>',
                            '<button class="js-flip-btn project-card-flip-btn">Close</button>',
                        '</div>',
                        '<div class="project-card-back-content">',
                            row.description ? esc( row.description ) : 'No description provided.',
                        '</div>',
                       '</div>',
                       '</div>',
                     '</div>',
                     '</div>',
                    row.description
                      ? '<div class="project-card-description">' + esc( row.description ) + '</div>'
                      : '',
                   '</div>',
                   '</div>',
                 '</div>'
                 '</div>'
Line 359: Line 386:
                         titles: chunk.map( function ( c ) { return c.page; } ).join( '|' ),
                         titles: chunk.map( function ( c ) { return c.page; } ).join( '|' ),
                         prop: 'revisions|contributors',
                         prop: 'revisions|contributors',
                         rvprop: 'timestamp', rvlimit: 1, pclimit: 20,
                         rvprop: 'timestamp',
                         format: 'json'
                         format: 'json'
                     } ).done( function ( data ) {
                     } ).done( function ( data ) {
Line 365: Line 392:
                         $.each( data.query.pages || {}, function ( _, page ) {
                         $.each( data.query.pages || {}, function ( _, page ) {
                             if ( page.missing !== undefined ) { return; }
                             if ( page.missing !== undefined ) { return; }
                             var match = chunk.filter( function ( c ) { return c.page === page.title; } );
                            var normalise = function ( s ) { return ( s || '' ).replace( /_/g, ' ' ); };
                             var match = chunk.filter( function ( c ) { return normalise( c.page ) === normalise( page.title ); } );
                             if ( !match.length ) { return; }
                             if ( !match.length ) { return; }
                             var $el = match[ 0 ].$el;
                             var $el = match[ 0 ].$el;

Latest revision as of 00:56, 5 April 2026

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

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

    $( function () {

        var PAGE_SIZE  = 24;
        var generation = 0; // Incremented on every new search to discard stale responses

        var state = {
            search  : '',
            tag     : '',
            license : '',
            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 $tagCloud     = $wrapper.find( '.project-hub-tagcloud' );
        var $licenseCloud = $wrapper.find( '.project-hub-licensecloud' );
        var $grid         = $wrapper.find( '.project-hub-grid' );
        var $count        = $( '<span>' ).addClass( 'project-hub-count' );

        // Create the Load More button entirely in JS.
        // <button> tags are stripped by the MediaWiki HTML sanitiser if placed in wikitext.
        var $more = $( '<button>' )
            .addClass( 'project-hub-loadmore' )
            .text( 'Load more projects' )
            .hide()
            .appendTo( $wrapper );

        // ── Filter bar ────────────────────────────────────────────────────────
        var $search = $( '<input>' ).attr( {
            type        : 'text',
            placeholder : 'Search name, creator, license, tags, description\u2026',
            'class'     : 'project-hub-search'
        } );
        var $sort = $( '<select>' ).addClass( 'project-hub-select' ).append(
            $( '<option>' ).val( 'newest' ).text( 'Newest first' ),
            $( '<option>' ).val( 'alpha'  ).text( 'A \u2013 Z' )
        );
        $filters.append( $search, $sort, $count );

        // ── Events ────────────────────────────────────────────────────────────
        var searchDebounce;
        $search.on( 'input', function () {
            clearTimeout( searchDebounce );
            searchDebounce = 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 pill — delegated so it works for both cloud pills and card tag spans
        $( 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' );
                $( '.js-tag-filter' ).filter( function () {
                    return $( this ).data( 'tag' ) === tag;
                } ).addClass( 'is-active' );
            }
            resetAndLoad();
        } );

        // License pill — delegated
        $( document ).on( 'click', '.js-license-filter', function ( e ) {
            e.stopPropagation();
            var lic = $( this ).data( 'license' );
            if ( state.license === lic ) {
                state.license = '';
                $( '.js-license-filter' ).removeClass( 'is-active' );
            } else {
                state.license = lic;
                $( '.js-license-filter' ).removeClass( 'is-active' );
                $( '.js-license-filter' ).filter( function () {
                    return $( this ).data( 'license' ) === lic;
                } ).addClass( 'is-active' );
            }
            resetAndLoad();
        } );

        // ── Boot ─────────────────────────────────────────────────────────────
        loadTagCloud();
        loadLicenseCloud();
        loadHubMeta();
        fetchPage();

        // ── Helpers ───────────────────────────────────────────────────────────
        function resetAndLoad() {
            generation++;          // Any in-flight request with a lower generation is discarded
            state.offset = 0;
            state.done   = false;
            state.busy   = false;  // Unblock immediately so the new fetch can start
            $grid.empty();
            $more.hide();
            fetchPage();
        }

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

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

        // ── Data ──────────────────────────────────────────────────────────────
        function fetchPage() {
            if ( state.busy || state.done ) { return; }
            state.busy = true;
            var gen = ++generation;
            $more.text( 'Loading\u2026' ).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; }
            if ( state.offset === 0 ) { fetchTotal( w ); }

            $.getJSON( mw.util.wikiScript( 'api' ), params )
                .done( function ( data ) {
                    if ( gen !== generation ) { return; } // Stale — discard silently

                    var rows    = ( data.cargoquery || [] ).map( function ( r ) { return r.title; } );
                    var prevOff = state.offset;
                    var batch   = [];

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

                    enrichCards( batch );

                    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 matching your search.</div>' );
                        $more.hide();
                    }
                } )
                .fail( function () {
                    if ( gen !== generation ) { return; }
                    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 ) {
                    $tagCloud.append(
                        $( '<span>' ).addClass( 'js-tag-filter' ).attr( 'data-tag', tag ).text( tag )
                    );
                } );
                $tagCloud.show();
            } );
        }

        function loadLicenseCloud() {
            $.getJSON( mw.util.wikiScript( 'api' ), {
                action   : 'cargoquery',
                tables   : 'Projects',
                fields   : 'license',
                group_by : 'license',
                where    : "license IS NOT NULL AND license != ''",
                order_by : 'license ASC',
                limit    : 20,
                format   : 'json'
            } ).done( function ( data ) {
                var licenses = ( data.cargoquery || [] )
                    .map( function ( r ) { return ( r.title.license || '' ).trim(); } )
                    .filter( Boolean );
                if ( !licenses.length ) { return; }
                licenses.forEach( function ( lic ) {
                    $licenseCloud.append(
                        $( '<span>' ).addClass( 'js-license-filter' )
                            .attr( 'data-license', lic ).text( lic )
                    );
                } );
                $licenseCloud.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 m = data.query && data.query.categorymembers;
                    if ( !m || !m.length ) { return; }
                    var str = m[ 0 ].title.replace( /^.*\//, '' ) + ' \u2014 ' +
                        ( m[ 0 ].timestamp
                            ? new Date( m[ 0 ].timestamp ).toLocaleDateString(
                                'en-GB', { day: 'numeric', month: 'short', year: 'numeric' } )
                            : '\u2014' );
                    $( '#hub-meta-last-submission, #hub-meta-last-edit' ).text( str );
                } );
            } );
        }

        // ── Card rendering ────────────────────────────────────────────────────
        function parseWikitext( text ) {
            if ( !text ) { return ''; }
            text = String( text );
            // External links: [https://url Display Text]
            text = text.replace( /\[(\S+?)\s+([^\]]+?)\]/g, function ( m, url, display ) {
                if ( !/^https?:\/\//i.test( url ) ) { return mw.html.escape( m ); }
                return '<a href="' + url.replace( /"/g, '%22' ) + '" target="_blank" rel="noopener noreferrer">' +
                    mw.html.escape( display ) + '</a>';
            } );
            // Internal links: [[Page Name|Display]] or [[Page Name]]
            text = text.replace( /\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, function ( m, page, display ) {
                return '<a href="' + mw.util.getUrl( page.trim() ) + '">' +
                    mw.html.escape( ( display || page ).trim() ) + '</a>';
            } );
            // Newlines to line breaks
            text = text.replace( /\n/g, '<br>' );
            return text;
        }

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

        function fileUrl( f ) {
            return f ? mw.config.get( 'wgServer' ) + '/wiki/Special:FilePath/' + encodeURIComponent( f ) : '';
        }

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

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

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

            return $( [
                '<div class="project-card-wrap">',
                  '<div class="project-card">',
                    '<div class="project-card-inner">',
                      '<div class="project-card-front">',
                        '<div class="project-card-image">', img, '</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,
                          tags ? '<div class="project-card-tags">' + tags + '</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>',
                            '<button class="js-flip-btn project-card-flip-btn">Read more</button>',
                          '</div>',
                        '</div>',
                      '</div>',
                      '<div class="project-card-back">',
                         '<div class="project-card-back-header">',
                            '<span class="project-card-back-title">About</span>',
                            '<button class="js-flip-btn project-card-flip-btn">Close</button>',
                         '</div>',
                         '<div class="project-card-back-content">',
                            row.description ? esc( row.description ) : 'No description provided.',
                         '</div>',
                      '</div>',
                    '</div>',
                  '</div>',
                '</div>'
            ].join( '' ) );
        }

        // ── Enrichment (contributor count + last-edited date) ─────────────────
        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',
                        format: 'json'
                    } ).done( function ( data ) {
                        if ( !data.query ) { return; }
                        $.each( data.query.pages || {}, function ( _, page ) {
                            if ( page.missing !== undefined ) { return; }
                            var normalise = function ( s ) { return ( s || '' ).replace( /_/g, ' ' ); };
                            var match = chunk.filter( function ( c ) { return normalise( c.page ) === normalise( 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' } ) : '\u2014';
                            $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 ) );
            }
        }

    } );

} () );