// ==UserScript==
// @name Kemono Grid Gallery Layout
// @namespace https://greasyfork.org/users/172087
// @version 0.4
// @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 = null;
this.postAttachments = null;
this.isInitialized = false;
this.loadingOverlay = null;
this.imageLoadPromises = [];
}
// Wait for the grid element to load
async waitForGrid() {
return new Promise((resolve) => {
// If the element already exists, return directly
const existingGrid = document.querySelector(".card-list__items");
if (existingGrid) {
this.grid = existingGrid;
resolve();
return;
}
// Otherwise, observe DOM changes until the element is found
const observer = new MutationObserver((_mutations, obs) => {
const grid = document.querySelector(".card-list__items");
if (grid) {
this.grid = grid;
obs.disconnect();
resolve();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
}
// Initialize the gallery
async init() {
// Wait for the grid element to load
await this.waitForGrid();
if (!this.grid) {
console.error("Grid element not found even after waiting");
return;
}
this.grid.style.removeProperty("--card-size");
// Observe additions of .post-card elements
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.classList && node.classList.contains("post-card")) {
this.processPostCard(node);
}
});
});
});
observer.observe(this.grid, {
childList: true,
subtree: true,
});
// Initialize data
await this.initializeData();
// Process existing cards
const existingCards = this.grid.querySelectorAll(".post-card");
existingCards.forEach((card) => {
this.processPostCard(card);
});
// Wait for all images to load
await Promise.all(this.imageLoadPromises);
// Remove loading overlay after all images are loaded
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();
}
}
// Process a single post-card element
async processPostCard(card) {
if (!this.isInitialized) {
console.log("Gallery not initialized yet");
return;
}
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(".post-card__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);
}
}
}
// 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,
])
);
}
}
// Initialize gallery immediately
const gallery = new KemonoGallery();
gallery.init().catch((error) => {
console.error("Failed to initialize gallery:", error);
});
})();