Danbooru Artist Feed

Follow artists and view their latest posts — cached, on-demand paging, native layout

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         Danbooru Artist Feed
// @namespace    danbooru-artist-feed
// @version      2.0
// @description  Follow artists and view their latest posts — cached, on-demand paging, native layout
// @match        *://danbooru.donmai.us/*
// @match        *://safebooru.donmai.us/*
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  /* Hard-loading /posts#/following used to flash the native Posts page before
     renderFeed() swapped in our UI (script ran at document-idle, after paint).
     Now we run at document-start and, if the URL is already the feed, hide the
     native #content immediately so it never appears. renderFeed() reveals it
     again the instant our skeleton is in place. */
  if (location.hash.indexOf('#/following') === 0) {
    var _preHide = document.createElement('style');
    _preHide.id = 'af-preload-hide';
    _preHide.textContent = '#content{visibility:hidden !important}';
    (document.head || document.documentElement).appendChild(_preHide);
  }

  /* ========== CONFIG ========== */
  var PER_PAGE       = 40;
  var PAGE_SIZE      = 20;
  var CONCUR         = 5;
  var DELAY          = 300;
  var PREFETCH_PAGES = 10;

  /* Auto-refresh settings (stored in GM) */
  function getAutoRefresh() {
    return JSON.parse(GM_getValue('af_auto_refresh', '{"enabled":true,"hours":24}'));
  }
  function saveAutoRefresh(cfg) {
    GM_setValue('af_auto_refresh', JSON.stringify(cfg));
  }

  /* ========== Storage — Artists ========== */
  /* In-memory cache: parse followed_artists once, keep an array + Set so
     isFollowed() is O(1) and injectFollowButtons() doesn't re-parse JSON per tag. */
  var _artistsArr = null, _followedSet = null;
  function _loadArtists() {
    if (_artistsArr) return;
    try { _artistsArr = JSON.parse(GM_getValue('followed_artists', '[]')); }
    catch (e) { _artistsArr = []; }
    if (!Array.isArray(_artistsArr)) _artistsArr = [];
    _followedSet = new Set(_artistsArr);
  }
  var getArtists  = function () { _loadArtists(); return _artistsArr.slice(); };
  var saveArtists = function (list) {
    _artistsArr = [...new Set(list)];
    _followedSet = new Set(_artistsArr);
    GM_setValue('followed_artists', JSON.stringify(_artistsArr));
  };
  var isFollowed  = function (tag) { _loadArtists(); return _followedSet.has(tag); };

  function toggleFollow(tag) {
    _loadArtists();
    if (_followedSet.has(tag)) { saveArtists(_artistsArr.filter(function (t) { return t !== tag; })); return false; }
    saveArtists(_artistsArr.concat([tag])); return true;
  }

  /* ========== Storage — Cache ========== */
  function loadCache() {
    try { return JSON.parse(GM_getValue('feed_cache', 'null')); }
    catch (e) { return null; }
  }

  function saveCache(posts, ts) {
    var slim = posts.map(function (p) {
      return {
        id: p.id, created_at: p.created_at,
        preview_file_url: p.preview_file_url,
        tag_string_artist: p.tag_string_artist || '',
        _v: p._v || extractVariants(p)
      };
    });
    GM_setValue('feed_cache', JSON.stringify({ ts: ts || Date.now(), posts: slim }));
  }

  /* Debounced cache writer — full JSON.stringify of a 40k-post array is 8-12MB
     and blocks the main thread, so coalesce bursty writes (paging, incremental
     merge) into one idle write. flushSave() forces an immediate write (unload). */
  var _saveTimer = null, _saveDirty = false;
  function scheduleSave() {
    _saveDirty = true;
    if (_saveTimer) return;
    var run = function () {
      _saveTimer = null;
      if (!_saveDirty) return;
      _saveDirty = false;
      saveCache(_loaded, _cacheTs || Date.now());
    };
    _saveTimer = (window.requestIdleCallback)
      ? requestIdleCallback(run, { timeout: 3000 })
      : setTimeout(run, 1500);
  }
  function flushSave() {
    if (!_saveDirty) return;
    if (_saveTimer) {
      if (window.cancelIdleCallback && typeof _saveTimer !== 'number') cancelIdleCallback(_saveTimer);
      else clearTimeout(_saveTimer);
      _saveTimer = null;
    }
    _saveDirty = false;
    saveCache(_loaded, _cacheTs || Date.now());
  }
  window.addEventListener('beforeunload', flushSave);

  function extractVariants(p) {
    if (!p.media_asset || !Array.isArray(p.media_asset.variants)) return null;
    var v180 = p.media_asset.variants.find(function (v) { return v.type === '180x180'; });
    var v360 = p.media_asset.variants.find(function (v) { return v.type === '360x360'; });
    if (!v180) return null;
    return { u1: v180.url, u2: v360 ? v360.url : null, w: v180.width, h: v180.height };
  }

  function timeAgo(ts) {
    var diff = Date.now() - ts;
    var mins = Math.floor(diff / 60000);
    if (mins < 1) return 'just now';
    if (mins < 60) return mins + 'm ago';
    var hrs = Math.floor(mins / 60);
    if (hrs < 24) return hrs + 'h ago';
    return Math.floor(hrs / 24) + 'd ago';
  }

  /* The clock time (HH:MM) the next auto-refresh is due, computed from the
     cache timestamp. A fixed time-of-day avoids needing a live-updating
     countdown. Empty string if auto-refresh is off or no cache timestamp yet. */
  function nextRefreshText() {
    var arCfg = getAutoRefresh();
    if (!arCfg.enabled || !_cacheTs) return '';
    var due = new Date(_cacheTs + arCfg.hours * 60 * 60 * 1000);
    var hh = ('0' + due.getHours()).slice(-2);
    var mm = ('0' + due.getMinutes()).slice(-2);
    // Note the day if the due time isn't today (e.g. 24h/48h intervals)
    var now = new Date();
    var dayDiff = Math.round(
      (new Date(due.getFullYear(), due.getMonth(), due.getDate()) -
       new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86400000
    );
    var dayLabel = dayDiff <= 0 ? '' : (dayDiff === 1 ? ' tomorrow' : ' +' + dayDiff + 'd');
    return 'Next refresh at ' + hh + ':' + mm + dayLabel;
  }

  /* ========== Auto-refresh timer (live, while the page stays open) ========== */
  /* Two complementary mechanisms keep the feed fresh:
     1. On open / re-entry (renderFeed Layer 1+2) — catches a stale cache from
        a previous session and triggers an immediate refresh.
     2. This periodic timer + visibilitychange — keeps refreshing in real time
        while the Following page stays open, without needing a manual reload. */
  var _autoTimer = null;
  var AUTO_CHECK_MS = 60 * 1000; // re-evaluate staleness once a minute

  /* Returns true if a refresh was kicked off. Guards: only on the feed page,
     auto-refresh enabled, not already refreshing, and cache older than interval. */
  function checkAutoRefresh() {
    if (!isFeedHash()) return false;
    if (_refreshing) return false;
    if (!_loaded.length || !_cacheTs) return false;
    var arCfg = getAutoRefresh();
    if (!arCfg.enabled) return false;
    var cacheAge = Date.now() - _cacheTs;
    if (cacheAge <= arCfg.hours * 60 * 60 * 1000) return false;
    doRefresh();
    return true;
  }

  function startAutoTimer() {
    if (_autoTimer) return;
    _autoTimer = setInterval(checkAutoRefresh, AUTO_CHECK_MS);
  }
  function stopAutoTimer() {
    if (!_autoTimer) return;
    clearInterval(_autoTimer);
    _autoTimer = null;
  }
  // A backgrounded tab throttles setInterval, so re-check the moment it
  // becomes visible again — covers the "left it open overnight" case promptly.
  document.addEventListener('visibilitychange', function () {
    if (!document.hidden) checkAutoRefresh();
  });

  /* ========== API ========== */
  var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
  var _lastError = '';

  function pairArtists(artists) {
    var pairs = [];
    for (var i = 0; i < artists.length; i += 2) {
      if (i + 1 < artists.length) pairs.push('~' + artists[i] + ' ~' + artists[i + 1]);
      else pairs.push(artists[i]);
    }
    return pairs;
  }

  async function fetchPairPage(query, beforeId) {
    var url = '/posts.json?tags=' + encodeURIComponent(query) + '&limit=' + PER_PAGE;
    if (beforeId) url += '&page=b' + beforeId;
    try {
      var res = await fetch(url, { credentials: 'same-origin' });
      if (!res.ok) { _lastError = 'HTTP ' + res.status; return []; }
      return await res.json();
    } catch (e) { _lastError = e.message; return []; }
  }

  /* Fetch the entire back-catalog of a single artist (for newly followed artists) */
  async function fetchArtistAll(artist, token) {
    var out = [];
    var before = null;
    var guard = 0;
    while (token === _renderToken && guard < 500) {
      guard++;
      var posts = await fetchPairPage(artist, before);
      if (!posts.length) break;
      for (var i = 0; i < posts.length; i++) {
        var p = posts[i];
        if (!p.preview_file_url || _seen.has(p.id)) continue;
        p._v = extractVariants(p);
        _seen.add(p.id);
        out.push(p);
      }
      before = posts[posts.length - 1].id;
      if (posts.length < PER_PAGE) break;
      await sleep(DELAY);
    }
    return out;
  }

  /* ========== K-way merge ========== */
  function newMerge(pairs) { return { pairs: pairs, buf: {}, cur: {}, done: {} }; }

  function mergeAllDone(m) {
    if (!m) return true;
    return m.pairs.every(function (p) { return m.done[p] && !(m.buf[p] && m.buf[p].length); });
  }

  async function fillBuffers(m, onProgress) {
    var need = m.pairs.filter(function (p) { return !m.done[p] && !(m.buf[p] && m.buf[p].length); });
    if (!need.length) return;
    var fetched = 0;
    for (var i = 0; i < need.length; i += CONCUR) {
      var batch = need.slice(i, i + CONCUR);
      var results = await Promise.all(batch.map(function (p) { return fetchPairPage(p, m.cur[p]); }));
      batch.forEach(function (p, idx) {
        var posts = results[idx];
        if (posts.length) {
          m.buf[p] = (m.buf[p] || []).concat(posts);
          m.cur[p] = posts[posts.length - 1].id;
        }
        if (posts.length < PER_PAGE) m.done[p] = true;
      });
      fetched += batch.length;
      if (onProgress) onProgress(fetched, need.length);
      if (i + CONCUR < need.length) await sleep(DELAY);
    }
  }

  async function nextBatch(m, count, onProgress) {
    var out = [];
    while (out.length < count) {
      await fillBuffers(m, onProgress);
      var best = null, bestTime = -1;
      for (var i = 0; i < m.pairs.length; i++) {
        var p = m.pairs[i];
        var b = m.buf[p];
        if (b && b.length) {
          var t = new Date(b[0].created_at).getTime();
          if (t > bestTime) { bestTime = t; best = p; }
        }
      }
      if (best === null) break;
      out.push(m.buf[best].shift());
    }
    return out;
  }

  /* ========== Helpers ========== */
  var esc = function (s) {
    return String(s).replace(/[&<>"]/g, function (c) {
      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
    });
  };

  function thumb(p) {
    if (p._v) return { url180: p._v.u1, url360: p._v.u2, w: p._v.w, h: p._v.h };
    var url180 = p.preview_file_url, url360 = null, w, h;
    var ma = p.media_asset;
    if (ma && Array.isArray(ma.variants)) {
      var v180 = ma.variants.find(function (v) { return v.type === '180x180'; });
      var v360 = ma.variants.find(function (v) { return v.type === '360x360'; });
      if (v180) { url180 = v180.url; w = v180.width; h = v180.height; }
      if (v360) url360 = v360.url;
    }
    if (!url360 && url180) url360 = url180.replace('/180x180/', '/360x360/');
    return { url180: url180, url360: url360, w: w, h: h };
  }

  /* ========== Styles (minimal — only what Danbooru doesn't provide) ========== */
  function injectStyles() {
    if (document.getElementById('af-styles')) return;
    var style = document.createElement('style');
    style.id = 'af-styles';
    style.textContent = [
      '.af-btn{cursor:pointer;margin-left:4px;font-size:13px;padding:0 4px;',
      '  border-radius:3px;border:1px solid #666;background:transparent;',
      '  color:var(--link-color,#0073ff);vertical-align:middle;line-height:1.4}',
      '.af-btn:hover{background:rgba(255,255,255,.08)}',
      '.af-btn.on{color:#e44;border-color:#e44}',
      '.af-status{color:#aaa;padding:6px 0;font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
      '.af-refresh{cursor:pointer;background:transparent;border:1px solid #666;color:var(--link-color,#0073ff);',
      '  border-radius:3px;padding:2px 8px;font-size:12px}',
      '.af-refresh:hover{background:rgba(255,255,255,.08)}',
      '.af-refresh:disabled{opacity:.4;cursor:default}',
      '.af-artist-label{font-size:11px;text-align:center;margin-top:2px;',
      '  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
      '.af-artist-label a{color:var(--tag-1-color,var(--link-color,#0073ff))}',
      '.af-loading{text-align:center;padding:40px;color:#aaa;font-size:14px}',
      '.af-list-row{display:flex;gap:12px;padding:12px 0;border-bottom:1px solid var(--default-border-color,#333);align-items:flex-start}',
      '.af-list-row:last-child{border-bottom:none}',
      '.af-list-info{flex:0 0 180px;min-width:0;display:flex;align-items:center;gap:6px}',
      '.af-list-name{font-weight:bold;font-size:16px;margin-bottom:4px}',
      '.af-list-name a{color:var(--artist-tag-color,var(--link-color))}',
      '.af-list-name a:hover{color:var(--artist-tag-hover-color,var(--link-hover-color))}',
      '.af-list-meta{font-size:11px;color:var(--muted-text-color,#888)}',
      '.af-list-previews{display:flex;gap:6px;flex:1;overflow:hidden;align-items:flex-start;flex-wrap:nowrap}',
      '.af-list-previews .af-list-thumb{display:block;flex-shrink:0}',
      '.af-list-previews .af-list-thumb img{height:120px;width:auto;border-radius:3px}',
      '.af-list-count{font-size:11px;color:var(--muted-text-color,#888);margin-left:4px}',
      '.af-unfollow{cursor:pointer;font-size:14px;padding:0 5px;border-radius:3px;border:1px solid #e44;background:transparent;color:#e44;line-height:1.3;flex-shrink:0}',
      '.af-unfollow:hover{background:rgba(238,68,68,.12)}',
      '.af-grid-unfollow{position:absolute;top:4px;right:4px;z-index:2;cursor:pointer;font-size:14px;padding:0 5px;border-radius:3px;border:1px solid #e44;background:rgba(0,0,0,.55);color:#e44;line-height:1.3}',
      '.af-grid-unfollow:hover{background:rgba(238,68,68,.25)}',
      '#af-artists-grid article.post-preview{position:relative}',
      '#af-posts .post-gallery-150 .post-preview-image{max-height:150px !important}#af-posts .post-gallery-180 .post-preview-image{max-height:180px !important}#af-posts .post-gallery-225 .post-preview-image{max-height:225px !important}#af-posts .post-gallery-270 .post-preview-image{max-height:270px !important}#af-posts .post-gallery-360 .post-preview-image{max-height:360px !important}#af-posts .post-gallery-720 .post-preview-image{max-height:720px !important}',
      '.af-view-toggle{display:inline-flex;border:1px solid var(--form-input-border-color,#555);border-radius:3px;overflow:hidden}',
      '.af-view-toggle a{padding:2px 8px;font-size:var(--text-sm);cursor:pointer;color:var(--link-color);background:transparent;text-decoration:none}',
      '.af-view-toggle a:hover{background:rgba(255,255,255,.05)}',
      '.af-view-toggle a.active{font-weight:bold;background:var(--subnav-menu-background-color,rgba(255,255,255,.08))}',
      '#af-posts article.post-preview,#af-artists-view article.post-preview{visibility:visible !important;opacity:1 !important}',
      '#af-posts .post-preview-image,#af-artists-view .post-preview-image{visibility:visible !important;opacity:1 !important}',
      '#af-posts .post-preview-container,#af-artists-view .post-preview-container{width:100% !important;height:100% !important}',
      '#af-posts .post-preview-container a,#af-artists-view .post-preview-container a{width:100%;height:100%;display:flex;align-items:flex-end;justify-content:center}',
      '#af-posts .post-preview-image,#af-artists-view .post-preview-image{max-width:100% !important;max-height:100% !important;width:auto !important;height:auto !important;object-fit:contain !important}'
    ].join('\n');
    document.head.appendChild(style);
  }

  /* ========== Nav Link ========== */
  function injectNavLink() {
    var postsLink = document.getElementById('nav-posts');
    if (!postsLink || document.getElementById('nav-following')) return;
    var a = document.createElement('a');
    a.id = 'nav-following';
    a.className = postsLink.className.replace(/current/g, '').trim();
    a.href = '/posts#/following';
    a.textContent = 'Following';
    a.addEventListener('click', function (e) {
      e.preventDefault();
      // Always do full navigation to get a clean /posts page
      location.href = '/posts#/following';
    });
    postsLink.after(a);
  }

  /* ========== Follow Buttons ========== */
  function injectFollowButtons() {
    document.querySelectorAll('.tag-type-1').forEach(function (el) {
      if (el.querySelector('.af-btn')) return;
      var tag = el.dataset.tagName;
      if (!tag) return;
      var btn = document.createElement('button');
      btn.className = 'af-btn' + (isFollowed(tag) ? ' on' : '');
      btn.textContent = isFollowed(tag) ? '\u2605' : '\u2606';
      btn.title = isFollowed(tag) ? 'Unfollow' : 'Follow';
      btn.addEventListener('click', function () {
        var now = toggleFollow(tag);
        btn.textContent = now ? '\u2605' : '\u2606';
        btn.title = now ? 'Unfollow' : 'Follow';
        btn.classList.toggle('on', now);
      });
      el.appendChild(btn);
    });
  }

  /* ========== Feed State ========== */
  var _merge = null, _loaded = [], _seen = null, _pageIdx = 0;
  var _renderToken = 0, _refreshing = false, _cacheTs = null;
  var _currentSize = GM_getValue('af_size', '180'); // persisted thumbnail size
  var _feedView = 'posts'; // 'posts' or 'artists'
  var _artistSort = 'followed'; // 'followed' or 'recent'
  var _artistLayout = GM_getValue('af_artist_layout', 'list'); // 'grid' or 'list', persisted
  var _artistPreviews = {}; // tag -> preview_file_url

  /* ========== Build sidebar for feed page ========== */
  function buildSidebar(pageArtists) {
    var iconBase = '';
    var useEl = document.querySelector('use[href*="icons-"]');
    if (useEl) iconBase = useEl.getAttribute('href').split('#')[0];

    var html = '';

    // Search box
    html += '<section id="search-box"><h2>Search</h2>';
    html += '<form id="search-box-form" class="flex" action="/posts" accept-charset="UTF-8" method="get">';
    html += '<input type="text" name="tags" id="tags" class="flex-auto" data-autocomplete="tag-query" autocapitalize="none" />';
    html += '<button id="search-box-submit" type="submit">';
    if (iconBase) {
      html += '<svg class="icon svg-icon search-icon" viewBox="0 0 512 512"><use fill="currentColor" href="' + iconBase + '#search"/></svg>';
    } else {
      html += '\uD83D\uDD0D';
    }
    html += '</button></form></section>';

    // Tag list — only artists visible on the current page
    html += '<section id="tag-box"><h2>Artists</h2>';
    html += '<ul class="tag-list search-tag-list">';
    for (var i = 0; i < pageArtists.length; i++) {
      var a = pageArtists[i];
      var display = esc(a.replace(/_/g, ' '));
      html += '<li class="tag-type-1" data-tag-name="' + esc(a) + '">';
      html += '<a class="wiki-link" href="/artists/show_or_new?name=' + encodeURIComponent(a) + '">?</a> ';
      html += '<a class="search-tag" href="/posts?tags=' + encodeURIComponent(a) + '">' + display + '</a>';
      html += '</li>';
    }
    html += '</ul></section>';

    // Related
    html += '<section id="related-box"><h2>Related</h2><ul>';
    html += '<li><a href="/posts">All Posts</a></li>';
    html += '<li><a href="/artists">Artists</a></li>';
    html += '</ul></section>';

    return html;
  }

  /* Extract unique artist tags from a slice of posts */
  function getPageArtists(slice) {
    var seen = {};
    var result = [];
    for (var i = 0; i < slice.length; i++) {
      var raw = (slice[i].tag_string_artist || '').trim();
      if (!raw) continue;
      var tags = raw.split(' ');
      for (var j = 0; j < tags.length; j++) {
        if (!seen[tags[j]]) {
          seen[tags[j]] = true;
          result.push(tags[j]);
        }
      }
    }
    return result;
  }

  /* ========== URL hash helpers (page persistence) ========== */
  function isFeedHash() { return location.hash.indexOf('#/following') === 0; }
  function parseHashPage() {
    var m = location.hash.match(/#\/following\/p\/(\d+)/);
    return m ? Math.max(1, parseInt(m[1], 10)) : 1;
  }
  function updateHashPage(page) {
    if (!history.replaceState) return;
    var url = '/posts#/following' + (page > 1 ? '/p/' + page : '');
    history.replaceState(null, '', url);
  }

  /* ========== Feed Page ========== */
  function renderFeed() {
    var content = document.getElementById('content');
    if (!content) return;

    // Show sidebar (will be populated with current page artists in renderPage)
    var sidebar = document.getElementById('sidebar');
    if (sidebar) {
      sidebar.style.display = '';
      sidebar.innerHTML = buildSidebar([]);
      injectFollowButtons();
    }

    // Fix nav: highlight Following, un-highlight Posts
    var navPosts = document.getElementById('nav-posts');
    var navFollowing = document.getElementById('nav-following');
    if (navPosts) navPosts.classList.remove('current', 'font-bold');
    if (navFollowing) navFollowing.classList.add('current', 'font-bold');

    // Fix subnav: hide posts subnav items
    var subnav = document.getElementById('subnav-menu');
    if (subnav) subnav.style.display = 'none';

    // Build content area matching Danbooru's native structure
    var feedContent = '';
    // post-sections menu bar (matches native "Posts | Artist" bar)
    feedContent += '<menu id="post-sections" class="flex items-center mb-2">';
    feedContent += '<li class="flex-grow-1 space-x-2">';
    feedContent += '<a href="#" id="af-tab-posts" class="' + (_feedView === 'posts' ? 'active' : '') + '">Posts</a>';
    feedContent += '<a href="#" id="af-tab-artists" class="' + (_feedView === 'artists' ? 'active' : '') + '">Artists</a>';
    feedContent += '</li>';

    // Right side: Sort + Size dropdown + options (···)
    feedContent += '<li class="flex items-center gap-2">';
    // Artist controls (only visible in artists view)
    feedContent += '<div id="af-sort-wrap" style="display:flex;align-items:center;gap:4px' +
      (_feedView !== 'artists' ? ';display:none' : '') + '">';
    // View toggle: Grid / List
    feedContent += '<div class="popup-menu inline-flex items-center h-full" data-hide-on-click="true">';
    feedContent += '<a class="popup-menu-button default-popup-menu-button" href="javascript:void(0)">';
    feedContent += '<span class="rounded flex gap-1 items-center leading-none text-sm">View \u25BE</span></a>';
    feedContent += '<ul class="popup-menu-content">';
    feedContent += '<li><a id="af-view-grid" class="' + (_artistLayout === 'grid' ? 'font-bold' : '') + '" href="#">Grid</a></li>';
    feedContent += '<li><a id="af-view-list" class="' + (_artistLayout === 'list' ? 'font-bold' : '') + '" href="#">List</a></li>';
    feedContent += '</ul></div>';
    // Sort dropdown
    feedContent += '<div class="popup-menu inline-flex items-center h-full" data-hide-on-click="true">';
    feedContent += '<a class="popup-menu-button default-popup-menu-button" href="javascript:void(0)">';
    feedContent += '<span class="rounded flex gap-1 items-center leading-none text-sm">Sort \u25BE</span></a>';
    feedContent += '<ul class="popup-menu-content">';
    feedContent += '<li><a id="af-sort-followed" class="' + (_artistSort === 'followed' ? 'font-bold' : '') + '" href="#">Follow order</a></li>';
    feedContent += '<li><a id="af-sort-recent" class="' + (_artistSort === 'recent' ? 'font-bold' : '') + '" href="#">Latest post</a></li>';
    feedContent += '</ul></div>';
    feedContent += '</div>';
    // Size dropdown — build our own so links stay on Following page
    var iconBase = '';
    var useElI = document.querySelector('use[href*="icons-"]');
    if (useElI) iconBase = useElI.getAttribute('href').split('#')[0];
    var sizes = [['150','Small'],['180','Medium'],['225','Large'],['270','Huge'],['360','Gigantic'],['720','Absurd']];
    feedContent += '<div class="popup-menu inline-flex items-center h-full" data-hide-on-click="true">';
    feedContent += '<a class="popup-menu-button default-popup-menu-button" href="javascript:void(0)">';
    feedContent += '<span class="rounded flex gap-1 items-center leading-none">';
    if (iconBase) feedContent += '<svg class="icon svg-icon image-icon" viewBox="0 0 512 512"><use fill="currentColor" href="' + iconBase + '#image"/></svg>';
    feedContent += ' <span class="text-sm">Size</span> ';
    if (iconBase) feedContent += '<svg class="icon svg-icon caret-down-icon text-xs self-center" viewBox="0 0 320 512"><use fill="currentColor" href="' + iconBase + '#caret-down"/></svg>';
    feedContent += '</span></a>';
    feedContent += '<ul class="popup-menu-content">';
    for (var si = 0; si < sizes.length; si++) {
      var sz = sizes[si][0], lb = sizes[si][1];
      var bold = (sz === _currentSize) ? ' class="font-bold"' : '';
      feedContent += '<li><a' + bold + ' href="#" data-af-size="' + sz + '">' + lb + '</a></li>';
    }
    feedContent += '</ul></div>';
    // Settings dropdown (auto-refresh toggle + frequency)
    var arCfg = getAutoRefresh();
    feedContent += '<div class="popup-menu inline-flex items-center h-full" data-hide-on-click="false">';
    feedContent += '<a class="popup-menu-button default-popup-menu-button" href="javascript:void(0)">';
    if (iconBase) feedContent += '<svg class="icon svg-icon ellipsis-icon" viewBox="0 0 448 512"><use fill="currentColor" href="' + iconBase + '#ellipsis"/></svg>';
    else feedContent += '\u22EF';
    feedContent += '</a>';
    feedContent += '<ul class="popup-menu-content" style="min-width:200px;padding:6px">';
    feedContent += '<li style="display:flex;align-items:center;gap:6px;padding:4px 0">';
    feedContent += '<label style="display:flex;align-items:center;gap:4px;cursor:pointer;flex:1">';
    feedContent += '<input type="checkbox" id="af-auto-toggle" class="toggle-switch"' + (arCfg.enabled ? ' checked' : '') + '>';
    feedContent += '<span>Auto-refresh</span></label></li>';
    feedContent += '<li style="display:flex;align-items:center;gap:6px;padding:4px 0">';
    feedContent += '<span style="white-space:nowrap">Every</span>';
    feedContent += '<select id="af-auto-hours" style="background:var(--form-input-background);border:1px solid var(--form-input-border-color);color:var(--text-color);padding:2px 4px;border-radius:3px">';
    var hrs = [1,2,4,6,12,24,48];
    for (var hi = 0; hi < hrs.length; hi++) {
      feedContent += '<option value="' + hrs[hi] + '"' + (arCfg.hours === hrs[hi] ? ' selected' : '') + '>' + hrs[hi] + 'h</option>';
    }
    feedContent += '</select>';
    feedContent += '</li>';
    feedContent += '<li style="padding:6px 0 2px;border-top:1px solid var(--default-border-color,#333);margin-top:4px">';
    feedContent += '<button id="af-repair" class="af-refresh" style="width:100%">\u2692 Repair &amp; re-sort cache</button>';
    feedContent += '</li></ul></div>';
    feedContent += '</li>';

    feedContent += '</menu>';

    // After innerHTML set, bind size click handlers
    setTimeout(function () {
      document.querySelectorAll('[data-af-size]').forEach(function (el) {
        el.addEventListener('click', function (e) {
          e.preventDefault();
          var sz = el.dataset.afSize;
          _currentSize = sz;
          GM_setValue('af_size', sz); // persist choice
          // Update gallery container class
          var gallery = document.querySelector('#af-posts .post-gallery');
          if (gallery) {
            gallery.className = gallery.className.replace(/post-gallery-\d+/g, '') + ' post-gallery-' + sz;
          }
          // Update every article's class
          document.querySelectorAll('#af-grid article.post-preview').forEach(function (a) {
            a.className = a.className.replace(/post-preview-\d+/g, '') + ' post-preview-' + sz;
          });
          // Update bold on menu items
          document.querySelectorAll('[data-af-size]').forEach(function (s) { s.classList.remove('font-bold'); });
          el.classList.add('font-bold');
        });
      });
      // View mode toggle handler
      var viewGrid = document.getElementById('af-view-grid');
      var viewList = document.getElementById('af-view-list');
      if (viewGrid) viewGrid.addEventListener('click', function (e) {
        e.preventDefault(); _artistLayout = 'grid'; GM_setValue('af_artist_layout', 'grid'); renderArtistsView();
      });
      if (viewList) viewList.addEventListener('click', function (e) {
        e.preventDefault(); _artistLayout = 'list'; GM_setValue('af_artist_layout', 'list'); renderArtistsView();
      });
      // Settings handlers
      var arToggle = document.getElementById('af-auto-toggle');
      var arHours = document.getElementById('af-auto-hours');
      if (arToggle) {
        arToggle.addEventListener('change', function () {
          var cfg = getAutoRefresh();
          cfg.enabled = arToggle.checked;
          saveAutoRefresh(cfg);
          // Apply immediately: (re)start the live timer and re-check staleness,
          // and refresh the "Next refresh at HH:MM" hint in the status bar.
          if (cfg.enabled) { startAutoTimer(); checkAutoRefresh(); }
          else stopAutoTimer();
          if (!_refreshing) statusBar(_loaded.length + ' posts \u00B7 Updated ' + timeAgo(_cacheTs || Date.now()), true);
        });
      }
      if (arHours) {
        arHours.addEventListener('change', function () {
          var v = parseInt(arHours.value, 10);
          if (v > 0) {
            var cfg = getAutoRefresh();
            cfg.hours = v;
            saveAutoRefresh(cfg);
            // New interval may make the current cache already stale.
            checkAutoRefresh();
            if (!_refreshing) statusBar(_loaded.length + ' posts \u00B7 Updated ' + timeAgo(_cacheTs || Date.now()), true);
          }
        });
      }
      var repairBtn = document.getElementById('af-repair');
      if (repairBtn) repairBtn.addEventListener('click', function () { repairCache(); });
    }, 0);
    // Status bar
    feedContent += '<div id="af-status" class="af-status"></div>';
    // Posts view
    feedContent += '<div id="af-posts-view"><div id="af-posts"><div class="post-gallery post-gallery-grid post-gallery-' + _currentSize + '">';
    feedContent += '<div id="af-grid" class="posts-container gap-2"></div>';
    feedContent += '</div></div>';
    feedContent += '<div id="af-paginator"></div></div>';
    // Artists view — use native post-gallery grid layout
    feedContent += '<div id="af-artists-view" style="display:none"><div class="post-gallery post-gallery-grid post-gallery-' + _currentSize + '">';
    feedContent += '<div id="af-artists-grid" class="posts-container gap-2"></div>';
    feedContent += '</div></div>';
    content.innerHTML = feedContent;
    // Skeleton is in place — reveal the page (removes the document-start cloak).
    var _ph = document.getElementById('af-preload-hide');
    if (_ph) _ph.remove();

    // Tab switching
    document.getElementById('af-tab-posts').addEventListener('click', function (e) {
      e.preventDefault(); _feedView = 'posts'; switchView();
    });
    document.getElementById('af-tab-artists').addEventListener('click', function (e) {
      e.preventDefault(); _feedView = 'artists'; switchView();
    });
    // Sort switching
    document.getElementById('af-sort-followed').addEventListener('click', function (e) {
      e.preventDefault(); _artistSort = 'followed'; renderArtistsView();
    });
    document.getElementById('af-sort-recent').addEventListener('click', function (e) {
      e.preventDefault(); _artistSort = 'recent'; renderArtistsView();
    });

    function switchView() {
      var tabP = document.getElementById('af-tab-posts');
      var tabA = document.getElementById('af-tab-artists');
      var viewP = document.getElementById('af-posts-view');
      var viewA = document.getElementById('af-artists-view');
      var sortW = document.getElementById('af-sort-wrap');
      if (_feedView === 'posts') {
        tabP.classList.add('active'); tabA.classList.remove('active');
        viewP.style.display = ''; viewA.style.display = 'none';
        if (sortW) sortW.style.display = 'none';
      } else {
        tabA.classList.add('active'); tabP.classList.remove('active');
        viewA.style.display = ''; viewP.style.display = 'none';
        if (sortW) sortW.style.display = '';
        renderArtistsView();
      }
    }

    // Restore page from URL (e.g. #/following/p/5)
    var startPage = parseHashPage();
    _pageIdx = startPage - 1;
    updateHashPage(startPage);

    // Layer 1: In-memory
    if (_loaded.length > 0) {
      gotoPage(_pageIdx);
      checkAutoRefresh(); // in-memory cache may have gone stale since last view
      startAutoTimer();
      return;
    }

    // Once we have cached data on screen, keep the live timer running.
    startAutoTimer();

    // Layer 2: Disk cache
    var cache = loadCache();
    if (cache && cache.posts && cache.posts.length) {
      _loaded = cache.posts;
      _seen = new Set(cache.posts.map(function (p) { return p.id; }));
      _cacheTs = cache.ts;
      _merge = null;
      var maxPage0 = Math.max(0, Math.ceil(_loaded.length / PAGE_SIZE) - 1);
      _pageIdx = Math.min(_pageIdx, maxPage0);
      updateHashPage(_pageIdx + 1);
      renderPage();
      // Auto-refresh based on settings
      var arCfg = getAutoRefresh();
      var cacheAge = Date.now() - (cache.ts || 0);
      if (arCfg.enabled && cacheAge > arCfg.hours * 60 * 60 * 1000) {
        statusBar(_loaded.length + ' posts \u00B7 Updated ' + timeAgo(cache.ts) + ' \u00B7 Auto-refreshing\u2026', false);
        doRefresh();
      } else {
        statusBar(_loaded.length + ' posts \u00B7 Updated ' + timeAgo(cache.ts), true);
      }
      return;
    }

    // Layer 3: Fresh fetch
    var artists = getArtists();
    if (!artists.length) {
      statusBar('No followed artists. Click \u2606 next to artist tags on any post.', false);
      return;
    }
    doRefresh();
  }

  function setStatus(html) {
    var s = document.getElementById('af-status');
    if (s) s.innerHTML = html;
  }

  function statusBar(text, showRefresh) {
    var html = '<span>' + esc(text) + '</span>';
    if (showRefresh) {
      html += ' <button class="af-refresh" id="af-refresh-btn">\u21BB Refresh</button>';
      var nr = nextRefreshText();
      if (nr) html += ' <span class="af-next-refresh" style="color:#888">\u00B7 ' + esc(nr) + '</span>';
    }
    setStatus(html);
    if (showRefresh) {
      var btn = document.getElementById('af-refresh-btn');
      if (btn) btn.addEventListener('click', function () { doRefresh(); });
    }
  }

  /* ---- Build native article element ---- */
  function makeArticle(p) {
    var t = thumb(p);
    if (!t.url180) return null;
    var raw = (p.tag_string_artist || '').trim();
    var tags = raw ? raw.split(' ') : [];
    var display = tags.length
      ? tags.map(function (s) { return esc(s.replace(/_/g, ' ')); }).join(', ')
      : '';
    var linkTag = tags[0] || '';

    var article = document.createElement('article');
    article.className = 'post-preview post-preview-fit-compact post-preview-' + _currentSize;
    article.dataset.id = p.id;

    var w = t.w || 180;
    var h = t.h || 180;
    var srcset1 = t.url180;
    var srcset2 = t.url360 || t.url180;

    var inner = '<div class="post-preview-container">';
    inner += '<a class="post-preview-link" draggable="false" href="/posts/' + p.id + '">';
    inner += '<picture>';
    inner += '<source type="image/jpeg" srcset="' + srcset1 + ' 1x, ' + srcset2 + ' 2x">';
    inner += '<img src="' + srcset1 + '"';
    inner += ' class="post-preview-image" alt="post #' + p.id + '" draggable="false">';
    inner += '</picture></a></div>';
    // Artist label below thumbnail
    if (display) {
      inner += '<div class="af-artist-label"><a href="/posts?tags=' + encodeURIComponent(linkTag) + '">' + display + '</a></div>';
    }
    article.innerHTML = inner;
    return article;
  }

  /* ---- Ensure enough posts loaded ---- */
  async function ensureLoaded(targetCount, token) {
    if (!_merge) {
      var artists = getArtists();
      if (!artists.length) return;
      _merge = newMerge(pairArtists(artists));
      if (_loaded.length > 0) {
        var oldestId = _loaded[_loaded.length - 1].id;
        for (var i = 0; i < _merge.pairs.length; i++) {
          _merge.cur[_merge.pairs[i]] = oldestId;
        }
      }
    }
    if (!_seen) _seen = new Set(_loaded.map(function (p) { return p.id; }));

    while (_loaded.length < targetCount && !mergeAllDone(_merge)) {
      var need = targetCount - _loaded.length;
      var batch = await nextBatch(_merge, Math.max(need, PAGE_SIZE), function (done, total) {
        if (token === _renderToken) {
          setStatus('<span>Fetching ' + done + '/' + total + ' pairs\u2026</span>');
        }
      });
      if (token !== _renderToken) return;
      if (!batch.length) break;
      for (var j = 0; j < batch.length; j++) {
        var p = batch[j];
        if (!p.preview_file_url || _seen.has(p.id)) continue;
        p._v = extractVariants(p);
        _seen.add(p.id);
        _loaded.push(p);
      }
      if (token === _renderToken) {
        setStatus('<span>Loading\u2026 ' + _loaded.length + ' posts found</span>');
      }
    }
  }

  /* ---- Prune: drop cached posts whose artists are ALL unfollowed ---- */
  /* A post is kept if at least one of its artist tags is still followed
     (so collab posts survive as long as one collaborator remains followed). */
  function pruneUnfollowed() {
    if (!_loaded || !_loaded.length) return 0;
    var followed = {};
    var list = getArtists();
    for (var i = 0; i < list.length; i++) followed[list[i]] = true;
    var before = _loaded.length;
    _loaded = _loaded.filter(function (p) {
      var raw = (p.tag_string_artist || '').trim();
      if (!raw) return false; // no artist info -> can't belong to a followed artist
      var tags = raw.split(' ');
      for (var j = 0; j < tags.length; j++) {
        if (followed[tags[j]]) return true;
      }
      return false;
    });
    var removed = before - _loaded.length;
    if (removed) {
      _seen = new Set(_loaded.map(function (p) { return p.id; }));
      _artistPreviews = {}; // previews may reference now-removed posts
    }
    return removed;
  }

  /* ---- Repair: de-duplicate and re-sort the whole cache locally (no fetch) ---- */
  function repairCache() {
    if (_refreshing) return;
    // Load from disk if not already in memory
    if (!_loaded.length) {
      var cache = loadCache();
      if (cache && cache.posts) { _loaded = cache.posts; _cacheTs = cache.ts; }
    }
    if (!_loaded.length) { statusBar('Nothing to repair — cache is empty.', true); return; }

    var before = _loaded.length;
    // De-duplicate by id, keeping the first occurrence
    var seen = {};
    var deduped = [];
    for (var i = 0; i < _loaded.length; i++) {
      var p = _loaded[i];
      if (p && p.id != null && !seen[p.id]) {
        seen[p.id] = true;
        deduped.push(p);
      }
    }
    // Re-sort by id descending (id is monotonic <-> chronological)
    deduped.sort(function (a, b) { return b.id - a.id; });
    _loaded = deduped;
    // Drop posts whose artists have all been unfollowed
    var unfollowedRemoved = pruneUnfollowed();
    _seen = new Set(_loaded.map(function (p) { return p.id; }));
    _pageIdx = 0;

    _cacheTs = Date.now();
    saveCache(_loaded, _cacheTs);
    _artistPreviews = {}; // rebuild previews from corrected order
    renderPage();

    var removed = before - _loaded.length;
    var dupRemoved = removed - unfollowedRemoved;
    statusBar('Repaired \u00B7 ' + _loaded.length + ' posts, ' + dupRemoved + ' duplicates + ' + unfollowedRemoved + ' unfollowed removed \u00B7 re-sorted', true);
  }

  /* ---- Refresh (incremental — only fetches posts newer than cache) ---- */
  async function doRefresh() {
    if (_refreshing) return;
    _refreshing = true;

    var btn = document.getElementById('af-refresh-btn');
    if (btn) { btn.disabled = true; btn.textContent = 'Refreshing\u2026'; }

    _renderToken++;
    var token = _renderToken;
    _lastError = '';

    var artists = getArtists();
    if (!artists.length) {
      _refreshing = false;
      statusBar('No followed artists.', true);
      return;
    }

    var pairs = pairArtists(artists);
    var hadData = _loaded.length > 0;

    if (hadData) {
      // ===== Incremental update: only fetch new posts =====
      setStatus('<span>Checking for new posts\u2026</span>');
      if (!_seen) _seen = new Set(_loaded.map(function (p) { return p.id; }));
      // Drop posts from artists we've since unfollowed (before computing newestId)
      var pruned = pruneUnfollowed();
      if (!_loaded.length) {
        // Everything in cache belonged to now-unfollowed artists
        _cacheTs = Date.now();
        saveCache(_loaded, _cacheTs);
        _pageIdx = 0;
        renderPage();
        statusBar(pruned + ' posts removed \u00B7 no followed artists left in cache', true);
        _refreshing = false;
        return;
      }
      var newestId = _loaded[0].id; // _loaded is sorted newest-first
      var newPosts = [];
      var fetched = 0;

      for (var i = 0; i < pairs.length; i += CONCUR) {
        var batch = pairs.slice(i, i + CONCUR);
        var results = await Promise.all(batch.map(function (q) {
          return fetchPairPage(q, null); // no cursor = newest first
        }));
        if (token !== _renderToken) { _refreshing = false; return; }

        for (var bi = 0; bi < results.length; bi++) {
          var posts = results[bi];
          for (var pi = 0; pi < posts.length; pi++) {
            var p = posts[pi];
            if (p.id <= newestId) continue; // already have this and older
            if (!p.preview_file_url || _seen.has(p.id)) continue;
            p._v = extractVariants(p);
            _seen.add(p.id);
            newPosts.push(p);
          }
        }
        fetched += batch.length;
        if (token === _renderToken) {
          setStatus('<span>Checking ' + fetched + '/' + pairs.length + ' pairs\u2026 ' + newPosts.length + ' new</span>');
        }
        if (i + CONCUR < pairs.length) await sleep(DELAY);
      }

      if (token !== _renderToken) { _refreshing = false; return; }

      // ===== Detect newly-followed artists (no posts in cache) and fetch their full back-catalog =====
      var seenArtists = {};
      for (var li = 0; li < _loaded.length; li++) {
        var lr = (_loaded[li].tag_string_artist || '').trim();
        if (!lr) continue;
        var lts = lr.split(' ');
        for (var lj = 0; lj < lts.length; lj++) seenArtists[lts[lj]] = true;
      }
      for (var ni = 0; ni < newPosts.length; ni++) {
        var nr = (newPosts[ni].tag_string_artist || '').trim();
        if (!nr) continue;
        var nts = nr.split(' ');
        for (var nj = 0; nj < nts.length; nj++) seenArtists[nts[nj]] = true;
      }
      var newArtists = artists.filter(function (a) { return !seenArtists[a]; });
      if (newArtists.length) {
        for (var ai = 0; ai < newArtists.length; ai++) {
          if (token !== _renderToken) { _refreshing = false; return; }
          setStatus('<span>Fetching new artist ' + (ai + 1) + '/' + newArtists.length + ': ' + esc(newArtists[ai].replace(/_/g, ' ')) + '\u2026 ' + newPosts.length + ' new</span>');
          var allPosts = await fetchArtistAll(newArtists[ai], token);
          for (var qi = 0; qi < allPosts.length; qi++) newPosts.push(allPosts[qi]);
        }
      }

      if (token !== _renderToken) { _refreshing = false; return; }

      // Merge new posts into the timeline and re-sort the whole list by time
      // (new posts may include a newly-followed artist's old back-catalog,
      //  which must interleave by date — not just sit at the front)
      if (newPosts.length) {
        _loaded = newPosts.concat(_loaded);
        // Danbooru post id is monotonic <-> chronological, so sort by id with a
        // pure numeric compare instead of parsing 1M+ Date strings on a 40k array.
        _loaded.sort(function (a, b) { return b.id - a.id; });
        _pageIdx = 0;
      }

      _cacheTs = Date.now();
      scheduleSave();
      renderPage();

      var msg = newPosts.length + ' new posts \u00B7 ' + _loaded.length + ' total \u00B7 Updated just now';
      if (pruned) msg += ' \u00B7 ' + pruned + ' unfollowed removed';
      if (_lastError) msg += ' \u00B7 Error: ' + _lastError;
      statusBar(msg, true);
      _refreshing = false;

    } else {
      // ===== Cold start: no cache, full fetch =====
      setStatus('<span>Loading\u2026 ' + artists.length + ' artists (' + pairs.length + ' pairs)</span>');

      _merge = newMerge(pairs);
      _loaded = [];
      _seen = new Set();
      _pageIdx = 0;

      var target = PREFETCH_PAGES * PAGE_SIZE;
      try {
        await ensureLoaded(target, token);
      } catch (e) {
        _lastError = e.message;
      }

      if (token !== _renderToken) { _refreshing = false; return; }

      _cacheTs = Date.now();
      scheduleSave();
      renderPage();

      var msg2 = _loaded.length + ' posts from ' + artists.length + ' artists \u00B7 Updated just now';
      if (_lastError) msg2 += ' \u00B7 Error: ' + _lastError;
      statusBar(msg2, true);
      _refreshing = false;

      // Continue loading all remaining posts in background
      backgroundLoad(token);
    }
  }

  /* ---- Background loader: keeps fetching until all artists exhausted ---- */
  async function backgroundLoad(token) {
    while (!mergeAllDone(_merge) && token === _renderToken) {
      var before = _loaded.length;
      try {
        await ensureLoaded(_loaded.length + PAGE_SIZE * 5, token);
      } catch (e) { break; }
      if (token !== _renderToken) return;
      if (_loaded.length === before) break;
      // Update status and paginator without re-rendering grid
      var knownPages = Math.ceil(_loaded.length / PAGE_SIZE);
      statusBar(_loaded.length + ' posts (' + knownPages + ' pages) \u00B7 Loading more\u2026', false);
      renderPaginator();
    }
    if (token !== _renderToken) return;
    _cacheTs = Date.now();
    saveCache(_loaded, _cacheTs);
    var finalMsg = _loaded.length + ' posts (' + Math.ceil(_loaded.length / PAGE_SIZE) + ' pages) \u00B7 Updated just now \u00B7 all loaded';
    if (_lastError) finalMsg += ' \u00B7 Error: ' + _lastError;
    statusBar(finalMsg, true);
  }

  /* ---- Page navigation ---- */
  async function gotoPage(idx) {
    var token = _renderToken;
    var targetCount = (idx + 1) * PAGE_SIZE;

    if (targetCount > _loaded.length && !mergeAllDone(_merge)) {
      var grid = document.getElementById('af-grid');
      if (grid) grid.innerHTML = '<div class="af-loading">Loading page ' + (idx + 1) + '\u2026</div>';
      try {
        await ensureLoaded(targetCount, token);
      } catch (e) {
        _lastError = e.message;
      }
      if (token !== _renderToken) return;
      _cacheTs = Date.now();
      scheduleSave();
    }

    var maxPage = Math.max(0, Math.ceil(_loaded.length / PAGE_SIZE) - 1);
    _pageIdx = Math.min(idx, maxPage);
    updateHashPage(_pageIdx + 1);
    renderPage();
    statusBar(_loaded.length + ' posts \u00B7 Updated ' + timeAgo(_cacheTs || Date.now()), true);
  }

  /* ---- Render current page ---- */
  function renderPage() {
    var grid = document.getElementById('af-grid');
    if (!grid) return;
    grid.innerHTML = '';
    var start = _pageIdx * PAGE_SIZE;
    var slice = _loaded.slice(start, start + PAGE_SIZE);
    for (var i = 0; i < slice.length; i++) {
      var article = makeArticle(slice[i]);
      if (article) grid.appendChild(article);
    }
    // Update sidebar with artists from current page
    var sidebar = document.getElementById('sidebar');
    if (sidebar) {
      var pageArtists = getPageArtists(slice);
      sidebar.innerHTML = buildSidebar(pageArtists);
      injectFollowButtons();
    }
    renderPaginator();
    window.scrollTo({ top: 0 });
  }

  /* ---- Artists gallery view ---- */
  /* Build { tag -> [posts...] } index in ONE pass over _loaded.
     Replaces the old O(artists × posts) per-artist scans. Posts stay in
     _loaded order (newest-first), so index[tag][0] is the artist's latest. */
  function buildArtistIndex() {
    var idx = {};
    for (var i = 0; i < _loaded.length; i++) {
      var raw = (_loaded[i].tag_string_artist || '').trim();
      if (!raw) continue;
      var tags = raw.split(' ');
      for (var j = 0; j < tags.length; j++) {
        var tg = tags[j];
        if (!tg) continue;
        (idx[tg] || (idx[tg] = [])).push(_loaded[i]);
      }
    }
    return idx;
  }

  function renderArtistsView() {
    var grid = document.getElementById('af-artists-grid');
    if (!grid) return;
    grid.innerHTML = '';

    var artists = getArtists();
    if (!artists.length) {
      grid.innerHTML = '<div class="af-loading">No followed artists.</div>';
      return;
    }

    // Single-pass index: tag -> posts (newest-first). Used for previews,
    // counts, and recent-sort below — no more repeated full scans of _loaded.
    var index = buildArtistIndex();

    // Sort
    var sorted = artists.slice();
    if (_artistSort === 'recent') {
      // Artists with loaded posts first (by newest post id), rest at end
      sorted.sort(function (a, b) {
        var pa = index[a], pb = index[b];
        var ia = (pa && pa.length) ? pa[0].id : -1;
        var ib = (pb && pb.length) ? pb[0].id : -1;
        return ib - ia;
      });
    } else {
      sorted.reverse(); // newest followed first
    }

    function unfollowBtn(tag, cls) {
      // ★ filled = currently followed; clicking unfollows
      return '<button class="' + cls + '" data-af-unfollow="' + esc(tag) +
        '" title="Unfollow">\u2605</button>';
    }

    if (_artistLayout === 'list') {
      // ===== List mode (Pixiv-style) =====
      grid.className = 'af-artist-list';
      for (var ci = 0; ci < sorted.length; ci++) {
        var tag = sorted[ci];
        var display = esc(tag.replace(/_/g, ' '));
        var posts = index[tag] || [];
        var total = posts.length;
        // Up to 4 newest previews (index is already newest-first)
        var previews = [];
        for (var pi = 0; pi < posts.length && previews.length < 4; pi++) {
          var tt = thumb(posts[pi]);
          if (tt.url180) previews.push({ url180: tt.url180, url360: tt.url360, id: posts[pi].id });
        }

        var row = document.createElement('div');
        row.className = 'af-list-row';

        var countTxt = total > 0
          ? total + (total >= PAGE_SIZE ? '+' : '') + ' posts loaded'
          : 'no posts loaded';
        var left = '<div class="af-list-info">';
        left += '<div class="af-list-meta">';
        left += unfollowBtn(tag, 'af-unfollow');
        left += '<a class="af-list-name" href="/posts?tags=' + encodeURIComponent(tag) + '">' + display + '</a>';
        left += '<span class="af-list-count">' + countTxt + '</span>';
        left += '</div></div>';

        var right = '<div class="af-list-previews">';
        for (var ri = 0; ri < previews.length; ri++) {
          var pp = previews[ri];
          right += '<a class="af-list-thumb" href="/posts/' + pp.id + '">';
          right += '<img src="' + (pp.url360 || pp.url180) + '" loading="lazy" decoding="async"></a>';
        }
        right += '</div>';

        row.innerHTML = left + right;
        grid.appendChild(row);
      }
    } else {
      // ===== Grid mode (native post-preview) =====
      grid.className = 'posts-container gap-2';
      for (var ci2 = 0; ci2 < sorted.length; ci2++) {
        var tag2 = sorted[ci2];
        var display2 = esc(tag2.replace(/_/g, ' '));
        var posts2 = index[tag2] || [];
        var prev2 = posts2.length ? thumb(posts2[0]) : null;

        var article = document.createElement('article');
        article.className = 'post-preview post-preview-fit-compact post-preview-' + _currentSize;

        var inner = '<div class="post-preview-container">';
        inner += '<a class="post-preview-link" draggable="false" href="/posts?tags=' + encodeURIComponent(tag2) + '">';
        if (prev2 && prev2.url180) {
          var s1 = prev2.url180;
          var s2 = prev2.url360 || s1;
          inner += '<picture>';
          inner += '<source type="image/jpeg" srcset="' + s1 + ' 1x, ' + s2 + ' 2x">';
          inner += '<img src="' + s1 + '" class="post-preview-image" alt="' + display2 + '" loading="lazy" decoding="async" draggable="false">';
          inner += '</picture>';
        } else {
          inner += '<span style="color:var(--muted-text-color);font-size:2em">?</span>';
        }
        inner += '</a></div>';
        inner += unfollowBtn(tag2, 'af-grid-unfollow');
        inner += '<div class="af-artist-label">' +
          '<a href="/posts?tags=' + encodeURIComponent(tag2) + '">' + display2 + '</a></div>';
        article.innerHTML = inner;
        grid.appendChild(article);
      }
    }

    // Unfollow buttons (event delegation) — toggle + re-render so the
    // removed artist disappears immediately; cached posts are dropped on next refresh.
    grid.querySelectorAll('[data-af-unfollow]').forEach(function (btn) {
      btn.addEventListener('click', function (e) {
        e.preventDefault();
        e.stopPropagation();
        toggleFollow(btn.dataset.afUnfollow);
        renderArtistsView();
      });
    });

    // Update sort button labels
    var sf = document.getElementById('af-sort-followed');
    var sr = document.getElementById('af-sort-recent');
    if (sf) sf.className = (_artistSort === 'followed' ? 'font-bold' : '');
    if (sr) sr.className = (_artistSort === 'recent' ? 'font-bold' : '');
    // Update layout toggle
    var lgrid = document.getElementById('af-view-grid');
    var llist = document.getElementById('af-view-list');
    if (lgrid) lgrid.className = (_artistLayout === 'grid' ? 'font-bold' : '');
    if (llist) llist.className = (_artistLayout === 'list' ? 'font-bold' : '');
  }

  /* ---- Paginator (native Danbooru structure) ---- */
  function renderPaginator() {
    var cont = document.getElementById('af-paginator');
    if (!cont) return;
    if (_loaded.length === 0) { cont.innerHTML = ''; return; }

    var done = mergeAllDone(_merge);
    var knownPages = Math.max(1, Math.ceil(_loaded.length / PAGE_SIZE));
    var cur = _pageIdx + 1;
    var hasNext = (_pageIdx < knownPages - 1) || !done;

    var iconBase = '';
    var useEl = document.querySelector('use[href*="icons-"]');
    if (useEl) iconBase = useEl.getAttribute('href').split('#')[0];

    function chevronSvg(name) {
      if (!iconBase) return name === 'chevron-left' ? '\u2039' : '\u203A';
      return '<svg class="icon svg-icon ' + name + '-icon" viewBox="0 0 384 512"><use fill="currentColor" href="' + iconBase + '#' + name + '"/></svg>';
    }

    var nav = document.createElement('div');
    nav.className = 'paginator numbered-paginator mt-8 mb-4 space-x-2 flex justify-center items-center';

    var html = '';

    // Prev
    if (_pageIdx > 0) {
      html += '<a class="paginator-prev" data-page="' + (_pageIdx - 1) + '">' + chevronSvg('chevron-left') + '</a>';
    } else {
      html += '<span class="paginator-prev">' + chevronSvg('chevron-left') + '</span>';
    }

    // Page numbers with ellipsis
    var win = 2;
    var pages = [];
    for (var n = 1; n <= knownPages; n++) {
      if (n === 1 || n === knownPages || (n >= cur - win && n <= cur + win)) {
        pages.push(n);
      } else if (pages[pages.length - 1] !== '...') {
        pages.push('...');
      }
    }

    for (var i = 0; i < pages.length; i++) {
      var pg = pages[i];
      if (pg === '...') {
        html += '<span>\u2026</span>';
      } else if (pg === cur) {
        html += '<span class="paginator-current font-bold">' + pg + '</span>';
      } else {
        html += '<a class="paginator-page desktop-only" data-page="' + (pg - 1) + '">' + pg + '</a>';
      }
    }

    if (!done) {
      html += '<span>\u2026</span>';
    }

    // Next
    if (hasNext) {
      html += '<a class="paginator-next" data-page="' + (_pageIdx + 1) + '">' + chevronSvg('chevron-right') + '</a>';
    } else {
      html += '<span class="paginator-next">' + chevronSvg('chevron-right') + '</span>';
    }

    // Page jump input
    html += '<span style="margin-left:8px">';
    html += '<input id="af-page-jump" type="number" min="1" placeholder="page" style="';
    html += 'width:60px;padding:2px 4px;border:1px solid var(--form-input-border-color,#555);';
    html += 'background:var(--input-background-color,#111);color:var(--text-color,#eee);';
    html += 'border-radius:3px;font-size:13px;text-align:center">';
    html += '<button id="af-page-go" style="';
    html += 'padding:2px 8px;margin-left:4px;border:1px solid var(--form-input-border-color,#555);';
    html += 'background:transparent;color:var(--link-color,#0073ff);border-radius:3px;';
    html += 'font-size:13px;cursor:pointer">Go</button>';
    html += '</span>';

    nav.innerHTML = html;
    cont.innerHTML = '';
    cont.appendChild(nav);

    cont.querySelectorAll('[data-page]').forEach(function (el) {
      el.addEventListener('click', function (e) {
        e.preventDefault();
        gotoPage(parseInt(el.dataset.page, 10));
      });
    });

    var jumpInput = document.getElementById('af-page-jump');
    var jumpBtn = document.getElementById('af-page-go');
    function doJump() {
      var val = parseInt(jumpInput.value, 10);
      if (!val || val < 1) return;
      gotoPage(val - 1);
    }
    if (jumpBtn) jumpBtn.addEventListener('click', doJump);
    if (jumpInput) jumpInput.addEventListener('keydown', function (e) {
      if (e.key === 'Enter') doJump();
    });
  }

  /* ========== Restore native page when leaving feed ========== */
  function leaveFeed() {
    // Restore Posts nav highlight
    var navPosts = document.getElementById('nav-posts');
    var navFollowing = document.getElementById('nav-following');
    if (navPosts) navPosts.classList.add('current', 'font-bold');
    if (navFollowing) navFollowing.classList.remove('current', 'font-bold');
    // Show subnav
    var subnav = document.getElementById('subnav-menu');
    if (subnav) subnav.style.display = '';
  }

  /* ========== Init ========== */
  var _inited = false;
  function init() {
    if (_inited) return;
    _inited = true;
    injectStyles();
    injectNavLink();
    injectFollowButtons();

    if (isFeedHash()) {
      renderFeed();
    }

    window.addEventListener('hashchange', function () {
      if (isFeedHash()) {
        renderFeed();
      } else {
        leaveFeed();
        stopAutoTimer(); // not on the feed — no need to keep polling
      }
    });
  }

  /* The visibility:hidden cloak alone wasn't enough: with @run-at document-start
     the browser still paints the native Posts page during the gap before
     DOMContentLoaded. So when the URL is already the feed, watch the DOM and run
     init() the instant #content exists — that swaps in our skeleton before the
     native page ever gets a frame, eliminating the Posts → Following flash.
     Off the feed we keep the normal (cheaper) DOMContentLoaded path. */
  if (isFeedHash()) {
    if (document.getElementById('content')) {
      init();
    } else {
      var _obs = new MutationObserver(function () {
        if (document.getElementById('content')) {
          _obs.disconnect();
          init();
        }
      });
      _obs.observe(document.documentElement, { childList: true, subtree: true });
      // Safety net: ensure init runs even if #content never matches as expected.
      document.addEventListener('DOMContentLoaded', function () { _obs.disconnect(); init(); });
    }
  } else if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();