MediaWiki:Common.portal.js: Difference between revisions
MediaWiki interface page
More actions
No edit summary |
No edit summary |
||
| (10 intermediate revisions by the same user not shown) | |||
| Line 56: | Line 56: | ||
return apiGetFrom( MEDIA_API, params, true ); | return apiGetFrom( MEDIA_API, params, true ); | ||
} | } | ||
var EPISODE_SEASONS = { | |||
'Miniseries': [ | |||
'Miniseries, Night 1', 'Miniseries, Night 2' | |||
], | |||
'Season 1 (RDM)': [ | |||
'33', 'Water', 'Bastille Day', 'Act of Contrition', | |||
"You Can't Go Home Again", 'Litmus', 'Six Degrees of Separation', | |||
'Flesh and Bone', 'Tigh Me Up, Tigh Me Down', 'The Hand of God (RDM)', | |||
'Colonial Day', "Kobol's Last Gleaming, Part I", "Kobol's Last Gleaming, Part II" | |||
], | |||
'Season 2 (RDM)': [ | |||
'Scattered', 'Valley of Darkness', 'Fragged', 'Resistance (episode)', | |||
'The Farm', 'Home, Part I', 'Home, Part II', 'Final Cut', | |||
'Flight of the Phoenix', 'Pegasus (episode)', | |||
'Resurrection Ship, Part I', 'Resurrection Ship, Part II', | |||
'Epiphanies', 'Black Market', 'Scar', 'Sacrifice', | |||
"The Captain's Hand", 'Downloaded', | |||
'Lay Down Your Burdens, Part I', 'Lay Down Your Burdens, Part II' | |||
], | |||
'Season 3 (RDM)': [ | |||
'Occupation', 'Precipice', 'Exodus, Part I', 'Exodus, Part II', | |||
'Collaborators', 'Torn', 'A Measure of Salvation', 'Hero', | |||
'Unfinished Business', 'The Passage', 'The Eye of Jupiter', 'Rapture', | |||
'Taking a Break From All Your Worries', 'The Woman King', | |||
'A Day in the Life', 'Dirty Hands', 'Maelstrom', | |||
'The Son Also Rises', 'Crossroads, Part I', 'Crossroads, Part II' | |||
], | |||
'Season 4 (RDM)': [ | |||
'He That Believeth in Me', 'Six of One', 'The Ties That Bind', | |||
'Escape Velocity', 'The Road Less Traveled', 'Faith', | |||
"Guess What's Coming to Dinner?", 'Sine Qua Non', 'The Hub', | |||
'Revelations', 'Sometimes a Great Notion', | |||
'A Disquiet Follows My Soul', 'The Oath', 'Blood on the Scales', | |||
'No Exit', 'Deadlock', 'Someone to Watch Over Me', | |||
'Islanded in a Stream of Stars', 'Daybreak, Part I', 'Daybreak, Part II' | |||
], | |||
'Webisodes (RDM)': [ | |||
'Razor Flashbacks, Episode 1', 'Razor Flashbacks, Episode 2', | |||
'Razor Flashbacks, Episode 3', 'Razor Flashbacks, Episode 4', | |||
'Razor Flashbacks, Episode 5', 'Razor Flashbacks, Episode 6', | |||
'Razor Flashbacks, Episode 7', | |||
'The Resistance, Episode 1', 'The Resistance, Episode 2', | |||
'The Resistance, Episode 3', 'The Resistance, Episode 4', | |||
'The Resistance, Episode 5', 'The Resistance, Episode 6', | |||
'The Resistance, Episode 7', 'The Resistance, Episode 8', | |||
'The Resistance, Episode 9', 'The Resistance, Episode 10', | |||
'The Face of the Enemy' | |||
], | |||
'DVD Movies (RDM)': [ | |||
'Razor', 'The Plan', 'Battlestar Galactica (Miniseries)' | |||
], | |||
/* ── Original Series (TOS) ── */ | |||
'Season 1 (TOS)': [ | |||
'Saga of a Star World', 'Lost Planet of the Gods, Part I', | |||
'Lost Planet of the Gods, Part II', 'The Lost Warrior', | |||
'The Long Patrol', 'The Gun on Ice Planet Zero, Part I', | |||
'The Gun on Ice Planet Zero, Part II', 'The Magnificent Warriors', | |||
'The Young Lords', 'The Living Legend, Part I', | |||
'The Living Legend, Part II', 'Fire in Space', | |||
'War of the Gods, Part I', 'War of the Gods, Part II', | |||
'The Man with Nine Lives', 'Murder on the Rising Star', | |||
'Greetings from Earth', "Baltar's Escape", | |||
'Experiment in Terra', 'Take the Celestra', | |||
'The Hand of God (TOS)' | |||
], | |||
'Season 1 (1980)': [ | |||
'Galactica Discovers Earth, Part I', 'Galactica Discovers Earth, Part II', | |||
'Galactica Discovers Earth, Part III', 'The Super Scouts, Part I', | |||
'The Super Scouts, Part II', 'Spaceball', | |||
'The Night the Cylons Landed, Part I', 'The Night the Cylons Landed, Part II', | |||
'Space Croppers', 'The Return of Starbuck' | |||
] | |||
}; | |||
function initEpisodeGuideSliders( page ) { | |||
var sliders = page.querySelectorAll( '.portal-epguide-slider[data-category]' ); | |||
if ( !sliders.length ) return; | |||
sliders.forEach( function ( el ) { | |||
var cat = el.dataset.category; | |||
var guide = el.dataset.guide || ''; | |||
var titles = EPISODE_SEASONS[ cat ]; | |||
if ( !titles || !titles.length ) { | |||
el.innerHTML = '<span style="font-size:0.8em;color:var(--color-base--subtle)">No episodes found.</span>'; | |||
return; | |||
} | |||
/* Show loading state */ | |||
el.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 0;color:var(--color-base--subtle);font-size:0.8em"><div class="portal-spinner"></div>Loading episodes\u2026</div>'; | |||
/* Fetch pageimages + extracts for all episodes in this season */ | |||
apiGet( { | |||
action: 'query', | |||
titles: titles.slice( 0, 20 ).join( '|' ), | |||
prop: 'pageimages|extracts', | |||
piprop: 'thumbnail', | |||
pithumbsize: '600', | |||
pilimit: '20', | |||
exintro: '1', | |||
exchars: '120', | |||
exsectionformat: 'plain', | |||
formatversion: '2' | |||
} ).then( function ( data ) { | |||
/* Build a map keyed by title for easy lookup */ | |||
var pageMap = {}; | |||
( data.query.pages || [] ).forEach( function ( p ) { | |||
pageMap[ p.title ] = p; | |||
} ); | |||
/* Build slides in episode order */ | |||
var slides = titles.map( function ( t ) { | |||
var p = pageMap[ t ] || {}; | |||
var thumb = p.thumbnail ? p.thumbnail.source : ''; | |||
var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim(); | |||
if ( extract.length > 100 ) extract = extract.slice( 0, 100 ).replace( /\s\S+$/, '' ) + '\u2026'; | |||
return { title: t, thumb: thumb, extract: extract }; | |||
} ).filter( function ( s ) { return s.thumb; } ); | |||
if ( !slides.length ) { | |||
el.innerHTML = '<span style="font-size:0.8em;color:var(--color-base--subtle)">No episode images available.</span>'; | |||
return; | |||
} | |||
buildEpSlider( el, slides, guide ); | |||
} ).catch( function () { | |||
el.innerHTML = '<span style="font-size:0.8em;color:var(--color-base--subtle)">Could not load episodes.</span>'; | |||
} ); | |||
} ); | |||
} | |||
function buildEpSlider( el, slides, guideUrl ) { | |||
var current = 0; | |||
var total = slides.length; | |||
var timer; | |||
function render() { | |||
var s = slides[ current ]; | |||
var dotsHtml = slides.map( function ( _, i ) { | |||
return '<span class="portal-ep-dot' + ( i === current ? ' is-active' : '' ) + '" role="button" tabindex="0" data-i="' + i + '"></span>'; | |||
} ).join( '' ); | |||
el.innerHTML = | |||
'<div class="portal-ep-slide">' + | |||
( s.thumb | |||
? '<a href="/' + encodeURIComponent( s.title.replace( / /g, '_' ) ) + '" class="portal-ep-thumb-wrap">' + | |||
'<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" class="portal-ep-thumb"></a>' | |||
: '' ) + | |||
'<div class="portal-ep-info">' + | |||
'<div class="portal-ep-title"><a href="/' + encodeURIComponent( s.title.replace( / /g, '_' ) ) + '">' + esc( s.title ) + '</a></div>' + | |||
( s.extract ? '<div class="portal-ep-extract">' + esc( s.extract ) + '</div>' : '' ) + | |||
'</div>' + | |||
'</div>' + | |||
'<div class="portal-ep-nav">' + | |||
'<span class="portal-ep-btn portal-ep-prev">\u2039</span>' + | |||
'<div class="portal-ep-dots">' + dotsHtml + '</div>' + | |||
'<span class="portal-ep-count">' + ( current + 1 ) + ' / ' + total + '</span>' + | |||
'<span class="portal-ep-btn portal-ep-next">\u203a</span>' + | |||
'</div>'; | |||
/* Wire nav */ | |||
el.querySelector( '.portal-ep-prev' ).addEventListener( 'click', function () { goTo( current - 1 ); } ); | |||
el.querySelector( '.portal-ep-next' ).addEventListener( 'click', function () { goTo( current + 1 ); } ); | |||
el.querySelectorAll( '.portal-ep-dot' ).forEach( function ( d ) { | |||
d.addEventListener( 'click', function () { goTo( +d.dataset.i ); } ); | |||
} ); | |||
} | |||
function goTo( n ) { | |||
current = ( n + total ) % total; | |||
clearInterval( timer ); | |||
render(); | |||
timer = setInterval( function () { goTo( current + 1 ); }, 7000 ); | |||
} | |||
render(); | |||
timer = setInterval( function () { goTo( current + 1 ); }, 7000 ); | |||
} | |||
/* ── Portal entry point ───────────────────────────────────────── */ | /* ── Portal entry point ───────────────────────────────────────── */ | ||
| Line 68: | Line 248: | ||
initPortalOrphans( page ); | initPortalOrphans( page ); | ||
initPortalFeatured( page ); | initPortalFeatured( page ); | ||
initEpisodeGuideSliders( page ); | |||
} ); | |||
/* Episode guide page — not wrapped in .portal-page */ | |||
mw.hook( 'wikipage.content' ).add( function () { | |||
if ( document.querySelector( '.portal-epguide' ) ) { | |||
initEpisodeGuideSliders( document ); | |||
} | |||
} ); | } ); | ||
| Line 246: | Line 434: | ||
var timer; | var timer; | ||
var cat = ( hero.dataset.category || '' ).toUpperCase(); | |||
var cat = ( hero.dataset.category || '' ).toUpperCase(); | |||
var seriesBadge = cat || 'BSG'; | var seriesBadge = cat || 'BSG'; | ||
var | /* Rebuild hero using bsw-slide pattern — same as Main Page */ | ||
var slideHTML = ''; | |||
slides.forEach( function ( s, i ) { | |||
var badge = s.badge || seriesBadge; | |||
slideHTML += | |||
'<div class="portal-slide' + ( i === 0 ? ' portal-active' : '' ) + '">' + | |||
'<div class="portal-slide-bg">' + | |||
( s.thumb ? '<img src="' + esc( s.thumb ) + '" alt="" class="portal-slide-bg-blur" aria-hidden="true"><img src="' + esc( s.thumb ) + '" alt="" class="portal-slide-bg-img">' : '' ) + | |||
'</div>' + | |||
'<div class="portal-slide-overlay"></div>' + | |||
'<div class="portal-slide-content">' + | |||
( badge ? '<div class="portal-slide-badge"><span class="portal-slide-badge-dot"></span><span>' + esc( badge ) + '</span></div>' : '' ) + | |||
'<div class="portal-slide-title"><a href="/' + encodeURIComponent( ( s.link || s.title ).replace( / /g, '_' ) ) + '">' + esc( s.title ) + '</a></div>' + | |||
( s.caption && s.caption !== s.title ? '<div class="portal-slide-desc">' + esc( s.caption ) + '</div>' : '' ) + | |||
'</div>' + | |||
'</div>'; | |||
} ); | |||
/* | /* Dots + nav — top-right like Main Page */ | ||
slideHTML += | |||
'<div class="portal-hero-dots">' + | |||
slides.map( function ( _, i ) { | |||
return '<span class="portal-hero-dot' + ( i === 0 ? ' is-active' : '' ) + '" role="button" tabindex="0"></span>'; | |||
hero | } ).join( '' ) + | ||
'</div>' + | |||
'<div class="portal-hero-nav">' + | |||
'<span class="portal-hero-btn" role="button" tabindex="0" id="portal-hero-prev">‹</span>' + | |||
'<span class="portal-hero-btn" role="button" tabindex="0" id="portal-hero-next">›</span>' + | |||
'</div>'; | |||
hero.innerHTML = slideHTML; | |||
var slideEls = hero.querySelectorAll( '.portal-slide' ); | |||
var dotsEl = hero.querySelector( '.portal-hero-dots' ); | |||
var prevBtn = hero.querySelector( '#portal-hero-prev' ); | |||
var nextBtn = hero.querySelector( '#portal-hero-next' ); | |||
function goTo( n ) { | function goTo( n ) { | ||
slideEls[ current ].classList.remove( 'portal-active' ); | |||
dotsEl.querySelectorAll( '.portal-hero-dot' )[ current ].classList.remove( 'is-active' ); | |||
current = ( n + total ) % total; | current = ( n + total ) % total; | ||
slideEls[ current ].classList.add( 'portal-active' ); | |||
dotsEl.querySelectorAll( '.portal-hero-dot' )[ current ].classList.add( 'is-active' ); | |||
resetTimer(); | resetTimer(); | ||
} | } | ||
| Line 373: | Line 528: | ||
function initPortalNewest( page ) { | function initPortalNewest( page ) { | ||
var inner = page.querySelector( '.portal-newest-inner' ); | var inner = page.querySelector( '.portal-newest-inner' ); | ||
var category = inner.dataset.category || ''; | var category = inner.dataset.category || ''; | ||
inner.innerHTML = '< | inner.innerHTML = '<span style="color:#aaa;font-size:0.8rem">Loading\u2026 (cat: ' + category + ')</span>'; | ||
apiGet( { | apiGet( { | ||
action: 'query', | action: 'query', | ||
| Line 392: | Line 542: | ||
formatversion: '2' | formatversion: '2' | ||
} ).then( function ( data ) { | } ).then( function ( data ) { | ||
var | var rc = ( ( data.query || {} ).recentchanges || [] ); | ||
var entries = rc.filter( function ( e ) { | |||
return e.title && e.title.indexOf( '/' ) === -1; | return e.title && e.title.indexOf( '/' ) === -1; | ||
} ); | } ); | ||
var titles = entries.map( function ( e ) { return e.title; } ); | var titles = entries.map( function ( e ) { return e.title; } ); | ||
| Line 401: | Line 552: | ||
entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } ); | entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } ); | ||
return apiGet( { | return apiGet( { | ||
action: 'query', | action: 'query', | ||
| Line 419: | Line 570: | ||
} ); | } ); | ||
var title = matched.length ? matched[0].title : entries[0].title; | var title = matched.length ? matched[0].title : entries[0].title; | ||
var ts = tsMap[ title ]; | var ts = tsMap[ title ]; | ||
return apiGet( { | return apiGet( { | ||
| Line 431: | Line 582: | ||
exsectionformat: 'plain', | exsectionformat: 'plain', | ||
piprop: 'thumbnail', | piprop: 'thumbnail', | ||
pithumbsize: ' | pithumbsize: '300', | ||
formatversion: '2' | formatversion: '2' | ||
} ).then( function ( d2 ) { | } ).then( function ( d2 ) { | ||
| Line 442: | Line 593: | ||
renderPortalNewest( inner, result.page, result.ts ); | renderPortalNewest( inner, result.page, result.ts ); | ||
} ).catch( function ( err ) { | } ).catch( function ( err ) { | ||
console.warn( '[Portal] initPortalNewest error:', err ); | |||
} ); | } ); | ||
} | } | ||
| Line 455: | Line 605: | ||
var date = ts ? new Date( ts ).toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } ) : ''; | var date = ts ? new Date( ts ).toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } ) : ''; | ||
/* Use bsw-fa-thumb matching Main Page newest article pattern */ | |||
var thumbHtml = thumb | var thumbHtml = thumb | ||
? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb | ? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb">' | ||
: ''; | : ''; | ||
inner.innerHTML = | inner.innerHTML = | ||
thumbHtml + | thumbHtml + | ||
'<div class="bsw-fa-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' + | |||
'<div class=" | ( date ? '<div class="bsw-newest-date">Created ' + esc( date ) + '</div>' : '' ) + | ||
( date ? '<div class=" | '<div class="bsw-fa-extract">' + esc( extract ) + '</div>' + | ||
'<div | '<div style="margin-top:0.375rem"><a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link)">Read more \u2192</a></div>' + | ||
'<div style="margin-top: | |||
'</div>'; | '</div>'; | ||
} | } | ||
| Line 591: | Line 739: | ||
var nextBtn = widget.querySelector( '.portal-featured-next' ); | var nextBtn = widget.querySelector( '.portal-featured-next' ); | ||
var episodes = ( widget.dataset.episodes || '' ).split( ' | var episodes = ( widget.dataset.episodes || '' ).split( ';' ).map( function ( s ) { return s.trim(); } ).filter( Boolean ); | ||
if ( !episodes.length || !body ) return; | if ( !episodes.length || !body ) return; | ||
| Line 699: | Line 847: | ||
return a; | return a; | ||
} | } | ||
/* ════════════════════════════════════════════════════════════════ | |||
6. EPISODE GUIDE SLIDERS | |||
Each .portal-epguide-slider[data-titles] gets a mini carousel | |||
showing episode screencaps fetched from the media wiki. | |||
════════════════════════════════════════════════════════════════ */ | |||
}() ); | }() ); | ||
Latest revision as of 20:53, 16 April 2026
/**
* BattlestarWiki — Portal page JavaScript
* MediaWiki:Common.portal.js (or append to MediaWiki:Common.js)
*
* Handles per-portal widgets on any .portal-page:
* 1. Image carousel
* 2. Newest article (portal-scoped)
* 3. Stats bar (live article count)
*
* Self-contained — does not depend on helpers from the main page JS.
*/
( function () {
'use strict';
/* ── API endpoints ──────────────────────────────────────────────── */
var API = 'https://en.battlestarwiki.org/w/api.php';
var MEDIA_API = 'https://media.battlestarwiki.org/w/api.php';
/* ── Shared helpers ─────────────────────────────────────────────── */
function dailySeed() {
var now = new Date();
return Math.floor( Date.UTC(
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()
) / 86400000 );
}
function esc( s ) {
return String( s || '' )
.replace( /&/g, '&' ).replace( /</g, '<' )
.replace( />/g, '>' ).replace( /"/g, '"' );
}
function apiGetFrom( baseUrl, params, crossOrigin ) {
params.format = 'json';
if ( crossOrigin ) { params.origin = '*'; }
var qs = Object.keys( params )
.map( function ( k ) { return encodeURIComponent( k ) + '=' + encodeURIComponent( params[ k ] ); } )
.join( '&' );
return fetch( baseUrl + '?' + qs, { headers: { Accept: 'application/json' } } )
.then( function ( r ) {
if ( !r.ok ) throw new Error( 'HTTP ' + r.status );
return r.json();
} );
}
/* Same-origin: use relative URL, no origin param needed */
function apiGet( params ) {
return apiGetFrom( '/w/api.php', params, false );
}
/* Cross-origin: use absolute URL + origin=* for CORS */
function apiGetCross( params ) {
return apiGetFrom( MEDIA_API, params, true );
}
var EPISODE_SEASONS = {
'Miniseries': [
'Miniseries, Night 1', 'Miniseries, Night 2'
],
'Season 1 (RDM)': [
'33', 'Water', 'Bastille Day', 'Act of Contrition',
"You Can't Go Home Again", 'Litmus', 'Six Degrees of Separation',
'Flesh and Bone', 'Tigh Me Up, Tigh Me Down', 'The Hand of God (RDM)',
'Colonial Day', "Kobol's Last Gleaming, Part I", "Kobol's Last Gleaming, Part II"
],
'Season 2 (RDM)': [
'Scattered', 'Valley of Darkness', 'Fragged', 'Resistance (episode)',
'The Farm', 'Home, Part I', 'Home, Part II', 'Final Cut',
'Flight of the Phoenix', 'Pegasus (episode)',
'Resurrection Ship, Part I', 'Resurrection Ship, Part II',
'Epiphanies', 'Black Market', 'Scar', 'Sacrifice',
"The Captain's Hand", 'Downloaded',
'Lay Down Your Burdens, Part I', 'Lay Down Your Burdens, Part II'
],
'Season 3 (RDM)': [
'Occupation', 'Precipice', 'Exodus, Part I', 'Exodus, Part II',
'Collaborators', 'Torn', 'A Measure of Salvation', 'Hero',
'Unfinished Business', 'The Passage', 'The Eye of Jupiter', 'Rapture',
'Taking a Break From All Your Worries', 'The Woman King',
'A Day in the Life', 'Dirty Hands', 'Maelstrom',
'The Son Also Rises', 'Crossroads, Part I', 'Crossroads, Part II'
],
'Season 4 (RDM)': [
'He That Believeth in Me', 'Six of One', 'The Ties That Bind',
'Escape Velocity', 'The Road Less Traveled', 'Faith',
"Guess What's Coming to Dinner?", 'Sine Qua Non', 'The Hub',
'Revelations', 'Sometimes a Great Notion',
'A Disquiet Follows My Soul', 'The Oath', 'Blood on the Scales',
'No Exit', 'Deadlock', 'Someone to Watch Over Me',
'Islanded in a Stream of Stars', 'Daybreak, Part I', 'Daybreak, Part II'
],
'Webisodes (RDM)': [
'Razor Flashbacks, Episode 1', 'Razor Flashbacks, Episode 2',
'Razor Flashbacks, Episode 3', 'Razor Flashbacks, Episode 4',
'Razor Flashbacks, Episode 5', 'Razor Flashbacks, Episode 6',
'Razor Flashbacks, Episode 7',
'The Resistance, Episode 1', 'The Resistance, Episode 2',
'The Resistance, Episode 3', 'The Resistance, Episode 4',
'The Resistance, Episode 5', 'The Resistance, Episode 6',
'The Resistance, Episode 7', 'The Resistance, Episode 8',
'The Resistance, Episode 9', 'The Resistance, Episode 10',
'The Face of the Enemy'
],
'DVD Movies (RDM)': [
'Razor', 'The Plan', 'Battlestar Galactica (Miniseries)'
],
/* ── Original Series (TOS) ── */
'Season 1 (TOS)': [
'Saga of a Star World', 'Lost Planet of the Gods, Part I',
'Lost Planet of the Gods, Part II', 'The Lost Warrior',
'The Long Patrol', 'The Gun on Ice Planet Zero, Part I',
'The Gun on Ice Planet Zero, Part II', 'The Magnificent Warriors',
'The Young Lords', 'The Living Legend, Part I',
'The Living Legend, Part II', 'Fire in Space',
'War of the Gods, Part I', 'War of the Gods, Part II',
'The Man with Nine Lives', 'Murder on the Rising Star',
'Greetings from Earth', "Baltar's Escape",
'Experiment in Terra', 'Take the Celestra',
'The Hand of God (TOS)'
],
'Season 1 (1980)': [
'Galactica Discovers Earth, Part I', 'Galactica Discovers Earth, Part II',
'Galactica Discovers Earth, Part III', 'The Super Scouts, Part I',
'The Super Scouts, Part II', 'Spaceball',
'The Night the Cylons Landed, Part I', 'The Night the Cylons Landed, Part II',
'Space Croppers', 'The Return of Starbuck'
]
};
function initEpisodeGuideSliders( page ) {
var sliders = page.querySelectorAll( '.portal-epguide-slider[data-category]' );
if ( !sliders.length ) return;
sliders.forEach( function ( el ) {
var cat = el.dataset.category;
var guide = el.dataset.guide || '';
var titles = EPISODE_SEASONS[ cat ];
if ( !titles || !titles.length ) {
el.innerHTML = '<span style="font-size:0.8em;color:var(--color-base--subtle)">No episodes found.</span>';
return;
}
/* Show loading state */
el.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 0;color:var(--color-base--subtle);font-size:0.8em"><div class="portal-spinner"></div>Loading episodes\u2026</div>';
/* Fetch pageimages + extracts for all episodes in this season */
apiGet( {
action: 'query',
titles: titles.slice( 0, 20 ).join( '|' ),
prop: 'pageimages|extracts',
piprop: 'thumbnail',
pithumbsize: '600',
pilimit: '20',
exintro: '1',
exchars: '120',
exsectionformat: 'plain',
formatversion: '2'
} ).then( function ( data ) {
/* Build a map keyed by title for easy lookup */
var pageMap = {};
( data.query.pages || [] ).forEach( function ( p ) {
pageMap[ p.title ] = p;
} );
/* Build slides in episode order */
var slides = titles.map( function ( t ) {
var p = pageMap[ t ] || {};
var thumb = p.thumbnail ? p.thumbnail.source : '';
var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim();
if ( extract.length > 100 ) extract = extract.slice( 0, 100 ).replace( /\s\S+$/, '' ) + '\u2026';
return { title: t, thumb: thumb, extract: extract };
} ).filter( function ( s ) { return s.thumb; } );
if ( !slides.length ) {
el.innerHTML = '<span style="font-size:0.8em;color:var(--color-base--subtle)">No episode images available.</span>';
return;
}
buildEpSlider( el, slides, guide );
} ).catch( function () {
el.innerHTML = '<span style="font-size:0.8em;color:var(--color-base--subtle)">Could not load episodes.</span>';
} );
} );
}
function buildEpSlider( el, slides, guideUrl ) {
var current = 0;
var total = slides.length;
var timer;
function render() {
var s = slides[ current ];
var dotsHtml = slides.map( function ( _, i ) {
return '<span class="portal-ep-dot' + ( i === current ? ' is-active' : '' ) + '" role="button" tabindex="0" data-i="' + i + '"></span>';
} ).join( '' );
el.innerHTML =
'<div class="portal-ep-slide">' +
( s.thumb
? '<a href="/' + encodeURIComponent( s.title.replace( / /g, '_' ) ) + '" class="portal-ep-thumb-wrap">' +
'<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" class="portal-ep-thumb"></a>'
: '' ) +
'<div class="portal-ep-info">' +
'<div class="portal-ep-title"><a href="/' + encodeURIComponent( s.title.replace( / /g, '_' ) ) + '">' + esc( s.title ) + '</a></div>' +
( s.extract ? '<div class="portal-ep-extract">' + esc( s.extract ) + '</div>' : '' ) +
'</div>' +
'</div>' +
'<div class="portal-ep-nav">' +
'<span class="portal-ep-btn portal-ep-prev">\u2039</span>' +
'<div class="portal-ep-dots">' + dotsHtml + '</div>' +
'<span class="portal-ep-count">' + ( current + 1 ) + ' / ' + total + '</span>' +
'<span class="portal-ep-btn portal-ep-next">\u203a</span>' +
'</div>';
/* Wire nav */
el.querySelector( '.portal-ep-prev' ).addEventListener( 'click', function () { goTo( current - 1 ); } );
el.querySelector( '.portal-ep-next' ).addEventListener( 'click', function () { goTo( current + 1 ); } );
el.querySelectorAll( '.portal-ep-dot' ).forEach( function ( d ) {
d.addEventListener( 'click', function () { goTo( +d.dataset.i ); } );
} );
}
function goTo( n ) {
current = ( n + total ) % total;
clearInterval( timer );
render();
timer = setInterval( function () { goTo( current + 1 ); }, 7000 );
}
render();
timer = setInterval( function () { goTo( current + 1 ); }, 7000 );
}
/* ── Portal entry point ───────────────────────────────────────── */
mw.hook( 'wikipage.content' ).add( function () {
var page = document.querySelector( '.portal-page' );
if ( !page ) return;
initPortalSlider( page );
initPortalNewest( page );
initPortalStats( page );
initPortalOrphans( page );
initPortalFeatured( page );
initEpisodeGuideSliders( page );
} );
/* Episode guide page — not wrapped in .portal-page */
mw.hook( 'wikipage.content' ).add( function () {
if ( document.querySelector( '.portal-epguide' ) ) {
initEpisodeGuideSliders( document );
}
} );
/* ════════════════════════════════════════════════════════════════
1. IMAGE CAROUSEL
Reads Portal:NAME/ImageList for a curated file list, falls back
to querying the category directly. Images are fetched via the
media wiki's imageinfo API for thumbnail URLs.
════════════════════════════════════════════════════════════════ */
function initPortalSlider( page ) {
var hero = page.querySelector( '.portal-hero' );
if ( !hero ) return;
var category = hero.dataset.category || '';
if ( !category ) return;
/* Random seed per page load — different slides every visit */
var seed = Math.floor( Math.random() * 2147483647 ) + 1;
/* Query articles from the portal category on en.battlestarwiki.org.
Mirror the Main Page bsw-hero pattern: article → pageimage → slide. */
apiGet( {
action: 'query',
list: 'categorymembers',
cmtitle: 'Category:' + category,
cmtype: 'page',
cmnamespace: '0',
cmlimit: '200'
} ).then( function ( data ) {
var members = ( data.query.categorymembers || [] )
.filter( function ( m ) {
/* Exclude subpages (slash in title) and redirects */
return m.title && m.title.indexOf( '/' ) === -1;
} );
if ( !members.length ) throw new Error( 'empty category' );
/* Random shuffle, cap at 20 candidates to fetch images for */
members = seededShuffle( members, seed ).slice( 0, 20 );
var titles = members.map( function ( m ) { return m.title; } ).join( '|' );
/* Fetch pageimage + extract + categories for each article */
return apiGet( {
action: 'query',
titles: titles,
prop: 'pageimages|extracts|categories',
piprop: 'thumbnail',
pithumbsize: '1200',
pilimit: '20',
exintro: '1',
exchars: '200',
exsectionformat: 'plain',
cllimit: '20',
formatversion: '2'
} ).then( function ( d ) {
var slides = [];
( d.query.pages || [] ).forEach( function ( p ) {
/* Skip articles with no image */
if ( !p.thumbnail || !p.thumbnail.source ) return;
var extract = ( p.extract || '' ).replace( /<[^>]+>/g, '' ).trim();
if ( extract.length > 180 ) {
extract = extract.replace( /\s\S+$/, '' ) + '…';
}
/* Derive badge label from article categories */
var badge = portalSlideBadge( p.categories || [] );
slides.push( {
thumb: p.thumbnail.source,
caption: extract || p.title,
title: p.title,
link: p.title,
badge: badge
} );
} );
return slides;
} );
} ).then( function ( slides ) {
if ( !slides.length ) throw new Error( 'No slides with images' );
buildCarousel( hero, slides );
} ).catch( function () { /* silent — hero stays dark */ } );
}
/** Map article categories to a human-readable slide badge label.
* Priority order matters — more specific checks first. */
function portalSlideBadge( cats ) {
var names = cats.map( function ( c ) {
return c.title.replace( /^Category:/, '' ).toLowerCase();
} );
/* Exact/prefix matches in priority order */
var checks = [
/* Episodes first — most specific */
[ 'episode guide', 'Episode' ],
[ 'episodes by', 'Episode' ],
[ 'miniseries', 'Episode' ],
/* Ships / Craft */
[ 'colonial craft', 'Ship' ],
[ 'cylon craft', 'Ship' ],
[ 'ships (', 'Ship' ],
[ 'battlestars (', 'Ship' ],
[ 'vipers (', 'Ship' ],
[ 'raptors', 'Ship' ],
/* Characters */
[ 'main characters', 'Character' ],
[ 'recurring guest', 'Character' ],
[ 'one-shot characters', 'Character' ],
[ 'deceased characters', 'Character' ],
[ 'mentioned-only characters', 'Character' ],
[ 'characters (', 'Character' ],
/* Cylons (before military) */
[ 'cylons (', 'Cylon' ],
[ 'cylon military', 'Cylon' ],
[ 'cylon religion', 'Cylon' ],
/* Military */
[ 'colonial military', 'Military' ],
[ 'colonial warriors', 'Military' ],
[ 'gaeta mutineers', 'Military' ],
[ 'fighter squadrons', 'Military' ],
/* Places / Locations */
[ 'planets (', 'Planet' ],
[ 'moons (', 'Moon' ],
[ 'places (', 'Location' ],
[ 'galactica areas', 'Location' ],
[ 'places on ', 'Location' ],
[ 'basestar areas', 'Location' ],
[ 'twelve colonies', 'Location' ],
/* Society / Culture */
[ 'colonial religion', 'Religion' ],
[ 'colonial society', 'Society' ],
[ 'colonial government', 'Government' ],
[ 'colonial history', 'History' ],
[ 'organizations (', 'Organization' ],
[ "ha'la'tha", 'Organization' ],
[ 'soldiers of the one', 'Organization' ],
/* Technology */
[ 'technology (', 'Technology' ],
[ 'weapons (', 'Weapon' ],
[ 'drugs (', 'Technology' ],
/* Production / Real world */
[ 'cast (', 'Cast' ],
[ 'crew (', 'Crew' ],
[ 'directors (', 'Director' ],
[ 'writers (', 'Writer' ],
[ 'producers (', 'Producer' ],
/* Comics / Books */
[ 'dynamite comics', 'Comic' ],
[ 'comics (', 'Comic' ],
[ 'novels', 'Novel' ],
[ 'books (', 'Book' ],
/* Terminology */
[ 'terminology (', 'Term' ],
[ 'descriptive terms', 'Term' ]
];
for ( var i = 0; i < checks.length; i++ ) {
for ( var j = 0; j < names.length; j++ ) {
if ( names[j].indexOf( checks[i][0] ) !== -1 ) {
return checks[i][1];
}
}
}
return ''; /* empty = fall back to seriesBadge in caller */
}
/** Build and wire the carousel DOM — bsw-hero style */
function buildCarousel( hero, slides ) {
var current = 0;
var total = slides.length;
var timer;
var cat = ( hero.dataset.category || '' ).toUpperCase();
var seriesBadge = cat || 'BSG';
/* Rebuild hero using bsw-slide pattern — same as Main Page */
var slideHTML = '';
slides.forEach( function ( s, i ) {
var badge = s.badge || seriesBadge;
slideHTML +=
'<div class="portal-slide' + ( i === 0 ? ' portal-active' : '' ) + '">' +
'<div class="portal-slide-bg">' +
( s.thumb ? '<img src="' + esc( s.thumb ) + '" alt="" class="portal-slide-bg-blur" aria-hidden="true"><img src="' + esc( s.thumb ) + '" alt="" class="portal-slide-bg-img">' : '' ) +
'</div>' +
'<div class="portal-slide-overlay"></div>' +
'<div class="portal-slide-content">' +
( badge ? '<div class="portal-slide-badge"><span class="portal-slide-badge-dot"></span><span>' + esc( badge ) + '</span></div>' : '' ) +
'<div class="portal-slide-title"><a href="/' + encodeURIComponent( ( s.link || s.title ).replace( / /g, '_' ) ) + '">' + esc( s.title ) + '</a></div>' +
( s.caption && s.caption !== s.title ? '<div class="portal-slide-desc">' + esc( s.caption ) + '</div>' : '' ) +
'</div>' +
'</div>';
} );
/* Dots + nav — top-right like Main Page */
slideHTML +=
'<div class="portal-hero-dots">' +
slides.map( function ( _, i ) {
return '<span class="portal-hero-dot' + ( i === 0 ? ' is-active' : '' ) + '" role="button" tabindex="0"></span>';
} ).join( '' ) +
'</div>' +
'<div class="portal-hero-nav">' +
'<span class="portal-hero-btn" role="button" tabindex="0" id="portal-hero-prev">‹</span>' +
'<span class="portal-hero-btn" role="button" tabindex="0" id="portal-hero-next">›</span>' +
'</div>';
hero.innerHTML = slideHTML;
var slideEls = hero.querySelectorAll( '.portal-slide' );
var dotsEl = hero.querySelector( '.portal-hero-dots' );
var prevBtn = hero.querySelector( '#portal-hero-prev' );
var nextBtn = hero.querySelector( '#portal-hero-next' );
function goTo( n ) {
slideEls[ current ].classList.remove( 'portal-active' );
dotsEl.querySelectorAll( '.portal-hero-dot' )[ current ].classList.remove( 'is-active' );
current = ( n + total ) % total;
slideEls[ current ].classList.add( 'portal-active' );
dotsEl.querySelectorAll( '.portal-hero-dot' )[ current ].classList.add( 'is-active' );
resetTimer();
}
function resetTimer() {
clearInterval( timer );
timer = setInterval( function () { goTo( current + 1 ); }, 6000 );
}
if ( prevBtn ) {
prevBtn.addEventListener( 'click', function () { goTo( current - 1 ); } );
prevBtn.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current - 1 ); }
} );
}
if ( nextBtn ) {
nextBtn.addEventListener( 'click', function () { goTo( current + 1 ); } );
nextBtn.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); goTo( current + 1 ); }
} );
}
hero.addEventListener( 'mouseenter', function () { clearInterval( timer ); } );
hero.addEventListener( 'mouseleave', resetTimer );
/* Touch/swipe support */
var touchX = null;
hero.addEventListener( 'touchstart', function ( e ) {
touchX = e.touches[0].clientX;
}, { passive: true } );
hero.addEventListener( 'touchend', function ( e ) {
if ( touchX === null ) return;
var dx = e.changedTouches[0].clientX - touchX;
if ( Math.abs( dx ) > 40 ) goTo( dx < 0 ? current + 1 : current - 1 );
touchX = null;
}, { passive: true } );
goTo( 0 );
}
/* ════════════════════════════════════════════════════════════════
2. NEWEST ARTICLE (portal-scoped)
Same logevents call as the main page newest widget, but filtered
to the portal's category via a follow-up categorymembers check.
════════════════════════════════════════════════════════════════ */
function initPortalNewest( page ) {
var inner = page.querySelector( '.portal-newest-inner' );
var category = inner.dataset.category || '';
inner.innerHTML = '<span style="color:#aaa;font-size:0.8rem">Loading\u2026 (cat: ' + category + ')</span>';
apiGet( {
action: 'query',
list: 'recentchanges',
rctype: 'new',
rcnamespace: '0',
rclimit: '50',
rcprop: 'title|timestamp',
formatversion: '2'
} ).then( function ( data ) {
var rc = ( ( data.query || {} ).recentchanges || [] );
var entries = rc.filter( function ( e ) {
return e.title && e.title.indexOf( '/' ) === -1;
} );
var titles = entries.map( function ( e ) { return e.title; } );
var tsMap = {};
entries.forEach( function ( e ) { tsMap[ e.title ] = e.timestamp; } );
return apiGet( {
action: 'query',
titles: titles.slice( 0, 20 ).join( '|' ),
prop: 'categories',
clcategories: 'Category:' + category,
cllimit: '5',
formatversion: '2'
} ).then( function ( d ) {
var pages = ( d.query || {} ).pages || [];
if ( !Array.isArray( pages ) ) { pages = Object.values( pages ); }
var matched = pages.filter( function ( p ) {
return p.categories && p.categories.length > 0;
} ).sort( function ( a, b ) {
return titles.indexOf( a.title ) - titles.indexOf( b.title );
} );
var title = matched.length ? matched[0].title : entries[0].title;
var ts = tsMap[ title ];
return apiGet( {
action: 'query',
titles: title,
prop: 'extracts|pageimages',
exintro: '1',
exchars: '400',
exsectionformat: 'plain',
piprop: 'thumbnail',
pithumbsize: '300',
formatversion: '2'
} ).then( function ( d2 ) {
var pages2 = ( d2.query || {} ).pages || [];
if ( !Array.isArray( pages2 ) ) { pages2 = Object.values( pages2 ); }
return { page: pages2[0] || {}, ts: ts };
} );
} );
} ).then( function ( result ) {
renderPortalNewest( inner, result.page, result.ts );
} ).catch( function ( err ) {
console.warn( '[Portal] initPortalNewest error:', err );
} );
}
function renderPortalNewest( inner, page, ts ) {
var title = page.title || '';
var url = '/' + encodeURIComponent( title.replace( / /g, '_' ) );
var thumb = page.thumbnail ? page.thumbnail.source : '';
var extract = ( page.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 300 );
if ( extract.length === 300 ) extract = extract.replace( /\s\S+$/, '' ) + '\u2026';
var date = ts ? new Date( ts ).toLocaleDateString( 'en-US', { year: 'numeric', month: 'long', day: 'numeric' } ) : '';
/* Use bsw-fa-thumb matching Main Page newest article pattern */
var thumbHtml = thumb
? '<img src="' + esc( thumb ) + '" alt="' + esc( title ) + '" class="bsw-fa-thumb">'
: '';
inner.innerHTML =
thumbHtml +
'<div class="bsw-fa-title"><a href="' + esc( url ) + '">' + esc( title ) + '</a></div>' +
( date ? '<div class="bsw-newest-date">Created ' + esc( date ) + '</div>' : '' ) +
'<div class="bsw-fa-extract">' + esc( extract ) + '</div>' +
'<div style="margin-top:0.375rem"><a href="' + esc( url ) + '" style="font-size:0.75rem;color:var(--color-link)">Read more \u2192</a></div>' +
'</div>';
}
/* ════════════════════════════════════════════════════════════════
3. STATS BAR — live article count
The stat_count span with data-category gets its number filled
in from categoryinfo. All other stats are static wikitext.
════════════════════════════════════════════════════════════════ */
function initPortalStats( page ) {
var countEls = page.querySelectorAll( '.portal-stat-count[data-category]' );
if ( !countEls.length ) return;
/* Batch all stat categories into a single categoryinfo query */
var titles = countEls.length
? Array.prototype.slice.call( countEls ).map( function ( el ) {
return 'Category:' + el.dataset.category;
} )
: [];
apiGet( {
action: 'query',
titles: titles.join( '|' ),
prop: 'categoryinfo',
formatversion: '2'
} ).then( function ( data ) {
var pageMap = {};
( data.query.pages || [] ).forEach( function ( p ) {
pageMap[ p.title ] = p;
} );
countEls.forEach( function ( el ) {
var key = 'Category:' + el.dataset.category;
var p = pageMap[ key ];
if ( p && p.categoryinfo ) {
el.textContent = ( p.categoryinfo.pages || 0 ).toLocaleString();
} else {
el.textContent = '\u2014';
}
} );
} ).catch( function () {
countEls.forEach( function ( el ) { el.textContent = '\u2014'; } );
} );
}
/* ════════════════════════════════════════════════════════════════
4. ORPHANED ARTICLES — dynamic, filtered to portal category
Uses the MW API to find articles with no incoming links that
are also members of the portal's stat_category.
════════════════════════════════════════════════════════════════ */
function initPortalOrphans( page ) {
var widget = page.querySelector( '#portal-widget-orphans[data-category]' );
if ( !widget ) return;
var inner = widget.querySelector( '.portal-orphans-inner' );
var category = widget.dataset.category;
if ( !inner || !category ) return;
inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';
/* Fetch global lonely pages, then filter to those in this portal's category.
list=querypage is available to all users; qplimit caps at 10 for non-sysops
but we fetch 500 and let the server cap it — more matches for the category. */
apiGet( {
action: 'query',
list: 'querypage',
qppage: 'Lonelypages',
qplimit: '500'
} ).then( function ( data ) {
var lonely = ( ( data.query.querypage || {} ).results || [] )
.map( function ( r ) { return r.title; } );
if ( !lonely.length ) return [];
/* Cross-reference against the portal's category in batches of 50 */
var batch = lonely.slice( 0, 50 ).join( '|' );
return apiGet( {
action: 'query',
titles: batch,
prop: 'categories',
clcategories: 'Category:' + category,
cllimit: '1',
formatversion: '2'
} ).then( function ( d ) {
var orphans = [];
( d.query.pages || [] ).forEach( function ( p ) {
if ( p.missing ) return;
/* categories present = this page IS in the portal category */
if ( p.categories && p.categories.length > 0 ) {
orphans.push( p.title );
}
} );
return orphans;
} );
} ).then( function ( orphans ) {
if ( !orphans.length ) {
inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">No orphaned articles found.</span>';
return;
}
inner.innerHTML = orphans.map( function ( t ) {
var url = '/' + encodeURIComponent( t.replace( / /g, '_' ) );
return '<a href="' + esc( url ) + '" style="display:block;font-size:0.85em;padding:2px 0">' + esc( t ) + '</a>';
} ).join( '' );
} ).catch( function () {
inner.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load orphaned articles.</span>';
} );
}
/* ════════════════════════════════════════════════════════════════
5. FEATURED EPISODE SLIDER
Reads a pipe-separated list of episode titles from the widget's
data-episodes attribute (one per season), fetches extract +
thumbnail for each, and builds a carousel.
════════════════════════════════════════════════════════════════ */
function initPortalFeatured( page ) {
var widget = page.querySelector( '#portal-widget-featured[data-episodes]' );
if ( !widget ) return;
var body = widget.querySelector( '.portal-featured-body' );
var dotsEl = widget.querySelector( '.portal-featured-dots' );
var prevBtn = widget.querySelector( '.portal-featured-prev' );
var nextBtn = widget.querySelector( '.portal-featured-next' );
var episodes = ( widget.dataset.episodes || '' ).split( ';' ).map( function ( s ) { return s.trim(); } ).filter( Boolean );
if ( !episodes.length || !body ) return;
body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Loading…</span>';
apiGet( {
action: 'query',
titles: episodes.join( '|' ),
prop: 'extracts|pageimages',
exintro: '1',
exchars: '500',
exsectionformat: 'plain',
piprop: 'thumbnail',
pithumbsize: '400',
formatversion: '2'
} ).then( function ( data ) {
var pageMap = {};
( data.query.pages || [] ).forEach( function ( p ) { pageMap[ p.title ] = p; } );
/* Preserve the requested episode order */
var slides = episodes.map( function ( t ) {
var p = pageMap[ t ] || {};
return {
title: t,
extract: ( p.extract || '' ).replace( /<[^>]+>/g, '' ).slice( 0, 400 ),
thumb: p.thumbnail ? p.thumbnail.source : '',
url: '/' + encodeURIComponent( t.replace( / /g, '_' ) )
};
} );
buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides );
} ).catch( function () {
body.innerHTML = '<span style="font-size:0.8em;color:var(--color-subtle,#888)">Could not load featured episodes.</span>';
} );
}
function buildFeaturedCarousel( body, dotsEl, prevBtn, nextBtn, slides ) {
var current = 0;
var total = slides.length;
function render( n ) {
current = ( n + total ) % total;
var s = slides[ current ];
var thumbHtml = s.thumb
? '<img src="' + esc( s.thumb ) + '" alt="' + esc( s.title ) + '" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:var(--border-radius-medium,6px);display:block;margin-bottom:10px">'
: '';
var extract = s.extract;
if ( extract.length >= 400 ) extract = extract.replace( /\s\S+$/, '' ) + '…';
body.innerHTML =
'<a href="' + esc( s.url ) + '" style="text-decoration:none;display:block">' + thumbHtml + '</a>' +
'<div style="font-weight:600;margin-bottom:4px"><a href="' + esc( s.url ) + '">' + esc( s.title ) + '</a></div>' +
'<div style="font-size:0.825em;color:var(--color-base--subtle,#888);line-height:1.5">' + esc( extract ) + '</div>' +
'<div style="margin-top:6px"><a href="' + esc( s.url ) + '" style="font-size:0.75rem">Read more →</a></div>';
if ( dotsEl ) {
dotsEl.querySelectorAll( '.portal-featured-dot' ).forEach( function ( d, i ) {
d.classList.toggle( 'is-active', i === current );
} );
}
}
/* Build dots */
if ( dotsEl ) {
dotsEl.innerHTML = '';
slides.forEach( function ( _, i ) {
var dot = document.createElement( 'span' );
dot.className = 'portal-featured-dot' + ( i === 0 ? ' is-active' : '' );
dot.setAttribute( 'role', 'button' );
dot.setAttribute( 'tabindex', '0' );
dot.title = 'Season ' + ( i + 1 );
dot.addEventListener( 'click', function () { render( i ); } );
dotsEl.appendChild( dot );
} );
}
if ( prevBtn ) {
prevBtn.addEventListener( 'click', function () { render( current - 1 ); } );
prevBtn.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current - 1 ); }
} );
}
if ( nextBtn ) {
nextBtn.addEventListener( 'click', function () { render( current + 1 ); } );
nextBtn.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); render( current + 1 ); }
} );
}
render( 0 );
}
/* ── Seeded shuffle (stable within a calendar day) ────────────── */
function seededShuffle( arr, seed ) {
var a = arr.slice();
var s = seed;
for ( var i = a.length - 1; i > 0; i-- ) {
/* Xorshift32 */
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
var j = ( ( s >>> 0 ) % ( i + 1 ) );
var t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
/* ════════════════════════════════════════════════════════════════
6. EPISODE GUIDE SLIDERS
Each .portal-epguide-slider[data-titles] gets a mini carousel
showing episode screencaps fetched from the media wiki.
════════════════════════════════════════════════════════════════ */
}() );