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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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