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