Jump to content

MediaWiki:Common.js: Difference between revisions

From SankoQuest Wiki
m Seed Sanko wiki page
 
m Seed Sanko wiki page
 
(2 intermediate revisions by the same user not shown)
Line 53: Line 53:
       returntoquery: returnToQuery
       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&section=' + (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);
      });
    }
   }
   }


Line 64: Line 163:
       node.remove();
       node.remove();
     });
     });
    // Header nav: keep it short as a compact "Log in" button. The full "Log in with Privy"
    // wording still shows on the login page CTA itself (loginButton above).
     document.querySelectorAll('#pt-login a span:last-child, #pt-login-2 a span:last-child').forEach(function (node) {
     document.querySelectorAll('#pt-login a span:last-child, #pt-login-2 a span:last-child').forEach(function (node) {
       node.textContent = 'Log in with Privy';
       node.textContent = 'Log in';
     });
     });
   }
   }
Line 189: Line 290:
   });
   });


   // Bounties are a wiki/Cargo feature (see Template:BountyPrompt + SankoQuest:Open bounties).
   // Bounties are a wiki/Cargo feature (see Template:BountyPrompt + SankoQuest:Open bounties):
   // There is no client-side bounty rendering or claim flow — the engine bounty routes are parked.
   // pages are tagged in wikitext and listed via Cargo. Nothing bounty-related renders from here.


   function engineBase() {
   function engineBase() {
Line 212: Line 313:
     return fetch(url, options || {}).then(function (response) {
     return fetch(url, options || {}).then(function (response) {
       if (!response.ok) {
       if (!response.ok) {
         throw new Error('Sanko engine request failed: ' + response.status);
         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();
       return response.json();
Line 252: Line 360:
       }[char];
       }[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('');
   }
   }


Line 267: Line 389:
       subject;
       subject;
     var actions = [];
     var actions = [];
     if (targetSubject && targetSubject !== subject && hasWriteAuth(subject)) {
    // 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(
       actions.push(
         '<button class="sanko-button" type="button" data-sanko-tip-subject="' +
         '<button class="sanko-button" type="button" data-sanko-tip-subject="' +
Line 286: Line 410:
       pill('Renown ' + (stats.renown || 0)),
       pill('Renown ' + (stats.renown || 0)),
       pill(titles),
       pill(titles),
       linked ? pill('CHAR #' + account.charTokenIds.join(', #')) : pill('Link a CHAR to unlock badges'),
       linked ? charChips(account.charTokenIds) : pill('Link a CHAR to unlock badges'),
       '</div>',
       '</div>',
       '</div>',
       '</div>',
Line 420: Line 544:
         button.disabled = false;
         button.disabled = false;
         button.textContent = 'Link failed';
         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';
        }
       });
       });
   }
   }
Line 427: Line 587:
       return linkFixtureChar(button);
       return linkFixtureChar(button);
     }
     }
     return linkWalletChar(button);
     return linkFromIdentity(button);
   }
   }


Line 454: Line 614:
         button.textContent = 'Tip failed';
         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 = (cfg.label ? cfg.label + ' ' : '') + 'keel — ' + (cfg.tagline || '');
    var labelHtml = cfg.label
      ? '<span class="sanko-sponsor-label">' + escapeHtml(cfg.label) + '</span>'
      : '';
    link.innerHTML = labelHtml + SPONSOR_MARK +
      '<span class="sanko-sponsor-word">keel</span>' +
      '<span class="sanko-sponsor-tagline">' + escapeHtml(cfg.tagline || '') + '</span>';
    // Sit past the search box but immediately to the left of the login controls. Find the
    // login/user-links element, walk up to whichever node is a direct child of header-end, and
    // insert before it; fall back to the far right if the login chrome isn't present.
    var loginEl = headerEnd.querySelector('.vector-user-links, #pt-login-2, #pt-login, .vector-user-menu');
    var anchor = loginEl;
    while (anchor && anchor.parentNode !== headerEnd) {
      anchor = anchor.parentNode;
    }
    if (anchor && anchor.parentNode === headerEnd) {
      headerEnd.insertBefore(link, anchor);
    } else {
      headerEnd.appendChild(link);
    }
   }
   }


Line 471: Line 681:
       redirectCreateAccountToPrivy();
       redirectCreateAccountToPrivy();
       polishLoginChrome();
       polishLoginChrome();
      enhanceEditForAnon();
       renderModelImages();
       renderModelImages();
       enhanceSearchChrome();
       enhanceSearchChrome();
      renderSponsorBadge();
       loadAccount();
       loadAccount();
     });
     });
Line 478: Line 690:
     redirectCreateAccountToPrivy();
     redirectCreateAccountToPrivy();
     polishLoginChrome();
     polishLoginChrome();
    enhanceEditForAnon();
     renderModelImages();
     renderModelImages();
     enhanceSearchChrome();
     enhanceSearchChrome();
    renderSponsorBadge();
     loadAccount();
     loadAccount();
   }
   }
}());
}());

Latest revision as of 20:42, 2 July 2026

(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&section=' + (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();
    });
    // Header nav: keep it short as a compact "Log in" button. The full "Log in with Privy"
    // wording still shows on the login page CTA itself (loginButton above).
    document.querySelectorAll('#pt-login a span:last-child, #pt-login-2 a span:last-child').forEach(function (node) {
      node.textContent = 'Log in';
    });
  }

  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 {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#39;'
      }[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 = (cfg.label ? cfg.label + ' ' : '') + 'keel — ' + (cfg.tagline || '');
    var labelHtml = cfg.label
      ? '<span class="sanko-sponsor-label">' + escapeHtml(cfg.label) + '</span>'
      : '';
    link.innerHTML = labelHtml + SPONSOR_MARK +
      '<span class="sanko-sponsor-word">keel</span>' +
      '<span class="sanko-sponsor-tagline">' + escapeHtml(cfg.tagline || '') + '</span>';
    // Sit past the search box but immediately to the left of the login controls. Find the
    // login/user-links element, walk up to whichever node is a direct child of header-end, and
    // insert before it; fall back to the far right if the login chrome isn't present.
    var loginEl = headerEnd.querySelector('.vector-user-links, #pt-login-2, #pt-login, .vector-user-menu');
    var anchor = loginEl;
    while (anchor && anchor.parentNode !== headerEnd) {
      anchor = anchor.parentNode;
    }
    if (anchor && anchor.parentNode === headerEnd) {
      headerEnd.insertBefore(link, anchor);
    } else {
      headerEnd.appendChild(link);
    }
  }

  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();
  }
}());