Better Pawchive

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();