☰

Sleazy Fork is available in English.

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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Danbooru 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();
    }
})();