Kemono Grid Gallery Layout

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

Pada tanggal 04 Desember 2024. Lihat %(latest_version_link).

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

  // Core configuration constants
  const CONSTANTS = {
    API_BASE_URL: "https://kemono.su/api/v1",
    GRID_GAP: "16px",
    GRID_MIN_COLUMN_WIDTH: "250px",
    IMAGE_BASE_URL: "https://img.kemono.su/thumbnail",
    SELECTORS: {
      GRID: ".card-list__items",
      POST_CARD: ".post-card",
      POST_IMAGE: ".post-card__image",
    },
    SUPPORTED_IMAGE_EXTENSIONS: [
      ".bmp",
      ".gif",
      ".jpeg",
      ".jpg",
      ".png",
      ".webp",
    ],
  };

  // Grid gallery layout styles
  GM_addStyle(`
        .card-list--legacy .card-list__items {
          display: grid !important;
          grid-template-columns: repeat(auto-fill, ${CONSTANTS.GRID_MIN_COLUMN_WIDTH});
          gap: ${CONSTANTS.GRID_GAP};
          padding: ${CONSTANTS.GRID_GAP};
          width: 100%;
          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 for common operations
  const utils = {
    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;
    },

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

    isImageFile(path) {
      return (
        path &&
        CONSTANTS.SUPPORTED_IMAGE_EXTENSIONS.some((ext) =>
          path.toLowerCase().endsWith(ext)
        )
      );
    },

    createImageLoadPromise(imgElement) {
      return new Promise((resolve) => {
        imgElement.onload = resolve;
        imgElement.onerror = resolve;
      });
    },
  };

  // Main gallery class handling grid layout and image loading
  class KemonoGallery {
    constructor() {
      this.grid = null;
      this.postAttachments = null;
      this.isInitialized = false;
      this.loadingOverlay = null;
      this.imageLoadPromises = [];
    }

    async waitForGrid() {
      return new Promise((resolve) => {
        const existingGrid = document.querySelector(CONSTANTS.SELECTORS.GRID);
        if (existingGrid) {
          this.grid = existingGrid;
          resolve();
          return;
        }

        const observer = new MutationObserver((_mutations, obs) => {
          const grid = document.querySelector(CONSTANTS.SELECTORS.GRID);
          if (grid) {
            this.grid = grid;
            obs.disconnect();
            resolve();
          }
        });

        observer.observe(document.body, {
          childList: true,
          subtree: true,
        });
      });
    }

    async init() {
      await this.waitForGrid();

      if (!this.grid) {
        console.error("Grid element not found even after waiting");
        return;
      }

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

      const existingCards = this.grid.querySelectorAll(
        CONSTANTS.SELECTORS.POST_CARD
      );
      existingCards.forEach((card) => {
        this.processPostCard(card);
      });

      await Promise.all(this.imageLoadPromises);

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

    async processPostCard(card) {
      if (!this.isInitialized) {
        throw new Error("Gallery not initialized");
      }

      const link = card.querySelector('a[href*="/user/"][href*="/post/"]');
      if (!link) return;

      const postId = link.href.split("/").pop();
      const attachmentPath = this.postAttachments.get(postId);

      const imgElement = card.querySelector(CONSTANTS.SELECTORS.POST_IMAGE);
      if (!imgElement) {
        console.log("No image element found for post:", postId);
        return;
      }

      if (attachmentPath && utils.isImageFile(attachmentPath)) {
        const newImageUrl = `${CONSTANTS.IMAGE_BASE_URL}${attachmentPath}`;

        try {
          imgElement.src = newImageUrl;
          const loadPromise = utils.createImageLoadPromise(imgElement);
          this.imageLoadPromises.push(loadPromise);
          await loadPromise;
        } catch (error) {
          console.error("Error loading image:", error);
        }
      }
    }

    async fetchPostsData({ service, userId }) {
      try {
        const url = `${CONSTANTS.API_BASE_URL}/${service}/user/${userId}`;
        const response = await fetch(url);

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

        return await response.json();
      } catch (error) {
        console.error("Failed to fetch posts data:", error);
        throw error;
      }
    }

    createAttachmentsMap(posts) {
      return new Map(
        posts.map((post) => [
          post.id,
          post.attachments?.[0]?.path || post.file?.path,
        ])
      );
    }

    cleanup() {
      if (this.loadingOverlay) {
        this.loadingOverlay.remove();
      }
      this.isInitialized = false;
      this.postAttachments = null;
      this.imageLoadPromises = [];
    }
  }

  let gallery = null;
  let isProcessing = false;

  // Initialize gallery when cards are loaded
  function initGallery() {
    if (isProcessing) return;
    isProcessing = true;

    if (gallery) {
      gallery.cleanup();
    }

    const observer = new MutationObserver((_mutations, obs) => {
      const cards = document.querySelectorAll(CONSTANTS.SELECTORS.POST_CARD);
      if (cards.length > 0) {
        obs.disconnect();
        gallery = new KemonoGallery();
        gallery
          .init()
          .catch((error) =>
            console.error("Failed to initialize gallery:", error)
          )
          .finally(() => {
            isProcessing = false;
          });
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }

  function debounce(fn, delay = 200) {
    let timeoutId;
    return (...args) => {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => fn.apply(this, args), delay);
    };
  }

  // Monitor URL changes to reinitialize gallery
  function setupUrlChangeListener() {
    let lastUrl = location.href;
    const debouncedInit = debounce(initGallery);

    const observer = new MutationObserver(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        debouncedInit();
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    window.addEventListener("popstate", initGallery);
  }

  initGallery();
  setupUrlChangeListener();
})();