Adds "Viewed" tags to visited posts on pawchive.pw, with mark-as-unread support and artist-grouped history.浏览增强:自动标记已读帖子,按创作者分组查看浏览历史。
// ==UserScript==
// @name Better Pawchive
// @namespace https://github.com/better-pawchive
// @version 1.3.0
// @description Adds "Viewed" tags to visited posts on pawchive.pw, with mark-as-unread support and artist-grouped history.浏览增强:自动标记已读帖子,按创作者分组查看浏览历史。
// @author Codex
// @match https://pawchive.pw/*
// @match https://pawchive.st/*
// @match https://*.pawchive.pw/*
// @license MIT
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
"use strict";
var STORAGE_KEY = "better_pawchive";
var BACKUP_KEY = "better_pawchive_backup";
var UNDO_KEY = "better_pawchive_undo";
var STORAGE_VERSION = 2;
var DEBOUNCE_MS = 200;
// ---------------------------------------------------------------------------
// Utility: debounce
// ---------------------------------------------------------------------------
function debounce(fn, delay) {
var timer;
return function () {
clearTimeout(timer);
timer = setTimeout(fn, delay);
};
}
// ---------------------------------------------------------------------------
// HTML escaping (covers all five XML-significant characters)
// ---------------------------------------------------------------------------
function esc(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// ---------------------------------------------------------------------------
// Merge helper — union of two stores, keeping newer timestamps for dupes
// excludePostKeys / excludeArtistKeys: optional objects whose keys are
// IDs to skip (e.g., posts explicitly deleted in this tab that should
// not be resurrected from disk).
// ---------------------------------------------------------------------------
function mergeStores(base, incoming, excludePostKeys, excludeArtistKeys) {
var result = {
version: Math.max(base.version || 0, incoming.version || 0) || STORAGE_VERSION,
artists: {},
posts: {}
};
// Merge posts: union, keep newer viewedAt on collision
var allPostKeys = {};
var k;
for (k in base.posts) { if (base.posts.hasOwnProperty(k)) allPostKeys[k] = true; }
for (k in incoming.posts) { if (incoming.posts.hasOwnProperty(k)) allPostKeys[k] = true; }
for (k in allPostKeys) {
if (excludePostKeys && excludePostKeys[k]) continue; // explicitly deleted
var bp = base.posts[k];
var ip = incoming.posts[k];
if (bp && ip) {
result.posts[k] = (ip.viewedAt || "") >= (bp.viewedAt || "") ? ip : bp;
} else {
result.posts[k] = bp || ip;
}
}
// Merge artists: union, keep newer lastSeenAt on collision
var allArtistKeys = {};
for (k in base.artists) { if (base.artists.hasOwnProperty(k)) allArtistKeys[k] = true; }
for (k in incoming.artists) { if (incoming.artists.hasOwnProperty(k)) allArtistKeys[k] = true; }
for (k in allArtistKeys) {
if (excludeArtistKeys && excludeArtistKeys[k]) continue; // explicitly deleted
var ba = base.artists[k];
var ia = incoming.artists[k];
if (ba && ia) {
result.artists[k] = (ia.lastSeenAt || "") >= (ba.lastSeenAt || "") ? ia : ba;
} else {
result.artists[k] = ba || ia;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Storage helpers
// ---------------------------------------------------------------------------
function emptyStore() {
return { version: STORAGE_VERSION, artists: {}, posts: {} };
}
function loadStore() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return emptyStore();
var data = JSON.parse(raw);
return migrateStore(data);
} catch (e) {
// Parse failed — data may be corrupted.
// Backup the raw string so the user can attempt manual recovery.
console.warn("[BetterPawchive] Failed to parse stored data. Backing up raw string to '" + BACKUP_KEY + "'.", e);
try {
var rawFallback = localStorage.getItem(STORAGE_KEY);
if (rawFallback) {
localStorage.setItem(BACKUP_KEY, rawFallback);
}
} catch (e2) { /* can't even back up — localStorage may be broken */ }
return emptyStore();
}
}
/**
* Migrate from v1 (artist key = userId) to v2 (artist key = service:userId).
* Runs once; subsequent loads are already v2.
*/
function migrateStore(data) {
if (!data || typeof data !== "object") return emptyStore();
if (!data.artists) data.artists = {};
if (!data.posts) data.posts = {};
if (!data.version || data.version < 2) {
var newArtists = {};
var skipped = 0;
Object.keys(data.artists).forEach(function (key) {
var artist = data.artists[key];
if (key.indexOf(":") === -1) {
// Old format: key is bare userId
if (!artist || !artist.service) {
// Missing service field — cannot construct composite key; skip to avoid ":userId" keys
skipped++;
return;
}
var newKey = artist.service + ":" + key;
if (newArtists[newKey]) {
// Key collision — keep the entry with the more recent lastSeenAt
var existing = newArtists[newKey];
if ((artist.lastSeenAt || "") > (existing.lastSeenAt || "")) {
newArtists[newKey] = artist;
}
} else {
newArtists[newKey] = artist;
}
} else {
// Already has ':' in key — likely already migrated or manually set
newArtists[key] = artist;
}
});
data.artists = newArtists;
data.version = STORAGE_VERSION;
if (skipped > 0) {
console.warn("[BetterPawchive] Migration skipped", skipped, "artist entries with missing service field.");
}
// Persist migrated data immediately
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) { /* quota */ }
}
return data;
}
/**
* Read-merge-write: re-reads localStorage, merges with in-memory store, then writes.
* This prevents multi-tab overwrites where Tab B clobbers Tab A's recent writes.
*/
function saveStore(storeRef) {
try {
if (storeRef._clearedAll) {
// Bypass read-merge-write for wholesale clears, we want to nuke disk
storeRef._clearedAll = false;
} else {
// Re-read disk state and merge to avoid clobbering another tab's writes
var diskRaw = localStorage.getItem(STORAGE_KEY);
if (diskRaw) {
try {
var diskData = JSON.parse(diskRaw);
// Merge: our in-memory store wins on collisions, but we preserve
// disk posts unless they were explicitly deleted in this tab
var merged = mergeStores(diskData, storeRef, storeRef._deletedPosts, storeRef._deletedArtists);
// Update in-memory store with any new data from disk
storeRef.posts = merged.posts;
storeRef.artists = merged.artists;
} catch (parseErr) {
console.warn("[BetterPawchive] Disk data corrupted during save, overwriting.", parseErr);
}
}
}
// Clear tracking maps after processing
storeRef._deletedPosts = {};
storeRef._deletedArtists = {};
localStorage.setItem(STORAGE_KEY, JSON.stringify({
version: storeRef.version,
artists: storeRef.artists,
posts: storeRef.posts
}));
storeRef._saveError = false;
} catch (e) {
console.warn("[BetterPawchive] localStorage write failed (quota exceeded?).", e);
storeRef._saveError = true;
}
}
// ---------------------------------------------------------------------------
// Undo support
// ---------------------------------------------------------------------------
function saveUndoSnapshot(storeRef) {
try {
localStorage.setItem(UNDO_KEY, JSON.stringify({
posts: storeRef.posts,
artists: storeRef.artists,
savedAt: new Date().toISOString()
}));
} catch (e) {
console.warn("[BetterPawchive] Could not save undo snapshot.", e);
}
}
function loadUndoSnapshot() {
try {
var raw = localStorage.getItem(UNDO_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function clearUndoSnapshot() {
try { localStorage.removeItem(UNDO_KEY); } catch (e) { /* ignore */ }
}
function restoreFromUndo(storeRef) {
var undo = loadUndoSnapshot();
if (!undo) return false;
if (undo.posts) storeRef.posts = undo.posts;
if (undo.artists) storeRef.artists = undo.artists;
saveStore(storeRef);
clearUndoSnapshot();
return true;
}
// ---------------------------------------------------------------------------
// Store instance
// ---------------------------------------------------------------------------
var store = loadStore();
// ---------------------------------------------------------------------------
// Cross-tab sync via storage event
// ---------------------------------------------------------------------------
window.addEventListener("storage", function (e) {
if (e.key !== STORAGE_KEY) return;
if (!e.newValue) return; // Key was removed
try {
var incoming = JSON.parse(e.newValue);
if (!incoming || !incoming.posts) return;
// Merge incoming data from the other tab into our in-memory store
var merged = mergeStores(store, incoming, store._deletedPosts, store._deletedArtists);
store.posts = merged.posts;
store.artists = merged.artists;
// Re-render tags and widget to reflect merged state
addViewedTags();
injectStatsWidget();
} catch (err) {
console.warn("[BetterPawchive] Failed to merge cross-tab update.", err);
}
});
// ---------------------------------------------------------------------------
// Post / Artist tracking
// ---------------------------------------------------------------------------
function artistKey(service, userId) {
return service + ":" + userId;
}
function markPostViewed(postId, service, userId, title) {
store.posts[postId] = {
id: postId,
service: service,
userId: userId,
title: title || "",
viewedAt: new Date().toISOString()
};
saveStore(store);
}
function markPostUnread(postId) {
delete store.posts[postId];
store._deletedPosts = store._deletedPosts || {};
store._deletedPosts[postId] = true;
saveStore(store);
}
function isPostViewed(postId) {
return postId in store.posts;
}
function trackArtist(userId, service, name) {
var key = artistKey(service, userId);
if (!store.artists[key]) {
store.artists[key] = {
id: userId,
service: service,
name: name || "",
lastSeenAt: new Date().toISOString()
};
} else {
store.artists[key].lastSeenAt = new Date().toISOString();
if (name) store.artists[key].name = name;
}
saveStore(store);
}
// ---------------------------------------------------------------------------
// URL parsing
// ---------------------------------------------------------------------------
var ARTIST_RE = /^\/([a-z0-9_]+)\/user\/([^/]+)\/?$/;
var POST_RE = /^\/([a-z0-9_]+)\/user\/([^/]+)\/post\/([^/]+)\/?$/;
function parseUrl(pathname) {
var m = pathname.match(POST_RE);
if (m) return { type: "post", service: m[1], userId: m[2], postId: m[3] };
m = pathname.match(ARTIST_RE);
if (m) return { type: "artist", service: m[1], userId: m[2] };
return { type: "other" };
}
// ---------------------------------------------------------------------------
// Page scanning
// ---------------------------------------------------------------------------
function getCurrentPageInfo() {
var p = parseUrl(window.location.pathname);
if (p.type === "post") {
var postTitleEl = document.querySelector(".post__title");
var postTitle = postTitleEl ? postTitleEl.textContent.trim().substring(0, 200) : "";
var postNameEl = document.querySelector(".post__user-name");
var postArtistName = postNameEl ? postNameEl.textContent.trim() : "";
trackArtist(p.userId, p.service, postArtistName);
markPostViewed(p.postId, p.service, p.userId, postTitle);
}
if (p.type === "artist") {
var artistNameEl = document.querySelector(".user-header__name span[itemprop='name']");
var artistPageName = artistNameEl ? artistNameEl.textContent.trim() : "";
trackArtist(p.userId, p.service, artistPageName);
}
}
// ---------------------------------------------------------------------------
// "Viewed" tag component
// ---------------------------------------------------------------------------
function createViewedTag(postId) {
var container = document.createElement("span");
container.className = "bp-viewed-container";
container.style.cssText = "display:inline-flex;align-items:center;gap:4px;margin-left:6px;";
var label = document.createElement("span");
label.className = "bp-viewed-tag";
label.textContent = "viewed";
label.style.cssText = "color:#b4ffb4;font-size:0.75em;font-weight:600;";
var unreadBtn = document.createElement("button");
unreadBtn.className = "bp-unread-btn";
unreadBtn.textContent = "\u2715";
unreadBtn.title = "Mark as Unread";
unreadBtn.style.cssText =
"background:none;border:1px solid #b4ffb4;color:#b4ffb4;font-size:0.65em;" +
"line-height:1;cursor:pointer;border-radius:3px;padding:1px 4px;";
unreadBtn.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
markPostUnread(postId);
container.remove();
});
// Prevent clicks on the tag from bubbling up to a parent <a> (post-card link)
container.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
});
container.appendChild(label);
container.appendChild(unreadBtn);
return container;
}
// ---------------------------------------------------------------------------
// Tag injection into card list & post page
// ---------------------------------------------------------------------------
function addViewedTags() {
document.querySelectorAll("article.post-card").forEach(function (card) {
var postId = card.getAttribute("data-id");
if (!postId || !isPostViewed(postId)) return;
if (card.querySelector(".bp-viewed-container")) return;
var footer = card.querySelector(".post-card__footer > div");
if (!footer) return;
footer.appendChild(createViewedTag(postId));
});
var postSection = document.querySelector(".site-section--post");
if (postSection) {
var postId = postSection.getAttribute("data-id");
if (postId && isPostViewed(postId)) {
var postInfo = document.querySelector(".post__info");
if (postInfo && !postInfo.querySelector(".bp-viewed-container")) {
var tag = createViewedTag(postId);
tag.style.marginTop = "4px";
postInfo.appendChild(tag);
}
}
}
}
// ---------------------------------------------------------------------------
// Sidebar stats widget
// ---------------------------------------------------------------------------
function injectStatsWidget() {
var sidebar = document.querySelector(".global-sidebar");
if (!sidebar) return;
var existing = document.getElementById("bp-stats-widget");
if (existing) existing.remove();
var postCount = Object.keys(store.posts).length;
var artistCount = Object.keys(store.artists).length;
var widget = document.createElement("div");
widget.id = "bp-stats-widget";
widget.className = "global-sidebar-entry";
var header = document.createElement("div");
header.className = "global-sidebar-entry-item header";
header.style.cssText = "display:flex;align-items:center;gap:6px;";
var checkIcon = document.createElement("span");
checkIcon.style.cssText = "font-size:0.85em;color:#b4ffb4;";
checkIcon.textContent = "\u2713";
var headerText = document.createElement("span");
headerText.textContent = "Better Pawchive";
header.appendChild(checkIcon);
header.appendChild(headerText);
// Show save error indicator if persistence is broken
if (store._saveError) {
var warnIcon = document.createElement("span");
warnIcon.textContent = "\u26A0";
warnIcon.title = "Warning: localStorage write failed. Your viewed history may not be saved. Try closing other tabs or clearing browser storage.";
warnIcon.style.cssText = "font-size:0.85em;color:#f90;cursor:help;";
header.appendChild(warnIcon);
}
var stats = document.createElement("div");
stats.className = "global-sidebar-entry-item";
stats.style.cssText = "font-size:0.8em;padding-left:28px;color:#aaa;";
stats.textContent = artistCount + " creators \u00B7 " + postCount + " posts";
var mgmtLink = document.createElement("a");
mgmtLink.id = "bp-mgmt-link";
mgmtLink.className = "global-sidebar-entry-item";
mgmtLink.href = "#";
mgmtLink.style.cssText = "font-size:0.8em;padding-left:28px;color:#666;cursor:pointer;";
mgmtLink.textContent = "View History";
mgmtLink.addEventListener("click", function (e) {
e.preventDefault();
showManagementOverlay();
});
widget.appendChild(header);
widget.appendChild(stats);
widget.appendChild(mgmtLink);
var accountEntry = sidebar.querySelector(".global-sidebar-entry.account");
if (accountEntry) {
sidebar.insertBefore(widget, accountEntry);
} else {
sidebar.appendChild(widget);
}
}
// ---------------------------------------------------------------------------
// View History overlay (grouped by artist)
// ---------------------------------------------------------------------------
function showManagementOverlay() {
var existing = document.getElementById("bp-overlay");
if (existing) { existing.remove(); return; }
var posts = Object.values(store.posts).sort(function (a, b) {
return new Date(b.viewedAt) - new Date(a.viewedAt);
});
// Group by service:userId
var groups = {};
var groupOrder = [];
posts.forEach(function (p) {
var key = p.service + ":" + p.userId;
if (!groups[key]) {
groups[key] = [];
groupOrder.push(key);
}
groups[key].push(p);
});
function getArtistName(service, userId) {
var a = store.artists[artistKey(service, userId)];
if (a && a.name) return a.name;
return userId;
}
// ---- Build overlay DOM ----
var overlay = document.createElement("div");
overlay.id = "bp-overlay";
overlay.style.cssText =
"position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;" +
"background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;";
var modal = document.createElement("div");
modal.style.cssText =
"background:#1a1a2e;border:1px solid #333;border-radius:8px;max-width:780px;width:93%;max-height:84vh;" +
"display:flex;flex-direction:column;";
// ---- Header bar ----
var headerBar = document.createElement("div");
headerBar.style.cssText =
"display:flex;justify-content:space-between;align-items:center;padding:16px 20px;" +
"border-bottom:1px solid #333;flex-shrink:0;flex-wrap:wrap;gap:8px;";
var headerTitle = document.createElement("h3");
headerTitle.style.cssText = "margin:0;color:#b4ffb4;";
headerTitle.textContent = "View History";
var headerControls = document.createElement("div");
headerControls.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;";
function makeSmallBtn(text, extraStyle) {
var btn = document.createElement("button");
btn.textContent = text;
btn.style.cssText =
"background:none;border:1px solid #555;color:#888;border-radius:4px;padding:3px 10px;cursor:pointer;font-size:0.8em;" +
(extraStyle || "");
return btn;
}
var expandAllBtn = makeSmallBtn("Expand All");
var collapseAllBtn = makeSmallBtn("Collapse All");
var summarySpan = document.createElement("span");
summarySpan.style.cssText = "color:#888;font-size:0.85em;";
summarySpan.textContent = Object.keys(store.posts).length + " posts \u00B7 " + groupOrder.length + " creators";
var clearAllBtn = document.createElement("button");
clearAllBtn.textContent = "Clear All";
clearAllBtn.style.cssText =
"margin-left:4px;background:#a00;color:#fff;border:none;border-radius:4px;padding:4px 10px;cursor:pointer;";
var closeBtn = document.createElement("button");
closeBtn.textContent = "Close";
closeBtn.style.cssText = "background:#333;color:#fff;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;";
headerControls.appendChild(expandAllBtn);
headerControls.appendChild(collapseAllBtn);
headerControls.appendChild(summarySpan);
headerControls.appendChild(clearAllBtn);
headerControls.appendChild(closeBtn);
headerBar.appendChild(headerTitle);
headerBar.appendChild(headerControls);
// ---- Undo bar (initially hidden) ----
var undoBar = document.createElement("div");
undoBar.style.cssText =
"display:none;padding:8px 20px;background:#2a2a1a;border-bottom:1px solid #555;" +
"align-items:center;gap:8px;flex-shrink:0;";
var undoText = document.createElement("span");
undoText.style.cssText = "color:#fc0;font-size:0.85em;";
undoText.textContent = "History cleared.";
var undoBtn = document.createElement("button");
undoBtn.textContent = "Undo";
undoBtn.style.cssText =
"background:#fc0;color:#000;border:none;border-radius:4px;padding:3px 12px;cursor:pointer;font-weight:600;font-size:0.85em;";
undoBar.appendChild(undoText);
undoBar.appendChild(undoBtn);
function showUndoBar(message) {
undoText.textContent = message || "History cleared.";
undoBar.style.display = "flex";
}
undoBtn.addEventListener("click", function () {
if (restoreFromUndo(store)) {
overlay.remove();
injectStatsWidget();
addViewedTags();
// Re-open overlay to show restored data
showManagementOverlay();
}
});
// ---- Body (scrollable) ----
var body = document.createElement("div");
body.style.cssText = "overflow:auto;padding:12px 20px 20px;";
if (groupOrder.length === 0) {
var emptyMsg = document.createElement("div");
emptyMsg.style.cssText = "text-align:center;color:#888;padding:20px;";
emptyMsg.textContent = "No viewed posts yet.";
body.appendChild(emptyMsg);
// Check if there's an undo snapshot available
var undoSnapshot = loadUndoSnapshot();
if (undoSnapshot) {
showUndoBar("Previous history was cleared. Click Undo to restore.");
}
} else {
groupOrder.forEach(function (key) {
var parts = key.split(":");
var service = parts[0];
var userId = parts.slice(1).join(":");
var groupPosts = groups[key];
var name = getArtistName(service, userId);
var groupDiv = document.createElement("div");
groupDiv.className = "bp-artist-group";
groupDiv.style.marginBottom = "2px";
// Group header
var gHeader = document.createElement("div");
gHeader.className = "bp-group-header";
gHeader.style.cssText =
"display:flex;align-items:center;justify-content:space-between;" +
"background:#252540;border-radius:4px;padding:8px 12px;cursor:pointer;user-select:none;" +
"border-left:3px solid #b4ffb4;";
var gHeaderLeft = document.createElement("div");
gHeaderLeft.style.cssText = "display:flex;align-items:center;gap:8px;";
var arrow = document.createElement("span");
arrow.className = "bp-group-arrow";
arrow.style.cssText = "color:#b4ffb4;font-size:0.8em;transition:transform 0.15s;";
arrow.textContent = "\u25B6";
var artistLink = document.createElement("a");
artistLink.href = "/" + encodeURIComponent(service) + "/user/" + encodeURIComponent(userId);
artistLink.style.cssText = "color:#ddd;font-weight:600;text-decoration:none;";
artistLink.textContent = name;
var countBadge = document.createElement("span");
countBadge.style.cssText = "color:#888;font-size:0.75em;background:#333;border-radius:999px;padding:1px 8px;";
countBadge.textContent = String(groupPosts.length);
gHeaderLeft.appendChild(arrow);
gHeaderLeft.appendChild(artistLink);
gHeaderLeft.appendChild(countBadge);
var gHeaderRight = document.createElement("div");
gHeaderRight.style.cssText = "display:flex;gap:6px;";
var clearGroupBtn = document.createElement("button");
clearGroupBtn.className = "bp-clear-group";
clearGroupBtn.textContent = "clear";
clearGroupBtn.style.cssText =
"background:none;border:1px solid #555;color:#888;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:0.75em;";
gHeaderRight.appendChild(clearGroupBtn);
gHeader.appendChild(gHeaderLeft);
gHeader.appendChild(gHeaderRight);
// Group body (collapsed by default)
var gBody = document.createElement("div");
gBody.className = "bp-group-body";
gBody.style.display = "none";
groupPosts.forEach(function (p) {
var row = document.createElement("div");
row.style.cssText =
"display:flex;align-items:center;justify-content:space-between;" +
"padding:5px 12px 5px 36px;border-bottom:1px solid #1a1a2e;font-size:0.9em;";
var postLink = document.createElement("a");
postLink.href = "/" + encodeURIComponent(p.service) + "/user/" + encodeURIComponent(p.userId) + "/post/" + encodeURIComponent(p.id);
postLink.style.cssText = "color:#b4ffb4;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
postLink.textContent = p.title || "(untitled)";
var dateSpan = document.createElement("span");
dateSpan.style.cssText = "color:#666;font-size:0.8em;";
dateSpan.textContent = p.viewedAt ? new Date(p.viewedAt).toLocaleString() : "";
var delBtn = document.createElement("button");
delBtn.className = "bp-del-btn";
delBtn.textContent = "\u2715";
delBtn.style.cssText = "background:none;border:none;color:#f66;cursor:pointer;font-size:0.95em;";
(function (postId) {
delBtn.addEventListener("click", function () {
markPostUnread(postId);
overlay.remove();
injectStatsWidget();
addViewedTags();
});
})(p.id);
row.appendChild(postLink);
row.appendChild(dateSpan);
row.appendChild(delBtn);
gBody.appendChild(row);
});
// Toggle group open/close
gHeader.addEventListener("click", function (e) {
if (e.target.tagName === "A" || e.target.tagName === "BUTTON") return;
if (gBody.style.display === "none") {
gBody.style.display = "block";
arrow.textContent = "\u25BC";
} else {
gBody.style.display = "none";
arrow.textContent = "\u25B6";
}
});
// Clear group — with undo
(function (groupKey, targets) {
clearGroupBtn.addEventListener("click", function (e) {
e.stopPropagation();
var gName = getArtistName(targets[0].service, targets[0].userId);
if (!confirm("Remove all " + targets.length + ' viewed posts from "' + gName + '"?')) return;
saveUndoSnapshot(store);
store._deletedPosts = store._deletedPosts || {};
targets.forEach(function (p) {
delete store.posts[p.id];
store._deletedPosts[p.id] = true;
});
saveStore(store);
overlay.remove();
injectStatsWidget();
addViewedTags();
});
})(key, groupPosts);
groupDiv.appendChild(gHeader);
groupDiv.appendChild(gBody);
body.appendChild(groupDiv);
});
}
modal.appendChild(headerBar);
modal.appendChild(undoBar);
modal.appendChild(body);
overlay.appendChild(modal);
document.body.appendChild(overlay);
// ---- Event handlers ----
overlay.addEventListener("click", function (e) {
if (e.target === overlay) overlay.remove();
});
closeBtn.addEventListener("click", function () { overlay.remove(); });
// Clear All — with undo
clearAllBtn.addEventListener("click", function () {
if (confirm("Clear all viewed history? This cannot be undone (but you can Undo immediately).")) {
saveUndoSnapshot(store);
store.posts = {};
store.artists = {};
store._clearedAll = true;
saveStore(store);
overlay.remove();
injectStatsWidget();
addViewedTags();
// Re-open to show the undo bar
showManagementOverlay();
}
});
function setAllGroups(open) {
overlay.querySelectorAll(".bp-group-body").forEach(function (b) {
b.style.display = open ? "block" : "none";
});
overlay.querySelectorAll(".bp-group-arrow").forEach(function (a) {
a.textContent = open ? "\u25BC" : "\u25B6";
});
}
expandAllBtn.addEventListener("click", function () { setAllGroups(true); });
collapseAllBtn.addEventListener("click", function () { setAllGroups(false); });
}
// ---------------------------------------------------------------------------
// MutationObserver (debounced)
// ---------------------------------------------------------------------------
var observer;
function onDomChange() {
getCurrentPageInfo();
addViewedTags();
}
var debouncedOnDomChange = debounce(onDomChange, DEBOUNCE_MS);
function startObserver() {
if (observer) observer.disconnect();
observer = new MutationObserver(debouncedOnDomChange);
var target = document.getElementById("main") || document.body;
observer.observe(target, { childList: true, subtree: true });
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
function init() {
getCurrentPageInfo();
addViewedTags();
injectStatsWidget();
startObserver();
console.log("[BetterPawchive] v1.3.0 Ready. Tracked:", Object.keys(store.posts).length, "posts,", Object.keys(store.artists).length, "artists");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// htmx SPA navigation support
document.body.addEventListener("htmx:afterSwap", function () {
setTimeout(function () {
getCurrentPageInfo();
addViewedTags();
injectStatsWidget();
}, 150);
});
})();