Follow artists and view their latest posts — cached, on-demand paging, native layout
// ==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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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 & 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();
}
})();