MediaWiki:Gadget-projects.js: Difference between revisions
MediaWiki interface page
More actions
No edit summary |
No edit summary |
||
| (4 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/* ============================================================================= | /* ============================================================================= | ||
Gadget-projects.js v2. | Gadget-projects.js v2.1 | ||
Unified project hub — API-powered search, tag filtering, | Unified project hub — API-powered search, tag/licence filtering, pagination. | ||
============================================================================= */ | ============================================================================= */ | ||
( function () { | ( function () { | ||
| Line 9: | Line 9: | ||
$( function () { | $( function () { | ||
var PAGE_SIZE = 24; | var PAGE_SIZE = 24; | ||
var generation = 0; // Incremented on every new search to discard stale responses | |||
var state = { | var state = { | ||
search : '', | search : '', | ||
tag | tag : '', | ||
sort | license : '', | ||
offset : 0, | sort : 'newest', | ||
done | offset : 0, | ||
busy | done : false, | ||
busy : false | |||
}; | }; | ||
| Line 22: | Line 25: | ||
if ( !$wrapper.length ) { return; } | if ( !$wrapper.length ) { return; } | ||
var $filters = $wrapper.find( '.project-hub-filters' ); | var $filters = $wrapper.find( '.project-hub-filters' ); | ||
var $ | var $tagCloud = $wrapper.find( '.project-hub-tagcloud' ); | ||
var $ | var $licenseCloud = $wrapper.find( '.project-hub-licensecloud' ); | ||
var $ | var $grid = $wrapper.find( '.project-hub-grid' ); | ||
var $count | 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( { | var $search = $( '<input>' ).attr( { | ||
type : 'text', | type : 'text', | ||
placeholder : 'Search | placeholder : 'Search name, creator, license, tags, description\u2026', | ||
'class' : 'project-hub-search' | 'class' : 'project-hub-search' | ||
} ); | } ); | ||
var $sort = $( '<select>' ).addClass( 'project-hub-select' ).append( | var $sort = $( '<select>' ).addClass( 'project-hub-select' ).append( | ||
$( '<option>' ).val( 'newest' ).text( 'Newest first' ), | $( '<option>' ).val( 'newest' ).text( 'Newest first' ), | ||
$( '<option>' ).val( 'alpha' ).text( 'A | $( '<option>' ).val( 'alpha' ).text( 'A \u2013 Z' ) | ||
); | ); | ||
$filters.append( $search, $sort, $count ); | $filters.append( $search, $sort, $count ); | ||
// ── | // ── Events ──────────────────────────────────────────────────────────── | ||
var | var searchDebounce; | ||
$search.on( 'input', function () { | $search.on( 'input', function () { | ||
clearTimeout( | clearTimeout( searchDebounce ); | ||
searchDebounce = setTimeout( function () { | |||
state.search = $search.val().trim(); | state.search = $search.val().trim(); | ||
resetAndLoad(); | resetAndLoad(); | ||
| Line 59: | Line 70: | ||
} ); | } ); | ||
// Tag | // Tag pill — delegated so it works for both cloud pills and card tag spans | ||
$( document ).on( 'click', '.js-tag-filter', function ( e ) { | $( document ).on( 'click', '.js-tag-filter', function ( e ) { | ||
e.stopPropagation(); | e.stopPropagation(); | ||
| Line 69: | Line 80: | ||
state.tag = tag; | state.tag = tag; | ||
$( '.js-tag-filter' ).removeClass( 'is-active' ); | $( '.js-tag-filter' ).removeClass( 'is-active' ); | ||
$( this ).addClass( 'is-active' ); | $( '.js-tag-filter' ).filter( function () { | ||
return $( this ).data( 'tag' ) === tag; | |||
} ).addClass( 'is-active' ); | |||
} | } | ||
resetAndLoad(); | 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(); | loadTagCloud(); | ||
loadLicenseCloud(); | |||
loadHubMeta(); | loadHubMeta(); | ||
fetchPage(); | fetchPage(); | ||
// ── | // ── Helpers ─────────────────────────────────────────────────────────── | ||
function resetAndLoad() { | function resetAndLoad() { | ||
generation++; // Any in-flight request with a lower generation is discarded | |||
state.offset = 0; | state.offset = 0; | ||
state.done = false; | state.done = false; | ||
state.busy = false; // Unblock immediately so the new fetch can start | |||
$grid.empty(); | $grid.empty(); | ||
$more.hide(); | $more.hide(); | ||
| Line 96: | Line 127: | ||
parts.push( | parts.push( | ||
"( name LIKE '%" + q + "%'" + | "( name LIKE '%" + q + "%'" + | ||
" OR | " OR creator LIKE '%" + q + "%'" + | ||
" OR license LIKE '%" + q + "%'" + | " OR license LIKE '%" + q + "%'" + | ||
" OR | " OR description LIKE '%" + q + "%' )" | ||
); | ); | ||
} | } | ||
if ( state.tag ) { | if ( state.tag ) { | ||
parts.push( "tags HOLDS '" + state.tag.replace( /'/g, "''" ) + "'" ); | parts.push( "tags HOLDS '" + state.tag.replace( /'/g, "''" ) + "'" ); | ||
} | |||
if ( state.license ) { | |||
parts.push( "license = '" + state.license.replace( /'/g, "''" ) + "'" ); | |||
} | } | ||
return parts.join( ' AND ' ); | return parts.join( ' AND ' ); | ||
| Line 111: | Line 145: | ||
} | } | ||
// ── Data | // ── Data ────────────────────────────────────────────────────────────── | ||
function fetchPage() { | function fetchPage() { | ||
if ( state.busy || state.done ) { return; } | if ( state.busy || state.done ) { return; } | ||
state.busy = true; | state.busy = true; | ||
$more.text( ' | var gen = ++generation; | ||
$more.text( 'Loading\u2026' ).show(); | |||
var params = { | var params = { | ||
| Line 131: | Line 166: | ||
var w = buildWhere(); | var w = buildWhere(); | ||
if ( w ) { params.where = w; } | if ( w ) { params.where = w; } | ||
if ( state.offset === 0 ) { fetchTotal( w ); } | if ( state.offset === 0 ) { fetchTotal( w ); } | ||
$.getJSON( mw.util.wikiScript( 'api' ), params ) | $.getJSON( mw.util.wikiScript( 'api' ), params ) | ||
.done( function ( data ) { | .done( function ( data ) { | ||
var rows | if ( gen !== generation ) { return; } // Stale — discard silently | ||
var prevOff | |||
var | var rows = ( data.cargoquery || [] ).map( function ( r ) { return r.title; } ); | ||
var prevOff = state.offset; | |||
var batch = []; | |||
rows.forEach( function ( row ) { | rows.forEach( function ( row ) { | ||
var $c = buildCard( row ); | var $c = buildCard( row ); | ||
$grid.append( $c ); | $grid.append( $c ); | ||
batch.push( { $el: $c, page: row.pagename } ); | |||
} ); | } ); | ||
enrichCards( | enrichCards( batch ); | ||
state.offset += rows.length; | state.offset += rows.length; | ||
state.done = | state.done = rows.length < PAGE_SIZE; | ||
state.busy = false; | state.busy = false; | ||
| Line 156: | Line 191: | ||
if ( rows.length === 0 && prevOff === 0 ) { | if ( rows.length === 0 && prevOff === 0 ) { | ||
$grid.html( '<div class="project-hub-empty">No projects found.</div>' ); | $grid.html( '<div class="project-hub-empty">No projects found matching your search.</div>' ); | ||
$more.hide(); | |||
} | } | ||
} ) | } ) | ||
.fail( function () { | .fail( function () { | ||
if ( gen !== generation ) { return; } | |||
state.busy = false; | state.busy = false; | ||
$more.text( 'Load more projects' ).toggle( !state.done ); | $more.text( 'Load more projects' ).toggle( !state.done ); | ||
| Line 166: | Line 203: | ||
function fetchTotal( where ) { | function fetchTotal( where ) { | ||
var p = { | var p = { action: 'cargoquery', tables: 'Projects', fields: 'COUNT(*)=n', limit: 1, format: 'json' }; | ||
if ( where ) { p.where = where; } | if ( where ) { p.where = where; } | ||
$.getJSON( mw.util.wikiScript( 'api' ), p ).done( function ( data ) { | $.getJSON( mw.util.wikiScript( 'api' ), p ).done( function ( data ) { | ||
var row = ( data.cargoquery || [] )[0]; | var row = ( data.cargoquery || [] )[ 0 ]; | ||
var n = row ? ( parseInt( row.title.n, 10 ) || 0 ) : 0; | var n = row ? ( parseInt( row.title.n, 10 ) || 0 ) : 0; | ||
$count.text( n + ' project' + ( n === 1 ? '' : 's' ) ); | $count.text( n + ' project' + ( n === 1 ? '' : 's' ) ); | ||
| Line 183: | Line 214: | ||
function loadTagCloud() { | function loadTagCloud() { | ||
$.getJSON( mw.util.wikiScript( 'api' ), { | $.getJSON( mw.util.wikiScript( 'api' ), { | ||
action : 'cargoquery', | action: 'cargoquery', tables: 'Projects', fields: 'tags', limit: 500, format: 'json' | ||
} ).done( function ( data ) { | } ).done( function ( data ) { | ||
var freq = {}; | var freq = {}; | ||
| Line 201: | Line 228: | ||
if ( !top.length ) { return; } | if ( !top.length ) { return; } | ||
top.forEach( function ( tag ) { | top.forEach( function ( tag ) { | ||
$ | $tagCloud.append( | ||
$( '<span>' ) | $( '<span>' ).addClass( 'js-tag-filter' ).attr( 'data-tag', tag ).text( tag ) | ||
); | |||
} ); | |||
.text( | $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(); | ||
} ); | } ); | ||
} | } | ||
| Line 215: | Line 264: | ||
mw.loader.using( 'mediawiki.api' ).then( function () { | mw.loader.using( 'mediawiki.api' ).then( function () { | ||
new mw.Api().get( { | new mw.Api().get( { | ||
action | action: 'query', list: 'categorymembers', cmtitle: 'Category:Projects', | ||
cmsort: 'timestamp', cmdir: 'desc', cmlimit: 1, | |||
cmtype: 'page', cmprop: 'ids|title|timestamp', format: 'json' | |||
cmsort | |||
cmtype | |||
} ).done( function ( data ) { | } ).done( function ( data ) { | ||
var | var m = data.query && data.query.categorymembers; | ||
if ( ! | if ( !m || !m.length ) { return; } | ||
var | 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 ); | |||
$( '#hub-meta-last-submission | |||
} ); | } ); | ||
} ); | } ); | ||
} | } | ||
// ── Card rendering | // ── Card rendering ──────────────────────────────────────────────────── | ||
function | function parseWikitext( text ) { | ||
if ( ! | if ( !text ) { return ''; } | ||
return mw. | 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 || '' ); } | ||
function fileUrl( f ) { | |||
return f ? mw.config.get( 'wgServer' ) + '/wiki/Special:FilePath/' + encodeURIComponent( f ) : ''; | |||
} | |||
function buildCard( row ) { | function buildCard( row ) { | ||
var page = row.pagename || ''; | var page = row.pagename || ''; | ||
var name = row.name || page.replace( /^.*\//, '' ); | |||
var url = mw.util.getUrl( page ); | var url = mw.util.getUrl( page ); | ||
var | var img = row.image | ||
? '<img src="' + esc( fileUrl( row.image ) ) + '" alt="' + esc( name ) + '" loading="lazy">' | ? '<img src="' + esc( fileUrl( row.image ) ) + '" alt="' + esc( name ) + '" loading="lazy">' | ||
: '<div class="project-card-image-fallback"></div>'; | : '<div class="project-card-image-fallback"></div>'; | ||
| Line 267: | Line 324: | ||
} | } | ||
var | 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 = ''; | var extras = ''; | ||
for ( var i = 1; i <= 4; i++ ) { | for ( var i = 1; i <= 4; i++ ) { | ||
if ( row[ 'field' + i + 'label' ] ) { | |||
extras += '<div class="project-card-extra">' + | extras += '<div class="project-card-extra">' + | ||
esc( | esc( row[ 'field' + i + 'label' ] ) + | ||
': <strong>' + parseWikitext( row[ 'field' + i + 'value' ] ) + '</strong></div>'; | |||
} | } | ||
} | } | ||
return $( [ | |||
'<div class="project-card-wrap">', | '<div class="project-card-wrap">', | ||
'<div class="project-card">', | '<div class="project-card">', | ||
'<div class="project-card-image">', | '<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>', | ||
'<div class="project-card- | '<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>', | ||
'</div>', | '</div>', | ||
'</div>' | '</div>' | ||
].join( '' ) | ].join( '' ) ); | ||
} | } | ||
// ── | // ── Enrichment (contributor count + last-edited date) ───────────────── | ||
function enrichCards( batch ) { | function enrichCards( batch ) { | ||
if ( !batch.length ) { return; } | if ( !batch.length ) { return; } | ||
| Line 327: | Line 383: | ||
( function ( chunk ) { | ( function ( chunk ) { | ||
api.get( { | api.get( { | ||
action | action: 'query', | ||
titles | titles: chunk.map( function ( c ) { return c.page; } ).join( '|' ), | ||
prop | prop: 'revisions|contributors', | ||
rvprop | rvprop: 'timestamp', | ||
format: 'json' | |||
format | |||
} ).done( function ( data ) { | } ).done( function ( data ) { | ||
if ( !data.query | if ( !data.query ) { return; } | ||
$.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 | var $el = match[ 0 ].$el; | ||
var ts | var ts = ( page.revisions && page.revisions[ 0 ] && page.revisions[ 0 ].timestamp ) || ''; | ||
var n = ( page.contributors ? page.contributors.length : 0 ) + ( page.anoncontributors || 0 ); | |||
var n | var d = ts ? new Date( ts ).toLocaleDateString( 'en-GB', { year: 'numeric', month: 'short' } ) : '\u2014'; | ||
var d | |||
$el.find( '.project-card-contributors' ) | $el.find( '.project-card-contributors' ) | ||
.text( n + ( n === 1 ? ' contributor' : ' contributors' ) ) | .text( n + ( n === 1 ? ' contributor' : ' contributors' ) ).addClass( 'loaded' ); | ||
$el.find( '.project-card-updated' ) | $el.find( '.project-card-updated' ) | ||
.text( 'Edited ' + d ) | .text( 'Edited ' + d ).addClass( 'loaded' ); | ||
} ); | } ); | ||
} ); | } ); | ||
| Line 362: | Line 409: | ||
} | } | ||
} ); | } ); | ||
} () ); | } () ); | ||
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 ) );
}
}
} );
} () );