P2 / frontend /app.js
q6's picture
Serve thumbnails and previews from cached originals
b2a8bc1
Raw History Blame Contribute Delete
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">&times;</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">&#9998;</button>`}
<button class="btn-icon btn-delete" title="Delete">&times;</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()