Civitai Larger Thumbnails + Arrow Key Navigation

Enlarges thumbnails and adds Up/Down arrow key navigation to instantly go to the next thumbnail. No more tired eyes from constantly moving your eyes from thumbnail to thumbnail.

// ==UserScript==
// @name        Civitai Larger Thumbnails + Arrow Key Navigation
// @namespace   Violentmonkey Scripts
// @match       *://*.civitai.com/*
// @grant       GM_addStyle
// @version     1.0
// @author      rainlizard
// @license     MIT
// @description Enlarges thumbnails and adds Up/Down arrow key navigation to instantly go to the next thumbnail. No more tired eyes from constantly moving your eyes from thumbnail to thumbnail.
// ==/UserScript==

(function() {
    'use strict';

    const GRID_CONTAINER_SELECTOR = '.mantine-1ofgurw';
    const ITEM_WRAPPER_IF_EXISTS_CLASS = 'CosmeticWrapper_wrapper__kH8WX'; // Class of the optional outer wrapper

    // This is the element that gets scrolled to and is made "fullscreen"
    // It's the direct div child of the grid container.
    const MAIN_CARD_SELECTOR_FOR_JS = `${GRID_CONTAINER_SELECTOR} > div`; // Used by JS for item list
    const MAIN_CARD_CSS_SELECTOR = `${GRID_CONTAINER_SELECTOR} > div`;    // Used by CSS

    // Selectors for content *within* the card structure.
    // These need to work whether MAIN_CARD_CSS_SELECTOR matches the wrapper or the card content holder itself.
    // Path if NO wrapper: MAIN_CARD_CSS_SELECTOR > .AspectRatioImageCard_content__IGj_A
    // Path WITH wrapper: MAIN_CARD_CSS_SELECTOR (wrapper) > div (inner shell) > .AspectRatioImageCard_content__IGj_A
    const ITEM_CONTENT_CSS_SELECTOR = `
        ${MAIN_CARD_CSS_SELECTOR}:not(.${ITEM_WRAPPER_IF_EXISTS_CLASS}) > .AspectRatioImageCard_content__IGj_A,
        ${MAIN_CARD_CSS_SELECTOR}.${ITEM_WRAPPER_IF_EXISTS_CLASS} > div > .AspectRatioImageCard_content__IGj_A
    `;
    const ITEM_LINK_CSS_SELECTOR = `
        ${MAIN_CARD_CSS_SELECTOR}:not(.${ITEM_WRAPPER_IF_EXISTS_CLASS}) > .AspectRatioImageCard_content__IGj_A > a,
        ${MAIN_CARD_CSS_SELECTOR}.${ITEM_WRAPPER_IF_EXISTS_CLASS} > div > .AspectRatioImageCard_content__IGj_A > a
    `;
    const ITEM_IMAGE_CSS_SELECTOR = `
        ${MAIN_CARD_CSS_SELECTOR}:not(.${ITEM_WRAPPER_IF_EXISTS_CLASS}) > .AspectRatioImageCard_content__IGj_A > a > img,
        ${MAIN_CARD_CSS_SELECTOR}.${ITEM_WRAPPER_IF_EXISTS_CLASS} > div > .AspectRatioImageCard_content__IGj_A > a > img
    `;
    const ITEM_FOOTER_CSS_SELECTOR = `
        ${MAIN_CARD_CSS_SELECTOR}:not(.${ITEM_WRAPPER_IF_EXISTS_CLASS}) > .AspectRatioImageCard_footer__FOU7a,
        ${MAIN_CARD_CSS_SELECTOR}.${ITEM_WRAPPER_IF_EXISTS_CLASS} > div > .AspectRatioImageCard_footer__FOU7a
    `;

    let currentItemIndex = 0;
    let items = []; // To store the list of item elements

    function updateItemList() {
        items = document.querySelectorAll(MAIN_CARD_SELECTOR_FOR_JS);
        if (currentItemIndex >= items.length && items.length > 0) {
            currentItemIndex = items.length - 1;
        } else if (items.length === 0) {
            currentItemIndex = 0;
        }
    }

    function scrollToItem(index) {
        // updateItemList() is called in handleKeyDown before this,
        // so 'items' should be fresh enough for this operation.
        if (index >= 0 && index < items.length) {
            currentItemIndex = index;
            const targetElement = items[currentItemIndex];
            if (targetElement) {
                targetElement.scrollIntoView({
                    behavior: 'instant', // 'instant' for immediate jump
                    block: 'start', // Align top of the item with top of the viewport
                    inline: 'nearest'
                });
            }
        }
    }

    function handleKeyDown(event) {
        updateItemList(); // Get fresh item list for accurate bounds checking for the current key press
        if (items.length === 0) return; // No items to navigate

        if (event.key === 'ArrowDown') {
            event.preventDefault();
            if (currentItemIndex < items.length - 1) {
                scrollToItem(currentItemIndex + 1);
            } else {
                scrollToItem(items.length - 1); // Stay on/scroll to the current last item
            }
        } else if (event.key === 'ArrowUp') {
            event.preventDefault();
            if (currentItemIndex > 0) {
                scrollToItem(currentItemIndex - 1);
            } else {
                scrollToItem(0); // Stay on/scroll to the first item
            }
        }
    }

    setTimeout(function() {
        GM_addStyle(`
            html, body {
                scroll-behavior: auto !important; /* Ensure instant scroll for navigation */
            }

            body {
                overflow-x: hidden !important; /* Prevent horizontal scroll issues */
            }

            /* Make the grid container a flex column to stack items vertically */
            ${GRID_CONTAINER_SELECTOR} {
                display: flex !important;
                flex-direction: column !important;
                align-items: center !important; /* Center items horizontally */
                width: 100% !important;
                gap: 15px !important; /* Space between fullscreen items */
                grid-template-columns: none !important; /* Override inline grid-template-columns */
            }

            /* Style individual grid items (MAIN_CARD_CSS_SELECTOR) to be fullscreen-like */
            ${MAIN_CARD_CSS_SELECTOR} {
                width: 100vw !important;
                max-width: 100%; /* Ensure it doesn't exceed body width due to scrollbars etc. */
                height: 97vh !important; /* Viewport height, leaving a little space */
                min-height: 400px; /* A sensible minimum height */
                display: flex !important;
                flex-direction: column !important;
                justify-content: flex-start !important;
                align-items: stretch !important; /* Children will stretch to fill */
                padding: 5px !important;
                box-sizing: border-box !important;
                background-color: #1A1B1E !important; /* Match site background for letterboxing */
                position: relative !important;
                left: auto !important;
                top: auto !important;
                margin: 0 !important; /* Reset margins, rely on parent's gap */
                float: none !important; /* Clear floats */
                transform: none !important; /* Reset transforms */
                aspect-ratio: auto !important; /* CRITICAL: Override inline aspect-ratio from wrappers */
            }

            /* If the main card is an outer wrapper (e.g., CosmeticWrapper_wrapper__kH8WX),
               make its direct div child expand to fill it and also become a flex column container. */
            ${MAIN_CARD_CSS_SELECTOR}.${ITEM_WRAPPER_IF_EXISTS_CLASS} > div {
                flex-grow: 1 !important; /* Expand to fill parent's height */
                min-height: 0 !important; /* Necessary for flex children to grow/shrink correctly */
                width: 100% !important; /* Ensure full width */
                display: flex !important;
                flex-direction: column !important;
                justify-content: flex-start !important;
                align-items: stretch !important;
            }

            /* Make the content area grow */
            ${ITEM_CONTENT_CSS_SELECTOR} {
                flex-grow: 1 !important; /* Allow this area to take up available vertical space */
                display: flex !important; /* Use flex to manage link inside */
                min-height: 0 !important; /* Crucial for flex children growth/shrink */
                overflow: hidden !important; /* Hide anything that might overflow */
                position: relative !important; /* Needed for absolute positioning inside if any */
            }

            /* Make the link fill the content area */
            ${ITEM_LINK_CSS_SELECTOR} {
                display: flex !important;
                flex-grow: 1 !important;
                height: 100% !important;
                justify-content: center !important; /* Center image horizontally */
                align-items: center !important; /* Center image vertically */
            }

            /* Style images within these items */
            ${ITEM_IMAGE_CSS_SELECTOR} {
                width: 100% !important;
                height: 100% !important;
                object-fit: contain !important; /* This is key for fitting while preserving aspect ratio */
                display: block !important;
                border-radius: 4px !important;
            }

            /* Style the footer area */
            ${ITEM_FOOTER_CSS_SELECTOR} {
                flex-shrink: 0 !important; /* Prevent footer from shrinking */
                width: 100% !important; /* Ensure footer uses full width */
                padding: 8px !important; /* Add some padding to the footer */
                box-sizing: border-box !important;
                background: rgba(26, 27, 30, 0.8) !important;
            }

            /*
               Note: If issues persist, the selectors involving specific class names
               (e.g., 'mantine-1ofgurw', 'AspectRatioImageCard_content__IGj_A', 'CosmeticWrapper_wrapper__kH8WX')
               might be dynamically generated or conflict with other site styles.
            */

            /* Optional: Hide header/footer for a more immersive view. Uncomment to use. */
            /*
            header.mantine-Header-root { display: none !important; }
            body > div#__next > div > div#main > footer { display: none !important; }
            */
        `);

        updateItemList(); // Perform an initial scan for items
        document.addEventListener('keydown', handleKeyDown);

    }, 1000); // Delay execution by 1000 milliseconds (1 second)

})();