const $ = s => document.querySelector(s) const $$ = s => document.querySelectorAll(s) const EXIF_NAMES = { 1: "nai", 2: "comfy", 3: "sd", 4: "mj", 5: "celsys", 6: "photoshop", 7: "nai_stealth", 8: "invoke", 9: "fooocus", 10: "swarm", 11: "nai_vibe" } const EXIF_CODES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] const CATEGORY_NAMES = { ...EXIF_NAMES, 0: "None" } const FILTER_CODES = [1, 7, 11, 2, 3, 4, 5, 6, 8, 9, 10, 0] const TOP_FILTER_CODES = [1, 7, 11, 2, 3] const BOTTOM_FILTER_CODES = [6, 5, 0] const COUNT_FILTER_CODES = FILTER_CODES.filter(code => !TOP_FILTER_CODES.includes(code) && !BOTTOM_FILTER_CODES.includes(code) ) const EXIF_FILTER_KEY = "pixif2-exif-types" const LONG_DIGITS_RE = /\d{6,}/g const POST_ID_RE = /\d+/g const PAGE_SIZE = 60 const SEARCH_PAGE_SIZE = 10 const EAGER_IMAGE_COUNT = 12 const IMAGE_URL_VERSION = 5 const VIEWER_GAP = 16 const VIEWER_MIN_SCALE = .25 const VIEWER_MAX_SCALE = 6 const VIEWER_BUTTON_STEP = .25 const VIEWER_WHEEL_STEP = .15 let viewerScale = 1 let viewerX = 0 let viewerY = VIEWER_GAP let viewerDrag = null let viewerPostId = "" let viewerPages = [] let viewerPageIndex = 0 let viewerItems = [] let viewerItemIndex = 0 let viewerFocus = null let explorerEvents = null let explorerPage = 1 let explorerDebounce = null let lastExplorerHash = "#/explorer" let currentExplorerView = null let loadedExplorerListPage = null let submittedSearchId = null let submittedFilterKey = null let resultCardCache = new Map() let postSearchCardCache = new Map() let exifCountsCache = new Map() function syncDeepScanTargets() { const enabled = $("#input-deep-scan").checked const targeted = $("#input-targeted-deep-scan") targeted.disabled = !enabled $("#deep-scan-filters").classList.toggle("hidden", !enabled || !targeted.checked) } $("#input-deep-scan").addEventListener("change", syncDeepScanTargets) $("#input-targeted-deep-scan").addEventListener("change", syncDeepScanTargets) $$(".tab").forEach(tab => { tab.addEventListener("click", () => { location.hash = tab.dataset.tab === "explorer" ? lastExplorerHash : `#/${tab.dataset.tab}` }) }) $("#btn-refresh-explorer").addEventListener("click", refreshExplorer) $("#sort-bookmarks").addEventListener("change", () => { const [path] = (location.hash.slice(2) || "").split("?") const parts = path.split("/").filter(Boolean) if (parts[0] !== "explorer" || !parts[1]) return const id = decodeURIComponent(parts[1]) const show = submittedSearchId === id const hash = explorerHash(id, 1) resultCardCache = new Map() currentExplorerView = null if (location.hash === hash) openSearch(id, 1, show) else location.hash = hash }) $("#btn-download-all").addEventListener("click", () => { const input = $("#download-through") const through = Math.min(parseInt(input.value) || 0, parseInt(input.max) || 0) if (through < 1) return input.value = through const id = $("#detail-title").textContent const types = getExifTypes() const exifTypes = types.length ? types.join(",") : "none" location.href = `/api/results/${encodeURIComponent(id)}/download?through=${through}&exif_types=${exifTypes}` }) $("#btn-submit").addEventListener("click", async () => { const url = $("#input-url").value.trim() if (!url) return const pages = parseInt($("#input-pages").value) || 30 const mode = $("#input-mode").value const deepScan = $("#input-deep-scan").checked const targeted = deepScan && $("#input-targeted-deep-scan").checked const deepScanTypes = targeted ? selectedCategoryTypes("#deep-scan-filters") : [] const status = $("#submit-status") if (targeted && !deepScanTypes.length) { status.textContent = "Select at least one deep scan category" status.className = "status-err" return } status.textContent = "Submitting..." status.className = "" const userIds = [...new Set(url.match(LONG_DIGITS_RE) || [])] const hasSearch = /search/i.test(url) const isUserInput = userIds.length > 0 && !hasSearch if (userIds.length > 0 && hasSearch) { status.textContent = "Ambiguous input - pick either user IDs or a search URL" status.className = "status-err" return } try { let resp if (isUserInput) { resp = await fetch("/api/submit_users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_ids: userIds, deep_scan: deepScan, deep_scan_types: deepScanTypes }) }) } else { resp = await fetch("/api/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, pages, mode, deep_scan: deepScan, deep_scan_types: deepScanTypes }) }) } const data = await resp.json() const ids = data.ids || [data.id] status.textContent = `Submitted as ${ids.join(", ")} - you can close this page` status.className = "status-ok" } catch (e) { status.textContent = `Error: ${e.message}` status.className = "status-err" } }) $("#btn-reclass").addEventListener("click", async () => { const types = selectedCategoryTypes("#reclass-filters") const status = $("#reclass-status") if (!types.length) { status.textContent = "Select at least one category" status.className = "status-err" return } $("#btn-reclass").disabled = true status.textContent = "Starting..." status.className = "" try { const resp = await fetch("/api/reclass", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ exif_types: types }) }) const data = await resp.json() if (data.error) throw new Error(data.error) status.textContent = `Submitted as ${data.id}` status.className = "status-ok" location.hash = explorerHash(data.id, 1) } catch (e) { status.textContent = `Error: ${e.message}` status.className = "status-err" } finally { $("#btn-reclass").disabled = false } }) $("#btn-post-search").addEventListener("click", async () => { const status = $("#post-search-status") const postIds = parsePostIds($("#input-post-ids").value) if (!postIds.length) { status.textContent = "Enter at least one post ID" status.className = "status-err" $("#post-search-grid").replaceChildren() return } $("#btn-post-search").disabled = true status.textContent = "Loading..." status.className = "" try { const resp = await fetch("/api/post_search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ post_ids: postIds }) }) const data = await resp.json() if (data.error) throw new Error(data.error) status.textContent = `${data.total} image${data.total === 1 ? "" : "s"}` status.className = "status-ok" postSearchCardCache = new Map() renderPostSearchResults(data.items || []) } catch (e) { status.textContent = `Error: ${e.message}` status.className = "status-err" } finally { $("#btn-post-search").disabled = false } }) function route() { const raw = location.hash.slice(2) || "submit" const [path, qs = ""] = raw.split("?") const parts = path.split("/").filter(Boolean) const tab = parts[0] || "submit" const params = new URLSearchParams(qs) if (tab === "progress") { location.hash = "#/explorer" return } $$(".tab").forEach(t => t.classList.toggle("active", t.dataset.tab === tab)) $$(".panel").forEach(p => p.classList.toggle("active", p.id === tab)) if (tab === "explorer") { lastExplorerHash = location.hash || "#/explorer" if (parts[1]) { closeExplorerEvents() const id = decodeURIComponent(parts[1]) const page = parseInt(params.get("page")) || 1 const show = submittedSearchId === id const view = explorerDetailView(id, page, show) if (currentExplorerView !== view) openSearch(id, page, show, view) } else { resetSubmittedSearch() const page = parseInt(params.get("page")) || 1 if (currentExplorerView !== "list" || loadedExplorerListPage !== page) loadSearches(page) openExplorerEvents() } } else { closeExplorerEvents() } } function parsePostIds(raw) { return [...new Set(raw.match(POST_ID_RE) || [])] } function explorerHash(id, page) { return `#/explorer/${encodeURIComponent(id)}?page=${page}` } function explorerDetailView(id, page, show) { const sort = $("#sort-bookmarks").checked ? 1 : 0 return `detail:${id}:${page}:${show ? 1 : 0}:${filterKey(getExifTypes())}:${sort}` } function resetSubmittedSearch() { submittedSearchId = null submittedFilterKey = null resultCardCache = new Map() $("#sort-bookmarks").checked = true } function taskCount(t) { if (t.raw_total) { const done = Math.min((t.scanned_base || 0) + (t.done || 0), t.raw_total) return `${done}/${t.raw_total}` } return t.total > 0 ? `${t.done}/${t.total}` : "..." } async function loadSearches(page = 1, silent = false) { explorerPage = page currentExplorerView = null loadedExplorerListPage = null const list = $("#search-list") const detail = $("#search-detail") detail.classList.add("hidden") if (!silent) list.innerHTML = "Loading..." try { const [searchResp, taskResp] = await Promise.all([fetch(`/api/searches?page=${page}`), fetch("/api/progress")]) const data = await searchResp.json() const tasks = await taskResp.json() const active = tasks.filter(t => t.phase !== "canceling" && (t.type === "search" || t.type === "search+scan" || t.type === "scan" || t.type === "user_search" || t.type === "reclass")) const activeIds = new Set(active.map(t => t.id)) const searches = (data.items || []).filter(s => (s.kind === "reclass" || (s.found_exif || 0) > 0) && !activeIds.has(s.id)) if (!searches.length && !active.length) { list.innerHTML = "No searches yet" currentExplorerView = "list" loadedExplorerListPage = page return } const activeHtml = active.map(t => { const label = taskCount(t) return `
${esc(t.id)} ${esc(t.type)} ${esc(t.phase)} ${label} ...
` }).join("") const savedHtml = searches.map(s => { const d = new Date(parseInt(s.created_at) * 1000) const ts = d.toLocaleString() const count = s.kind === "reclass" ? `${s.changed || 0}/${s.total || 0}` : `${s.found_exif || 0}/${s.total_searched || 0}` const kind = s.kind === "reclass" ? "reclass " : "" const title = s.kind === "reclass" ? "changed / total" : "found exif / total searched" return `
${esc(s.id)} ${kind}${ts} ${count} ${s.kind === "reclass" ? "" : ``}
` }).join("") list.innerHTML = activeHtml + savedHtml + renderSearchPager(data) list.querySelectorAll(".search-item").forEach(el => { if (!el.dataset.id) return el.querySelector(".id").addEventListener("click", () => { resetSubmittedSearch() location.hash = explorerHash(el.dataset.id, 1) }) const rename = el.querySelector(".btn-rename") const del = el.querySelector(".btn-delete") if (rename) rename.addEventListener("click", e => { e.stopPropagation(); renameSearch(el.dataset.id) }) if (del) del.addEventListener("click", e => { e.stopPropagation(); deleteSearch(el.dataset.id) }) }) const prev = $("#search-prev") const next = $("#search-next") if (prev) prev.onclick = () => { location.hash = `#/explorer?page=${Math.max(page - 1, 1)}` } if (next) next.onclick = () => { location.hash = `#/explorer?page=${Math.min(page + 1, data.pages)}` } currentExplorerView = "list" loadedExplorerListPage = page } catch (e) { list.innerHTML = `Error: ${e.message}` } } async function refreshExplorer() { const btn = $("#btn-refresh-explorer") btn.disabled = true btn.textContent = "Refreshing" try { await fetch("/api/searches/refresh", { method: "POST" }) exifCountsCache = new Map() currentExplorerView = null if ((location.hash.slice(2) || "submit").startsWith("explorer/")) route() else await loadSearches(explorerPage, true) } finally { btn.disabled = false btn.textContent = "Refresh" } } function openExplorerEvents() { if (explorerEvents) return explorerEvents = new EventSource("/api/events") explorerEvents.onmessage = () => { const raw = location.hash.slice(2) || "submit" const [path] = raw.split("?") const parts = path.split("/").filter(Boolean) if ((parts[0] || "submit") === "explorer" && !parts[1]) { clearTimeout(explorerDebounce) explorerDebounce = setTimeout(() => loadSearches(explorerPage, true), 500) } } } function closeExplorerEvents() { if (!explorerEvents) return explorerEvents.close() explorerEvents = null } function renderSearchPager(data) { if (!data.total || data.pages <= 1) return "" const start = (data.page - 1) * SEARCH_PAGE_SIZE + 1 const end = Math.min(data.page * SEARCH_PAGE_SIZE, data.total) return `
${start}-${end} of ${data.total} | page ${data.page}/${data.pages}
` } function getExifTypes() { const raw = localStorage.getItem(EXIF_FILTER_KEY) if (raw === null) return [...FILTER_CODES] const types = raw.split(",").map(Number).filter(n => FILTER_CODES.includes(n)) return types } function setExifTypes(types) { const sorted = [...new Set(types)].filter(n => FILTER_CODES.includes(n)).sort((a, b) => a - b) if (sorted.length === FILTER_CODES.length && sorted.every(n => FILTER_CODES.includes(n))) { localStorage.removeItem(EXIF_FILTER_KEY) } else { localStorage.setItem(EXIF_FILTER_KEY, sorted.join(",")) } return sorted } function filterKey(types) { return [...new Set(types)].filter(n => FILTER_CODES.includes(n)).sort((a, b) => a - b).join(",") } function selectedExifTypes() { return [...$$("#exif-filters .filter-btn")] .filter(el => el.classList.contains("active")) .map(el => Number(el.dataset.code)) } function selectedCategoryTypes(root) { return [...$$(`${root} .filter-btn`)] .filter(el => el.classList.contains("active")) .map(el => Number(el.dataset.code)) } function renderCategoryFilters(root, codes = FILTER_CODES) { const buttons = codes.map(code => { return `` }).join("") $(root).innerHTML = `${buttons} ` $$(`${root} .filter-btn`).forEach(btn => { btn.onclick = () => btn.classList.toggle("active") }) $$(`${root} .filter-action`).forEach(btn => { btn.onclick = () => { const filters = [...$$(`${root} .filter-btn`)] if (btn.dataset.action === "all") filters.forEach(el => el.classList.add("active")) if (btn.dataset.action === "none") filters.forEach(el => el.classList.remove("active")) } }) } function renderExifFilters(id, submitted = false, counts = null) { const shownCounts = counts ?? exifCountsCache.get(id) ?? visibleExifCounts(id) if (shownCounts) exifCountsCache.set(id, shownCounts) const active = new Set(getExifTypes()) const remaining = [...COUNT_FILTER_CODES].sort((a, b) => (shownCounts?.[b] || 0) - (shownCounts?.[a] || 0) || COUNT_FILTER_CODES.indexOf(a) - COUNT_FILTER_CODES.indexOf(b) ) const codes = [...TOP_FILTER_CODES, ...remaining, ...BOTTOM_FILTER_CODES] const buttons = codes.map(code => { const count = shownCounts ? ` (${shownCounts[code] || 0})` : "" return `` }).join("") const root = $("#exif-filters") root.dataset.id = id root.innerHTML = `${buttons} ` $$("#exif-filters .filter-btn").forEach(btn => { btn.onclick = () => btn.classList.toggle("active") }) $$("#exif-filters .filter-action").forEach(btn => { btn.onclick = () => { const action = btn.dataset.action const filters = [...$$("#exif-filters .filter-btn")] if (action === "all") filters.forEach(el => el.classList.add("active")) if (action === "none") filters.forEach(el => el.classList.remove("active")) if (action !== "submit") return const types = selectedExifTypes() const key = filterKey(types) if (submittedSearchId === id && submittedFilterKey === key) return setExifTypes(types) if (submittedSearchId !== id) resultCardCache = new Map() submittedSearchId = id submittedFilterKey = key const hash = explorerHash(id, 1) if (location.hash === hash) openSearch(id, 1, true) else location.hash = hash } }) } function visibleExifCounts(id) { const root = $("#exif-filters") if (root.dataset.id !== id) return null const counts = {} let found = false $$("#exif-filters .filter-btn").forEach(btn => { const match = btn.textContent.match(/\((\d+)\)$/) if (!match) return counts[Number(btn.dataset.code)] = Number(match[1]) found = true }) return found ? counts : null } async function openSearch(id, page = 1, show = false, view = explorerDetailView(id, page, show)) { currentExplorerView = null loadedExplorerListPage = null const list = $("#search-list") const detail = $("#search-detail") const exifTypes = getExifTypes() const sortBookmarks = $("#sort-bookmarks").checked list.innerHTML = "" detail.classList.remove("hidden") $("#detail-title").textContent = id $("#detail-stats").textContent = "" $("#pager").innerHTML = "" $("#download-all").classList.add("hidden") if (!show) $("#results-grid").replaceChildren() $("#btn-back").onclick = () => { location.hash = "#/explorer" } const reclass = await loadReclass(id) if (reclass) { $("#bookmark-sort").classList.add("hidden") detail.classList.add("reclass-detail") renderReclassDetail(reclass) currentExplorerView = view return } $("#bookmark-sort").classList.remove("hidden") detail.classList.remove("reclass-detail") $("#detail-stats").classList.remove("reclass-stats") $("#results-grid").classList.remove("log-grid") renderExifFilters(id, show) if (!show) { $("#detail-stats").textContent = "Loading counts..." try { const resp = await fetch(`/api/result_counts/${encodeURIComponent(id)}`) const data = await resp.json() if (data.error) { $("#detail-stats").textContent = data.error; return } renderExifFilters(id, show, data.type_counts || {}) $("#detail-stats").textContent = `${data.found_exif}/${data.raw_total} found | ${data.scanned_count}/${data.raw_total} scanned` currentExplorerView = view } catch (e) { $("#detail-stats").textContent = e.message } return } $("#detail-stats").textContent = "Loading..." try { const typesParam = exifTypes.length ? exifTypes.join(",") : "none" const url = `/api/results/${encodeURIComponent(id)}?page=${page}&exif_types=${typesParam}&sort_bookmarks=${sortBookmarks}` const resp = await fetch(url) const data = await resp.json() if (data.error) { $("#detail-stats").textContent = data.error; return } renderExifFilters(id, show, data.type_counts || {}) $("#detail-stats").textContent = `${data.total}/${data.raw_total} shown | ${data.scanned_count}/${data.raw_total} scanned` const input = $("#download-through") input.max = data.raw_total if (input.dataset.id !== id) input.value = data.raw_total input.dataset.id = id $("#download-all").classList.remove("hidden") renderPager(id, data) renderResults(data) currentExplorerView = view } catch (e) { $("#detail-stats").textContent = e.message } } async function loadReclass(id) { if (!id.endsWith("_reclass")) return null try { const resp = await fetch(`/api/reclass/${encodeURIComponent(id)}`) const data = await resp.json() return data.error ? null : data } catch { return null } } function renderReclassDetail(data) { const grid = $("#results-grid") $("#search-detail").classList.add("reclass-detail") $("#exif-filters").innerHTML = "" $("#pager").innerHTML = "" const filters = (data.summary.filters || data.filters.map(code => CATEGORY_NAMES[code] || code)).join(", ") const stats = $("#detail-stats") stats.classList.add("reclass-stats") if (!stats.querySelector("[data-stat]")) { stats.innerHTML = ` ` } stats.querySelector('[data-stat="changed"]').textContent = `${data.changed}/${data.total} changed` stats.querySelector('[data-stat="scanned"]').textContent = `${data.scanned}/${data.total} scanned` stats.querySelector('[data-stat="filters"]').textContent = filters stats.querySelector('[data-stat="phase"]').textContent = data.active ? data.active.phase : "" const log = data.log.length ? data.log.map(item => `
${esc(item.post_id)} ${esc(item.from)} ${esc(item.to)}
`).join("") : `
No classification changes
` grid.classList.add("log-grid") const panel = grid.querySelector(".reclass-panel") if (panel && panel.querySelector("[data-count]")) { panel.querySelector('[data-count="total"]').textContent = data.total panel.querySelector('[data-count="scanned"]').textContent = data.scanned panel.querySelector('[data-count="changed"]').textContent = data.changed const list = panel.querySelector(".log-list") if (list.innerHTML !== log) { const scrollTop = list.scrollTop list.innerHTML = log list.scrollTop = scrollTop } } else { grid.innerHTML = `

Summary

Total${data.total}
Scanned${data.scanned}
Changed${data.changed}

Detailed log

Post IDFromTo
${log}
` } } function pageSuffix(url) { if (!url) return "" const m = url.match(/_p(\d+)\./) return m ? `_p${m[1]}` : "" } function resultCardKey(item) { return `${item.post_id}|${item.url || ""}|${(item.pages || []).join(",")}|${(item.exif_types || []).join(",")}|${item.bookmark_count ?? ""}` } function resultBadge(item) { if (!item.scanned) return `not scanned` const types = item.exif_types?.length ? item.exif_types : item.exif_type ? [item.exif_type] : [] if (!types.length) return `NIL` return `${types.map(type => `${EXIF_NAMES[type] || "?"}` ).join("")}` } function resultCardHtml(item, eager) { const pid = item.post_id const pg = item.url ? pageSuffix(item.url) : "" const label = pid + pg const index = item.scan_index || 0 const pageCount = item.page_count || 1 const download = item.download_url ? `Download` : "" const thumb = item.image_url ? `` : "" return `
${thumb}
${label} ${download}
${resultBadge(item)} ${pageCount > 1 ? `${pageCount}` : ""} ${item.bookmark_count ?? "-"} ${index}
` } function createResultCard(item, eager = false) { const template = document.createElement("template") template.innerHTML = resultCardHtml(item, eager).trim() const card = template.content.firstElementChild card.querySelector(".result-link").addEventListener("click", () => { window.open(`https://www.pixiv.net/artworks/${card.dataset.pid}`, "_blank") }) const thumb = card.querySelector(".thumb") if (thumb) { const img = thumb.querySelector("img") thumb.addEventListener("click", () => openViewer(item)) img.addEventListener("load", () => thumb.classList.add("thumb-loaded")) img.addEventListener("error", () => thumb.classList.add("thumb-error")) if (img.complete) thumb.classList.add(img.naturalWidth ? "thumb-loaded" : "thumb-error") } return card } function renderResults(data) { viewerItems = data.items const cards = data.items.map((item, index) => { const key = resultCardKey(item) let card = resultCardCache.get(key) if (!card || card.querySelector(".thumb-error")) { card = createResultCard(item, index < EAGER_IMAGE_COUNT) resultCardCache.set(key, card) } return card }) $("#results-grid").replaceChildren(...cards) } function renderPostSearchResults(items) { viewerItems = items const cards = items.map((item, index) => { const key = resultCardKey(item) let card = postSearchCardCache.get(key) if (!card || card.querySelector(".thumb-error")) { card = createResultCard(item, index < EAGER_IMAGE_COUNT) postSearchCardCache.set(key, card) } return card }) $("#post-search-grid").replaceChildren(...cards) } function openViewer(item) { if (!item.preview_url && !item.image_url) return viewerFocus = document.activeElement viewerItemIndex = Math.max(viewerItems.findIndex(row => row.post_id === item.post_id), 0) setViewerItem(item) viewerScale = 1 $("#viewer").classList.remove("hidden") $("#viewer").setAttribute("aria-hidden", "false") $("#viewer").focus({ preventScroll: true }) showViewerPage() } function setViewerItem(item) { viewerPostId = item.post_id const pages = item.pages?.length ? item.pages : [item.page || 0] const enabled = new Set(getExifTypes()) const hasTags = item.exif_types?.length viewerPages = pages.filter(page => { const type = Number(item.page_types?.[page] ?? item.exif_type ?? 0) return enabled.has(type) && (type || !hasTags) }) if (!viewerPages.length) viewerPages = pages viewerPageIndex = Math.max(viewerPages.indexOf(item.page || 0), 0) } function showViewerPage() { const page = viewerPages[viewerPageIndex] const pid = encodeURIComponent(viewerPostId) $("#viewer-img").src = `/api/i/${pid}/v?p=${page}&v=${IMAGE_URL_VERSION}` $("#viewer-download").href = `/api/i/${pid}/o?p=${page}&v=${IMAGE_URL_VERSION}` $("#viewer-prev").disabled = viewerPageIndex <= 0 $("#viewer-next").disabled = viewerPageIndex >= viewerPages.length - 1 $("#viewer-prev").classList.toggle("hidden", viewerPages.length <= 1) $("#viewer-next").classList.toggle("hidden", viewerPages.length <= 1) viewerScale = 1 viewerX = 0 viewerY = VIEWER_GAP applyViewerZoom() } function moveViewerPage(step) { const next = Math.min(Math.max(viewerPageIndex + step, 0), viewerPages.length - 1) if (next === viewerPageIndex) return viewerPageIndex = next showViewerPage() } function moveViewerPost(step) { const next = Math.min(Math.max(viewerItemIndex + step, 0), viewerItems.length - 1) if (next === viewerItemIndex) return viewerItemIndex = next setViewerItem(viewerItems[next]) showViewerPage() } function closeViewer() { $("#viewer").classList.add("hidden") $("#viewer").setAttribute("aria-hidden", "true") $("#viewer-img").src = "" $("#viewer-download").href = "" viewerPostId = "" viewerPages = [] viewerDrag = null $("#viewer-stage").classList.remove("dragging") viewerFocus?.focus({ preventScroll: true }) viewerFocus = null } function applyViewerZoom() { $("#viewer-img").style.transform = `translate3d(${viewerX}px, ${viewerY}px, 0) scale(${viewerScale})` $("#viewer-zoom").textContent = `${Math.round(viewerScale * 100)}%` } function resetViewerZoom() { const stage = $("#viewer-stage") viewerScale = 1 viewerX = (stage.clientWidth - $("#viewer-img").naturalWidth) / 2 viewerY = VIEWER_GAP applyViewerZoom() } function zoomViewer(scale, x, y) { const next = Math.min(Math.max(scale, VIEWER_MIN_SCALE), VIEWER_MAX_SCALE) if (next === viewerScale) return const rect = $("#viewer-stage").getBoundingClientRect() const anchorX = (x ?? rect.left + rect.width / 2) - rect.left const anchorY = (y ?? rect.top + rect.height / 2) - rect.top const ratio = next / viewerScale viewerX = anchorX - (anchorX - viewerX) * ratio viewerY = anchorY - (anchorY - viewerY) * ratio viewerScale = next applyViewerZoom() } $("#viewer-img").addEventListener("load", resetViewerZoom) $("#viewer-zoom-in").addEventListener("click", () => { zoomViewer(viewerScale + VIEWER_BUTTON_STEP) }) $("#viewer-zoom-out").addEventListener("click", () => { zoomViewer(viewerScale - VIEWER_BUTTON_STEP) }) $("#viewer-close").addEventListener("click", closeViewer) $("#viewer-prev").addEventListener("click", () => moveViewerPage(-1)) $("#viewer-next").addEventListener("click", () => moveViewerPage(1)) $("#viewer").addEventListener("click", e => { if (e.target.id === "viewer") closeViewer() }) $("#viewer-stage").addEventListener("wheel", e => { e.preventDefault() zoomViewer(viewerScale + (e.deltaY < 0 ? VIEWER_WHEEL_STEP : -VIEWER_WHEEL_STEP), e.clientX, e.clientY) }) $("#viewer-stage").addEventListener("pointerdown", e => { if (e.button !== 0 || e.target.closest(".viewer-nav")) return e.preventDefault() const stage = $("#viewer-stage") viewerDrag = { x: e.clientX, y: e.clientY, left: viewerX, top: viewerY, } stage.classList.add("dragging") stage.setPointerCapture(e.pointerId) }) $("#viewer-stage").addEventListener("pointermove", e => { if (!viewerDrag) return viewerX = viewerDrag.left + e.clientX - viewerDrag.x viewerY = viewerDrag.top + e.clientY - viewerDrag.y applyViewerZoom() }) $("#viewer-stage").addEventListener("pointerup", e => { viewerDrag = null $("#viewer-stage").classList.remove("dragging") $("#viewer-stage").releasePointerCapture(e.pointerId) }) $("#viewer-stage").addEventListener("pointercancel", () => { viewerDrag = null $("#viewer-stage").classList.remove("dragging") }) $("#viewer").addEventListener("keydown", e => { const actions = { Escape: closeViewer, ArrowLeft: () => moveViewerPage(-1), ArrowRight: () => moveViewerPage(1), ArrowUp: () => moveViewerPost(-1), ArrowDown: () => moveViewerPost(1), } const action = actions[e.key] if (!action) return e.preventDefault() e.stopPropagation() action() }) function renderPager(id, data) { const pager = $("#pager") const prev = Math.max(data.page - 1, 1) const next = Math.min(data.page + 1, data.pages) const start = data.total ? (data.page - 1) * PAGE_SIZE + 1 : 0 const end = Math.min(data.page * PAGE_SIZE, data.total) pager.innerHTML = ` ${start}-${end} of ${data.total} | page ${data.page}/${data.pages} ` $("#page-prev").onclick = () => { location.hash = explorerHash(id, prev) } $("#page-next").onclick = () => { location.hash = explorerHash(id, next) } } function esc(s) { const d = document.createElement("div") d.textContent = s return d.innerHTML } async function deleteSearch(id) { if (!confirm(`Cancel/delete "${id}"?`)) return await fetch(`/api/search/${id}`, { method: "DELETE" }) location.hash = "#/explorer" loadSearches(explorerPage) } async function renameSearch(id) { const newId = prompt("New name:", id) if (!newId || newId === id) return await fetch(`/api/search/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ new_id: newId }) }) location.hash = "#/explorer" loadSearches() } window.addEventListener("hashchange", route) renderCategoryFilters("#deep-scan-filters", EXIF_CODES) renderCategoryFilters("#reclass-filters") syncDeepScanTargets() if (!location.hash) location.hash = "#/submit" route()