Better Pawchive

Adds "Viewed" tags to visited posts on pawchive.pw, with mark-as-unread support and artist-grouped history.浏览增强:自动标记已读帖子,按创作者分组查看浏览历史。

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#39;");
  }

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