MediaWiki:Common.js
Appearance
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
(function () {
var tip;
function showTip(target, event) {
var title = target.getAttribute('data-minetip-title');
if (!title) {
return;
}
if (!tip) {
tip = document.createElement('div');
tip.className = 'sanko-minetip';
document.body.appendChild(tip);
}
tip.textContent = title;
tip.style.display = 'block';
moveTip(event);
}
function moveTip(event) {
if (!tip) {
return;
}
tip.style.left = event.clientX + 14 + 'px';
tip.style.top = event.clientY + 14 + 'px';
}
function hideTip() {
if (tip) {
tip.style.display = 'none';
}
}
function wikiScriptUrl(title, params) {
var url = new URL('/w/index.php', window.location.origin);
url.searchParams.set('title', title);
Object.keys(params || {}).forEach(function (key) {
if (params[key]) {
url.searchParams.set(key, params[key]);
}
});
return url.toString();
}
function redirectCreateAccountToPrivy() {
if (!window.mw || !mw.config || mw.config.get('wgCanonicalSpecialPageName') !== 'CreateAccount') {
return;
}
var params = new URLSearchParams(window.location.search);
var returnTo = params.get('returnto') || 'Main Page';
var returnToQuery = params.get('returntoquery') || '';
window.location.replace(wikiScriptUrl('Special:UserLogin', {
returnto: returnTo,
returntoquery: returnToQuery
}));
}
function enhanceEditForAnon() {
if (!window.mw || !mw.config) {
return;
}
var loggedIn = mw.user && typeof mw.user.isAnon === 'function'
? !mw.user.isAnon()
: Boolean(mw.config.get('wgUserName'));
if (loggedIn) {
return;
}
var namespace = mw.config.get('wgNamespaceNumber');
if (typeof namespace === 'number' && namespace < 0) {
return; // Special/virtual pages have nothing to edit.
}
var loginHref = wikiScriptUrl('Special:UserLogin', {
returnto: mw.config.get('wgPageName'),
returntoquery: 'action=edit'
});
// 1) Always surface a clear "Edit" tab. Logged-out users get sent through
// Privy login and returned to the page in edit mode (no anon editing).
var viewSource = document.getElementById('ca-viewsource');
if (viewSource) {
var vsLink = viewSource.querySelector('a');
if (vsLink) {
vsLink.setAttribute('href', loginHref);
vsLink.setAttribute('title', 'Log in to edit this page');
var vsLabel = vsLink.querySelector('span') || vsLink;
vsLabel.textContent = 'Edit';
}
} else if (!document.getElementById('ca-edit') && mw.util && mw.util.addPortletLink) {
mw.util.addPortletLink('p-views', loginHref, 'Edit', 'ca-edit', 'Log in to edit this page');
}
// 2) On the edit / view-source page itself, add an explicit login call to action.
var action = mw.config.get('wgAction');
if ((action === 'edit' || action === 'submit') && !document.getElementById('sanko-edit-login-cta')) {
var content = document.getElementById('mw-content-text');
if (content) {
var cta = document.createElement('div');
cta.id = 'sanko-edit-login-cta';
cta.className = 'sanko-edit-login-cta';
var copy = document.createElement('div');
var heading = document.createElement('strong');
heading.textContent = 'Log in to edit this page';
var detail = document.createElement('p');
detail.className = 'sanko-muted';
detail.textContent = "Editing the wiki needs a free account. Sign in with Privy and you'll come straight back here to edit.";
copy.appendChild(heading);
copy.appendChild(detail);
var loginLink = document.createElement('a');
loginLink.className = 'sanko-button';
loginLink.setAttribute('href', loginHref);
loginLink.textContent = 'Log in with Privy';
cta.appendChild(copy);
cta.appendChild(loginLink);
content.insertBefore(cta, content.firstChild);
}
}
// 3) Section-level [edit] links. MediaWiki omits these for users without edit
// rights and no config restores them (even Extension:Unregistered Edit Links
// skips sections), so add native-styled links next to each heading on the
// article view — each routes through login and back to that section's editor.
if (action === 'view') {
var sectionRoot = document.getElementById('mw-content-text');
var headlines = sectionRoot ? sectionRoot.querySelectorAll('.mw-headline') : [];
Array.prototype.forEach.call(headlines, function (headline, index) {
var heading = headline.parentNode;
if (!heading || heading.querySelector('.mw-editsection')) {
return;
}
var sectionHref = wikiScriptUrl('Special:UserLogin', {
returnto: mw.config.get('wgPageName'),
returntoquery: 'action=edit§ion=' + (index + 1)
});
var wrap = document.createElement('span');
wrap.className = 'mw-editsection';
var open = document.createElement('span');
open.className = 'mw-editsection-bracket';
open.textContent = '[';
var sectionLink = document.createElement('a');
sectionLink.setAttribute('href', sectionHref);
sectionLink.setAttribute('title', 'Log in to edit this section');
sectionLink.textContent = 'edit';
var close = document.createElement('span');
close.className = 'mw-editsection-bracket';
close.textContent = ']';
wrap.appendChild(open);
wrap.appendChild(sectionLink);
wrap.appendChild(close);
heading.appendChild(wrap);
});
}
}
function polishLoginChrome() {
var loginButton = document.getElementById('mw-input-pluggableauthlogin0');
if (loginButton) {
loginButton.textContent = 'Log in with Privy';
loginButton.value = 'Log in with Privy';
}
document.querySelectorAll('#pt-createaccount, #pt-createaccount-2, #mw-createaccount-cta').forEach(function (node) {
node.remove();
});
document.querySelectorAll('#pt-login a span:last-child, #pt-login-2 a span:last-child').forEach(function (node) {
node.textContent = 'Log in with Privy';
});
}
var itemThumbsPromise;
function loadItemThumbnails() {
if (!itemThumbsPromise) {
itemThumbsPromise = fetch('/w/sanko-assets/sanko/item-thumbnails.json', {
headers: { Accept: 'application/json' }
})
.then(function (response) { return response.ok ? response.json() : {}; })
.catch(function () { return {}; });
}
return itemThumbsPromise;
}
function itemThumbnailFor(title, thumbnails) {
var normalized = String(title || '').replace(/_/g, ' ').trim();
return thumbnails[normalized] || '';
}
function imageUrl(relativePath) {
return '/w/sanko-assets/' + String(relativePath || '').replace(/^\/+/, '');
}
function renderModelImages() {
document.querySelectorAll('.sanko-model-render[data-sanko-render-image], .sanko-row-render[data-sanko-render-image]').forEach(function (container) {
if (container.querySelector('img')) {
return;
}
var relativePath = container.getAttribute('data-sanko-render-image');
if (!relativePath) {
return;
}
var img = document.createElement('img');
img.src = imageUrl(relativePath);
img.alt = container.getAttribute('data-sanko-render-alt') || '';
img.loading = 'lazy';
container.appendChild(img);
});
}
function addSearchThumb(container, title, href, thumbnails) {
if (!container || container.querySelector('.sanko-search-thumb')) {
return;
}
var relativePath = itemThumbnailFor(title, thumbnails);
if (!relativePath) {
return;
}
var link = document.createElement(href ? 'a' : 'span');
link.className = 'sanko-search-thumb';
if (href) {
link.href = href;
}
var img = document.createElement('img');
img.src = imageUrl(relativePath);
img.alt = '';
img.loading = 'lazy';
img.onerror = function () {
link.remove();
if (!container.querySelector('.sanko-search-thumb')) {
container.classList.remove('sanko-search-with-thumb');
}
};
link.appendChild(img);
container.insertBefore(link, container.firstChild);
container.classList.add('sanko-search-with-thumb');
}
function enhanceSearchResults(thumbnails) {
document.querySelectorAll('.mw-search-results .mw-search-result').forEach(function (result) {
var heading = result.querySelector('.mw-search-result-heading a');
if (!heading) {
return;
}
addSearchThumb(result, heading.textContent, heading.href, thumbnails);
});
}
function suggestionTitle(node) {
var label = node.querySelector('.cdx-menu-item__text__label, .cdx-menu-item__content, .cdx-typeahead-search__search-footer__text');
return (label || node).textContent || '';
}
function enhanceSearchSuggestions(thumbnails) {
document.querySelectorAll('.cdx-typeahead-search__menu .cdx-menu-item, .cdx-menu__listbox .cdx-menu-item').forEach(function (item) {
if (item.querySelector('.sanko-search-thumb')) {
return;
}
addSearchThumb(item, suggestionTitle(item), null, thumbnails);
});
}
function enhanceSearchChrome() {
if (!document.querySelector('.mw-search-results, .cdx-typeahead-search, #p-search')) {
return;
}
loadItemThumbnails().then(function (thumbnails) {
enhanceSearchResults(thumbnails);
enhanceSearchSuggestions(thumbnails);
var observer = new MutationObserver(function () {
enhanceSearchSuggestions(thumbnails);
});
var searchRoot = document.querySelector('.cdx-typeahead-search, #p-search') || document.body;
observer.observe(searchRoot, { childList: true, subtree: true });
});
}
document.addEventListener('mouseover', function (event) {
var target = event.target.closest('[data-minetip-title]');
if (target) {
showTip(target, event);
}
});
document.addEventListener('mousemove', moveTip);
document.addEventListener('mouseout', function (event) {
if (event.target.closest('[data-minetip-title]')) {
hideTip();
}
});
// Bounties are a wiki/Cargo feature (see Template:BountyPrompt + SankoQuest:Open bounties):
// pages are tagged in wikitext and listed via Cargo. Nothing bounty-related renders from here.
function engineBase() {
return (window.sankoEngineApiBase || 'https://api.sankoquest.wiki').replace(/\/$/, '');
}
function currentSubject() {
if (window.sankoEngineSubject) {
return window.sankoEngineSubject;
}
if (window.sankoFixtureSubject) {
return window.sankoFixtureSubject;
}
if (window.mw && mw.config && mw.config.get('wgUserName')) {
return 'did:privy:' + String(mw.config.get('wgUserName')).toLowerCase().replace(/[^a-z0-9]+/g, '-');
}
return '';
}
function jsonFetch(url, options) {
return fetch(url, options || {}).then(function (response) {
if (!response.ok) {
return response.json().catch(function () {
return {};
}).then(function (body) {
var error = new Error('Sanko engine request failed: ' + response.status);
error.status = response.status;
error.code = body && body.error;
throw error;
});
}
return response.json();
});
}
function authHeaders(subject, includeJson) {
var headers = includeJson ? { 'content-type': 'application/json' } : {};
if (window.sankoPrivyIdentityToken) {
headers.authorization = 'Bearer ' + window.sankoPrivyIdentityToken;
} else if (window.sankoWikiAuthToken) {
headers.authorization = 'Bearer ' + window.sankoWikiAuthToken;
} else if (window.sankoEnableFixtureAuth === true && subject) {
headers['x-sanko-sub'] = subject;
}
return headers;
}
function hasWriteAuth(subject) {
return Boolean(
window.sankoPrivyIdentityToken ||
window.sankoWikiAuthToken ||
(window.sankoEnableFixtureAuth === true && subject)
);
}
function pill(text) {
return '<span class="sanko-pill">' + escapeHtml(text) + '</span>';
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, function (char) {
return {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[char];
});
}
var CHAR_CONTRACT = '0xAA2F665a011d404d71e8f49d4d09f7006C49aC5c';
function charChips(tokenIds) {
return (tokenIds || []).map(function (tokenId) {
var id = String(tokenId).replace(/[^0-9]/g, '');
if (!id) {
return '';
}
var href = 'https://arbiscan.io/token/' + CHAR_CONTRACT + '?a=' + id;
return '<a class="sanko-pill sanko-char-chip" href="' + href +
'" target="_blank" rel="noopener noreferrer">CHAR #' + escapeHtml(id) + '</a>';
}).join('');
}
function renderAccount(root, profile) {
var account = profile.account || {};
var stats = profile.stats || {};
var name = account.charName || account.displayName || account.handle || 'Unlinked adventurer';
var linked = account.linkedSankoAddress && account.charTokenIds && account.charTokenIds.length;
var titles = stats.titles && stats.titles.length ? stats.titles.join(', ') : 'New Chronicler';
var subject = currentSubject();
var targetSubject =
root.getAttribute('data-tip-target-subject') ||
window.sankoTipTargetSubject ||
account.subject ||
subject;
var actions = [];
// Tipping stays hidden until the GOLD transfer path is live (window.sankoTipsEnabled,
// injected server-side from MW_SANKO_TIPS_ENABLED). The engine also 404s /v1/tips while off.
if (window.sankoTipsEnabled === true && targetSubject && targetSubject !== subject && hasWriteAuth(subject)) {
actions.push(
'<button class="sanko-button" type="button" data-sanko-tip-subject="' +
escapeHtml(targetSubject) + '">Tip 25 GOLD</button>'
);
}
actions.push(
linked ? '<button class="sanko-button" type="button" disabled>Linked</button>' :
'<button class="sanko-button" type="button" data-sanko-link-char>Link Sanko Character</button>'
);
root.innerHTML = [
'<div class="sanko-account-card">',
'<div>',
'<div class="sanko-kicker">Adventurer\'s Log</div>',
'<div class="sanko-account-title">' + escapeHtml(name) + '</div>',
'<div class="sanko-muted">' + (linked ? 'CHAR linked' : 'No Sanko Character linked yet') + '</div>',
'<div class="sanko-account-meta">',
pill('Renown ' + (stats.renown || 0)),
pill(titles),
linked ? charChips(account.charTokenIds) : pill('Link a CHAR to unlock badges'),
'</div>',
'</div>',
'<div class="sanko-account-actions">' + actions.join('') + '</div>',
'</div>'
].join('');
}
function loadAccount() {
var root = document.querySelector('.sanko-account-panel[data-live="true"]');
if (!root || !window.fetch) {
return;
}
var subject = currentSubject();
if (!subject) {
root.innerHTML = '<div class="sanko-account-card sanko-muted">Log in to view your Adventurer\'s Log.</div>';
return;
}
var base = engineBase();
jsonFetch(base + '/v1/profiles/' + encodeURIComponent(subject))
.catch(function () {
if (!hasWriteAuth(subject)) {
throw new Error('Sanko write auth unavailable.');
}
return jsonFetch(base + '/v1/me', { headers: authHeaders(subject, false) }).then(function () {
return jsonFetch(base + '/v1/profiles/' + encodeURIComponent(subject));
});
})
.then(function (profile) {
renderAccount(root, profile);
})
.catch(function () {
root.innerHTML = '<div class="sanko-account-card sanko-muted">Adventurer profile is unavailable.</div>';
});
}
function sha256Hex(text) {
if (!window.crypto || !window.crypto.subtle || !window.TextEncoder) {
return Promise.reject(new Error('Web Crypto is unavailable.'));
}
return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)).then(function (buffer) {
return Array.prototype.map.call(new Uint8Array(buffer), function (byte) {
return byte.toString(16).padStart(2, '0');
}).join('');
});
}
function linkFixtureChar(button) {
var subject = currentSubject();
var base = engineBase();
var address = window.sankoFixtureCharAddress || '0x2222222222222222222222222222222222222222';
if (!subject || !button || window.sankoEnableFixtureCharLink !== true) {
return;
}
button.disabled = true;
button.textContent = 'Linking...';
jsonFetch(base + '/v1/char-link/nonce', {
method: 'POST',
headers: authHeaders(subject, true),
body: JSON.stringify({ domain: 'sankoquest.wiki', linkedSankoAddress: address })
})
.then(function (nonce) {
return sha256Hex(nonce.message + ':' + address.toLowerCase()).then(function (hash) {
return jsonFetch(base + '/v1/char-link/verify', {
method: 'POST',
headers: authHeaders(subject, true),
body: JSON.stringify({
nonce: nonce.nonce,
linkedSankoAddress: address,
charTokenIds: [384, 7],
charName: 'Wiki-local Chronicler',
signature: 'fixture:' + hash
})
});
});
})
.then(loadAccount)
.catch(function () {
button.disabled = false;
button.textContent = 'Link failed';
});
}
function signPersonalMessage(address, message) {
return window.ethereum.request({ method: 'personal_sign', params: [message, address] })
.catch(function () {
return window.ethereum.request({ method: 'personal_sign', params: [address, message] });
});
}
function linkWalletChar(button) {
var subject = currentSubject();
var base = engineBase();
if (!subject || !button) {
return;
}
if (!hasWriteAuth(subject)) {
button.textContent = 'Sign in with Privy first';
return;
}
if (!window.ethereum || !window.ethereum.request) {
button.textContent = 'Wallet unavailable';
return;
}
button.disabled = true;
button.textContent = 'Waiting for wallet...';
window.ethereum.request({ method: 'eth_requestAccounts' })
.then(function (accounts) {
var address = String(accounts && accounts[0] ? accounts[0] : '').toLowerCase();
if (!/^0x[0-9a-f]{40}$/.test(address)) {
throw new Error('No EVM wallet address returned.');
}
return jsonFetch(base + '/v1/char-link/nonce', {
method: 'POST',
headers: authHeaders(subject, true),
body: JSON.stringify({ domain: location.hostname || 'sankoquest.wiki', linkedSankoAddress: address })
}).then(function (nonce) {
return signPersonalMessage(address, nonce.message).then(function (signature) {
return jsonFetch(base + '/v1/char-link/verify', {
method: 'POST',
headers: authHeaders(subject, true),
body: JSON.stringify({
nonce: nonce.nonce,
linkedSankoAddress: address,
signature: signature
})
});
});
});
})
.then(loadAccount)
.catch(function () {
button.disabled = false;
button.textContent = 'Link failed';
});
}
function linkFromIdentity(button) {
var subject = currentSubject();
var base = engineBase();
if (!subject || !button) {
return;
}
if (!hasWriteAuth(subject)) {
button.textContent = 'Sign in with Privy first';
return;
}
button.disabled = true;
button.textContent = 'Linking...';
return jsonFetch(base + '/v1/char-link/from-identity', {
method: 'POST',
headers: authHeaders(subject, true)
})
.then(loadAccount)
.catch(function (error) {
var code = error && error.code;
var walletCapable = window.ethereum && window.ethereum.request;
// CHAR lives in a wallet Privy doesn't know about — prove control of it
// with a signature, if the browser has an injected wallet to sign with.
if ((code === 'char_not_found' || code === 'wallet_not_in_identity') && walletCapable) {
return linkWalletChar(button);
}
button.disabled = false;
if (code === 'char_not_found') {
button.textContent = 'No CHAR in your wallet';
} else if (code === 'wallet_not_in_identity') {
button.textContent = 'Connect a wallet in Privy';
} else {
button.textContent = 'Link failed';
}
});
}
function linkSankoChar(button) {
if (window.sankoEnableFixtureCharLink === true) {
return linkFixtureChar(button);
}
return linkFromIdentity(button);
}
function tipAdventurer(button) {
var subject = currentSubject();
var targetSubject = button && button.getAttribute('data-sanko-tip-subject');
if (!targetSubject || !subject || !hasWriteAuth(subject)) {
return;
}
button.disabled = true;
button.textContent = 'Tipping...';
jsonFetch(engineBase() + '/v1/tips', {
method: 'POST',
headers: authHeaders(subject, true),
body: JSON.stringify({
toSubject: targetSubject,
amount: 25,
token: 'GOLD'
})
})
.then(function () {
button.textContent = 'Tipped';
})
.catch(function () {
button.disabled = false;
button.textContent = 'Tip failed';
});
}
// Keel "sail" mark — kept in sync with the server-rendered footer copy in LocalSettings.php.
// currentColor lets it inherit the theme so it reads light/gold on the dark header.
var SPONSOR_MARK =
'<svg class="sanko-sponsor-mark" viewBox="0 0 40 40" aria-hidden="true" focusable="false">' +
'<path d="M20 33 L20 2 L33 25 Z" fill="currentColor" opacity="0.55"/>' +
'<path d="M18 9 L8 27 L18 27 Z" fill="currentColor" opacity="0.32"/>' +
'<path d="M5 33 C10 33 16 37 22 37 C28 37 33 33 38 33" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" opacity="0.75"/>' +
'</svg>';
// Vector 2022 has no server-side header slot, so the sponsor badge is placed here, into
// .vector-header-end. window.sankoSponsor is injected server-side (LocalSettings.php) and
// gated by MW_SANKO_SPONSOR_ENABLED, so this no-ops when the sponsor is turned off.
function renderSponsorBadge() {
var cfg = window.sankoSponsor;
if (!cfg || !cfg.url || document.getElementById('sanko-sponsor-header')) {
return;
}
var headerEnd = document.querySelector('.vector-header-end');
if (!headerEnd) {
return;
}
var link = document.createElement('a');
link.id = 'sanko-sponsor-header';
link.className = 'sanko-sponsor sanko-sponsor-header';
link.href = cfg.url.replace(/\/$/, '') +
'?utm_source=sankoquest.wiki&utm_medium=wiki-header&utm_campaign=sanko-sponsor';
link.target = '_blank';
link.rel = 'noopener';
link.title = 'keel — ' + (cfg.tagline || '');
link.innerHTML = SPONSOR_MARK +
'<span class="sanko-sponsor-word">keel</span>' +
'<span class="sanko-sponsor-tagline">' + escapeHtml(cfg.tagline || '') + '</span>';
headerEnd.insertBefore(link, headerEnd.firstChild);
}
document.addEventListener('click', function (event) {
var button = event.target.closest('[data-sanko-link-char]');
if (button) {
linkSankoChar(button);
}
var tipButton = event.target.closest('[data-sanko-tip-subject]');
if (tipButton) {
tipAdventurer(tipButton);
}
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
redirectCreateAccountToPrivy();
polishLoginChrome();
enhanceEditForAnon();
renderModelImages();
enhanceSearchChrome();
renderSponsorBadge();
loadAccount();
});
} else {
redirectCreateAccountToPrivy();
polishLoginChrome();
enhanceEditForAnon();
renderModelImages();
enhanceSearchChrome();
renderSponsorBadge();
loadAccount();
}
}());