Kemono Grid Gallery Layout

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

2024-11-15 기준 버전입니다. 최신 버전을 확인하세요.

// ==UserScript==
// @name         Kemono Grid Gallery Layout
// @namespace    https://greasyfork.org/users/172087
// @version      0.1
// @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");
    }

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

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

    // Updates post covers with first attachment images
    async updatePostCovers() {
      const loadingOverlay = utils.createLoadingOverlay();
      const urlParams = utils.parseUrlParams();

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

      try {
        const posts = await this.fetchPostsData(urlParams);
        const postAttachments = this.createAttachmentsMap(posts);
        await this.updateImages(postAttachments);
      } catch (error) {
        console.error("Failed to update post covers:", error);
      } finally {
        loadingOverlay.remove();
      }
    }

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

    // Updates image elements with attachment thumbnails
    async updateImages(postAttachments) {
      const imagePromises = [];
      const cards = document.querySelectorAll(".post-card");

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

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

        if (attachmentPath && imgElement && utils.isImageFile(attachmentPath)) {
          imgElement.src = `${CONSTANTS.IMAGE_BASE_URL}${attachmentPath}`;
          imagePromises.push(utils.createImageLoadPromise(imgElement));
        }
      });

      await Promise.all(imagePromises);
    }
  }

  // Initialize gallery when page loads
  window.addEventListener("load", () => {
    const gallery = new KemonoGallery();
    gallery.init();
  });
})();