Danbooru Post Preview

Alt+Click or thumbnail overlay button to preview Danbooru posts inline with FLIP zoom animation, side navigation arrows, scrollable thumbnail filmstrip, and seamless bidirectional infinite pagination.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Danbooru Post Preview
// @namespace    https://github.com/Kovinda/Userscripts
// @version      1.3.1
// @description  Alt+Click or thumbnail overlay button to preview Danbooru posts inline with FLIP zoom animation, side navigation arrows, scrollable thumbnail filmstrip, and seamless bidirectional infinite pagination.
// @author       Kovinda
// @license      MIT
// @match        *://danbooru.donmai.us/*
// @icon         https://danbooru.donmai.us/favicon.ico
// @homepageURL  https://github.com/Kovinda/Userscripts
// @supportURL   https://github.com/Kovinda/Userscripts/issues
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // --- Constants ---
    const STORAGE_KEY_QUALITY = 'dbpreview_quality_mode';
    const STORAGE_KEY_BTN_POS = 'dbpreview_btn_position';
    const VIDEO_EXTS = new Set(['webm', 'mp4', 'zip']); // zip = ugoira (animated)
    const POST_LINK_RE = /\/posts\/(\d+)/;
    const PROCESSED_ATTR = 'data-dbpreview-btn';

    // In-memory cache for post JSON responses
    const postDataCache = new Map();

    // Button position: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
    const getBtnPosition = () => GM_getValue(STORAGE_KEY_BTN_POS, 'top-right');
    const setBtnPosition = (pos) => GM_setValue(STORAGE_KEY_BTN_POS, pos);

    // Quality modes: 'large' | 'original' | 'progressive'
    const getQualityMode = () => localStorage.getItem(STORAGE_KEY_QUALITY) || 'large';
    const setQualityMode = (mode) => localStorage.setItem(STORAGE_KEY_QUALITY, mode);

    // Video volume persistence & session mute state
    const STORAGE_KEY_VOLUME = 'dbpreview_video_volume';
    const getVideoVolume = () => {
        const v = localStorage.getItem(STORAGE_KEY_VOLUME);
        return v !== null ? parseFloat(v) : 0.8;
    };
    const setVideoVolume = (v) => localStorage.setItem(STORAGE_KEY_VOLUME, v);

    // Active session mute state: resets to true whenever leaving modal, preserved while browsing inside modal
    let sessionMuted = true;

    // --- Tampermonkey Menu Commands ---
    const POSITIONS = [
        { key: 'top-right', label: 'Top Right' },
        { key: 'top-left', label: 'Top Left' },
        { key: 'bottom-right', label: 'Bottom Right' },
        { key: 'bottom-left', label: 'Bottom Left' },
    ];

    function registerMenuCommands() {
        POSITIONS.forEach(({ key, label }) => {
            GM_registerMenuCommand(`Preview Button: ${label}`, () => {
                setBtnPosition(key);
                applyBtnPosition(key);
                showToast(`Preview button moved to ${label}`);
            });
        });
    }
    registerMenuCommands();

    // --- Styles ---
    const STYLES = `
        /* ===== Overlay ===== */
        .dbpreview-overlay {
            position: fixed;
            inset: 0;
            z-index: 99999;
            display: flex;
            align-items: center;
            justify-content: center;
            background: rgba(0, 0, 0, 0.78);
            backdrop-filter: blur(8px);
            -webkit-backdrop-filter: blur(8px);
            opacity: 0;
            transition: opacity 0.2s ease;
            cursor: pointer;
            user-select: none;
        }
        .dbpreview-overlay.is-visible {
            opacity: 1;
        }

        /* ===== Modal ===== */
        .dbpreview-modal {
            position: relative;
            display: flex;
            flex-direction: column;
            align-items: center;
            width: fit-content;
            min-width: 380px;
            max-width: min(94vw, 1280px);
            max-height: 94vh;
            background: #141424;
            border: 1px solid rgba(255, 255, 255, 0.1);
            border-radius: 14px;
            box-shadow: 0 28px 80px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.05);
            overflow: hidden;
            cursor: default;
            user-select: auto;
            opacity: 0;
            transition: opacity 0.2s ease, width 0.22s cubic-bezier(0.22, 1, 0.36, 1);
        }
        .dbpreview-overlay.is-visible .dbpreview-modal {
            opacity: 1;
        }

        /* ===== Close Button ===== */
        .dbpreview-close {
            position: absolute;
            top: 10px;
            right: 10px;
            z-index: 20;
            width: 32px;
            height: 32px;
            display: flex;
            align-items: center;
            justify-content: center;
            border: none;
            border-radius: 8px;
            background: rgba(0, 0, 0, 0.6);
            color: #fff;
            font-size: 16px;
            cursor: pointer;
            transition: background 0.15s ease, transform 0.15s ease;
            line-height: 1;
            backdrop-filter: blur(4px);
        }
        .dbpreview-close:hover {
            background: rgba(220, 50, 50, 0.85);
            transform: scale(1.1);
        }

        /* ===== Navigation Arrows ===== */
        .dbpreview-nav-btn {
            position: absolute;
            top: calc(50% - 40px);
            transform: translateY(-50%);
            z-index: 18;
            width: 44px;
            height: 64px;
            display: flex;
            align-items: center;
            justify-content: center;
            border: none;
            border-radius: 10px;
            background: rgba(10, 10, 20, 0.55);
            color: rgba(255, 255, 255, 0.85);
            font-size: 26px;
            font-weight: 300;
            cursor: pointer;
            backdrop-filter: blur(6px);
            -webkit-backdrop-filter: blur(6px);
            transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
            padding: 0;
            line-height: 1;
        }
        .dbpreview-nav-btn.dbpreview-prev {
            left: 10px;
        }
        .dbpreview-nav-btn.dbpreview-next {
            right: 10px;
        }
        .dbpreview-nav-btn:hover:not(.is-disabled) {
            background: rgba(110, 142, 251, 0.8);
            color: #fff;
            transform: translateY(-50%) scale(1.08);
        }
        .dbpreview-nav-btn:active:not(.is-disabled) {
            transform: translateY(-50%) scale(0.96);
        }
        .dbpreview-nav-btn.is-disabled {
            opacity: 0.18;
            cursor: not-allowed;
            pointer-events: none;
        }

        /* ===== Media Container ===== */
        .dbpreview-media-wrap {
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            background: #0d0d16;
            overflow: hidden;
            margin: 0 auto;
            max-width: min(92vw, 1278px);
            max-height: calc(94vh - 145px);
            transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1), height 0.22s cubic-bezier(0.22, 1, 0.36, 1);
        }

        /* Enlarged Thumbnail Placeholder */
        .dbpreview-placeholder {
            position: absolute;
            inset: 0;
            width: 100%;
            height: 100%;
            object-fit: contain;
            opacity: 1;
            transition: opacity 0.3s ease;
            pointer-events: none;
            z-index: 1;
            display: block;
        }
        .dbpreview-placeholder.is-hidden {
            opacity: 0;
        }

        /* Actual Media (video or image) */
        .dbpreview-media {
            display: none;
            width: 100%;
            height: 100%;
            max-width: min(92vw, 1278px);
            max-height: calc(94vh - 145px);
            object-fit: contain;
            border-radius: 0;
            position: relative;
            z-index: 2;
            opacity: 0;
            transition: opacity 0.25s ease;
        }
        .dbpreview-media.is-ready {
            display: block;
            opacity: 1;
        }

        /* ===== Loading Spinner ===== */
        .dbpreview-spinner {
            position: absolute;
            inset: 0;
            display: flex;
            align-items: center;
            justify-content: center;
            pointer-events: none;
            background: rgba(13, 13, 22, 0.45);
            backdrop-filter: blur(2px);
            transition: opacity 0.25s ease;
            z-index: 10;
        }
        .dbpreview-spinner.is-hidden {
            opacity: 0;
            pointer-events: none;
        }
        .dbpreview-spinner::after {
            content: '';
            width: 38px;
            height: 38px;
            border: 3px solid rgba(255, 255, 255, 0.15);
            border-top-color: #6e8efb;
            border-radius: 50%;
            animation: dbpreview-spin 0.7s linear infinite;
        }
        @keyframes dbpreview-spin {
            to { transform: rotate(360deg); }
        }

        /* ===== Info Bar ===== */
        .dbpreview-info {
            display: flex;
            align-items: center;
            gap: 10px;
            padding: 8px 14px;
            background: rgba(255, 255, 255, 0.03);
            border-top: 1px solid rgba(255, 255, 255, 0.07);
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            font-size: 13px;
            color: #b0b8c8;
            flex-wrap: wrap;
            min-height: 38px;
        }
        .dbpreview-info a {
            color: #6e8efb;
            text-decoration: none;
            transition: color 0.15s;
        }
        .dbpreview-info a:hover {
            color: #93abff;
            text-decoration: underline;
        }

        /* ===== Metadata Pills ===== */
        .dbpreview-pill {
            display: inline-flex;
            align-items: center;
            gap: 4px;
            padding: 2px 8px;
            border-radius: 6px;
            background: rgba(255, 255, 255, 0.07);
            font-size: 12px;
            white-space: nowrap;
        }
        .dbpreview-pill-icon {
            font-size: 13px;
        }
        .dbpreview-rating {
            font-weight: 600;
            text-transform: uppercase;
            letter-spacing: 0.5px;
        }
        .dbpreview-rating[data-rating="g"] {
            color: #4ade80;
            background: rgba(74, 222, 128, 0.12);
        }
        .dbpreview-rating[data-rating="s"] {
            color: #facc15;
            background: rgba(250, 204, 21, 0.12);
        }
        .dbpreview-rating[data-rating="q"] {
            color: #fb923c;
            background: rgba(251, 146, 60, 0.12);
        }
        .dbpreview-rating[data-rating="e"] {
            color: #f87171;
            background: rgba(248, 113, 113, 0.12);
        }

        .dbpreview-spacer {
            flex: 1;
        }

        /* ===== Quality Switcher ===== */
        .dbpreview-quality-group {
            display: inline-flex;
            border-radius: 6px;
            overflow: hidden;
            border: 1px solid rgba(255,255,255,0.12);
        }
        .dbpreview-quality-btn {
            padding: 2px 8px;
            border: none;
            background: transparent;
            color: #8090a8;
            font-size: 11px;
            cursor: pointer;
            transition: background 0.15s, color 0.15s;
            font-family: inherit;
        }
        .dbpreview-quality-btn:hover {
            background: rgba(255,255,255,0.08);
        }
        .dbpreview-quality-btn.is-active {
            background: rgba(110, 142, 251, 0.25);
            color: #93abff;
            font-weight: 600;
        }

        /* ===== Open Link ===== */
        .dbpreview-open-link {
            font-weight: 500;
        }

        /* ===== Thumbnail Rail ===== */
        .dbpreview-rail-wrap {
            width: 100%;
            max-width: 100%;
            min-width: 0;
            box-sizing: border-box;
            display: flex;
            align-items: center;
            background: rgba(0, 0, 0, 0.45);
            border-top: 1px solid rgba(255, 255, 255, 0.06);
            position: relative;
            height: 66px;
            flex-shrink: 0;
            overflow: hidden;
        }
        .dbpreview-rail-track {
            flex: 1;
            display: flex;
            align-items: center;
            gap: 6px;
            padding: 7px 8px;
            overflow-x: auto;
            overflow-y: hidden;
            scrollbar-width: thin;
            scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
            min-height: 66px;
            max-height: 66px;
            box-sizing: border-box;
            scroll-behavior: smooth;
        }
        .dbpreview-rail-track::-webkit-scrollbar {
            height: 5px;
        }
        .dbpreview-rail-track::-webkit-scrollbar-thumb {
            background: rgba(255, 255, 255, 0.2);
            border-radius: 3px;
        }
        .dbpreview-rail-track::-webkit-scrollbar-thumb:hover {
            background: rgba(255, 255, 255, 0.35);
        }
        .dbpreview-rail-scroll-btn {
            width: 24px;
            height: 100%;
            display: flex;
            align-items: center;
            justify-content: center;
            border: none;
            background: rgba(0, 0, 0, 0.35);
            color: rgba(255, 255, 255, 0.7);
            font-size: 18px;
            cursor: pointer;
            flex-shrink: 0;
            transition: background 0.15s, color 0.15s, transform 0.1s;
            padding: 0;
            z-index: 5;
            line-height: 1;
        }
        .dbpreview-rail-scroll-btn:hover {
            background: rgba(110, 142, 251, 0.6);
            color: #fff;
        }
        .dbpreview-rail-scroll-btn:active {
            transform: scale(0.92);
        }

        .dbpreview-rail-item {
            flex: 0 0 52px;
            height: 52px;
            border-radius: 6px;
            overflow: hidden;
            cursor: pointer;
            border: 2px solid transparent;
            opacity: 0.55;
            background: #1e1e30;
            transition: opacity 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
            position: relative;
            flex-shrink: 0;
        }
        .dbpreview-rail-item img {
            width: 100%;
            height: 100%;
            object-fit: cover;
            display: block;
        }
        .dbpreview-rail-item:hover {
            opacity: 0.9;
            transform: translateY(-2px);
        }
        .dbpreview-rail-item.is-active {
            opacity: 1;
            border-color: #6e8efb;
            box-shadow: 0 0 10px rgba(110, 142, 251, 0.6);
            transform: scale(1.05);
        }

        /* ===== Error State ===== */
        .dbpreview-error {
            padding: 50px 32px;
            text-align: center;
            color: #f87171;
            font-size: 14px;
        }

        /* ===== Thumbnail Preview Button on Page Grid ===== */
        article.post-preview .post-preview-link {
            position: relative !important;
            display: inline-block !important;
        }
        article.post-preview .post-preview-link > picture,
        article.post-preview .post-preview-link > img {
            display: block !important;
        }

        .dbpreview-thumb-btn {
            position: absolute;
            z-index: 10;
            width: 24px;
            height: 24px;
            display: flex;
            align-items: center;
            justify-content: center;
            border: none;
            border-radius: 5px;
            background: rgba(0, 0, 0, 0.7);
            color: rgba(255, 255, 255, 0.95);
            font-size: 13px;
            line-height: 1;
            cursor: pointer;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.15s ease, background 0.15s ease, transform 0.1s ease;
            backdrop-filter: blur(4px);
            -webkit-backdrop-filter: blur(4px);
            padding: 0;
            margin: 0;
            top: 4px;
            right: 4px;
            bottom: auto;
            left: auto;
        }
        article.post-preview:hover .dbpreview-thumb-btn,
        article.post-preview:focus-within .dbpreview-thumb-btn {
            opacity: 1;
            pointer-events: auto;
        }
        .dbpreview-thumb-btn:hover {
            background: rgba(110, 142, 251, 0.9);
            color: #fff;
            transform: scale(1.1);
        }
        .dbpreview-thumb-btn:active {
            transform: scale(0.95);
        }

        /* Position variants */
        :root[data-dbpreview-pos="top-right"] .dbpreview-thumb-btn {
            top: 4px; right: 4px; bottom: auto; left: auto;
        }
        :root[data-dbpreview-pos="top-left"] .dbpreview-thumb-btn {
            top: 4px; left: 4px; bottom: auto; right: auto;
        }
        :root[data-dbpreview-pos="bottom-right"] .dbpreview-thumb-btn {
            bottom: 4px; right: 4px; top: auto; left: auto;
        }
        :root[data-dbpreview-pos="bottom-left"] .dbpreview-thumb-btn {
            bottom: 4px; left: 4px; top: auto; right: auto;
        }

        /* ===== Toast ===== */
        .dbpreview-toast {
            position: fixed;
            bottom: 24px;
            left: 50%;
            transform: translateX(-50%) translateY(12px);
            z-index: 100000;
            padding: 8px 20px;
            border-radius: 8px;
            background: rgba(25, 25, 45, 0.95);
            border: 1px solid rgba(255,255,255,0.12);
            color: #d0d8e8;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            font-size: 13px;
            backdrop-filter: blur(8px);
            box-shadow: 0 8px 32px rgba(0,0,0,0.5);
            opacity: 0;
            transition: opacity 0.25s ease, transform 0.25s ease;
            pointer-events: none;
        }
        .dbpreview-toast.is-visible {
            opacity: 1;
            transform: translateX(-50%) translateY(0);
        }
    `;

    // Inject styles once
    const styleEl = document.createElement('style');
    styleEl.textContent = STYLES;
    document.head.appendChild(styleEl);

    // Apply button position
    function applyBtnPosition(pos) {
        document.documentElement.setAttribute('data-dbpreview-pos', pos);
    }
    applyBtnPosition(getBtnPosition());

    // --- Toast Notification ---
    let toastTimeout = null;
    function showToast(msg) {
        const existing = document.querySelector('.dbpreview-toast');
        if (existing) existing.remove();
        clearTimeout(toastTimeout);

        const toast = document.createElement('div');
        toast.className = 'dbpreview-toast';
        toast.textContent = msg;
        document.body.appendChild(toast);
        toast.offsetHeight; // reflow
        toast.classList.add('is-visible');

        toastTimeout = setTimeout(() => {
            toast.classList.remove('is-visible');
            setTimeout(() => toast.remove(), 300);
        }, 2000);
    }

    // --- Global State ---
    let currentOverlay = null;
    let currentModal = null;
    let currentPostId = null;
    let activeFetchAbort = null;

    // --- Helpers ---
    const RATING_LABELS = { g: 'General', s: 'Sensitive', q: 'Questionable', e: 'Explicit' };

    function escapeHtml(str) {
        const div = document.createElement('div');
        div.textContent = str;
        return div.innerHTML;
    }

    function formatArtist(artistStr) {
        if (!artistStr || !artistStr.trim()) return null;
        return artistStr.split(' ').map(a =>
            `<a href="/posts?tags=${encodeURIComponent(a)}" target="_blank" title="Search posts by ${escapeHtml(a)}">${escapeHtml(a.replace(/_/g, ' '))}</a>`
        ).join(', ');
    }

    function getMediaUrl(post, mode) {
        if (mode === 'original') return post.file_url;
        return post.large_file_url || post.file_url;
    }

    function isVideo(post) {
        return VIDEO_EXTS.has(post.file_ext);
    }

    // --- State for Gallery & Bidirectional Pagination ---
    let galleryPosts = [];
    let isFetchingNext = false;
    let isFetchingPrev = false;
    let hasMoreNext = true;
    let hasMorePrev = true;

    /** Extract all post items present in the current DOM gallery */
    function getPagePosts() {
        const articles = document.querySelectorAll('article.post-preview[data-id]');
        articles.forEach(art => {
            const id = parseInt(art.dataset.id, 10);
            if (!id || isNaN(id)) return;
            if (!galleryPosts.some(p => p.id === id)) {
                const img = art.querySelector('img.post-preview-image') || art.querySelector('img');
                const thumbUrl = img?.src || img?.getAttribute('data-src') || '';
                galleryPosts.push({ id, thumbUrl, element: art });
            }
        });
        return galleryPosts;
    }

    /** Create a rail item element */
    function createRailItemElement(postItem) {
        const itemEl = document.createElement('div');
        itemEl.className = 'dbpreview-rail-item' + (postItem.id === currentPostId ? ' is-active' : '');
        itemEl.dataset.postId = postItem.id;
        itemEl.title = `Post #${postItem.id}`;

        if (postItem.thumbUrl) {
            const img = document.createElement('img');
            img.src = postItem.thumbUrl;
            img.alt = `#${postItem.id}`;
            img.loading = 'lazy';
            itemEl.appendChild(img);
        }

        itemEl.addEventListener('click', (e) => {
            e.stopPropagation();
            if (currentPostId !== postItem.id) {
                fetchAndShowPreview(postItem.id);
            }
        });

        return itemEl;
    }

    /** Append newly fetched items to the end of the open rail track */
    function appendRailItems(items) {
        if (!currentModal || items.length === 0) return;
        const track = currentModal.querySelector('.dbpreview-rail-track');
        if (!track) return;

        const fragment = document.createDocumentFragment();
        items.forEach(postItem => {
            fragment.appendChild(createRailItemElement(postItem));
        });
        track.appendChild(fragment);
    }

    /** Prepend newly fetched items to the start of the open rail track without jumping scroll */
    function prependRailItems(items) {
        if (!currentModal || items.length === 0) return;
        const track = currentModal.querySelector('.dbpreview-rail-track');
        if (!track) return;

        const prevScrollWidth = track.scrollWidth;
        const prevScrollLeft = track.scrollLeft;

        const fragment = document.createDocumentFragment();
        items.forEach(postItem => {
            fragment.appendChild(createRailItemElement(postItem));
        });
        track.insertBefore(fragment, track.firstChild);

        // Adjust scrollLeft to maintain visual scroll position
        const widthDiff = track.scrollWidth - prevScrollWidth;
        track.scrollLeft = prevScrollLeft + widthDiff;
    }

    // --- Fetch Next Page (Older Posts) from Danbooru API ---
    async function fetchNextPage() {
        if (isFetchingNext || !hasMoreNext) return;
        isFetchingNext = true;

        getPagePosts();

        const validIds = galleryPosts.map(p => p.id).filter(id => typeof id === 'number' && !isNaN(id));
        if (validIds.length === 0) {
            isFetchingNext = false;
            return;
        }
        const lowestId = Math.min(...validIds);

        const params = new URLSearchParams(window.location.search);
        const tags = params.get('tags') || params.get('q') || '';
        const apiUrl = `/posts.json?tags=${encodeURIComponent(tags)}&page=b${lowestId}&limit=20`;

        try {
            const resp = await fetch(apiUrl);
            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
            const posts = await resp.json();

            if (!Array.isArray(posts) || posts.length === 0) {
                hasMoreNext = false;
                return;
            }

            const newItems = [];
            posts.forEach(post => {
                if (post.id && !galleryPosts.some(p => p.id === post.id)) {
                    postDataCache.set(post.id, post);
                    const thumbUrl = post.preview_file_url ||
                                     post.media_asset?.variants?.find(v => v.type === '180x180')?.url ||
                                     '';
                    const item = { id: post.id, thumbUrl };
                    galleryPosts.push(item);
                    newItems.push(item);
                }
            });

            if (newItems.length === 0) {
                hasMoreNext = false;
            } else {
                appendRailItems(newItems);
                if (currentPostId) updateNavButtons(currentPostId);
            }
        } catch (err) {
            console.warn('[Danbooru Preview] Failed to fetch next posts:', err);
        } finally {
            isFetchingNext = false;
        }
    }

    // --- Fetch Previous Page (Newer Posts) from Danbooru API ---
    async function fetchPrevPage() {
        if (isFetchingPrev || !hasMorePrev) return;
        isFetchingPrev = true;

        getPagePosts();

        const validIds = galleryPosts.map(p => p.id).filter(id => typeof id === 'number' && !isNaN(id));
        if (validIds.length === 0) {
            isFetchingPrev = false;
            return;
        }
        const highestId = Math.max(...validIds);

        const params = new URLSearchParams(window.location.search);
        const tags = params.get('tags') || params.get('q') || '';
        const apiUrl = `/posts.json?tags=${encodeURIComponent(tags)}&page=a${highestId}&limit=20`;

        try {
            const resp = await fetch(apiUrl);
            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
            const posts = await resp.json();

            if (!Array.isArray(posts) || posts.length === 0) {
                hasMorePrev = false;
                return;
            }

            // Sort descending by ID so newest posts come first
            posts.sort((a, b) => b.id - a.id);

            const newItems = [];
            posts.forEach(post => {
                if (post.id && !galleryPosts.some(p => p.id === post.id)) {
                    postDataCache.set(post.id, post);
                    const thumbUrl = post.preview_file_url ||
                                     post.media_asset?.variants?.find(v => v.type === '180x180')?.url ||
                                     '';
                    const item = { id: post.id, thumbUrl };
                    newItems.push(item);
                }
            });

            if (newItems.length === 0) {
                hasMorePrev = false;
            } else {
                // Prepend to galleryPosts (newItems is descending, so prepend in original order)
                galleryPosts.unshift(...newItems);
                prependRailItems(newItems);
                if (currentPostId) updateNavButtons(currentPostId);
            }
        } catch (err) {
            console.warn('[Danbooru Preview] Failed to fetch previous posts:', err);
        } finally {
            isFetchingPrev = false;
        }
    }

    // --- Fetch Post Data with In-Memory Cache ---
    async function fetchPostData(postId) {
        if (postDataCache.has(postId)) {
            return postDataCache.get(postId);
        }
        const resp = await fetch(`/posts/${postId}.json`);
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        const data = await resp.json();
        postDataCache.set(postId, data);
        return data;
    }

    // --- Relative Navigation (Next / Prev) ---
    function navigateRelative(delta) {
        if (!currentPostId) return;
        const posts = getPagePosts();
        if (posts.length === 0) return;

        const currentIndex = posts.findIndex(p => p.id === currentPostId);
        if (currentIndex === -1) {
            if (delta > 0 && posts.length > 0) {
                fetchAndShowPreview(posts[0].id);
            }
            return;
        }

        const targetIndex = currentIndex + delta;
        if (targetIndex >= 0 && targetIndex < posts.length) {
            fetchAndShowPreview(posts[targetIndex].id);
        }

        // Auto-fetch in relevant direction if approaching boundaries
        if (delta > 0 && currentIndex >= posts.length - 5) {
            fetchNextPage();
        } else if (delta < 0 && currentIndex <= 4) {
            fetchPrevPage();
        }
    }

    // --- Update / Render Thumbnail Rail ---
    function renderThumbnailRail(trackEl, targetPostId) {
        const posts = getPagePosts();
        trackEl.innerHTML = '';

        if (posts.length === 0) return;

        posts.forEach(postItem => {
            const itemEl = createRailItemElement(postItem);
            trackEl.appendChild(itemEl);

            if (postItem.id === targetPostId) {
                setTimeout(() => {
                    itemEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
                }, 50);
            }
        });
    }

    // --- Update Rail Active State in place ---
    function updateRailActive(targetPostId) {
        if (!currentModal) return;
        const track = currentModal.querySelector('.dbpreview-rail-track');
        if (!track) return;

        const items = track.querySelectorAll('.dbpreview-rail-item');
        let activeEl = null;
        items.forEach(item => {
            const id = parseInt(item.dataset.postId, 10);
            const isActive = id === targetPostId;
            item.classList.toggle('is-active', isActive);
            if (isActive) activeEl = item;
        });

        if (activeEl) {
            activeEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
        }

        // Auto-fetch if active post is near boundaries
        const posts = getPagePosts();
        const index = posts.findIndex(p => p.id === targetPostId);
        if (index >= posts.length - 5) {
            fetchNextPage();
        } else if (index <= 4) {
            fetchPrevPage();
        }
    }

    // --- Update Navigation Buttons in place ---
    function updateNavButtons(targetPostId) {
        if (!currentModal) return;
        const prevBtn = currentModal.querySelector('.dbpreview-prev');
        const nextBtn = currentModal.querySelector('.dbpreview-next');
        if (!prevBtn || !nextBtn) return;

        const posts = getPagePosts();
        const index = posts.findIndex(p => p.id === targetPostId);

        if (index === -1) {
            prevBtn.classList.add('is-disabled');
            nextBtn.classList.add('is-disabled');
        } else {
            // Keep buttons enabled if more pages can be fetched in that direction
            prevBtn.classList.toggle('is-disabled', index <= 0 && !hasMorePrev);
            nextBtn.classList.toggle('is-disabled', index >= posts.length - 1 && !hasMoreNext);
        }
    }

    // --- Build Full Modal Shell ---
    function createModalShell() {
        const overlay = document.createElement('div');
        overlay.className = 'dbpreview-overlay';

        const modal = document.createElement('div');
        modal.className = 'dbpreview-modal';
        overlay.appendChild(modal);

        // Close button
        const closeBtn = document.createElement('button');
        closeBtn.className = 'dbpreview-close';
        closeBtn.innerHTML = '✕';
        closeBtn.title = 'Close (Esc)';
        closeBtn.type = 'button';
        modal.appendChild(closeBtn);

        // Navigation Arrows
        const prevBtn = document.createElement('button');
        prevBtn.className = 'dbpreview-nav-btn dbpreview-prev';
        prevBtn.innerHTML = '‹';
        prevBtn.title = 'Previous Post (Left Arrow / ‹)';
        prevBtn.type = 'button';
        prevBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            navigateRelative(-1);
        });
        modal.appendChild(prevBtn);

        const nextBtn = document.createElement('button');
        nextBtn.className = 'dbpreview-nav-btn dbpreview-next';
        nextBtn.innerHTML = '›';
        nextBtn.title = 'Next Post (Right Arrow / ›)';
        nextBtn.type = 'button';
        nextBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            navigateRelative(1);
        });
        modal.appendChild(nextBtn);

        // Media Wrap
        const mediaWrap = document.createElement('div');
        mediaWrap.className = 'dbpreview-media-wrap';
        modal.appendChild(mediaWrap);

        // Spinner
        const spinner = document.createElement('div');
        spinner.className = 'dbpreview-spinner';
        mediaWrap.appendChild(spinner);

        // Info Bar
        const info = document.createElement('div');
        info.className = 'dbpreview-info';
        modal.appendChild(info);

        // Thumbnail Rail Wrapper with Side Scroll Buttons
        const railWrap = document.createElement('div');
        railWrap.className = 'dbpreview-rail-wrap';

        const scrollLeftBtn = document.createElement('button');
        scrollLeftBtn.className = 'dbpreview-rail-scroll-btn dbpreview-rail-prev';
        scrollLeftBtn.innerHTML = '‹';
        scrollLeftBtn.title = 'Scroll thumbnails left';
        scrollLeftBtn.type = 'button';
        scrollLeftBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            railTrack.scrollBy({ left: -220, behavior: 'smooth' });
        });
        railWrap.appendChild(scrollLeftBtn);

        const railTrack = document.createElement('div');
        railTrack.className = 'dbpreview-rail-track';
        railWrap.appendChild(railTrack);

        const scrollRightBtn = document.createElement('button');
        scrollRightBtn.className = 'dbpreview-rail-scroll-btn dbpreview-rail-next';
        scrollRightBtn.innerHTML = '›';
        scrollRightBtn.title = 'Scroll thumbnails right';
        scrollRightBtn.type = 'button';
        scrollRightBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            railTrack.scrollBy({ left: 220, behavior: 'smooth' });
        });
        railWrap.appendChild(scrollRightBtn);

        // Horizontal mouse wheel scrolling for rail
        railTrack.addEventListener('wheel', (e) => {
            if (e.deltaY !== 0) {
                e.preventDefault();
                railTrack.scrollLeft += e.deltaY;
            }
        }, { passive: false });

        // Auto-fetch when scrolling near either end of the rail
        railTrack.addEventListener('scroll', () => {
            if (railTrack.scrollLeft + railTrack.clientWidth >= railTrack.scrollWidth - 180) {
                fetchNextPage();
            } else if (railTrack.scrollLeft <= 180) {
                fetchPrevPage();
            }
        });

        modal.appendChild(railWrap);

        // Event wiring
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) closePreview();
        });
        closeBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            closePreview();
        });
        modal.addEventListener('click', (e) => e.stopPropagation());

        return { overlay, modal };
    }

    /** Calculate target width and height fitting within the viewport preserving aspect ratio */
    function calculateFittedSize(origW, origH) {
        if (!origW || !origH) return { width: 480, height: 360 };
        const maxW = Math.min(window.innerWidth * 0.92, 1278);
        const maxH = Math.max(200, window.innerHeight * 0.94 - 145);
        const fitRatio = Math.min(maxW / origW, maxH / origH);
        return {
            width: Math.max(160, Math.round(origW * fitRatio)),
            height: Math.max(160, Math.round(origH * fitRatio))
        };
    }

    /** Flying thumbnail transition from grid thumbnail to modal center */
    function animateThumbnailFly(startRect, thumbUrl, targetW, targetH, onComplete) {
        const controlsH = 104;
        const totalModalH = targetH + controlsH;
        const modalW = Math.max(targetW, 460);
        const targetLeft = Math.round((window.innerWidth - modalW) / 2 + (modalW - targetW) / 2);
        const targetTop = Math.round(Math.max(12, (window.innerHeight - totalModalH) / 2));

        const flyer = document.createElement('img');
        flyer.src = thumbUrl;
        flyer.className = 'dbpreview-flyer';
        flyer.style.cssText = `
            position: fixed;
            top: ${startRect.top}px;
            left: ${startRect.left}px;
            width: ${startRect.width}px;
            height: ${startRect.height}px;
            object-fit: contain;
            border-radius: 6px;
            z-index: 100002;
            pointer-events: none;
            box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6);
            transition: top 0.3s cubic-bezier(0.16, 1, 0.3, 1),
                        left 0.3s cubic-bezier(0.16, 1, 0.3, 1),
                        width 0.3s cubic-bezier(0.16, 1, 0.3, 1),
                        height 0.3s cubic-bezier(0.16, 1, 0.3, 1),
                        border-radius 0.3s ease;
        `;
        document.body.appendChild(flyer);
        flyer.offsetHeight; // force reflow

        requestAnimationFrame(() => {
            flyer.style.top = targetTop + 'px';
            flyer.style.left = targetLeft + 'px';
            flyer.style.width = targetW + 'px';
            flyer.style.height = targetH + 'px';
            flyer.style.borderRadius = '14px 14px 0 0';
        });

        setTimeout(() => {
            flyer.remove();
            if (onComplete) onComplete();
        }, 300);
    }

    // --- Update Modal Content for a given post ---
    function fillModalContent(modal, post, fallbackThumb) {
        const mode = getQualityMode();
        const mediaUrl = getMediaUrl(post, mode);
        const isVid = isVideo(post);

        const mediaWrap = modal.querySelector('.dbpreview-media-wrap');
        const spinner = modal.querySelector('.dbpreview-spinner');
        const info = modal.querySelector('.dbpreview-info');

        // Pre-size modal and mediaWrap to exact aspect ratio and dimensions
        const rawW = post.image_width || post.media_asset?.image_width || 480;
        const rawH = post.image_height || post.media_asset?.image_height || 360;
        const { width: targetW, height: targetH } = calculateFittedSize(rawW, rawH);

        const modalW = Math.max(targetW, 460);
        modal.style.width = modalW + 'px';
        mediaWrap.style.width = targetW + 'px';
        mediaWrap.style.height = targetH + 'px';

        // Clear existing media and placeholders, keep spinner
        const oldMedia = mediaWrap.querySelectorAll('.dbpreview-media, .dbpreview-placeholder, .dbpreview-error');
        oldMedia.forEach(el => el.remove());

        // Thumbnail placeholder for instant visual preview
        const thumbUrl = post.preview_file_url || fallbackThumb || '';
        let placeholder = null;
        if (thumbUrl) {
            placeholder = document.createElement('img');
            placeholder.className = 'dbpreview-placeholder';
            placeholder.src = thumbUrl;
            placeholder.alt = '';
            mediaWrap.appendChild(placeholder);
        }

        spinner.classList.remove('is-hidden');

        // Create new media element
        let mediaEl;
        if (isVid) {
            mediaEl = document.createElement('video');
            mediaEl.className = 'dbpreview-media';
            mediaEl.style.display = 'none'; // Keep hidden until loaded to prevent native black box & double spinner
            mediaEl.autoplay = true;
            mediaEl.loop = true;
            mediaEl.playsInline = true;
            mediaEl.preload = 'auto';
            mediaEl.src = mediaUrl;

            // Apply persisted volume and active session mute preference
            mediaEl.muted = sessionMuted;
            mediaEl.volume = Math.min(1, Math.max(0, getVideoVolume()));

            // Persist user changes to volume or mute toggle for this session
            mediaEl.addEventListener('volumechange', () => {
                setVideoVolume(mediaEl.volume);
                sessionMuted = mediaEl.muted;
            });

            let readyFired = false;
            const onReady = () => {
                if (readyFired) return;
                readyFired = true;
                mediaEl.controls = true; // Show native video controls once buffered
                mediaEl.style.display = 'block';
                mediaEl.offsetHeight; // reflow
                mediaEl.classList.add('is-ready');
                spinner.classList.add('is-hidden');
                if (placeholder) {
                    setTimeout(() => placeholder.classList.add('is-hidden'), 250);
                }
                mediaEl.play().catch(() => {
                    // If browser blocks unmuted autoplay without recent interaction, fallback to muted
                    if (!mediaEl.muted) {
                        mediaEl.muted = true;
                        sessionMuted = true;
                        mediaEl.play().catch(() => {});
                    }
                });
            };

            mediaEl.addEventListener('loadeddata', onReady, { once: true });
            mediaEl.addEventListener('canplay', onReady, { once: true });
            mediaEl.addEventListener('error', () => {
                spinner.classList.add('is-hidden');
                mediaWrap.innerHTML = '<div class="dbpreview-error">Failed to load video.</div>';
            }, { once: true });
        } else {
            mediaEl = document.createElement('img');
            mediaEl.className = 'dbpreview-media';
            mediaEl.style.display = 'none';
            mediaEl.alt = `Post #${post.id}`;
            mediaEl.src = mediaUrl;

            mediaEl.addEventListener('load', () => {
                mediaEl.style.display = 'block';
                mediaEl.offsetHeight; // reflow
                mediaEl.classList.add('is-ready');
                spinner.classList.add('is-hidden');
                if (placeholder) {
                    setTimeout(() => placeholder.classList.add('is-hidden'), 250);
                }
                if (mode === 'progressive' && post.large_file_url && post.large_file_url !== post.file_url) {
                    const fullImg = new Image();
                    fullImg.onload = () => {
                        if (currentPostId === post.id) mediaEl.src = post.file_url;
                    };
                    fullImg.src = post.file_url;
                }
            }, { once: true });
            mediaEl.addEventListener('error', () => {
                spinner.classList.add('is-hidden');
                mediaWrap.innerHTML = '<div class="dbpreview-error">Failed to load image.</div>';
            }, { once: true });
        }
        mediaWrap.appendChild(mediaEl);

        // Populate Info Bar
        info.innerHTML = '';

        // Rating pill
        const ratingPill = document.createElement('span');
        ratingPill.className = 'dbpreview-pill dbpreview-rating';
        ratingPill.dataset.rating = post.rating;
        ratingPill.textContent = RATING_LABELS[post.rating] || post.rating;
        info.appendChild(ratingPill);

        // Artist
        const artistHtml = formatArtist(post.tag_string_artist);
        if (artistHtml) {
            const artistPill = document.createElement('span');
            artistPill.className = 'dbpreview-pill';
            artistPill.innerHTML = `<span class="dbpreview-pill-icon">🎨</span> ${artistHtml}`;
            info.appendChild(artistPill);
        }

        // Score
        const scorePill = document.createElement('span');
        scorePill.className = 'dbpreview-pill';
        scorePill.innerHTML = `<span class="dbpreview-pill-icon">▲</span> ${post.score}`;
        scorePill.title = `↑${post.up_score} ↓${post.down_score}`;
        info.appendChild(scorePill);

        // Fav count
        const favPill = document.createElement('span');
        favPill.className = 'dbpreview-pill';
        favPill.innerHTML = `<span class="dbpreview-pill-icon">♥</span> ${post.fav_count}`;
        info.appendChild(favPill);

        // Spacer
        const spacer = document.createElement('span');
        spacer.className = 'dbpreview-spacer';
        info.appendChild(spacer);

        // Quality switcher
        const qualGroup = document.createElement('span');
        qualGroup.className = 'dbpreview-quality-group';
        ['large', 'original', 'progressive'].forEach(qm => {
            const btn = document.createElement('button');
            btn.className = 'dbpreview-quality-btn' + (qm === mode ? ' is-active' : '');
            btn.textContent = qm === 'progressive' ? 'prog' : qm;
            btn.title = qm === 'large' ? 'Load large version (~720p)'
                : qm === 'original' ? 'Load original full quality'
                : 'Load large first, then swap to original';
            btn.addEventListener('click', (e) => {
                e.stopPropagation();
                setQualityMode(qm);
                fillModalContent(modal, post, fallbackThumb);
            });
            qualGroup.appendChild(btn);
        });
        info.appendChild(qualGroup);

        // Open link
        const openLink = document.createElement('a');
        openLink.className = 'dbpreview-open-link';
        openLink.href = `/posts/${post.id}`;
        openLink.target = '_blank';
        openLink.textContent = `Open #${post.id} →`;
        info.appendChild(openLink);
    }

    // --- Show / Close Modal ---
    function openOverlay(startRect, thumbUrl, targetW, targetH) {
        if (currentOverlay) return;
        const { overlay, modal } = createModalShell();
        currentOverlay = overlay;
        currentModal = modal;
        document.body.appendChild(overlay);
        document.body.style.overflow = 'hidden';

        overlay.classList.add('is-visible');

        // Flying thumbnail animation from grid thumbnail to modal
        if (startRect && thumbUrl && targetW && targetH) {
            modal.style.opacity = '0';
            animateThumbnailFly(startRect, thumbUrl, targetW, targetH, () => {
                modal.style.opacity = '1';
            });
        } else {
            modal.style.opacity = '1';
        }

        // Initial rail populate
        const track = modal.querySelector('.dbpreview-rail-track');
        if (track) renderThumbnailRail(track, currentPostId);
    }

    function closePreview() {
        if (!currentOverlay) return;
        const ol = currentOverlay;
        currentOverlay = null;
        currentModal = null;
        currentPostId = null;
        sessionMuted = true; // Reset mute preference to muted whenever leaving the modal
        ol.classList.remove('is-visible');
        document.body.style.overflow = '';
        setTimeout(() => ol.remove(), 250);
    }

    // --- Main Fetch & Show Handler ---
    async function fetchAndShowPreview(postId, triggerRect) {
        currentPostId = postId;

        const article = document.querySelector(`article.post-preview[data-id="${postId}"]`);
        const domImg = article?.querySelector('img.post-preview-image') || article?.querySelector('img');
        const fallbackThumb = domImg?.src || '';
        const startRect = triggerRect || (domImg ? domImg.getBoundingClientRect() : null);

        const cached = postDataCache.get(postId);
        const rawW = cached?.image_width || domImg?.naturalWidth || domImg?.width || 480;
        const rawH = cached?.image_height || domImg?.naturalHeight || domImg?.height || 360;
        const { width: targetW, height: targetH } = calculateFittedSize(rawW, rawH);

        // Open shell if not already open
        if (!currentOverlay) {
            openOverlay(startRect, fallbackThumb, targetW, targetH);
        }

        updateNavButtons(postId);
        updateRailActive(postId);

        const mediaWrap = currentModal.querySelector('.dbpreview-media-wrap');
        const spinner = currentModal.querySelector('.dbpreview-spinner');

        // Pre-size modal and mediaWrap
        currentModal.style.width = Math.max(targetW, 460) + 'px';
        mediaWrap.style.width = targetW + 'px';
        mediaWrap.style.height = targetH + 'px';

        // Set preliminary placeholder
        if (fallbackThumb) {
            const oldMedia = mediaWrap.querySelectorAll('.dbpreview-media, .dbpreview-placeholder, .dbpreview-error');
            oldMedia.forEach(el => el.remove());

            const initPlaceholder = document.createElement('img');
            initPlaceholder.className = 'dbpreview-placeholder';
            initPlaceholder.src = fallbackThumb;
            mediaWrap.appendChild(initPlaceholder);
        }

        spinner.classList.remove('is-hidden');

        try {
            const post = await fetchPostData(postId);
            if (currentPostId !== postId) return; // Stale request check

            fillModalContent(currentModal, post, fallbackThumb);
            updateNavButtons(postId);
            updateRailActive(postId);
        } catch (err) {
            console.error('[Danbooru Preview]', err);
            if (currentPostId !== postId) return;

            spinner.classList.add('is-hidden');
            mediaWrap.innerHTML = `
                <div class="dbpreview-error">
                    Failed to load post #${postId}<br>
                    <small>${escapeHtml(err.message)}</small>
                </div>
            `;
        }
    }

    // --- Thumbnail Preview Button Injection on Grid ---
    function injectPreviewButton(article) {
        if (article.hasAttribute(PROCESSED_ATTR)) return;
        article.setAttribute(PROCESSED_ATTR, '1');

        const postId = article.dataset.id;
        if (!postId) return;

        const link = article.querySelector('a.post-preview-link') || article.querySelector('.post-preview-container') || article;
        if (!link) return;

        const btn = document.createElement('button');
        btn.className = 'dbpreview-thumb-btn';
        btn.innerHTML = '👁';
        btn.title = 'Preview (Alt+Click also works)';
        btn.type = 'button';

        btn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            const targetImg = article.querySelector('img.post-preview-image') || article.querySelector('img') || btn;
            fetchAndShowPreview(parseInt(postId, 10), targetImg.getBoundingClientRect());
        });

        btn.addEventListener('mousedown', (e) => {
            e.stopPropagation();
        });

        link.appendChild(btn);
    }

    function scanAndInjectButtons() {
        const articles = document.querySelectorAll('article.post-preview:not([' + PROCESSED_ATTR + '])');
        articles.forEach(injectPreviewButton);
    }

    // Initial scan
    scanAndInjectButtons();

    // Observe dynamically loaded posts (infinite scroll / AJAX)
    const observer = new MutationObserver((mutations) => {
        let needsScan = false;
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    if (node.matches?.('article.post-preview') || node.querySelector?.('article.post-preview')) {
                        needsScan = true;
                        break;
                    }
                }
            }
            if (needsScan) break;
        }
        if (needsScan) {
            scanAndInjectButtons();
            // If modal is open, refresh the thumbnail rail
            if (currentModal && currentPostId) {
                const track = currentModal.querySelector('.dbpreview-rail-track');
                if (track) renderThumbnailRail(track, currentPostId);
            }
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // --- Event Listeners ---

    // Alt+Click interception
    document.addEventListener('click', (e) => {
        if (!e.altKey) return;

        const link = e.target.closest('a[href]');
        if (!link) return;

        const href = link.getAttribute('href') || '';
        const match = href.match(POST_LINK_RE);
        if (!match) return;

        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();

        const postId = parseInt(match[1], 10);
        const targetImg = link.querySelector('img.post-preview-image') || link.querySelector('img') || link;
        fetchAndShowPreview(postId, targetImg.getBoundingClientRect());
    }, true);

    // Keyboard Shortcuts: Escape, Left Arrow, Right Arrow
    // Intercepted on window during capture phase to completely block background pagination
    function handleKeyDown(e) {
        if (!currentOverlay) return;

        // Ignore if user is typing in an input/textarea/contentEditable
        const activeTag = document.activeElement?.tagName?.toLowerCase();
        if (activeTag === 'input' || activeTag === 'textarea' || document.activeElement?.isContentEditable) {
            return;
        }

        if (e.key === 'Escape') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            closePreview();
        } else if (e.key === 'ArrowLeft') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            navigateRelative(-1);
        } else if (e.key === 'ArrowRight') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            navigateRelative(1);
        }
    }

    function handleKeyUp(e) {
        if (!currentOverlay) return;
        if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Escape') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
        }
    }

    window.addEventListener('keydown', handleKeyDown, true);
    window.addEventListener('keyup', handleKeyUp, true);

    console.log('[Danbooru Preview] Loaded — Alt+Click, 👁 button, Left/Right arrow keys, or Thumbnail Rail to preview.');
})();