☰

Danbooru Enhanced Gallery

Overhauls Danbooru's default gallery view with a sleek, responsive justified grid layout. Includes high-res thumbnails, hover previews, infinite scroll, and more.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Danbooru Enhanced Gallery
// @namespace    https://github.com/yourusername/danbooru-justified-gallery
// @version      1.60
// @description  Overhauls Danbooru's default gallery view with a sleek, responsive justified grid layout. Includes high-res thumbnails, hover previews, infinite scroll, and more.
// @author       Claude (prompted by orx_ibx)
// @match        https://danbooru.donmai.us/
// @match        https://danbooru.donmai.us/posts
// @match        https://danbooru.donmai.us/posts?*
// @match        https://danbooru.donmai.us/?tags=*
// @grant        none
// @noframes
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/justifiedGallery/3.8.1/js/jquery.justifiedGallery.min.js
// @license      MIT (https://opensource.org/licenses/MIT)
// ==/UserScript==

(function () {
    "use strict";

    // The infinite scroll loader pulls pages through a hidden iframe; don't run in it
    if (window.top !== window.self) return;

    // Fail loudly (instead of cryptically) if the @require'd libraries did not load.
    if (
        !window.jQuery ||
        !window.jQuery.fn ||
        !window.jQuery.fn.justifiedGallery
    ) {
        console.error(
            "[Danbooru Enhanced Gallery] jQuery / Justified Gallery failed to load; script disabled.",
        );
        return;
    }

    const $ = window.jQuery.noConflict(true);

    // ======================================================================
    // Configuration
    // ======================================================================

    const CONFIG = {
        // toggle features
        enableHoverPreview: true,
        enableHighRes: true,
        enableRelocateSearchBar: true,
        enableHiddenSidebar: true,
        enableDynamicSearch: true, // red search button / Enter key search results in place, no page load
        enableInfiniteScroll: true, // load the next page automatically when you scroll near the bottom

        // High rez thumbnail loader config
        viewportBuffer: 600, // pixels below viewport to start loading HR thumbs
        maxRetries: 0, // retry attempts for failed post-info requests (network / 429 / 5xx)
        batchSize: 5, // num of HR thumbnails to load at once
        batchDelay: 0, // delay between each HR batch load
        enableScrollPause: true,
        scrollThrottleDelay: 0, // ms to wait after scrolling stops before loading resumes

        // Infinite scroll config
        infiniteScrollThreshold: 400, // px from the bottom at which the next page starts loading
        infiniteScrollThrottle: 200, // ms between scroll checks

        // Hover preview config
        hoverDelay: 300, // ms delay before showing preview
    };

    // Settings that can be toggled from the Settings dropdown and are persisted
    const SETTINGS_STORAGE_KEY = "DJG_settings";
    const PERSISTED_SETTINGS = [
        "enableHoverPreview",
        "enableRelocateSearchBar",
        "enableHiddenSidebar",
        "enableDynamicSearch",
        "enableInfiniteScroll",
    ];

    function loadSettings() {
        try {
            const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
            if (!raw) return;
            const saved = JSON.parse(raw);
            if (!saved || typeof saved !== "object") return;
            PERSISTED_SETTINGS.forEach((key) => {
                if (typeof saved[key] === "boolean") CONFIG[key] = saved[key];
            });
        } catch (e) {
            // ignore storage / parse errors and keep defaults
        }
    }

    function saveSettings() {
        try {
            const data = {};
            PERSISTED_SETTINGS.forEach((key) => {
                data[key] = CONFIG[key];
            });
            localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(data));
        } catch (e) {
            // ignore storage errors
        }
    }

    // Restore saved values over the defaults above
    loadSettings();

    // Config for Justified Gallery (a fresh copy is handed to the plugin each time)
    const JG_CONFIG = {
        rowHeight: 240,
        maxRowHeight: 1080,
        lastRow: "nojustify",
        margins: 8,
        border: 0,
        captions: true,
        randomize: false,
        waitThumbnailsLoad: true,
        cssAnimation: false,
        imagesAnimationDuration: 0,
        captionSettings: {
            animationDuration: 0,
            visibleOpacity: 0.5,
            nonVisibleOpacity: 0.0,
        },
    };
    const jgOptions = () => ({
        ...JG_CONFIG,
        captionSettings: { ...JG_CONFIG.captionSettings },
    });

    // Elements that trigger a Justified Gallery reset when clicked.
    // Handled through one delegated listener, so elements injected later
    // (e.g. by other userscripts) work too.
    const JG_RESET_SELECTORS = [
        "#jg-reload-btn",
        "#danbooru-search-enhancement button#local-sort",
        "#danbooru-filter-panel #apply-filter",
        "#blacklist-box input.toggle-switch",
    ];

    const GALLERY_ID = "justified-gallery-container";
    const MAX_URL_CACHE = 2000;
    const MAX_TOOLTIP_CACHE = 50;

    // Icons (escaped so the file survives any encoding round-trip)
    const ICON_UPVOTE = "\u{1F845}";
    const ICON_FAVORITE = "\u2764\uFE0E";
    const ICON_INFO = "\u{1F6C8}";

    // ======================================================================
    // Small utilities
    // ======================================================================

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    const HTML_ESCAPES = {
        "&": "&",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
    };
    const escapeHtml = (value) =>
        String(value).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);

    const isPostId = (value) => typeof value === "string" && /^\d+$/.test(value);

    function extractPostIdFromUrl(url) {
        const match = /\/posts\/(\d+)/.exec(url || "");
        return match ? match[1] : null;
    }

    function isHttpUrl(value) {
        try {
            const url = new URL(value, window.location.href);
            return url.protocol === "http:" || url.protocol === "https:";
        } catch (e) {
            return false;
        }
    }

    function getCsrfToken() {
        const meta = document.querySelector('meta[name="csrf-token"]');
        return meta ? meta.getAttribute("content") : null;
    }

    function apiHeaders(csrfToken) {
        return {
            "Content-Type": "application/json",
            "X-CSRF-Token": csrfToken,
            "X-Requested-With": "XMLHttpRequest",
        };
    }

    // ======================================================================
    // Post image URL resolver (shared by hover preview + high-res loader)
    // ======================================================================

    // Video / archive URLs can't be shown in an <img>; don't waste bandwidth on them.
    const NON_IMAGE_URL = /\.(?:mp4|webm|mkv|zip|swf)(?:[?#]|$)/i;

    class HttpError extends Error {
        constructor(status) {
            super(`HTTP ${status}`);
            this.status = status;
        }
    }

    class PostUrlResolver {
        constructor() {
            this.cache = new Map(); // postId -> url | null (bounded)
            this.pending = new Map(); // postId -> Promise (de-duplicates requests)
        }

        // Always resolves (never rejects) with a URL string or null.
        resolve(postId) {
            if (this.cache.has(postId)) {
                return Promise.resolve(this.cache.get(postId));
            }
            let request = this.pending.get(postId);
            if (!request) {
                request = this.fetchWithRetry(postId)
                    .then((url) => {
                        this.remember(postId, url);
                        return url;
                    })
                    .catch(() => null) // transient failures are not cached
                    .finally(() => this.pending.delete(postId));
                this.pending.set(postId, request);
            }
            return request;
        }

        remember(postId, url) {
            if (this.cache.size >= MAX_URL_CACHE) {
                this.cache.delete(this.cache.keys().next().value);
            }
            this.cache.set(postId, url);
        }

        async fetchWithRetry(postId) {
            for (let attempt = 0; ; attempt++) {
                try {
                    return await this.fetchUrl(postId);
                } catch (error) {
                    const retryable =
                        !(error instanceof HttpError) ||
                        error.status === 429 ||
                        error.status >= 500;
                    if (!retryable || attempt >= CONFIG.maxRetries) throw error;
                    await sleep(500 * 2 ** attempt);
                }
            }
        }

        async fetchUrl(postId) {
            const response = await fetch(`/posts/${postId}.json`, {
                headers: { Accept: "application/json" },
            });
            // Restricted / deleted posts: definitive "no image", safe to cache
            if (response.status === 403 || response.status === 404) return null;
            if (!response.ok) throw new HttpError(response.status);

            const data = await response.json();
            const url = data && (data.large_file_url || data.file_url);
            if (typeof url !== "string" || !isHttpUrl(url)) return null;
            if (NON_IMAGE_URL.test(url)) return null;
            return url;
        }
    }

    const urlResolver = new PostUrlResolver();

    // ======================================================================
    // Hover preview (single instance, driven by delegated gallery listeners)
    // ======================================================================

    class HoverPreview {
        constructor() {
            this.el = null;
            this.current = null; // <img> currently loading / shown
            this.active = false; // pointer is over a gallery image
            this.token = 0; // invalidates stale async work
            this.timer = null;
            this.clearTimer = null;
            this.raf = 0;
            this.x = 0;
            this.y = 0;
            this.size = { w: 0, h: 0 };
        }

        ensureElement() {
            if (!this.el || !this.el.isConnected) {
                this.el = document.createElement("div");
                this.el.id = "danbooru-hover-preview";
                document.body.appendChild(this.el);
            }
            return this.el;
        }

        start(postId, x, y) {
            this.stop();
            this.active = true;
            this.x = x;
            this.y = y;
            const token = this.token;

            this.timer = setTimeout(async () => {
                this.timer = null;
                const url = await urlResolver.resolve(postId);
                if (token !== this.token || !url || !CONFIG.enableHoverPreview) {
                    return;
                }
                this.show(url);
            }, CONFIG.hoverDelay);
        }

        // Pointer left the image (or the preview must be cancelled)
        stop() {
            this.active = false;
            this.token++;
            clearTimeout(this.timer);
            this.timer = null;
            this.hide();
        }

        moveTo(x, y) {
            this.x = x;
            this.y = y;
            if (!this.current || this.raf) return;
            this.raf = requestAnimationFrame(() => {
                this.raf = 0;
                this.reposition();
            });
        }

        show(url) {
            const el = this.ensureElement();
            clearTimeout(this.clearTimer);
            el.style.width = "";
            el.style.height = "";
            this.size = { w: 0, h: 0 };

            const img = document.createElement("img");
            this.current = img;

            img.onload = () => {
                if (this.current !== img) return;
                this.fit(img);
                this.reposition();
                el.classList.add("visible"); // fade in only once there is something to show
            };
            img.onerror = () => {
                if (this.current === img) this.hide();
            };

            el.replaceChildren(img);
            img.src = url;
        }

        // Display the image at its native device-pixel size (so browser zoom /
        // HiDPI don't change its apparent resolution), capped to the viewport.
        fit(img) {
            const naturalWidth = img.naturalWidth;
            const naturalHeight = img.naturalHeight;
            if (!naturalWidth || !naturalHeight) return;

            const dpr = window.devicePixelRatio || 1;
            const fitScale = Math.min(
                (window.innerWidth * 0.95 * dpr) / naturalWidth,
                (window.innerHeight * 0.95 * dpr) / naturalHeight,
                1, // never scale up
            );
            const scale = fitScale / dpr;

            img.style.transform = `scale(${scale})`;
            img.style.transformOrigin = "top left";
            img.style.width = `${naturalWidth}px`;
            img.style.height = `${naturalHeight}px`;

            this.size = { w: naturalWidth * scale, h: naturalHeight * scale };
            this.el.style.width = `${this.size.w}px`;
            this.el.style.height = `${this.size.h}px`;
        }

        reposition() {
            if (!this.el) return;
            const dpr = window.devicePixelRatio || 1;
            const gap = 15 / dpr;
            const margin = 10 / dpr;
            const { w, h } = this.size;
            const vw = window.innerWidth;
            const vh = window.innerHeight;

            let left = this.x + gap;
            let top = this.y + gap;
            if (left + w > vw) left = this.x - w - gap;
            if (top + h > vh) top = this.y - h - gap;

            left = Math.max(margin, Math.min(left, vw - w - margin));
            top = Math.max(margin, Math.min(top, vh - h - margin));

            this.el.style.left = `${left}px`;
            this.el.style.top = `${top}px`;
        }

        hide() {
            const el = this.el;
            if (!el) return;

            const img = this.current;
            this.current = null;
            el.classList.remove("visible");

            // Abort a still-loading image
            if (img && !img.complete) img.removeAttribute("src");

            // Clear content after the fade-out
            clearTimeout(this.clearTimer);
            this.clearTimer = setTimeout(() => {
                if (this.current) return;
                el.replaceChildren();
                el.style.width = "";
                el.style.height = "";
            }, 200);
        }
    }

    const hoverPreview = new HoverPreview();

    // ======================================================================
    // High-resolution thumbnail loader
    // ======================================================================

    const SCROLL_EVENTS = ["scroll", "wheel", "touchmove"];

    class HighResLoader {
        constructor() {
            this.queue = new Map(); // postId -> img (insertion ordered)
            this.jobs = new Map(); // postId -> in-flight job
            this.processing = false;
            this.destroyed = false;
            this.isScrolling = false;
            this.scrollTimer = null;
            this.nextTimer = null;

            this.observer = new IntersectionObserver(
                (entries) => this.onIntersect(entries),
                {
                    // preload only below the viewport
                    rootMargin: `0px 0px ${CONFIG.viewportBuffer}px 0px`,
                    threshold: 0.1,
                },
            );

            this.onScroll = () => {
                this.isScrolling = true;
                clearTimeout(this.scrollTimer);
                this.scrollTimer = setTimeout(() => {
                    this.isScrolling = false;
                    this.process();
                }, CONFIG.scrollThrottleDelay);
            };
            if (CONFIG.enableScrollPause) {
                SCROLL_EVENTS.forEach((name) =>
                    window.addEventListener(name, this.onScroll, {
                        passive: true,
                    }),
                );
            }
        }

        observe(img) {
            if (!this.destroyed) this.observer.observe(img);
        }

        get paused() {
            return CONFIG.enableScrollPause && this.isScrolling;
        }

        onIntersect(entries) {
            for (const entry of entries) {
                const img = entry.target;
                const postId = img.dataset.postId;
                if (!postId) continue;

                if (entry.isIntersecting) {
                    if (img.dataset.highResLoaded !== "true") {
                        this.queue.delete(postId);
                        this.queue.set(postId, img);
                    }
                } else {
                    // Left the viewport: drop it from the queue / stop loading it
                    this.queue.delete(postId);
                    this.cancel(postId);
                }
            }
            this.process();
        }

        async process() {
            if (this.destroyed || this.processing || this.queue.size === 0) return;
            if (this.paused) return;

            this.processing = true;
            try {
                const batch = [];
                for (const [postId, img] of this.queue) {
                    batch.push([postId, img]);
                    this.queue.delete(postId);
                    if (batch.length >= CONFIG.batchSize) break;
                }
                await Promise.all(batch.map(([id, img]) => this.load(id, img)));
            } finally {
                this.processing = false;
            }

            if (!this.destroyed && this.queue.size > 0 && !this.paused) {
                this.nextTimer = setTimeout(
                    () => this.process(),
                    CONFIG.batchDelay,
                );
            }
        }

        cancel(postId) {
            const job = this.jobs.get(postId);
            if (!job) return;
            this.jobs.delete(postId);
            job.cancelled = true;
            job.img.dataset.highResLoading = "false";
            if (job.abort) job.abort();
        }

        // Never rejects: on any failure the low-res thumbnail simply stays.
        async load(postId, img) {
            if (img.dataset.highResLoaded === "true" || this.jobs.has(postId)) {
                return;
            }

            const job = { img, cancelled: false, abort: null };
            this.jobs.set(postId, job);
            img.dataset.highResLoading = "true";

            try {
                const url = await urlResolver.resolve(postId);
                if (!url || job.cancelled || !img.isConnected) return;

                if (url !== img.src) {
                    const ok = await this.preload(url, job);
                    if (!ok || job.cancelled || !img.isConnected) return;
                    this.applySource(img, url);
                }
                img.dataset.highResLoaded = "true";
                this.observer.unobserve(img); // nothing left to do for this image
            } catch (error) {
                // keep the thumbnail
            } finally {
                if (this.jobs.get(postId) === job) {
                    this.jobs.delete(postId);
                    img.dataset.highResLoading = "false";
                }
            }
        }

        // Pre-load into a detached Image so the swap is instant; abortable.
        preload(url, job) {
            return new Promise((resolve) => {
                const temp = new Image();
                const finish = (ok) => {
                    temp.onload = temp.onerror = null;
                    job.abort = null;
                    resolve(ok);
                };
                job.abort = () => {
                    temp.src = "";
                    finish(false);
                };
                temp.onload = () => finish(true);
                temp.onerror = () => finish(false);
                temp.src = url;
            });
        }

        applySource(img, url) {
            // Justified Gallery re-applies its own stored source (data "jg.src") every
            // time it (re)displays an entry. That happens to the unfinished last row
            // (lastRow: "nojustify") on every "norewind" refresh, and to every entry when
            // the gallery re-justifies. If JG kept the thumbnail URL, those images would
            // silently fall back to low-res, so keep JG's record in sync with the upgrade.
            const $img = $(img);
            if ($img.data("jg.src")) $img.data("jg.src", url);
            // If JG hasn't analysed this entry yet it will read the source from here instead
            img.setAttribute("data-safe-src", url);

            // Pin the current layout size so the justified rows don't shift
            const width = img.offsetWidth;
            const height = img.offsetHeight;
            img.src = url;
            if (width && height) {
                img.style.width = `${width}px`;
                img.style.height = `${height}px`;
                img.style.objectFit = "cover";
            }
        }

        destroy() {
            this.destroyed = true;
            this.observer.disconnect();
            clearTimeout(this.scrollTimer);
            clearTimeout(this.nextTimer);
            SCROLL_EVENTS.forEach((name) =>
                window.removeEventListener(name, this.onScroll),
            );
            this.queue.clear();
            Array.from(this.jobs.keys()).forEach((id) => this.cancel(id));
        }
    }

    // ======================================================================
    // Gallery construction / lifecycle
    // ======================================================================

    // Runtime state
    let initialized = false;
    let gallery = null; // { container, postsContainer, postIds, loader, postsObserver, syncTimer }
    let rebuildTimer = null;
    let searchBarState = null;

    function getPostId(post) {
        const fromAttr = post.getAttribute("data-id");
        if (isPostId(fromAttr)) return fromAttr;
        const link = post.querySelector(".post-preview-link");
        return link ? extractPostIdFromUrl(link.href) : null;
    }

    // Builds one justified-gallery entry (<a><img><caption/></a>) from a Danbooru post
    function createGalleryItem(post, postId) {
        const link = post.querySelector(".post-preview-link");
        const img = post.querySelector(".post-preview-image");
        if (!link || !img) return null;

        const parsedScore = Number.parseInt(post.getAttribute("data-score"), 10);
        const score = Number.isFinite(parsedScore) ? parsedScore : 0;
        const scoreState =
            score > 0 ? "positive" : score < 0 ? "negative" : "neutral";

        const newLink = document.createElement("a");
        newLink.href = link.href;

        const newImg = document.createElement("img");
        newImg.src = img.src;
        newImg.alt = img.alt;
        newImg.setAttribute("data-safe-src", img.src);
        newImg.dataset.postId = postId;

        // Original dimensions, if available
        newImg.setAttribute(
            "width",
            img.getAttribute("width") || img.naturalWidth || 180,
        );
        newImg.setAttribute(
            "height",
            img.getAttribute("height") || img.naturalHeight || 180,
        );

        const caption = document.createElement("div");
        caption.className = "jg-caption";

        const durationEl = post.querySelector(
            "a.post-preview-link div.post-animation-icon .post-duration",
        );
        const durationHtml = durationEl
            ? `<span class="content-duration">${escapeHtml(
                  durationEl.textContent.trim(),
              )}</span>`
            : "";

        // postId is validated (digits only) and score is a number, so this is safe.
        caption.innerHTML = `
            <span class="caption-button upvote-btn" data-post-id="${postId}">${ICON_UPVOTE}</span>
            <span class="score-display ${scoreState}">${score}</span>
            <span class="caption-button favorite-btn" data-post-id="${postId}">${ICON_FAVORITE}</span>
            <div class="right-side-wrapper">${durationHtml}<span class="info-button" data-post-id="${postId}">${ICON_INFO}</span></div>
        `;

        newLink.appendChild(newImg);
        newLink.appendChild(caption);
        return newLink;
    }

    // Collects gallery entries for posts that aren't in the gallery yet
    function collectNewItems(g) {
        const items = [];
        g.postsContainer
            .querySelectorAll("article.post-preview")
            .forEach((post) => {
                const postId = getPostId(post);
                if (!postId || g.postIds.has(postId)) return;
                const item = createGalleryItem(post, postId);
                if (!item) return;
                g.postIds.add(postId);
                items.push(item);
            });
        return items;
    }

    function observeItems(g, items) {
        if (!g.loader) return;
        items.forEach((item) => {
            const img = item.querySelector("img");
            if (img) g.loader.observe(img);
        });
    }

    // Appends posts that were added to Danbooru's container (e.g. infinite scroll)
    function syncNewPosts(g) {
        if (gallery !== g || !g.container.isConnected) return;
        const items = collectNewItems(g);
        if (items.length === 0) {
            notifyLayoutDone(); // nothing to lay out, don't keep infinite scroll waiting
            return;
        }

        const fragment = document.createDocumentFragment();
        items.forEach((item) => fragment.appendChild(item));
        g.container.appendChild(fragment);
        observeItems(g, items);

        try {
            $(g.container).justifiedGallery("norewind");
        } catch (error) {
            console.warn("[DJG] Gallery refresh failed:", error);
            notifyLayoutDone();
        }
    }

    function watchPostsContainer(g) {
        const scheduleSync = () => {
            clearTimeout(g.syncTimer);
            g.syncTimer = setTimeout(() => syncNewPosts(g), 50);
        };

        g.postsObserver = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType !== 1) continue;
                    if (
                        node.matches("article.post-preview") ||
                        node.querySelector("article.post-preview")
                    ) {
                        scheduleSync();
                        return;
                    }
                }
            }
        });
        g.postsObserver.observe(g.postsContainer, {
            childList: true,
            subtree: true,
        });
    }

    function attachHoverHandlers(container) {
        container.addEventListener("mouseover", (e) => {
            if (!CONFIG.enableHoverPreview) return;
            const img = e.target;
            if (!(img instanceof HTMLImageElement)) return;
            const postId = img.dataset.postId;
            if (!postId) return;
            hoverPreview.start(postId, e.clientX, e.clientY);
        });

        container.addEventListener("mouseout", (e) => {
            if (e.target instanceof HTMLImageElement) hoverPreview.stop();
        });

        container.addEventListener(
            "mousemove",
            (e) => {
                if (hoverPreview.active) hoverPreview.moveTo(e.clientX, e.clientY);
            },
            { passive: true },
        );
    }

    function buildGallery() {
        const postsContainer = document.querySelector(".posts-container");
        if (!postsContainer || !postsContainer.parentNode) return;

        const container = document.createElement("div");
        container.className = "justified-gallery";
        container.id = GALLERY_ID;

        const g = {
            container,
            postsContainer,
            postIds: new Set(),
            loader: null,
            postsObserver: null,
            syncTimer: null,
        };

        const items = collectNewItems(g);
        const fragment = document.createDocumentFragment();
        items.forEach((item) => fragment.appendChild(item));
        container.appendChild(fragment);

        postsContainer.parentNode.insertBefore(container, postsContainer);

        // Infinite scroll waits for the layout; bind before init in case it finishes instantly
        $(container).on("jg.complete", notifyLayoutDone);
        holdForLayout();

        try {
            $(container).justifiedGallery(jgOptions());
        } catch (error) {
            // Fall back to Danbooru's default gallery rather than showing nothing
            console.error("[DJG] Justified Gallery init failed:", error);
            $(container).off("jg.complete");
            container.remove();
            document.documentElement.classList.add("DJG-failed");
            notifyLayoutDone();
            return;
        }
        document.documentElement.classList.remove("DJG-failed");

        gallery = g;

        if (CONFIG.enableHighRes) {
            g.loader = new HighResLoader();
            observeItems(g, items);
        }

        container.addEventListener("click", handleGalleryClick);
        attachHoverHandlers(container);
        watchPostsContainer(g);
    }

    function destroyGallery() {
        hoverPreview.stop();
        hideTooltip();

        const g = gallery;
        gallery = null;

        if (g) {
            clearTimeout(g.syncTimer);
            if (g.postsObserver) g.postsObserver.disconnect();
            if (g.loader) g.loader.destroy();
            $(g.container).off("jg.complete");
            try {
                $(g.container).justifiedGallery("destroy");
            } catch (e) {
                // plugin was not initialised; nothing to destroy
            }
            g.container.remove();
        }

        // Safety net: never leave a stray duplicate behind
        const stray = document.getElementById(GALLERY_ID);
        if (stray) stray.remove();
    }

    function rebuildGallery() {
        destroyGallery();
        buildGallery();
    }

    // Coalesces any number of rebuild triggers (resize, settings, buttons...) into one
    function scheduleRebuild(delay = 0) {
        clearTimeout(rebuildTimer);
        rebuildTimer = setTimeout(() => {
            rebuildTimer = null;
            rebuildGallery();
        }, delay);
    }

    // ======================================================================
    // Caption actions (vote / favorite) - one delegated listener per gallery
    // ======================================================================

    function setScore(scoreEl, value) {
        scoreEl.textContent = String(value);
        scoreEl.classList.toggle("positive", value > 0);
        scoreEl.classList.toggle("negative", value < 0);
        scoreEl.classList.toggle("neutral", value === 0);
    }

    async function toggleVote(button) {
        const csrfToken = getCsrfToken();
        if (!csrfToken) {
            console.warn("[DJG] CSRF token not found");
            return;
        }
        const postId = button.dataset.postId;
        if (!isPostId(postId)) return;

        const caption = button.closest(".jg-caption");
        const scoreEl = caption && caption.querySelector(".score-display");
        const wasUpvoted = button.classList.contains("upvoted");

        button.classList.add("processing");
        try {
            const response = await fetch(`/posts/${postId}/votes`, {
                method: wasUpvoted ? "DELETE" : "POST",
                headers: apiHeaders(csrfToken),
                body: JSON.stringify({ score: 1 }),
            });
            if (!response.ok) {
                console.warn(`[DJG] Vote failed (HTTP ${response.status})`);
                return;
            }
            // Adjust by one locally; don't depend on the server response shape
            if (scoreEl) {
                const current = Number.parseInt(scoreEl.textContent, 10) || 0;
                setScore(scoreEl, current + (wasUpvoted ? -1 : 1));
            }
            button.classList.toggle("upvoted", !wasUpvoted);
            button.classList.toggle("active", !wasUpvoted);
        } catch (error) {
            console.error("[DJG] Error voting:", error);
        } finally {
            button.classList.remove("processing");
        }
    }

    async function toggleFavorite(button) {
        const csrfToken = getCsrfToken();
        if (!csrfToken) {
            console.warn("[DJG] CSRF token not found");
            return;
        }
        const postId = button.dataset.postId;
        if (!isPostId(postId)) return;

        const wasFavorited = button.classList.contains("favorited");

        button.classList.add("processing");
        try {
            const response = await fetch(
                wasFavorited ? `/favorites/${postId}.json` : "/favorites.json",
                {
                    method: wasFavorited ? "DELETE" : "POST",
                    headers: apiHeaders(csrfToken),
                    body: JSON.stringify({ post_id: postId }),
                },
            );
            if (!response.ok) {
                console.warn(`[DJG] Favorite failed (HTTP ${response.status})`);
                return;
            }
            button.classList.toggle("active", !wasFavorited);
            button.classList.toggle("favorited", !wasFavorited);
        } catch (error) {
            console.error("[DJG] Error favoriting:", error);
        } finally {
            button.classList.remove("processing");
        }
    }

    function handleGalleryClick(e) {
        if (!(e.target instanceof Element)) return;
        // Clicks anywhere on the caption bar must not open the post
        if (!e.target.closest(".jg-caption")) return;
        e.preventDefault();

        const button = e.target.closest(".upvote-btn, .favorite-btn");
        if (!button) return;
        e.stopPropagation();
        if (button.classList.contains("processing")) return;

        if (button.classList.contains("upvote-btn")) toggleVote(button);
        else toggleFavorite(button);
    }

    // ======================================================================
    // Post info tooltip
    // ======================================================================

    const TAG_LISTS = [
        ["artist", "Artist"],
        ["copyright", "Copyright"],
        ["character", "Character"],
        ["general", "General"],
        ["meta", "Meta"],
    ];

    const BLOCKED_TAGS =
        "script,style,iframe,object,embed,template,noscript,link,meta,base,form,frame,frameset,applet";
    const URL_ATTRIBUTES = ["href", "src", "action", "formaction", "poster"];

    const tooltipState = {
        abort: null,
        cache: new Map(), // postId -> DocumentFragment (bounded)
    };

    function createTooltip() {
        const tooltip = document.createElement("div");
        tooltip.className = "post-info-tooltip";
        tooltip.id = "post-info-tooltip";
        document.body.appendChild(tooltip);
        return tooltip;
    }

    function hideTooltip() {
        if (tooltipState.abort) {
            tooltipState.abort.abort();
            tooltipState.abort = null;
        }
        const tooltip = document.getElementById("post-info-tooltip");
        if (tooltip) tooltip.classList.remove("visible");
    }

    function positionTooltip(tooltip, button) {
        const buttonRect = button.getBoundingClientRect();
        const tooltipRect = tooltip.getBoundingClientRect();
        const viewportWidth = window.innerWidth;
        const viewportHeight = window.innerHeight;

        // Convert viewport coordinates to document coordinates
        const scrollX = window.pageXOffset || document.documentElement.scrollLeft;
        const scrollY = window.pageYOffset || document.documentElement.scrollTop;

        // Prefer the right side of the button, otherwise the left
        let left =
            buttonRect.right + tooltipRect.width + 20 < viewportWidth
                ? buttonRect.right + 10 + scrollX
                : buttonRect.left - tooltipRect.width - 10 + scrollX;

        // Vertically centred on the button
        let top =
            buttonRect.top +
            buttonRect.height / 2 -
            tooltipRect.height / 2 +
            scrollY;

        // Keep within the viewport
        const minTop = 10 + scrollY;
        const maxTop = scrollY + viewportHeight - tooltipRect.height - 10;
        const minLeft = 10 + scrollX;
        const maxLeft = scrollX + viewportWidth - tooltipRect.width - 10;

        if (top < minTop) top = minTop;
        if (top > maxTop) top = maxTop;
        if (left < minLeft) left = minLeft;
        if (left > maxLeft) left = maxLeft;

        tooltip.style.left = `${left}px`;
        tooltip.style.top = `${top}px`;
    }

    // Removes anything that could execute or navigate somewhere unexpected.
    // (The markup comes from Danbooru itself, but we still don't trust it blindly.)
    function sanitizeTree(root) {
        root.querySelectorAll(BLOCKED_TAGS).forEach((el) => el.remove());

        root.querySelectorAll("*").forEach((el) => {
            Array.from(el.attributes).forEach((attr) => {
                const name = attr.name.toLowerCase();
                if (name.startsWith("on") || name === "srcdoc") {
                    el.removeAttribute(attr.name);
                } else if (
                    URL_ATTRIBUTES.includes(name) &&
                    !isHttpUrl(attr.value)
                ) {
                    el.removeAttribute(attr.name);
                }
            });
            if (el.tagName === "A" && el.hasAttribute("target")) {
                el.setAttribute("rel", "noopener noreferrer");
            }
        });
    }

    function makeElement(tag, className, text) {
        const el = document.createElement(tag);
        if (className) el.className = className;
        if (text !== undefined) el.textContent = text;
        return el;
    }

    function appendListSection(parent, doc, selector, title, className) {
        const sourceList = doc.querySelector(selector);
        const items = sourceList ? sourceList.querySelectorAll("li") : [];
        if (items.length === 0) return;

        const section = makeElement("div", `tooltip-section ${className}`);
        section.appendChild(makeElement("h3", "", title));

        const list = document.createElement("ul");
        items.forEach((item) => {
            const imported = document.importNode(item, true);
            sanitizeTree(imported);
            const li = document.createElement("li");
            while (imported.firstChild) li.appendChild(imported.firstChild);
            list.appendChild(li);
        });
        section.appendChild(list);
        parent.appendChild(section);
    }

    // Turns a fetched post page into tooltip content (tags + information + options)
    function buildTooltipContent(html) {
        const doc = new DOMParser().parseFromString(html, "text/html");
        const fragment = document.createDocumentFragment();

        TAG_LISTS.forEach(([type, label]) => {
            const list = doc.querySelector(`ul.${type}-tag-list`);
            if (!list) return;

            const wrapper = makeElement("div", "tooltip-tags");
            list.querySelectorAll("li").forEach((item) => {
                const link = item.querySelector(".search-tag");
                if (!link) return;
                const typeMatch = /tag-type-(\d+)/.exec(item.className);
                const anchor = makeElement(
                    "a",
                    `tooltip-tag tag-type-${typeMatch ? typeMatch[1] : "0"}`,
                    link.textContent.trim(),
                );
                const href = link.getAttribute("href");
                if (href && isHttpUrl(href)) anchor.setAttribute("href", href);
                wrapper.appendChild(anchor);
            });
            if (!wrapper.firstChild) return;

            const section = makeElement("div", "tooltip-section");
            section.appendChild(makeElement("h3", "", label));
            section.appendChild(wrapper);
            fragment.appendChild(section);
        });

        appendListSection(
            fragment,
            doc,
            "#post-information ul",
            "Information",
            "tooltip-info",
        );
        appendListSection(
            fragment,
            doc,
            "#post-options ul",
            "Options",
            "tooltip-options",
        );
        return fragment;
    }

    function renderTooltip(tooltip, content) {
        const exitContainer = makeElement("div", "tooltip-exit-button-container");
        const exitButton = makeElement("button", "tooltip-exit-button", "\u00D7");
        exitButton.id = "tooltip-exit-button";
        exitButton.type = "button";
        exitButton.setAttribute("aria-label", "Close");
        exitContainer.appendChild(exitButton);

        tooltip.replaceChildren(exitContainer, content);
    }

    function renderTooltipMessage(tooltip, message) {
        tooltip.replaceChildren(makeElement("div", "tooltip-loading", message));
    }

    function rememberTooltip(postId, fragment) {
        const cache = tooltipState.cache;
        if (cache.size >= MAX_TOOLTIP_CACHE) {
            cache.delete(cache.keys().next().value);
        }
        cache.set(postId, fragment);
    }

    async function showPostInfo(postId, button) {
        if (!isPostId(postId)) return;
        const tooltip =
            document.getElementById("post-info-tooltip") || createTooltip();

        // Clicking the same button again toggles the tooltip off
        if (
            tooltip.classList.contains("visible") &&
            tooltip.dataset.currentPostId === postId
        ) {
            hideTooltip();
            return;
        }

        // Cancel any request for a previously clicked post
        if (tooltipState.abort) tooltipState.abort.abort();
        tooltipState.abort = null;

        tooltip.dataset.currentPostId = postId;
        tooltip.classList.add("visible");

        const cached = tooltipState.cache.get(postId);
        if (cached) {
            renderTooltip(tooltip, cached.cloneNode(true));
            positionTooltip(tooltip, button);
            return;
        }

        renderTooltipMessage(tooltip, "Loading post information...");
        positionTooltip(tooltip, button);

        const controller = new AbortController();
        tooltipState.abort = controller;

        try {
            const response = await fetch(`/posts/${postId}`, {
                signal: controller.signal,
                headers: { Accept: "text/html" },
            });
            if (!response.ok) throw new HttpError(response.status);
            const html = await response.text();

            // Ignore stale results (user moved on to another post / closed the tooltip)
            if (
                controller.signal.aborted ||
                tooltip.dataset.currentPostId !== postId ||
                !tooltip.classList.contains("visible")
            ) {
                return;
            }

            const content = buildTooltipContent(html);
            rememberTooltip(postId, content.cloneNode(true));
            renderTooltip(tooltip, content);
            positionTooltip(tooltip, button);
        } catch (error) {
            if (error.name === "AbortError") return;
            console.error("[DJG] Error fetching post info:", error);
            if (tooltip.dataset.currentPostId === postId) {
                renderTooltipMessage(tooltip, "Error loading post information");
            }
        } finally {
            if (tooltipState.abort === controller) tooltipState.abort = null;
        }
    }

    // One click + one keydown listener for the whole page (registered once)
    function setupTooltipHandlers() {
        document.addEventListener("click", (e) => {
            if (!(e.target instanceof Element)) return;

            const infoButton = e.target.closest(".info-button");
            if (infoButton) {
                e.preventDefault();
                e.stopPropagation();
                showPostInfo(infoButton.dataset.postId, infoButton);
                return;
            }

            if (e.target.closest(".tooltip-exit-button")) {
                hideTooltip();
                return;
            }

            // Click outside the tooltip closes it
            if (!e.target.closest(".post-info-tooltip")) hideTooltip();
        });

        document.addEventListener("keydown", (e) => {
            if (e.key === "Escape" || e.key === "Esc") hideTooltip();
        });
    }

    // ======================================================================
    // Search bar relocation
    // ======================================================================

    function relocateSearchBar() {
        // Already relocated
        if (searchBarState) return true;

        const searchForm = document.getElementById("search-box-form");
        const contentSection = document.getElementById("content");
        if (!searchForm || !contentSection) return false;

        // Remember the original state so the change can be reverted
        const state = {
            form: searchForm,
            parent: searchForm.parentNode,
            next: searchForm.nextSibling,
            styles: [], // [element, original style attribute]
            container: null,
            containerDisplay: "",
            input: null,
            placeholder: null,
            wrapper: null,
        };
        const setStyle = (el, css) => {
            state.styles.push([el, el.getAttribute("style")]);
            el.style.cssText = css;
        };

        // Hide the original search form container to prevent empty space
        const originalContainer = searchForm.closest(
            '.search-box, #search-box, [class*="search"]',
        );
        if (originalContainer && originalContainer !== searchForm) {
            state.container = originalContainer;
            state.containerDisplay = originalContainer.style.display;
            originalContainer.style.display = "none";
        }

        // New container for the relocated search bar
        const searchContainer = document.createElement("div");
        searchContainer.id = "relocated-search-container";
        searchContainer.style.cssText = `
            display: flex;
            justify-content: stretch;
            margin-bottom: 5px;
            padding: 0;
        `;
        state.wrapper = searchContainer;

        setStyle(
            searchForm,
            `
            display: flex;
            align-items: center;
            width: 100%;
            background: #1a1a1a;
            border: 1px solid #333;
            border-radius: 4px;
            overflow: hidden;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        `,
        );

        const searchInput = searchForm.querySelector("#tags");
        if (searchInput) {
            state.input = searchInput;
            state.placeholder = searchInput.getAttribute("placeholder");
            setStyle(
                searchInput,
                `
                flex: 1;
                padding: 8px 12px;
                border: none;
                background: transparent;
                color: #fff;
                font-size: 14px;
                outline: none;
                min-width: 0;
            `,
            );
            searchInput.setAttribute(
                "placeholder",
                state.placeholder || "Search tags...",
            );
        }

        // (hover colour for the button is handled in CSS)
        const searchButton = searchForm.querySelector("#search-box-submit");
        if (searchButton) {
            setStyle(
                searchButton,
                `
                padding: 8px 12px;
                border: none;
                background: #0066cc;
                color: white;
                cursor: pointer;
                display: flex;
                align-items: center;
                justify-content: center;
                transition: background-color 0.2s;
                min-width: 40px;
            `,
            );
        }

        // (skip the icon cloned into the dynamic-search button)
        const searchIcon = Array.from(
            searchForm.querySelectorAll(".search-icon"),
        ).find((el) => !el.closest("#DJG-dynamic-search-btn"));
        if (searchIcon) {
            setStyle(
                searchIcon,
                `
                width: 16px;
                height: 16px;
            `,
            );
        }

        // Move the form into the new container, at the top of the content section
        searchContainer.appendChild(searchForm);
        contentSection.insertBefore(searchContainer, contentSection.firstChild);

        searchBarState = state;
        return true;
    }

    function restoreSearchBar() {
        const state = searchBarState;
        if (!state) return;

        // Move the form back to where it was
        if (state.parent && state.parent.isConnected) {
            const ref =
                state.next && state.next.parentNode === state.parent
                    ? state.next
                    : null;
            state.parent.insertBefore(state.form, ref);
        }

        // Restore inline styles / attributes
        state.styles.forEach(([el, style]) => {
            if (style === null) el.removeAttribute("style");
            else el.setAttribute("style", style);
        });
        if (state.input) {
            if (state.placeholder === null) {
                state.input.removeAttribute("placeholder");
            } else {
                state.input.setAttribute("placeholder", state.placeholder);
            }
        }

        // Show the original container again
        if (state.container) {
            state.container.style.display = state.containerDisplay;
        }

        if (state.wrapper) state.wrapper.remove();
        searchBarState = null;
    }

    // ======================================================================
    // Dynamic search (runs searches in place, without a page load)
    // ======================================================================

    const dynamicSearch = {
        button: null,
        busy: false,
        navigated: false, // true once this page has pushed a history entry
        toastTimer: null,
    };

    function showToast(message) {
        let toast = document.getElementById("DJG-toast");
        if (!toast) {
            toast = document.createElement("div");
            toast.id = "DJG-toast";
            document.body.appendChild(toast);
        }
        toast.textContent = message;
        clearTimeout(dynamicSearch.toastTimer);
        dynamicSearch.toastTimer = setTimeout(() => toast.remove(), 2000);
    }

    function setDynamicSearchBusy(busy) {
        dynamicSearch.busy = busy;
        if (dynamicSearch.button) dynamicSearch.button.disabled = busy;
    }

    function importSanitized(node) {
        const imported = document.importNode(node, true);
        sanitizeTree(imported);
        return imported;
    }

    async function runDynamicSearch() {
        const input = document.querySelector("#tags");
        if (!input) return;

        const query = input.value.trim();
        if (!query) {
            showToast("Enter a search query!");
            return;
        }
        if (dynamicSearch.busy) return;

        const url = new URL("/posts", window.location.origin);
        url.searchParams.set("tags", query);
        url.searchParams.set("page", "1");

        // No gallery on this page (e.g. an empty result page): nothing to update in place
        const container = document.querySelector(".posts-container");
        if (!container) {
            window.location.assign(url.href);
            return;
        }

        setDynamicSearchBusy(true);
        try {
            const response = await fetch(url, {
                headers: { Accept: "text/html" },
            });
            if (!response.ok) throw new HttpError(response.status);
            const doc = new DOMParser().parseFromString(
                await response.text(),
                "text/html",
            );

            const newContainer = doc.querySelector(".posts-container");
            if (!newContainer || newContainer.children.length === 0) {
                showToast("No posts found!");
                return;
            }
            if (!container.isConnected) return; // page changed while we were fetching

            // Keep pagination in sync. If the new results need a paginator that this
            // page doesn't have, there is no reliable place to put it: do a normal load.
            const oldPaginators = Array.from(
                document.querySelectorAll(".paginator"),
            );
            const newPaginators = Array.from(doc.querySelectorAll(".paginator"));
            if (newPaginators.length > oldPaginators.length) {
                window.location.assign(url.href);
                return;
            }

            container.replaceChildren(...importSanitized(newContainer).children);
            oldPaginators.forEach((oldPaginator, i) => {
                const next = newPaginators[i];
                if (next) oldPaginator.replaceWith(importSanitized(next));
                else oldPaginator.remove(); // new results fit on one page
            });

            // Infinite scroll must continue from the new results, not the old query
            resetInfiniteScroll();

            if (url.href !== window.location.href) {
                history.pushState({}, "", url);
                dynamicSearch.navigated = true;
            }
            document.title = doc.title;

            // Rebuild the justified gallery from the new posts right away
            // (replaces the delayed rebuild the history hook just scheduled)
            scheduleRebuild(0);

            showToast(`Loaded: "${query}"`);
        } catch (error) {
            console.error("[DJG] Dynamic search failed:", error);
            showToast(`Error: ${error.message}`);
        } finally {
            setDynamicSearchBusy(false);
        }
    }

    function createDynamicSearchButton() {
        if (dynamicSearch.button && dynamicSearch.button.isConnected) return;

        const submit = document.querySelector("#search-box-submit");
        if (!submit || !submit.parentNode) return;

        const button = document.createElement("button");
        button.id = "DJG-dynamic-search-btn";
        button.type = "button";
        button.title = "Search without reloading the page";
        button.setAttribute("aria-label", "Search without reloading the page");

        // Reuse the original button's icon so it looks identical
        const icon = submit.querySelector("svg");
        if (icon) {
            const clone = icon.cloneNode(true);
            clone.removeAttribute("style");
            button.appendChild(clone);
        } else {
            button.textContent = "\u{1F50D}";
        }

        // Same styles as the original button, but red
        Object.assign(button.style, {
            padding: "8px 12px",
            border: "none",
            borderRadius: "0",
            background: "rgb(253, 7, 75)",
            color: "white",
            cursor: "pointer",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            transition: "background-color 0.2s",
            minWidth: "40px",
        });

        button.addEventListener("click", (e) => {
            e.preventDefault();
            runDynamicSearch();
        });

        submit.parentNode.insertBefore(button, submit);
        dynamicSearch.button = button;
    }

    function removeDynamicSearchButton() {
        if (dynamicSearch.button) dynamicSearch.button.remove();
        dynamicSearch.button = null;
    }

    function applyDynamicSearchSetting() {
        if (CONFIG.enableDynamicSearch) createDynamicSearchButton();
        else removeDynamicSearchButton();
    }

    // Registered once; every handler checks CONFIG.enableDynamicSearch when it fires,
    // so toggling the setting needs no listener bookkeeping.
    function setupDynamicSearch() {
        const input = document.querySelector("#tags");
        if (input) {
            // Enter runs the dynamic search (unless an autocomplete suggestion is highlighted)
            input.addEventListener(
                "keydown",
                (e) => {
                    if (!CONFIG.enableDynamicSearch) return;
                    if (e.key !== "Enter" || e.isComposing) return;
                    if (document.querySelector(".ui-autocomplete .ui-state-active")) {
                        return;
                    }
                    // Without a gallery to update, let the normal form submit run
                    if (!document.querySelector(".posts-container")) return;
                    e.preventDefault();
                    e.stopImmediatePropagation();
                    runDynamicSearch();
                },
                true,
            );
        }

        // The page content no longer matches the URL after back/forward: reload it
        window.addEventListener("popstate", () => {
            if (dynamicSearch.navigated) window.location.reload();
        });

        applyDynamicSearchSetting();
    }

    // ======================================================================
    // Infinite scroll (loads the next page(s) as you approach the bottom)
    // ======================================================================
    //
    // Pages are loaded through a hidden iframe and their posts are read from the
    // iframe's document (the approach that is known to work on Danbooru; a plain
    // fetch() + DOMParser did not). New posts are appended to Danbooru's own
    // (hidden) .posts-container, and the posts observer then adds them to the
    // justified gallery. The next page is only requested once the gallery has laid
    // out the previous one, otherwise the page would still look "short" and every
    // page would be requested in a rush.

    const INFINITE_FRAME_TIMEOUT = 20000; // give up on a page that never finishes loading
    const INFINITE_LAYOUT_FALLBACK = 5000; // longest wait for the gallery to lay out new posts
    const INFINITE_MAX_RETRIES = 2;
    const INFINITE_COOLDOWN = 30000; // pause after repeated failures

    const infinite = {
        initialized: false,
        nextUrl: null, // next page to load (null = no more pages)
        loading: false,
        frame: null,
        generation: 0, // bumped to discard any in-flight load
        layoutPending: false, // new posts were added; wait for the gallery layout
        layoutTimer: null,
        scrollTimer: null,
        watchdog: null,
        retries: 0,
        blockedUntil: 0,
    };

    // Next-page link of a document (same-origin http(s) only), or null
    function getNextPageUrl(doc) {
        const link =
            doc.querySelector(
                "a[rel='next'], a.paginator-next, .paginator a[alt='next']",
            ) ||
            Array.from(doc.querySelectorAll(".paginator a")).find((a) =>
                /^(>|\u203A|\u00BB|next)/i.test(a.textContent.trim()),
            );
        if (!link || !link.href) return null;
        try {
            const url = new URL(link.href, window.location.href);
            return url.origin === window.location.origin ? url.href : null;
        } catch (e) {
            return null;
        }
    }

    function ensureInfiniteFrame() {
        if (infinite.frame && infinite.frame.isConnected) return infinite.frame;

        const frame = document.createElement("iframe");
        frame.id = "DJG-infinite-frame";
        frame.title = "Infinite scroll loader";
        frame.tabIndex = -1;
        frame.setAttribute("aria-hidden", "true");
        frame.style.cssText =
            "width:0;height:0;border:0;visibility:hidden;position:absolute";
        frame.addEventListener("load", onInfiniteFrameLoad);
        document.body.appendChild(frame);

        infinite.frame = frame;
        return frame;
    }

    // Navigate the loader to a blank page: cancels a pending load and frees the old page
    function releaseInfiniteFrame() {
        const frame = infinite.frame;
        if (!frame || !frame.isConnected) return;
        try {
            frame.contentWindow.location.replace("about:blank");
        } catch (e) {
            // frame not reachable; nothing to release
        }
    }

    function infiniteCheck() {
        if (!CONFIG.enableInfiniteScroll) return;
        if (infinite.loading || infinite.layoutPending) return;
        if (!infinite.nextUrl || Date.now() < infinite.blockedUntil) return;

        const remaining =
            document.documentElement.scrollHeight -
            (window.innerHeight + window.scrollY);
        if (remaining < CONFIG.infiniteScrollThreshold) loadNextPage();
    }

    function loadNextPage() {
        if (infinite.loading || !infinite.nextUrl) return;

        let frame;
        try {
            frame = ensureInfiniteFrame();
        } catch (e) {
            return;
        }

        infinite.loading = true;
        const token = infinite.generation;
        infinite.watchdog = setTimeout(() => {
            if (token === infinite.generation && infinite.loading) {
                failInfiniteLoad();
            }
        }, INFINITE_FRAME_TIMEOUT);

        try {
            frame.contentWindow.location.replace(infinite.nextUrl);
        } catch (error) {
            console.warn("[DJG] Infinite scroll could not start a load:", error);
            failInfiniteLoad();
        }
    }

    function onInfiniteFrameLoad() {
        const frame = infinite.frame;
        if (!infinite.loading || !frame) return;

        let doc;
        try {
            doc = frame.contentDocument;
        } catch (e) {
            return;
        }
        // Ignore the frame's initial / released "about:blank" documents
        if (!doc || !doc.location || doc.location.href === "about:blank") return;

        // Give the page a moment to finish rendering before reading it
        const token = infinite.generation;
        setTimeout(() => {
            if (token !== infinite.generation || !infinite.loading) return;
            consumeInfinitePage(doc);
        }, 100);
    }

    function consumeInfinitePage(doc) {
        clearTimeout(infinite.watchdog);

        const container = document.querySelector(".posts-container");
        const pageContainer = doc.querySelector(".posts-container");
        if (!container || !pageContainer) {
            // Not a result page (rate limit, outage...): retry instead of stopping for good
            failInfiniteLoad();
            return;
        }

        const pagePosts = Array.from(
            doc.querySelectorAll(".posts-container .post-preview"),
        );
        const existing = new Set(
            Array.from(container.querySelectorAll(".post-preview"), (p) => p.dataset.id),
        );
        const fresh = pagePosts.filter((p) => !existing.has(p.dataset.id));

        if (fresh.length > 0) {
            const fragment = document.createDocumentFragment();
            fresh.forEach((post) => fragment.appendChild(importSanitized(post)));
            container.appendChild(fragment);
            // The posts observer adds them to the gallery; wait for that layout
            if (gallery) holdForLayout();
        }

        console.debug(
            `[DJG] Infinite scroll: +${fresh.length} posts from ${doc.location.href}`,
        );

        // A page whose posts were all duplicates (posts shifted between pages) can still
        // have a next page; only an empty page or a missing next link ends the list.
        infinite.nextUrl = pagePosts.length > 0 ? getNextPageUrl(doc) : null;
        infinite.retries = 0;
        infinite.loading = false;

        if (!infinite.nextUrl) releaseInfiniteFrame();
        if (fresh.length === 0) infiniteCheck(); // otherwise the layout callback checks
    }

    function failInfiniteLoad() {
        clearTimeout(infinite.watchdog);
        infinite.loading = false;
        releaseInfiniteFrame();

        if (infinite.retries < INFINITE_MAX_RETRIES) {
            infinite.retries++;
            const token = infinite.generation;
            setTimeout(() => {
                if (token === infinite.generation) infiniteCheck();
            }, 1500 * infinite.retries);
            return;
        }

        infinite.retries = 0;
        infinite.blockedUntil = Date.now() + INFINITE_COOLDOWN;
        showToast("Couldn't load more posts");
    }

    // The gallery is about to lay out new entries: hold further page loads until it is done
    function holdForLayout() {
        if (!CONFIG.enableInfiniteScroll) return;
        infinite.layoutPending = true;
        clearTimeout(infinite.layoutTimer);
        infinite.layoutTimer = setTimeout(notifyLayoutDone, INFINITE_LAYOUT_FALLBACK);
    }

    // Called on Justified Gallery's "jg.complete" (or by the fallback timer)
    function notifyLayoutDone() {
        if (!infinite.layoutPending) return;
        infinite.layoutPending = false;
        clearTimeout(infinite.layoutTimer);
        setTimeout(infiniteCheck, 50); // let the new layout settle before measuring
    }

    // Stop whatever is loading; results of in-flight loads are discarded
    function cancelInfiniteLoad() {
        infinite.generation++;
        infinite.loading = false;
        clearTimeout(infinite.watchdog);
        releaseInfiniteFrame();
    }

    // Start over from the page's current paginator (first run, or after a dynamic search)
    function resetInfiniteScroll() {
        cancelInfiniteLoad();
        infinite.initialized = true;
        infinite.retries = 0;
        infinite.blockedUntil = 0;
        infinite.nextUrl = getNextPageUrl(document);
    }

    function applyInfiniteScrollSetting() {
        if (CONFIG.enableInfiniteScroll) {
            // Keep our place when re-enabled: pages already loaded stay in the gallery
            if (!infinite.initialized) resetInfiniteScroll();
            infiniteCheck();
        } else {
            cancelInfiniteLoad();
        }
    }

    // The scroll listener is registered once and checks CONFIG when it fires
    function setupInfiniteScroll() {
        window.addEventListener(
            "scroll",
            () => {
                if (!CONFIG.enableInfiniteScroll || infinite.scrollTimer) return;
                infinite.scrollTimer = setTimeout(() => {
                    infinite.scrollTimer = null;
                    infiniteCheck();
                }, CONFIG.infiniteScrollThrottle);
            },
            { passive: true },
        );
        applyInfiniteScrollSetting();
    }

    // ======================================================================
    // Settings dropdown
    // ======================================================================

    function applyHoverPreviewSetting() {
        if (!CONFIG.enableHoverPreview) hoverPreview.stop();
        // Listeners are delegated and check CONFIG on every event, so nothing else to do
    }

    function applySearchBarSetting() {
        if (CONFIG.enableRelocateSearchBar) relocateSearchBar();
        else restoreSearchBar();
    }

    function applySidebarSetting() {
        let style = document.getElementById("DJG-sidebar-style");
        if (!style) {
            style = document.createElement("style");
            style.id = "DJG-sidebar-style";
            document.head.appendChild(style);
        }
        style.textContent = CONFIG.enableHiddenSidebar
            ? "#sidebar {display: none;}"
            : "";
    }

    const SETTINGS_DEFS = [
        {
            key: "enableHoverPreview",
            label: "Hover preview",
            apply: applyHoverPreviewSetting,
        },
        {
            key: "enableRelocateSearchBar",
            label: "Wide search bar",
            apply: applySearchBarSetting,
        },
        {
            key: "enableDynamicSearch",
            label: "Dynamic search",
            apply: applyDynamicSearchSetting,
        },
        {
            key: "enableInfiniteScroll",
            label: "Infinite scroll",
            apply: applyInfiniteScrollSetting,
        },
        {
            key: "enableHiddenSidebar",
            label: "Hide sidebar",
            apply: () => {
                applySidebarSetting();
                // Sidebar visibility changes the available width, so re-layout the gallery
                scheduleRebuild(0);
            },
        },
    ];

    function syncSettingsUI() {
        const menu = document.getElementById("DJG-settings-menu");
        if (!menu) return;
        menu.querySelectorAll("input[data-setting-key]").forEach((input) => {
            input.checked = !!CONFIG[input.dataset.settingKey];
        });
    }

    function createSettingsMenu() {
        // Already built (listeners are only attached once, together with the elements)
        if (document.getElementById("DJG-settings")) return;

        const wrapper = document.createElement("span");
        wrapper.id = "DJG-settings";

        const button = document.createElement("button");
        button.id = "DJG-settings-btn";
        button.type = "button";
        button.textContent = "\u2699 Settings";
        button.setAttribute("aria-haspopup", "true");
        button.setAttribute("aria-expanded", "false");

        const menu = document.createElement("div");
        menu.id = "DJG-settings-menu";
        menu.setAttribute("role", "menu");

        SETTINGS_DEFS.forEach((def) => {
            const label = document.createElement("label");
            label.className = "DJG-settings-item";

            const input = document.createElement("input");
            input.type = "checkbox";
            input.dataset.settingKey = def.key;
            input.checked = !!CONFIG[def.key];

            const text = document.createElement("span");
            text.textContent = def.label;

            input.addEventListener("change", () => {
                CONFIG[def.key] = input.checked;
                saveSettings();
                def.apply();
            });

            label.appendChild(input);
            label.appendChild(text);
            menu.appendChild(label);
        });

        wrapper.appendChild(button);
        wrapper.appendChild(menu);

        // Place next to the reload button
        const postsSection = document.querySelector("#post-sections li");
        if (postsSection) {
            postsSection.appendChild(wrapper);
        } else {
            // Fallback to body if the posts section is not found
            wrapper.classList.add("floating");
            document.body.appendChild(wrapper);
        }

        const setOpen = (open) => {
            menu.classList.toggle("visible", open);
            button.setAttribute("aria-expanded", String(open));
            if (open) syncSettingsUI();
        };

        button.addEventListener("click", (e) => {
            e.preventDefault();
            e.stopPropagation();
            setOpen(!menu.classList.contains("visible"));
        });

        // Close when clicking outside the menu
        document.addEventListener("click", (e) => {
            if (!wrapper.contains(e.target)) setOpen(false);
        });

        // Close on Escape
        document.addEventListener("keydown", (e) => {
            if (e.key === "Escape" || e.key === "Esc") setOpen(false);
        });
    }

    function createReloadButton() {
        // Don't create a second reload button
        if (document.getElementById("jg-reload-btn")) return;

        const postsSection = document.querySelector("#post-sections li");
        if (!postsSection) return;

        const reloadButton = document.createElement("button");
        reloadButton.id = "jg-reload-btn";
        reloadButton.type = "button";
        reloadButton.textContent = "\u21BB";
        reloadButton.title = "Reload gallery layout";
        reloadButton.setAttribute("aria-label", "Reload gallery layout");
        postsSection.appendChild(reloadButton);
        // The click is handled by the delegated reset handler (JG_RESET_SELECTORS)
    }

    // ======================================================================
    // Global triggers: reset buttons, navigation, resize
    // ======================================================================

    function setupResetHandlers() {
        const selector = JG_RESET_SELECTORS.join(",");
        if (!selector) return;
        // Capture phase so page handlers calling stopPropagation can't hide the click;
        // the rebuild itself runs afterwards (timeout), once their handlers are done.
        document.addEventListener(
            "click",
            (e) => {
                if (e.target instanceof Element && e.target.closest(selector)) {
                    scheduleRebuild(0);
                }
            },
            true,
        );
    }

    // Rebuild when the URL changes without a full page load (history API / back-forward)
    function watchNavigation() {
        let currentUrl = window.location.href;
        const check = () => {
            if (currentUrl === window.location.href) return;
            currentUrl = window.location.href;
            scheduleRebuild(500);
        };

        try {
            ["pushState", "replaceState"].forEach((method) => {
                const original = history[method];
                history[method] = function () {
                    const result = original.apply(this, arguments);
                    check();
                    return result;
                };
            });
        } catch (e) {
            // history is not patchable here; popstate/hashchange below still work
        }
        window.addEventListener("popstate", check);
        window.addEventListener("hashchange", check);
    }

    // Rebuild on width / zoom changes (avoids the high-res thumbnail layout bug).
    // Height-only resizes (mobile URL bar, devtools) don't affect the layout.
    function watchResize() {
        let lastWidth = window.innerWidth;
        let lastDpr = window.devicePixelRatio;
        window.addEventListener("resize", () => {
            if (
                window.innerWidth === lastWidth &&
                window.devicePixelRatio === lastDpr
            ) {
                return;
            }
            lastWidth = window.innerWidth;
            lastDpr = window.devicePixelRatio;
            scheduleRebuild(300);
        });
    }

    // ======================================================================
    // Styles
    // ======================================================================

    const CSS = `
            @import url("https://cdnjs.cloudflare.com/ajax/libs/justifiedGallery/3.8.1/css/justifiedGallery.min.css");

            /* Custom styles for Danbooru integration */
            .justified-gallery {
                margin: 0;
                padding: 0;
            }

            .justified-gallery > a {
                border: 2px solid transparent;
                border-radius: 6px;
                background: none !important;
                transition: border 0.2s ease, box-shadow 0.2s ease;
            }

            .justified-gallery > a:hover {
                border: 2px solid #faf9f6;
                box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
            }

            .justified-gallery > a > img {
                border-radius: 4px;
                transition: transform 0.2s ease, opacity 0.3s ease;
            }

            .justified-gallery > a:hover > img {
                transform: scale(1.02);
            }

            /* Hide original gallery (unless the justified gallery failed to start) */
            html:not(.DJG-failed) .post-gallery-grid .posts-container {
                display: none;
            }

            /* Ensure proper spacing */
            #posts {
                padding: 10px 0px;
            }

            /* Caption styling */
            .jg-caption {
                transition: border 0.2s ease, box-shadow 0.2s ease !important;
                padding: 4px !important;
                font-size: 14px !important;
                font-weight: 500 !important;
            }

            .caption-button {
                opacity: 1;
                cursor: pointer;
                padding: 2px 4px;
                border-radius: 2px;
                transition: background-color 0.2s ease;
                user-select: none;
            }

            .caption-button:hover {
                background-color: rgba(255, 255, 255, 0.2);
            }

            .score-display {
                padding: 2px 2px 2px 0px;
            }

            .caption-button.processing {
                opacity: 0.5;
            }

            .caption-button.processing:hover {
                background-color: transparent;
            }

            .jg-caption .score-display.positive {
                color: green;
            }

            .jg-caption .score-display.negative {
                color: red;
            }

            .jg-caption .favorite-btn.active {
                color: red;
            }

            .jg-caption .upvote-btn.active {
                color: green;
            }

            .jg-caption .content-duration {
                padding: 2px 4px;
                right: 8px;
            }

            .jg-caption .right-side-wrapper {
                position: absolute;
                right: 4px;
                bottom: 4px;
            }

            #danbooru-hover-preview {
                position: fixed;
                z-index: 10000;
                pointer-events: none;
                opacity: 0;
                transition: opacity 0.2s ease;
                overflow: visible;
            }

            #danbooru-hover-preview.visible {
                opacity: 1;
            }

            #danbooru-hover-preview img {
                display: block;
                /* Size is controlled by JavaScript (native size + transform scaling) */
                height: auto;
                width: auto;
                image-rendering: -webkit-optimize-contrast;
                image-rendering: crisp-edges;
                border: 4px solid #faf9f6;
                border-radius: 4px;
                box-shadow: rgba(0, 0, 0, 0.5) 1px 1px 5px, rgba(0, 0, 0, 0.5) -1px 1px 5px,
                    rgba(0, 0, 0, 0.5) 1px -1px 5px, rgba(0, 0, 0, 0.5) -1px -1px 5px;
            }

            /* Relocated search bar */
            #relocated-search-container #search-box-submit:hover {
                background-color: #0052a3 !important;
            }

            /* Dynamic search */
            #DJG-dynamic-search-btn:hover:not(:disabled) {
                background-color: rgb(220, 0, 62) !important;
            }

            #DJG-dynamic-search-btn:disabled {
                opacity: 0.6;
                cursor: wait !important;
            }

            #DJG-toast {
                position: fixed;
                bottom: 20px;
                right: 20px;
                background: #222;
                color: #fff;
                padding: 10px 15px;
                border-radius: 6px;
                z-index: 9999;
                font-size: 14px;
            }

            /* Settings button + dropdown */
            #DJG-settings {
                position: relative;
                display: inline-block;
                margin-left: 10px;
                vertical-align: middle;
            }

            #DJG-settings.floating {
                position: fixed;
                top: 10px;
                right: 10px;
                margin-left: 0;
                z-index: 10001;
            }

            #DJG-settings-btn {
                background: rgb(0, 116, 172);
                color: white;
                border: none;
                border-radius: 4px;
                padding: 3px 6px;
                font-size: 13px;
                font-weight: 500;
                cursor: pointer;
                transition: all 0.2s ease;
                user-select: none;
                vertical-align: middle;
            }

            #DJG-settings-btn:hover {
                background: #23598fff;
            }

            #DJG-settings-btn:active {
                transform: scale(0.98);
            }

            #DJG-settings-menu {
                display: none;
                position: absolute;
                top: calc(100% + 6px);
                left: 0;
                z-index: 10001;
                min-width: 200px;
                padding: 4px;
                background: rgba(20, 20, 20, 0.98);
                border: 1px solid #444;
                border-radius: 6px;
                box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
            }

            #DJG-settings.floating #DJG-settings-menu {
                left: auto;
                right: 0;
            }

            #DJG-settings-menu.visible {
                display: block;
            }

            .DJG-settings-item {
                display: flex;
                align-items: center;
                gap: 8px;
                padding: 6px 8px;
                border-radius: 4px;
                font-size: 13px;
                color: #ddd;
                cursor: pointer;
                user-select: none;
                transition: background-color 0.2s ease;
            }

            .DJG-settings-item:hover {
                background-color: rgba(255, 255, 255, 0.1);
            }

            .DJG-settings-item input[type="checkbox"] {
                margin: 0;
                cursor: pointer;
                accent-color: rgb(0, 116, 172);
            }

            #jg-reload-btn {
                background-color: rgb(0, 116, 172);
                color: white;
                border: none;
                padding: 3px 6px;
                border-radius: 4px;
                font-size: 13px;
                cursor: pointer;
                font-weight: 500;
                margin-left: 10px;
                transition: all 0.2s ease;
                user-select: none;
                vertical-align: middle;
            }

            #jg-reload-btn:hover {
                background-color: #23598fff;
            }

            /* Info tooltip styles */
            .info-button {
                cursor: pointer;
                padding: 2px 4px;
                border-radius: 2px;
                transition: background-color 0.2s ease;
                user-select: none;
            }

            .info-button:hover {
                background-color: rgba(255, 255, 255, 0.2);
            }

            .post-info-tooltip {
                position: absolute;
                z-index: 1000;
                background: rgba(20, 20, 20, 0.95);
                border: 1px solid #444;
                border-radius: 6px;
                padding: 8px;
                max-width: 200px;
                min-width: 180px;
                max-height: 300px; /* Enables vertical scrolling when content is too long */
                overflow-y: auto; /* Scrollbar appears only when needed */
                overscroll-behavior: contain; /* Don't scroll the page when the tooltip hits its end */
                box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
                color: #fff;
                font-size: 11px;
                line-height: 1.3;
                backdrop-filter: blur(8px);
                display: none;
                scrollbar-width: thin; /* For Firefox */
            }

            /* Optional scrollbar styling for WebKit browsers (Chrome, Edge, Safari) */
            .post-info-tooltip::-webkit-scrollbar {
                width: 6px;
            }
            .post-info-tooltip::-webkit-scrollbar-thumb {
                background-color: rgba(255, 255, 255, 0.2);
                border-radius: 4px;
            }

            .post-info-tooltip.visible {
                display: block;
            }

            .tooltip-section {
                margin-bottom: 16px;
            }

            .tooltip-section:last-child {
                margin-bottom: 0;
            }

            .tooltip-section h3 {
                margin: 0 0 8px 0;
                font-size: 14px;
                font-weight: bold;
                color: #ccc;
                border-bottom: 1px solid #444;
                padding-bottom: 4px;
            }

            .tooltip-tags {
                display: flex;
                flex-wrap: wrap;
                gap: 4px;
                margin-bottom: 8px;
            }

            .tooltip-tag {
                padding: 2px 6px;
                border-radius: 3px;
                font-size: 12px;
                text-decoration: none;
                color: white;
                transition: opacity 0.2s ease;
            }

            .tooltip-tag:hover {
                opacity: 0.8;
            }

            .tooltip-info ul {
                list-style: none;
                padding: 0;
                margin: 0;
            }

            .tooltip-info li {
                margin-bottom: 6px;
                font-size: 12px;
                color: #ccc;
            }

            .tooltip-info a {
                color: #66b3ff;
                text-decoration: none;
            }

            .tooltip-info a:hover {
                text-decoration: underline;
            }

            .tooltip-loading {
                text-align: center;
                color: #888;
                padding: 20px;
            }

            .tooltip-exit-button-container {
                position: sticky;
                top: 0;
                z-index: 1001;
            }

            .tooltip-exit-button {
                position: absolute;
                top: 0px;
                right: 0px;
                border: none;
                background: transparent;
                font-size: 20px;
                font-weight: bold;
                cursor: pointer;
                color: #888;
                padding: 0px 4px;
            }

            .tooltip-exit-button:hover {
                color: #fff;
            }

            #page {
                margin-left: 16px;
                margin-right: 16px;
            }
    `;

    function injectStyles() {
        if (document.getElementById("DJG-style")) return;
        const style = document.createElement("style");
        style.id = "DJG-style";
        style.textContent = CSS;
        document.head.appendChild(style);
    }

    // ======================================================================
    // Init
    // ======================================================================

    function init() {
        // Guard against running twice
        if (initialized) return;
        initialized = true;

        // Prevent error message by defining resetImgSrc as a no-op on images
        if (
            typeof HTMLImageElement !== "undefined" &&
            !HTMLImageElement.prototype.resetImgSrc
        ) {
            HTMLImageElement.prototype.resetImgSrc = function () {};
        }

        injectStyles();

        // Hide sidebar (controlled by setting, toggled live via the Settings menu)
        applySidebarSetting();

        // Reload button + Settings button/dropdown
        createReloadButton();
        createSettingsMenu();

        // Main functionality
        buildGallery();

        // Global triggers (all registered exactly once)
        setupResetHandlers();
        setupTooltipHandlers();
        watchNavigation();
        watchResize();
        window.addEventListener("blur", () => hoverPreview.stop());

        // Dynamic search button (before relocating, so the original icon is cloned unstyled)
        setupDynamicSearch();

        // Infinite scroll (after the gallery exists, so it can wait for its layout)
        setupInfiniteScroll();

        // Relocate search bar
        if (CONFIG.enableRelocateSearchBar) relocateSearchBar();
    }

    // Initialize when DOM is ready (short delay lets Danbooru's own scripts finish first)
    const start = () => setTimeout(init, 100);
    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", start, { once: true });
    } else {
        start();
    }
})();