Download frontend/app.js from q6/P2: direct link, hf CLI and curl.
- Browser
- Download file 34 kB
-
https://huggingface.co/spaces/q6/P2/resolve/main/frontend/app.js
- Command line
-
hf download hf://spaces/q6/P2/frontend/app.js
-
curl -L -o app.js https://huggingface.co/spaces/q6/P2/resolve/main/frontend/app.js
34 kB
| 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 `<div class="search-item active-task" data-id="${esc(t.id)}"> | |
| <span class="id">${esc(t.id)}</span> | |
| <span class="time">${esc(t.type)} ${esc(t.phase)} ${label}</span> | |
| <span class="search-actions"> | |
| <span class="search-status active-mark">...</span> | |
| <button class="btn-icon btn-delete" title="Cancel and delete">×</button> | |
| </span> | |
| </div>` | |
| }).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 `<div class="search-item" data-id="${esc(s.id)}"> | |
| <span class="id">${esc(s.id)}</span> | |
| <span class="time">${kind}${ts}</span> | |
| <span class="search-actions"> | |
| <span class="search-status done-mark" title="${title}">${count}</span> | |
| ${s.kind === "reclass" ? "" : `<button class="btn-icon btn-rename" title="Rename">✎</button>`} | |
| <button class="btn-icon btn-delete" title="Delete">×</button> | |
| </span> | |
| </div>` | |
| }).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 `<div class="list-pager"> | |
| <button class="btn-secondary" id="search-prev" ${data.page <= 1 ? "disabled" : ""}>Prev</button> | |
| <span>${start}-${end} of ${data.total} | page ${data.page}/${data.pages}</span> | |
| <button class="btn-secondary" id="search-next" ${data.page >= data.pages ? "disabled" : ""}>Next</button> | |
| </div>` | |
| } | |
| 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 `<button class="filter-btn active" data-code="${code}">${esc(CATEGORY_NAMES[code])}</button>` | |
| }).join("") | |
| $(root).innerHTML = `${buttons} | |
| <button class="filter-action" data-action="all">Select all</button> | |
| <button class="filter-action" data-action="none">Select none</button>` | |
| $$(`${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 `<button class="filter-btn${active.has(code) ? " active" : ""}" data-code="${code}">${esc(CATEGORY_NAMES[code])}${count}</button>` | |
| }).join("") | |
| const root = $("#exif-filters") | |
| root.dataset.id = id | |
| root.innerHTML = `${buttons} | |
| <button class="filter-action" data-action="all">Select all</button> | |
| <button class="filter-action" data-action="none">Select none</button> | |
| <button class="filter-action submit" data-action="submit">${submitted ? "Update" : "Submit"}</button>` | |
| $$("#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 = `<span class="reclass-count" data-stat="changed"></span> | |
| <span class="reclass-count" data-stat="scanned"></span> | |
| <span data-stat="filters"></span> | |
| <span class="reclass-phase" data-stat="phase"></span>` | |
| } | |
| 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 => `<div class="log-row"> | |
| <span>${esc(item.post_id)}</span> | |
| <span>${esc(item.from)}</span> | |
| <span>${esc(item.to)}</span> | |
| </div>`).join("") | |
| : `<div class="empty-log">No classification changes</div>` | |
| 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 = `<div class="reclass-panel"> | |
| <h2>Summary</h2> | |
| <div class="summary-grid"> | |
| <div class="summary-item"><span>Total</span><strong data-count="total">${data.total}</strong></div> | |
| <div class="summary-item"><span>Scanned</span><strong data-count="scanned">${data.scanned}</strong></div> | |
| <div class="summary-item"><span>Changed</span><strong data-count="changed">${data.changed}</strong></div> | |
| </div> | |
| <h2>Detailed log</h2> | |
| <div class="log-head"><span>Post ID</span><span>From</span><span>To</span></div> | |
| <div class="log-list">${log}</div> | |
| </div>` | |
| } | |
| } | |
| 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 `<span class="result-badge not-scanned">not scanned</span>` | |
| const types = item.exif_types?.length ? item.exif_types : item.exif_type ? [item.exif_type] : [] | |
| if (!types.length) return `<span class="result-badge no-exif">NIL</span>` | |
| return `<span class="result-badges">${types.map(type => | |
| `<span class="result-badge exif-badge exif-${type}">${EXIF_NAMES[type] || "?"}</span>` | |
| ).join("")}</span>` | |
| } | |
| 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 ? `<a class="download-link" href="${esc(item.download_url)}">Download</a>` : "" | |
| const thumb = item.image_url | |
| ? `<button class="thumb" data-preview="${esc(item.preview_url || item.image_url)}" data-download="${esc(item.download_url || "")}"><img src="${esc(item.image_url)}" loading="${eager ? "eager" : "lazy"}" decoding="async" alt="${esc(label)}"></button>` | |
| : "" | |
| return `<div class="result-card" data-pid="${esc(pid)}"> | |
| ${thumb} | |
| <div class="result-meta"> | |
| <span class="result-link">${label}</span> | |
| ${download} | |
| </div> | |
| ${resultBadge(item)} | |
| ${pageCount > 1 ? `<span class="result-pages">${pageCount}</span>` : ""} | |
| <span class="result-bookmarks">${item.bookmark_count ?? "-"}</span> | |
| <span class="result-index">${index}</span> | |
| </div>` | |
| } | |
| 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 = `<button class="btn-secondary" id="page-prev" ${data.page <= 1 ? "disabled" : ""}>Prev</button> | |
| <span>${start}-${end} of ${data.total} | page ${data.page}/${data.pages}</span> | |
| <button class="btn-secondary" id="page-next" ${data.page >= data.pages ? "disabled" : ""}>Next</button>` | |
| $("#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() | |