Kemono Grid Gallery Layout

Add a responsive grid gallery layout for the kemono.su thumbnails, using the first attachment image file as the cover

Versione datata 15/11/2024. Vedi la nuova versione l'ultima versione.

// ==UserScript==
// @name         Kemono Grid Gallery Layout
// @namespace    https://greasyfork.org/users/172087
// @version      0.3
// @description  Add a responsive grid gallery layout for the kemono.su thumbnails, using the first attachment image file as the cover
// @author       Neko_Aria
// @icon         https://kemono.su/static/favicon.ico
// @match        https://kemono.su/*
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // Constants definition
  const CONSTANTS = {
    API_BASE_URL: "https://kemono.su/api/v1",
    IMAGE_BASE_URL: "https://img.kemono.su/thumbnail",
    SUPPORTED_IMAGE_EXTENSIONS: [
      ".jpg",
      ".jpeg",
      ".png",
      ".gif",
      ".webp",
      ".bmp",
    ],
    GRID_MAX_WIDTH: "1600px",
    GRID_MIN_COLUMN_WIDTH: "250px",
    GRID_GAP: "16px",
  };

  // Add grid gallery layout styles
  GM_addStyle(`
    .card-list__items {
      display: grid !important;
      grid-template-columns: repeat(auto-fill, minmax(${CONSTANTS.GRID_MIN_COLUMN_WIDTH}, 1fr));
      gap: ${CONSTANTS.GRID_GAP};
      padding: ${CONSTANTS.GRID_GAP};
      width: 100%;
      max-width: ${CONSTANTS.GRID_MAX_WIDTH};
      margin: 0 auto;
      grid-auto-rows: auto;
    }

    .post-card {
      width: 100% !important;
      margin: 0 !important;
      break-inside: avoid;
      background: rgba(0, 0, 0, 0.5);
      border-radius: 8px;
      overflow: hidden;
      height: auto !important;
      transition: transform 0.2s ease;
    }

    .post-card:hover {
      transform: translateY(-2px);
    }

    .post-card__image-container {
      position: relative;
      width: 100%;
      height: auto !important;
    }

    .post-card__image {
      width: 100%;
      height: 100%;
      object-fit: cover;
      display: block;
    }

    .loading-overlay {
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      background: rgba(0, 0, 0, 0.8);
      color: white;
      padding: 20px;
      border-radius: 8px;
      z-index: 9999;
      display: flex;
      align-items: center;
    }

    .loading-spinner {
      width: 20px;
      height: 20px;
      border: 3px solid #fff;
      border-radius: 50%;
      border-top-color: transparent;
      animation: spin 1s linear infinite;
      margin-right: 10px;
    }

    @keyframes spin {
      to { transform: rotate(360deg); }
    }
  `);

  // Utility functions
  const utils = {
    // Creates and returns a loading overlay element
    createLoadingOverlay() {
      const overlay = document.createElement("div");
      overlay.className = "loading-overlay";
      overlay.innerHTML = `
        <div class="loading-spinner"></div>
        <span>Loading images...</span>
      `;
      document.body.appendChild(overlay);
      return overlay;
    },

    // Extracts service and user ID from the current URL
    parseUrlParams() {
      const path = window.location.pathname;
      const matches = path.match(/^\/([^\/]+)\/user\/(\d+)/);
      return matches
        ? {
            service: matches[1],
            userId: matches[2],
          }
        : null;
    },

    // Checks if a given path ends with a supported image extension
    isImageFile(path) {
      return (
        path &&
        CONSTANTS.SUPPORTED_IMAGE_EXTENSIONS.some((ext) =>
          path.toLowerCase().endsWith(ext)
        )
      );
    },

    // Creates a promise that resolves when an image loads or errors
    createImageLoadPromise(imgElement) {
      return new Promise((resolve) => {
        imgElement.onload = resolve;
        imgElement.onerror = resolve;
      });
    },
  };

  // Main gallery class
  class KemonoGallery {
    constructor() {
      this.grid = document.querySelector(".card-list__items");
      this.postAttachments = null;
      this.isInitialized = false;
      this.loadingOverlay = null;
      this.imageLoadPromises = [];
    }

    // Initialize the gallery
    async init() {
      if (!this.grid) return;

      this.grid.style.removeProperty("--card-size");

      // Observe additions of .post-card elements
      const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          mutation.addedNodes.forEach((node) => {
            if (node.classList && node.classList.contains("post-card")) {
              this.processPostCard(node);
            }
          });
        });
      });

      observer.observe(this.grid, {
        childList: true,
        subtree: true,
      });

      // Initialize data
      await this.initializeData();

      // Process existing cards
      const existingCards = this.grid.querySelectorAll(".post-card");
      const processPromises = Array.from(existingCards).map((card) =>
        this.processPostCard(card)
      );

      // Wait for all images to load
      await Promise.all(this.imageLoadPromises);

      // Remove loading overlay after all images are loaded
      if (this.loadingOverlay) {
        this.loadingOverlay.remove();
      }
    }

    async initializeData() {
      this.loadingOverlay = utils.createLoadingOverlay();
      const urlParams = utils.parseUrlParams();

      if (!urlParams) {
        this.loadingOverlay.remove();
        return;
      }

      try {
        const posts = await this.fetchPostsData(urlParams);
        this.postAttachments = this.createAttachmentsMap(posts);
        this.isInitialized = true;
      } catch (error) {
        console.error("Failed to initialize data:", error);
        this.loadingOverlay.remove();
      }
    }

    // Process a single post-card element
    async processPostCard(card) {
      if (!this.isInitialized) return;

      const link = card.querySelector("a.image-link");
      if (!link) return;

      const postId = link.href.split("/").pop();
      const attachmentPath = this.postAttachments.get(postId);
      const imgElement = card.querySelector(".post-card__image");

      if (attachmentPath && imgElement && utils.isImageFile(attachmentPath)) {
        imgElement.src = `${CONSTANTS.IMAGE_BASE_URL}${attachmentPath}`;
        const loadPromise = utils.createImageLoadPromise(imgElement);
        this.imageLoadPromises.push(loadPromise);
        await loadPromise;
      }
    }

    // Fetches posts data from the API
    async fetchPostsData({ service, userId }) {
      const response = await fetch(
        `${CONSTANTS.API_BASE_URL}/${service}/user/${userId}`
      );

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      return response.json();
    }

    // Creates a map of post IDs to their first attachment path
    createAttachmentsMap(posts) {
      return new Map(
        posts.map((post) => [
          post.id,
          post.attachments?.[0]?.path || post.file?.path,
        ])
      );
    }
  }

  // Initialize gallery immediately
  const gallery = new KemonoGallery();
  gallery.init();
})();