Kemono QoL Improvements

Allow you to hide or highlight posts by user id on Posts / Popular Posts page.

// ==UserScript==
// @name        Kemono QoL Improvements
// @namespace   Kemono_QoL_Improvements
// @license     WTFPL
// @match       https://kemono.cr/*
// @match       https://coomer.st/*
// @grant       none
// @version     3.0
// @author      Kaban
// @description Allow you to hide or highlight posts by user id on Posts / Popular Posts page.
// @description Allow you to hide posts with no preview images on Posts / Popular Posts page.
// @description Auto-sorts posts by Post time on Posts / Popular Posts page (within current page).
// @description Makes post thumbnails taller, allow in-post images to be tiled horizontally.
// ==/UserScript==
(() => {
// ==<User Settings>==

const option_hide_posts_with_no_preview = true;

const hide_group = [
  "fanbox-000000000",  // [UserName] I recommend you to add comments like this,
  "patreon-000000000", // [UserName] so you don't forget why you added them to the blacklist.
];

const highlight_group_1 = [ // Highlight posts from these users (red background)
  "fanbox-000000000",  // [UserName] I use this to mark creators who have their contents
  "patreon-000000000", // [UserName] stored on external services like MEGA / Google Drive.
];

const highlight_group_2 = [ // Highlight posts from these users (green background)
  "fanbox-000000000",  // [UserName] You can leave this list empty (highlight_group_2 = [];)
  "patreon-000000000", // [UserName] if you don't need this feature.
];

const custom_header = new Map([ // Display a custom line below the post title
  ["fanbox-000000000",  "Custom Name"], // You can use this to display the user's changed name,
  ["patreon-000000000", "Custom Tags"], // or anything you want like tags. Leave empty to disable.
]);

/* Here's a helper bookmarklet that converts your favorite users into the format of the "custom_header" above:

javascript:(function()%7Blet%20output%3D%5B%5D%3BJSON.parse(document.getElementsByTagName('textarea')%5B0%5D.textContent).artists.forEach(artist%3D%3E%7Blet%20key%3D%60%24%7Bartist.service%7D-%24%7Bartist.id%7D%60%3Blet%20line%3D%60%5B%22%24%7Bkey.trim()%7D%22%2C%60%3Boutput.push(%60%20%20%24%7Bline.padEnd(21%2C%20'%20')%7D%20%22%24%7Bartist.name.trim()%7D%22%5D%2C%60)%7D)%3Bnavigator.clipboard.writeText(output.join('%5Cn'))%7D)()%3B

1. Save the entire line as a bookmark and put it on the bookmark bar
2. Go to Export Favorites page (https://kemono.cr/account/favorites/export)
3. Click on "Export Favorites", wait for it to finish (you'll see a wall of text in a textbox)
4. Click on the bookmarklet to run the script, the output will be copied to the clipboard
*/

// ==<User Settings>==

// ==<CSS>==
// Custom classes (__usr_*) are added to the <article> elements with functions below,
// so we won't have long and complicate css selectors as user lists above grows.

const css_posts_page = `img.post-card__image { object-fit: contain; }
article.post-card { height: calc(var(--card-size) / 2 * 3); }
article.__usr_blacklisted { opacity: 0.5; }
article.__usr_no_previews { height: fit-content; }
article.__usr_highlight_group_1 header, article.__usr_highlight_group_1 footer { background: rgb(153 0 0 / 50%) !important; }
article.__usr_highlight_group_2 header, article.__usr_highlight_group_2 footer { background: rgb(24 153 0 / 50%) !important; }
article header > div { text-align: right; }`;

const css_posts_page_blacklisted = `article.__usr_blacklisted { display: none; }`;
const css_posts_page_no_previews = `article.__usr_no_previews { display: none; }`;

// Don't hide or highlight posts on user page
const css_user_page = `img.post-card__image { object-fit: contain; }
article.post-card { height: calc(var(--card-size) / 2 * 3); }`;

// Allow images to tile horizontally on single post page
const css_post_page = `div.post__files { flex-flow: wrap; }`;

// ==</CSS>==

// ==<Script Functions>==

let old_url = "";
function addStyles() {
  if (window.location.href === old_url) return; // run only once per url change
  old_url = window.location.href;

  let styleElement = document.getElementById("__usr__page_style");
  if (styleElement) styleElement.remove();
  styleElement = document.createElement("style");
  styleElement.id = "__usr__page_style";

  if (window.location.pathname === "/posts" || window.location.pathname === "/posts/popular") {
    styleElement.textContent = css_posts_page;
    document.head.append(styleElement);

    styleElement = document.getElementById("__usr__page_style_hide_blacklisted");
    if (styleElement) styleElement.remove();
    styleElement = document.createElement("style");
    styleElement.id = "__usr__page_style_hide_blacklisted";
    styleElement.textContent = css_posts_page_blacklisted;
    document.head.append(styleElement);

    styleElement = document.getElementById("__usr__page_style_hide_no_previews");
    if (styleElement) styleElement.remove();
    styleElement = document.createElement("style");
    styleElement.id = "__usr__page_style_hide_no_previews";
    styleElement.textContent = css_posts_page_no_previews;
    document.head.append(styleElement);

  } else if (window.location.pathname.includes("/user/")) {
    if (window.location.pathname.includes("/post/")) {
      styleElement.textContent = css_post_page;
    } else {
      styleElement.textContent = css_user_page;
    }
    document.head.append(styleElement);
  }
}

function sortPosts() {
  let sortMessageElement = document.getElementById("__usr__sort_message");
  if (sortMessageElement) sortMessageElement.remove();
  let hidPostsMessageElement = document.getElementById("__usr__hid_posts_message");
  if (hidPostsMessageElement) hidPostsMessageElement.remove();

  let postsHolder = document.getElementsByClassName("card-list__items")[0];
  let posts = postsHolder.getElementsByTagName("article");
  if (posts.length === 0) return;

  let count_posts_blacklisted = 0;
  let count_posts_no_previews = 0;
  let sortedPosts = Array.from(posts).sort( (a, b) => new Date(b.getElementsByTagName("time")[0].dateTime) - new Date(a.getElementsByTagName("time")[0].dateTime) );
  postsHolder.replaceChildren();
  sortedPosts.forEach(post => {
    let key = `${post.getAttribute("data-service")}-${post.getAttribute("data-user")}`;
    if (hide_group.includes(key)) {
      post.classList.add("__usr_blacklisted");
      count_posts_blacklisted++;
    }
    if (option_hide_posts_with_no_preview && post.getElementsByClassName("post-card__image-container").length === 0) {
      post.classList.add("__usr_no_previews");
      count_posts_no_previews++;
    }
    if (highlight_group_1.includes(key)) {
      post.classList.add("__usr_highlight_group_1");
    } else if (highlight_group_2.includes(key)) {
      post.classList.add("__usr_highlight_group_2");
    }
    if (custom_header.get(key)) {
      let creatorElement = document.createElement("div");
      creatorElement.textContent = custom_header.get(key);
      post.getElementsByTagName("header")[0].appendChild(creatorElement);
    }
    postsHolder.appendChild(post);
  });

  sortMessageElement = document.createElement("small");
  sortMessageElement.id = "__usr__sort_message";
  hidPostsMessageElement = document.createElement("div");
  hidPostsMessageElement.id = "__usr__hid_posts_message";
  let insertPosition = document.getElementById("paginator-top").getElementsByTagName("small")[0];
  if (insertPosition) {
    sortMessageElement.textContent = " posts (sorted by Post time).";
    insertPosition.after(sortMessageElement);
  } else {
    sortMessageElement.textContent = `Found ${posts.length} ${posts.length > 1 ? "posts (sorted by Post time)." : "post."}`;
    document.getElementById("paginator-top").appendChild(sortMessageElement);
  }
  sortMessageElement.after(hidPostsMessageElement);

  let hidPostsMessages = [];
  if (count_posts_blacklisted > 0) {
    let blacklistedUsersMessageElement = document.createElement("small");
    blacklistedUsersMessageElement.id = "__usr__hid_posts_message_blacklisted";
    blacklistedUsersMessageElement.addEventListener('click', function() { showPostsFromBlacklistedUsers(); });
    blacklistedUsersMessageElement.textContent = `${count_posts_blacklisted} ${count_posts_blacklisted > 1 ? "posts from blacklisted users" : "post from a blacklisted user"}`;
    hidPostsMessages.push(blacklistedUsersMessageElement);
  }
  if (count_posts_no_previews > 0) {
    let noPreviewImagesMessageElement = document.createElement("small");
    noPreviewImagesMessageElement.id = "__usr__hid_posts_message_no_previews";
    noPreviewImagesMessageElement.addEventListener('click', function() { showPostsWithNoPreviewImages(); });
    noPreviewImagesMessageElement.textContent = `${count_posts_no_previews} ${count_posts_no_previews > 1 ? "posts with no preview images" : "post with no preview image"}`;
    hidPostsMessages.push(noPreviewImagesMessageElement);
  }

  if (hidPostsMessages.length > 0) {
    let prefix = document.createElement("small");
    prefix.textContent = "Hid ";
    hidPostsMessageElement.appendChild(prefix)
    hidPostsMessageElement.appendChild(hidPostsMessages[0]);
    if (hidPostsMessages.length > 1) {
      let separator = document.createElement("small");
      separator.id = "__usr__hid_posts_message_separator";
      separator.textContent = ", ";
      hidPostsMessageElement.appendChild(separator);
      hidPostsMessageElement.appendChild(hidPostsMessages[1]);
    }
    let suffix = document.createElement("small");
    suffix.textContent = ".";
    hidPostsMessageElement.appendChild(suffix)
  }
}

function showPostsFromBlacklistedUsers() {
  document.getElementById("__usr__page_style_hide_blacklisted").remove();
  observer.disconnect();
  if (document.getElementById("__usr__hid_posts_message_separator")) {
    document.getElementById("__usr__hid_posts_message_separator").remove();
    document.getElementById("__usr__hid_posts_message_blacklisted").remove()
  } else {
    document.getElementById("__usr__hid_posts_message").remove();
  }
  observer.observe(document.body, { childList: true, subtree: true });
}

function showPostsWithNoPreviewImages() {
  document.getElementById("__usr__page_style_hide_no_previews").remove();
  observer.disconnect();
  if (document.getElementById("__usr__hid_posts_message_separator")) {
    document.getElementById("__usr__hid_posts_message_separator").remove();
    document.getElementById("__usr__hid_posts_message_no_previews").remove()
  } else {
    document.getElementById("__usr__hid_posts_message").remove();
  }
  observer.observe(document.body, { childList: true, subtree: true });
}

// ==</Script Functions>==

// ==<Main>==
// Kemono is a SPA (Single Page Application).
// We use MutationObserver to (re)run scripts when page content changes.

let observer = new MutationObserver(mutations => {
  addStyles(); // this will fire multiple times as the page loads, but I want the styles to be added as early as possible so keep it here

  let pageLoaded = false;
  if (window.location.pathname === "/posts" || window.location.pathname === "/posts/popular") {
    mutations.forEach(mutation => {
      if (mutation.target.classList.contains("ad-container")) pageLoaded = true; // div.ad-container is the last mutations observerd on page update
    });
    if (pageLoaded) {
      observer.disconnect();
      sortPosts();
      observer.observe(document.body, { childList: true, subtree: true });
    }
  }
});
observer.observe(document.body, { childList: true, subtree: true });

// ==</Main>==
})();