const $ = s => document.querySelector(s) const $$ = s => document.querySelectorAll(s) const EXIF_NAMES = { 1: "novelai", 2: "sd", 3: "comfy", 4: "mj", 5: "celsys", 6: "photoshop", 7: "stealth" } const EXIF_CODES = Object.keys(EXIF_NAMES).map(Number) const FILTER_CODES = EXIF_CODES const EXIF_FILTER_KEY = "pixif2-exif-types" const LONG_DIGITS_RE = /\d{6,}/g const PAGE_SIZE = 60 const SEARCH_PAGE_SIZE = 10 let viewerScale = 1 let viewerDrag = null let explorerEvents = null let explorerPage = 1 $$(".tab").forEach(tab => { tab.addEventListener("click", () => { location.hash = `#/${tab.dataset.tab}` }) }) $("#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 status = $("#submit-status") 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 }) }) } else { resp = await fetch("/api/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, pages, mode }) }) } 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" } }) 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") { if (parts[1]) { closeExplorerEvents() openSearch(decodeURIComponent(parts[1]), parseInt(params.get("page")) || 1) } else { loadSearches(parseInt(params.get("page")) || 1) openExplorerEvents() } } else { closeExplorerEvents() } } function explorerHash(id, page) { return `#/explorer/${encodeURIComponent(id)}?page=${page}` } async function loadSearches(page = 1) { explorerPage = page const list = $("#search-list") const detail = $("#search-detail") detail.classList.add("hidden") 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.type === "search" || t.type === "search+scan" || t.type === "user_search") const searches = (data.items || []).filter(s => (s.found_exif || 0) > 0) if (!searches.length && !active.length) { list.innerHTML = "No searches yet"; return } const activeHtml = active.map(t => { const label = t.total > 0 ? `${t.done}/${t.total}` : "..." 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.found_exif || 0}/${s.total_searched || 0}` return `
${esc(s.id)} ${ts} ${count}
` }).join("") list.innerHTML = activeHtml + savedHtml + renderSearchPager(data) list.querySelectorAll(".search-item").forEach(el => { if (!el.dataset.id) return el.querySelector(".id").addEventListener("click", () => { 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)}` } } catch (e) { list.innerHTML = `Error: ${e.message}` } } 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]) loadSearches(explorerPage) } } 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 [...EXIF_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 === EXIF_CODES.length && sorted.every(n => EXIF_CODES.includes(n))) { localStorage.removeItem(EXIF_FILTER_KEY) } else { localStorage.setItem(EXIF_FILTER_KEY, sorted.join(",")) } return sorted } function renderExifFilters(id) { const active = new Set(getExifTypes()) const buttons = FILTER_CODES.map(code => ``).join("") $("#exif-filters").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 !== "update") return const types = filters.filter(el => el.classList.contains("active")).map(el => Number(el.dataset.code)) setExifTypes(types) const hash = explorerHash(id, 1) if (location.hash === hash) openSearch(id, 1) else location.hash = hash } }) } async function openSearch(id, page = 1) { const list = $("#search-list") const detail = $("#search-detail") const exifTypes = getExifTypes() list.innerHTML = "" detail.classList.remove("hidden") $("#detail-title").textContent = id $("#detail-stats").textContent = "Loading..." $("#pager").innerHTML = "" $("#results-grid").innerHTML = "" renderExifFilters(id) try { const typesParam = exifTypes.length ? exifTypes.join(",") : "none" const resp = await fetch(`/api/results/${encodeURIComponent(id)}?page=${page}&exif_types=${typesParam}`) const data = await resp.json() if (data.error) { $("#detail-stats").textContent = data.error; return } const allScanned = data.scanned_count >= data.raw_total $("#detail-stats").textContent = `${data.total}/${data.raw_total} shown | ${data.scanned_count}/${data.raw_total} scanned` if (!allScanned) resumeScan(id) $("#btn-back").onclick = () => { location.hash = "#/explorer" } renderPager(id, data) renderResults(data) } catch (e) { $("#detail-stats").textContent = e.message } } async function resumeScan(id) { await fetch("/api/scan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ search_id: id }) }) } function pageSuffix(url) { if (!url) return "" const m = url.match(/_p(\d+)\./) return m ? `_p${m[1]}` : "" } function renderResults(data) { const grid = $("#results-grid") const offset = (data.page - 1) * PAGE_SIZE grid.innerHTML = data.items.map((item, i) => { const pid = item.post_id const pg = item.url ? pageSuffix(item.url) : "" const label = pid + pg const index = offset + i + 1 let badge = "" if (!item.scanned) { badge = `not scanned` } else if (item.exif_type) { const name = EXIF_NAMES[item.exif_type] || "?" badge = `${name}` } else { badge = `NIL` } const download = item.download_url ? `Download` : "" const thumb = item.image_url ? `` : "" return `
${thumb}
${label} ${download}
${badge} ${index}
` }).join("") grid.querySelectorAll(".result-link").forEach(el => { el.addEventListener("click", () => { const pid = el.closest(".result-card").dataset.pid window.open(`https://www.pixiv.net/artworks/${pid}`, "_blank") }) }) grid.querySelectorAll(".thumb").forEach(el => { el.addEventListener("click", () => openViewer(el.dataset.preview, el.dataset.download)) el.querySelector("img").addEventListener("load", () => el.classList.add("thumb-loaded")) el.querySelector("img").addEventListener("error", () => el.classList.add("thumb-error")) }) } function openViewer(src, download) { if (!src) return viewerScale = 1 $("#viewer-img").src = src $("#viewer-download").href = download || src $("#viewer").classList.remove("hidden") $("#viewer").setAttribute("aria-hidden", "false") applyViewerZoom() } function closeViewer() { $("#viewer").classList.add("hidden") $("#viewer").setAttribute("aria-hidden", "true") $("#viewer-img").src = "" $("#viewer-download").href = "" viewerDrag = null $("#viewer-stage").classList.remove("dragging") } function applyViewerZoom() { $("#viewer-img").style.transform = `scale(${viewerScale})` $("#viewer-zoom").textContent = `${Math.round(viewerScale * 100)}%` } $("#viewer-zoom-in").addEventListener("click", () => { viewerScale = Math.min(viewerScale + .25, 6) applyViewerZoom() }) $("#viewer-zoom-out").addEventListener("click", () => { viewerScale = Math.max(viewerScale - .25, .25) applyViewerZoom() }) $("#viewer-close").addEventListener("click", closeViewer) $("#viewer").addEventListener("click", e => { if (e.target.id === "viewer") closeViewer() }) $("#viewer-stage").addEventListener("wheel", e => { e.preventDefault() viewerScale = Math.min(Math.max(viewerScale + (e.deltaY < 0 ? .15 : -.15), .25), 6) applyViewerZoom() }) $("#viewer-stage").addEventListener("pointerdown", e => { if (e.button !== 0) return const stage = $("#viewer-stage") viewerDrag = { x: e.clientX, y: e.clientY, left: stage.scrollLeft, top: stage.scrollTop, } stage.classList.add("dragging") stage.setPointerCapture(e.pointerId) }) $("#viewer-stage").addEventListener("pointermove", e => { if (!viewerDrag) return const stage = $("#viewer-stage") stage.scrollLeft = viewerDrag.left - e.clientX + viewerDrag.x stage.scrollTop = viewerDrag.top - e.clientY + viewerDrag.y }) $("#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") }) window.addEventListener("keydown", e => { if (e.key === "Escape") closeViewer() }) 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(`Delete search "${id}"?`)) return await fetch(`/api/search/${id}`, { method: "DELETE" }) location.hash = "#/explorer" loadSearches() } 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) if (!location.hash) location.hash = "#/submit" route()