您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add a responsive grid gallery layout for the kemono.su thumbnails, using the first attachment image file as the cover
当前为
// ==UserScript== // @name Kemono Grid Gallery Layout // @namespace https://greasyfork.org/users/172087 // @version 0.5 // @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_MAX_WIDTH: "1600px", 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__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 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(); })();