Better Pawchive

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

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

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

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

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

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

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

(I already have a user script manager, let me install it!)

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

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

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

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

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

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

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Better Pawchive
// @namespace    https://github.com/better-pawchive
// @version      1.1.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";

  function loadStore() {
    try {
      var raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return { artists: {}, posts: {} };
      return JSON.parse(raw);
    } catch (e) {
      return { artists: {}, posts: {} };
    }
  }

  function saveStore(store) {
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); } catch (e) {}
  }

  var store = loadStore();

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

  function isPostViewed(postId) {
    return postId in store.posts;
  }

  function trackArtist(artistId, service, name) {
    if (!store.artists[artistId]) {
      store.artists[artistId] = { id: artistId, service: service, name: name || "", lastSeenAt: new Date().toISOString() };
    } else {
      store.artists[artistId].lastSeenAt = new Date().toISOString();
      if (name) store.artists[artistId].name = name;
    }
    saveStore(store);
  }

  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" };
  }

  function getCurrentPageInfo() {
    var p = parseUrl(window.location.pathname);
    if (p.type === "post") {
      var titleEl = document.querySelector(".post__title");
      var title = titleEl ? titleEl.textContent.trim().substring(0, 200) : "";
      var nameEl = document.querySelector(".post__user-name");
      var artistName = nameEl ? nameEl.textContent.trim() : "";
      trackArtist(p.userId, p.service, artistName);
      markPostViewed(p.postId, p.service, p.userId, title);
    }
    if (p.type === "artist") {
      var nameEl = document.querySelector(".user-header__name span[itemprop='name']");
      var artistName = nameEl ? nameEl.textContent.trim() : "";
      trackArtist(p.userId, p.service, artistName);
    }
  }

  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.innerHTML = "✕";
    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();
    });

    container.addEventListener("click", function(e) {
      e.preventDefault();
      e.stopPropagation();
    });

    container.appendChild(label);
    container.appendChild(unreadBtn);
    return container;
  }

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

  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";
    widget.innerHTML =
      '<div class="global-sidebar-entry-item header" style="display:flex;align-items:center;gap:6px;">' +
      '<span style="font-size:0.85em;color:#b4ffb4;">&#x2713;</span>' +
      '<span>Better Pawchive</span></div>' +
      '<div class="global-sidebar-entry-item" style="font-size:0.8em;padding-left:28px;color:#aaa;">' +
      artistCount + " creators &middot; " + postCount + " posts</div>" +
      '<a id="bp-mgmt-link" class="global-sidebar-entry-item" href="#" style="font-size:0.8em;padding-left:28px;color:#666;cursor:pointer;">View History</a>';

    var accountEntry = sidebar.querySelector(".global-sidebar-entry.account");
    if (accountEntry) {
      sidebar.insertBefore(widget, accountEntry);
    } else {
      sidebar.appendChild(widget);
    }

    document.getElementById("bp-mgmt-link").addEventListener("click", function(e) {
      e.preventDefault();
      showManagementOverlay();
    });
  }

  // --- 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[userId];
      if (a && a.name) return a.name;
      return userId;
    }

    var bodyHtml = "";
    if (groupOrder.length === 0) {
      bodyHtml = '<div style="text-align:center;color:#888;padding:20px;">No viewed posts yet.</div>';
    } else {
      groupOrder.forEach(function(key) {
        var parts = key.split(":");
        var service = parts[0];
        var userId = parts[1];
        var groupPosts = groups[key];
        var artistName = esc(getArtistName(service, userId));
        var groupId = "bp-group-" + key.replace(/[^a-z0-9]/g, "-");

        bodyHtml +=
          '<div class="bp-artist-group" style="margin-bottom:2px;">' +
          '<div class="bp-group-header" data-group="' + groupId + '" style="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;">' +
          '<div style="display:flex;align-items:center;gap:8px;">' +
          '<span class="bp-group-arrow" style="color:#b4ffb4;font-size:0.8em;transition:transform 0.15s;">\u25B6</span>' +
          '<a href="/' + esc(service) + '/user/' + esc(userId) + '" style="color:#ddd;font-weight:600;text-decoration:none;">' + artistName + '</a>' +
          '<span style="color:#888;font-size:0.75em;background:#333;border-radius:999px;padding:1px 8px;">' + groupPosts.length + '</span>' +
          '</div>' +
          '<div style="display:flex;gap:6px;">' +
          '<button class="bp-clear-group" data-key="' + esc(key) + '" style="background:none;border:1px solid #555;color:#888;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:0.75em;">clear</button>' +
          '</div>' +
          '</div>' +
          '<div class="bp-group-body" id="' + groupId + '" style="display:none;">';

        groupPosts.forEach(function(p) {
          var link = "/" + p.service + "/user/" + p.userId + "/post/" + p.id;
          var title = p.title || "(untitled)";
          var date = p.viewedAt ? new Date(p.viewedAt).toLocaleString() : "";
          bodyHtml +=
            '<div style="display:flex;align-items:center;justify-content:space-between;' +
            'padding:5px 12px 5px 36px;border-bottom:1px solid #1a1a2e;font-size:0.9em;">' +
            '<a href="' + link + '" style="color:#b4ffb4;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(title) + '</a>' +
            '<span style="color:#666;font-size:0.8em;">' + date + '</span>' +
            '<button class="bp-del-btn" data-id="' + p.id + '" style="background:none;border:none;color:#f66;cursor:pointer;font-size:0.95em;">\u2715</button>' +
            '</div>';
        });

        bodyHtml += '</div></div>';
      });
    }

    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;";

    overlay.innerHTML =
      '<div style="background:#1a1a2e;border:1px solid #333;border-radius:8px;max-width:780px;width:93%;max-height:84vh;' +
      'display:flex;flex-direction:column;">' +
      '<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;' +
      'border-bottom:1px solid #333;flex-shrink:0;">' +
      '<h3 style="margin:0;color:#b4ffb4;">View History</h3>' +
      '<div style="display:flex;align-items:center;gap:8px;">' +
      '<button id="bp-expand-all" style="background:none;border:1px solid #555;color:#888;border-radius:4px;padding:3px 10px;cursor:pointer;font-size:0.8em;">Expand All</button>' +
      '<button id="bp-collapse-all" style="background:none;border:1px solid #555;color:#888;border-radius:4px;padding:3px 10px;cursor:pointer;font-size:0.8em;">Collapse All</button>' +
      '<span style="color:#888;font-size:0.85em;">' + Object.keys(store.posts).length + ' posts &middot; ' + groupOrder.length + ' creators</span>' +
      '<button id="bp-clear-all" style="margin-left:4px;background:#a00;color:#fff;border:none;border-radius:4px;padding:4px 10px;cursor:pointer;">Clear All</button>' +
      '<button id="bp-close-overlay" style="background:#333;color:#fff;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;">Close</button>' +
      '</div></div>' +
      '<div style="overflow:auto;padding:12px 20px 20px;">' +
      bodyHtml +
      '</div></div>';

    document.body.appendChild(overlay);

    overlay.addEventListener("click", function(e) { if (e.target === overlay) overlay.remove(); });
    overlay.querySelector("#bp-close-overlay").addEventListener("click", function() { overlay.remove(); });

    overlay.querySelector("#bp-clear-all").addEventListener("click", function() {
      if (confirm("Clear all viewed history? This cannot be undone.")) {
        store.posts = {};
        store.artists = {};
        saveStore(store);
        overlay.remove();
        injectStatsWidget();
        addViewedTags();
      }
    });

    // Toggle group open/close
    overlay.querySelectorAll(".bp-group-header").forEach(function(header) {
      header.addEventListener("click", function(e) {
        if (e.target.tagName === "A" || e.target.tagName === "BUTTON") return;
        var groupId = header.getAttribute("data-group");
        var body = document.getElementById(groupId);
        if (!body) return;
        var arrow = header.querySelector(".bp-group-arrow");
        if (body.style.display === "none") {
          body.style.display = "block";
          arrow.textContent = "\u25BC";
        } else {
          body.style.display = "none";
          arrow.textContent = "\u25B6";
        }
      });
    });

    function setAllGroups(open) {
      overlay.querySelectorAll(".bp-group-body").forEach(function(body) {
        body.style.display = open ? "block" : "none";
      });
      overlay.querySelectorAll(".bp-group-arrow").forEach(function(arrow) {
        arrow.textContent = open ? "\u25BC" : "\u25B6";
      });
    }
    overlay.querySelector("#bp-expand-all").addEventListener("click", function() { setAllGroups(true); });
    overlay.querySelector("#bp-collapse-all").addEventListener("click", function() { setAllGroups(false); });

    overlay.querySelectorAll(".bp-del-btn").forEach(function(btn) {
      btn.addEventListener("click", function() {
        markPostUnread(btn.getAttribute("data-id"));
        overlay.remove();
        injectStatsWidget();
        addViewedTags();
      });
    });

    overlay.querySelectorAll(".bp-clear-group").forEach(function(btn) {
      btn.addEventListener("click", function(e) {
        e.stopPropagation();
        var key = btn.getAttribute("data-key");
        var targets = groups[key];
        if (!targets) return;
        var artistName = getArtistName(targets[0].service, targets[0].userId);
        if (!confirm("Remove all " + targets.length + " viewed posts from \"" + artistName + "\"?")) return;
        targets.forEach(function(p) { delete store.posts[p.id]; });
        saveStore(store);
        overlay.remove();
        injectStatsWidget();
        addViewedTags();
      });
    });
  }

  function esc(str) {
    return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
  }

  var observer;
  function startObserver() {
    if (observer) observer.disconnect();
    observer = new MutationObserver(function() {
      getCurrentPageInfo();
      addViewedTags();
    });
    var target = document.getElementById("main") || document.body;
    observer.observe(target, { childList: true, subtree: true });
  }

  function init() {
    getCurrentPageInfo();
    addViewedTags();
    injectStatsWidget();
    startObserver();
    console.log("[BetterPawchive] Ready. Tracked:", Object.keys(store.posts).length, "posts");
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }

  document.body.addEventListener("htmx:afterSwap", function() {
    setTimeout(function() {
      getCurrentPageInfo();
      addViewedTags();
      injectStatsWidget();
    }, 150);
  });

})();