您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add a responsive grid gallery layout for the kemono.su/coomer.su thumbnails, using the first attachment image file as the cover
// ==UserScript== // @name Kemono/Coomer Grid Gallery Layout // @namespace https://greasyfork.org/users/172087 // @version 0.8 // @description Add a responsive grid gallery layout for the kemono.su/coomer.su thumbnails, using the first attachment image file as the cover // @author Neko_Aria // @icon https://kemono.su/static/favicon.ico // @match https://coomer.su/* // @match https://kemono.su/* // @grant GM_addStyle // @license MIT // ==/UserScript== (function () { "use strict"; // Core configuration constants const CONFIG = { LAYOUT: { GRID_GAP: "16px", GRID_MIN_COLUMN_WIDTH: "250px", }, SELECTORS: { GRID: ".card-list__items", POST_CARD: ".post-card", POST_IMAGE: ".post-card__image", }, SUPPORTED_IMAGES: new Set([ ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp", ]), SITES: { "kemono.su": { API_BASE_URL: "https://kemono.su/api/v1", IMAGE_BASE_URL: "https://img.kemono.su/thumbnail/data", }, "coomer.su": { API_BASE_URL: "https://coomer.su/api/v1", IMAGE_BASE_URL: "https://img.coomer.su/thumbnail/data", }, }, }; // Get current site configuration function getCurrentSiteConfig() { const domain = window.location.hostname; return CONFIG.SITES[domain] || null; } // Inject styles const STYLES = ` .card-list--legacy .card-list__items { display: grid !important; grid-template-columns: repeat(auto-fill, ${CONFIG.LAYOUT.GRID_MIN_COLUMN_WIDTH}); gap: ${CONFIG.LAYOUT.GRID_GAP}; padding: ${CONFIG.LAYOUT.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 classes class DOMUtils { static 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; } static waitForElement(selector) { return new Promise((resolve) => { const element = document.querySelector(selector); if (element) { resolve(element); return; } const observer = new MutationObserver((_, obs) => { const element = document.querySelector(selector); if (element) { obs.disconnect(); resolve(element); } }); observer.observe(document.body, { childList: true, subtree: true, }); }); } static createImageLoadPromise(imgElement) { return new Promise((resolve) => { imgElement.onload = resolve; imgElement.onerror = resolve; }); } } class URLParser { static parseUserPath() { const path = window.location.pathname; const matches = path.match(/^\/([^\/]+)\/user\/(.+)/); return matches ? { service: matches[1], userId: matches[2] } : null; } static isImageFile(path) { if (!path) return false; const extension = path.toLowerCase().slice(path.lastIndexOf(".")); return CONFIG.SUPPORTED_IMAGES.has(extension); } } // Gallery core class class Gallery { constructor() { this.grid = null; this.postAttachments = new Map(); this.loadingOverlay = null; this.imageLoadPromises = []; this.siteConfig = getCurrentSiteConfig(); } async initialize() { try { this.grid = await DOMUtils.waitForElement(CONFIG.SELECTORS.GRID); this.grid.style.removeProperty("--card-size"); await this.loadPostData(); await this.processExistingCards(); } catch (error) { console.error("Gallery initialization failed:", error); } finally { this.cleanup(); } } async loadPostData() { this.loadingOverlay = DOMUtils.createLoadingOverlay(); const urlParams = URLParser.parseUserPath(); if (!urlParams || !this.siteConfig) { return; } try { const posts = await this.fetchPosts(urlParams); this.processPostsData(posts); } catch (error) { console.error("Failed to load post data:", error); } } async fetchPosts({ service, userId }) { const response = await fetch( `${this.siteConfig.API_BASE_URL}/${service}/user/${userId}` ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } processPostsData(posts) { posts.forEach((post) => { const attachmentPath = post.attachments?.[0]?.path || post.file?.path; if (attachmentPath) { this.postAttachments.set(post.id, attachmentPath); } }); } async processExistingCards() { const cards = this.grid.querySelectorAll(CONFIG.SELECTORS.POST_CARD); const processPromises = Array.from(cards).map((card) => this.processCard(card) ); await Promise.all(processPromises); } async processCard(card) { 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(CONFIG.SELECTORS.POST_IMAGE); if ( imgElement && attachmentPath && URLParser.isImageFile(attachmentPath) ) { const imageUrl = `${this.siteConfig.IMAGE_BASE_URL}${attachmentPath}`; imgElement.src = imageUrl; const loadPromise = DOMUtils.createImageLoadPromise(imgElement); this.imageLoadPromises.push(loadPromise); await loadPromise; } } cleanup() { if (this.loadingOverlay) { this.loadingOverlay.remove(); } this.imageLoadPromises = []; } } // Initialization and monitoring logic class GalleryManager { constructor() { this.gallery = null; this.isProcessing = false; this.lastUrl = location.href; this.initializeGallery = this.initializeGallery.bind(this); this.debouncedInit = this.debounce(this.initializeGallery); } async initializeGallery() { if (this.isProcessing) return; this.isProcessing = true; try { if (this.gallery) { this.gallery.cleanup(); } this.gallery = new Gallery(); await this.gallery.initialize(); } catch (error) { console.error("Gallery initialization failed:", error); } finally { this.isProcessing = false; } } setupUrlChangeListener() { const observer = new MutationObserver(() => { if (location.href !== this.lastUrl) { this.lastUrl = location.href; this.debouncedInit(); } }); observer.observe(document.body, { childList: true, subtree: true }); window.addEventListener("popstate", this.initializeGallery); } debounce(fn, delay = 200) { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn.apply(this, args), delay); }; } start() { GM_addStyle(STYLES); this.initializeGallery(); this.setupUrlChangeListener(); } } // Start application new GalleryManager().start(); })();