import { downloadUpscale } from "/upscale/image.js" const $ = (selector, root = document) => root.querySelector(selector) const $$ = (selector, root = document) => [...root.querySelectorAll(selector)] const p = new URLSearchParams(location.search).get("p") const api = (path, query = {}) => `${path}?${new URLSearchParams({ p, ...query })}` const preview = image => api("/api/images/preview", { path: image.path, v: image.version }) const original = image => api("/api/images/original", { path: image.path, v: image.version }) const MIN_ZOOM = 1 const MAX_ZOOM = 8 const ZOOM_STEP = 1.2 const INFINITE = "*" const FAVORITES = "favorites" const SEARCH_DELAY = 250 const LOAD_MARGIN = "640px" const PREVIEW_MARGIN = "320px" class ImageExplorer extends HTMLElement { connectedCallback() { this.innerHTML = ` ← Image folders → Folders Favorites Select all Clear Download 7z Delete Loading Upscale Close − Reset + ` this.cache = new Map() this.setupPreviews() this.pageObserver = new IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting)) this.loadMore() }, { rootMargin: LOAD_MARGIN }) this.load() $(".back", this).addEventListener("click", () => this.load()) $(".date-prev", this).addEventListener("click", () => this.navigateDate(1)) $(".date-next", this).addEventListener("click", () => this.navigateDate(-1)) $(".all", this).addEventListener("click", () => this.select(true)) $(".clear", this).addEventListener("click", () => this.select(false)) $(".favorites", this).addEventListener("click", () => this.toggleFavorites()) $(".download", this).addEventListener("click", () => this.download()) $(".delete", this).addEventListener("click", () => this.delete()) $("input[type=search]", this).addEventListener("input", event => { this.filter(event.target.value) }) this.setupLightbox() } async load(folder, favoritesOnly = false, query = "") { clearTimeout(this.searchTimer) this.controller?.abort() const controller = new AbortController() this.controller = controller this.folder = folder $(".lightbox", this).close() this.favoritesOnly = folder === FAVORITES || favoritesOnly this.searchQuery = query this.images = [] this.imagePaths = new Set() this.groupGrids = new Map() this.nextOffset = folder ? 0 : null this.loadingPage = false this.more = null $(".status", this).textContent = "Loading" this.previewObserver.disconnect() this.pageObserver.disconnect() $(".content", this).replaceChildren() $("h1", this).textContent = folder === FAVORITES ? "Favorites" : folder === INFINITE ? "Infinite" : folder || "Image folders" $(".back", this).hidden = !folder $(".all", this).textContent = folder ? "Select loaded" : "Select all" const favorites = $(".favorites", this) favorites.hidden = !folder || folder === FAVORITES favorites.setAttribute("aria-pressed", this.favoritesOnly) this.updateDateNav() const search = $("input[type=search]", this) search.value = query search.placeholder = folder ? "Search positive prompts" : "Filter folders" search.setAttribute("aria-label", search.placeholder) if (folder) { if (folder !== INFINITE && folder !== FAVORITES) this.addImages([]) this.more = document.createElement("button") this.more.className = "load-more" this.more.type = "button" this.more.textContent = "Load more" this.more.addEventListener("click", () => this.loadMore()) $(".content", this).append(this.more) this.pageObserver.observe(this.more) await this.loadMore() return } try { let data = this.cache.get(folder) if (!data) { const response = await fetch(api("/api/images"), { signal: controller.signal }) if (!response.ok) throw new Error("Unable to load folders") data = await response.json() } if (controller !== this.controller) return this.cache.set(folder, data) this.folders = [FAVORITES, INFINITE, ...data.folders.map(item => item.name)] this.addFolders(data.folders) this.update() } catch (error) { if (controller === this.controller) $(".status", this).textContent = error.message } } loadMore() { if (this.loadingPage) return this.pageTask if (this.nextOffset === null || !this.folder || this.pendingStars?.size) return this.pageTask = this.loadPage() return this.pageTask } async loadPage() { const controller = this.controller const grouped = this.folder === INFINITE || this.folder === FAVORITES const query = { offset: this.nextOffset, favorites: this.favoritesOnly, search: this.searchQuery, ...(grouped ? { infinite: true } : { folder: this.folder }), } this.loadingPage = true this.more.disabled = true this.more.textContent = "Loading" try { const response = await fetch(api("/api/images", query), { signal: controller.signal }) if (!response.ok) throw new Error("Unable to load images") const data = await response.json() if (controller !== this.controller) return if (grouped) this.addGroups(data.groups) else this.addImages(data.images) this.nextOffset = data.next_offset this.more.hidden = this.nextOffset === null this.more.textContent = "Load more" this.update() this.pageObserver.unobserve(this.more) if (this.nextOffset !== null) this.pageObserver.observe(this.more) } catch (error) { if (controller !== this.controller) return this.pageObserver.unobserve(this.more) this.more.textContent = "Retry loading images" $(".status", this).textContent = error.message } finally { if (controller === this.controller) { this.loadingPage = false this.more.disabled = false } } } updateDateNav() { const index = this.folders?.indexOf(this.folder) ?? -1 const previous = $(".date-prev", this) const next = $(".date-next", this) previous.hidden = index < 0 next.hidden = index < 0 if (index < 0) return previous.disabled = index === this.folders.length - 1 next.disabled = index === 0 previous.title = this.folders[index + 1] || "" next.title = this.folders[index - 1] || "" } navigateDate(step) { const index = this.folders.indexOf(this.folder) + step if (this.folders[index]) this.load(this.folders[index]) } setupPreviews() { this.previewObserver = new IntersectionObserver(entries => { for (const entry of entries) { if (!entry.isIntersecting) continue const image = entry.target image.src = image.dataset.src delete image.dataset.src this.previewObserver.unobserve(image) } }, { rootMargin: PREVIEW_MARGIN }) } addFolders(folders) { const grid = document.createElement("div") grid.className = "grid folders" for (const [folder, label] of [[FAVORITES, "Favorites"], [INFINITE, "Infinite"]]) { const card = document.createElement("div") card.className = "folder entry" card.dataset.name = label.toLowerCase() const open = document.createElement("button") open.className = "folder-open" open.type = "button" open.addEventListener("click", () => this.load(folder)) const name = document.createElement("strong") name.textContent = label open.append(name) card.append(open) grid.append(card) } for (const folder of folders) { const card = document.createElement("div") card.className = "folder entry" card.dataset.name = folder.name.toLowerCase() const input = this.checkbox(folder.name) const open = document.createElement("button") open.className = "folder-open" open.type = "button" open.addEventListener("click", () => this.load(folder.name)) const name = document.createElement("strong") name.textContent = folder.name open.append(name) card.append(input, open) grid.append(card) } $(".content", this).append(grid) } appendImages(grid, images) { const cards = document.createDocumentFragment() for (const image of images) { if (this.imagePaths.has(image.path)) continue this.imagePaths.add(image.path) this.images.push(image) cards.append(this.card(image)) } grid.append(cards) } addImages(images) { let grid = $(".content > .grid", this) if (!grid) { grid = document.createElement("div") grid.className = "grid" $(".content", this).append(grid) } this.appendImages(grid, images) } addGroups(groups) { const content = $(".content", this) for (const group of groups) { let grid = this.groupGrids.get(group.name) if (!grid) { const section = document.createElement("section") section.className = "date-group" const heading = document.createElement("h2") heading.textContent = group.name grid = document.createElement("div") grid.className = "grid" section.append(heading, grid) content.insertBefore(section, this.more) this.groupGrids.set(group.name, grid) } this.appendImages(grid, group.images) grid.parentElement.hidden = !$(".entry:not([hidden])", grid) } } checkbox(path) { const input = document.createElement("input") input.type = "checkbox" input.className = "item" input.dataset.path = path input.setAttribute("aria-label", `Select ${path}`) input.addEventListener("change", () => this.update()) return input } card(image) { const card = document.createElement("div") card.className = "image entry" card.classList.toggle("favorite", image.starred) card.dataset.name = image.name.toLowerCase() card.dataset.prompt = image.prompt.toLowerCase() card.dataset.path = image.path const open = document.createElement("button") open.className = "preview" open.type = "button" open.addEventListener("click", () => this.openImage(image)) const picture = document.createElement("img") picture.alt = image.name picture.decoding = "async" picture.dataset.src = preview(image) this.previewObserver.observe(picture) open.append(picture) const star = this.starButton(image, "card-star") const info = document.createElement("label") info.className = "image-info" const input = this.checkbox(image.path) const name = document.createElement("strong") name.className = "name" name.textContent = image.name info.append(input, name) card.append(open, star, info) return card } starButton(image, className) { const button = document.createElement("button") button.className = `star ${className}` button.type = "button" button.dataset.path = image.path button.addEventListener("click", () => this.toggleStar(image)) this.renderStar(button, image.starred) return button } renderStar(button, starred) { button.textContent = starred ? "★" : "☆" button.setAttribute("aria-pressed", starred) button.setAttribute("aria-label", `${starred ? "Unstar" : "Star"} image`) } setStar(path, starred) { for (const image of this.images) { if (image.path === path) image.starred = starred } for (const button of $$(".star", this)) { if (button.dataset.path === path) this.renderStar(button, starred) } for (const card of $$(".image", this)) { if (card.dataset.path !== path) continue card.classList.toggle("favorite", starred) card.hidden = this.favoritesOnly && !starred if (card.hidden) $(".item", card).checked = false } for (const group of $$(".date-group", this)) { group.hidden = !$(".entry:not([hidden])", group) } this.update() } async toggleStar(image) { this.pendingStars ||= new Set() if (this.pendingStars.has(image.path)) return this.pendingStars.add(image.path) const controller = this.controller const buttons = $$(".star", this).filter(button => ( button.dataset.path === image.path )) for (const button of buttons) button.disabled = true const starred = !image.starred try { await this.pageTask if (controller !== this.controller) return this.setStar(image.path, starred) const response = await fetch(api("/api/images/star"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: image.path, starred }), }) if (!response.ok) throw new Error("Unable to update star") if (controller === this.controller && this.favoritesOnly && !starred && this.nextOffset !== null) { this.nextOffset = Math.max(0, this.nextOffset - 1) } } catch (error) { this.setStar(image.path, !starred) $(".status", this).textContent = error.message } finally { this.pendingStars.delete(image.path) for (const button of buttons) button.disabled = false if (this.more && this.nextOffset !== null) { this.pageObserver.unobserve(this.more) this.pageObserver.observe(this.more) } } } select(checked) { for (const input of $$(".entry:not([hidden]) .item", this)) input.checked = checked this.update() } update() { const count = $$(".item:checked", this).length $(".status", this).textContent = `${count} selected` } toggleFavorites() { this.load(this.folder, !this.favoritesOnly, $("input[type=search]", this).value) } filter(value) { clearTimeout(this.searchTimer) if (this.folder) { this.searchTimer = setTimeout(() => { this.load(this.folder, this.favoritesOnly, value) }, SEARCH_DELAY) return } const query = value.trim().toLowerCase() for (const entry of $$(".entry", this)) { entry.hidden = !entry.dataset.name.includes(query) } } async download() { const items = $$(".item:checked", this).map(input => input.dataset.path) if (!items.length) return const button = $(".download", this) button.disabled = true $(".status", this).textContent = "Building archive" try { const response = await fetch(api("/api/images/download"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items }), }) if (!response.ok) throw new Error("Archive failed") const url = URL.createObjectURL(await response.blob()) const link = document.createElement("a") link.href = url link.download = "images.7z" link.click() URL.revokeObjectURL(url) } catch (error) { $(".status", this).textContent = error.message return } finally { button.disabled = false } this.update() } async delete() { const items = $$(".item:checked", this).map(input => input.dataset.path) if (!items.length || !confirm(`Delete ${items.length} selected item(s)?`)) return const button = $(".delete", this) button.disabled = true $(".status", this).textContent = "Deleting" try { const response = await fetch(api("/api/images/delete"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items }), }) if (!response.ok) throw new Error("Delete failed") this.cache.clear() await this.load(this.folder, this.favoritesOnly, $("input[type=search]", this).value) } catch (error) { $(".status", this).textContent = error.message } finally { button.disabled = false } } async upscale() { const image = this.images[this.imageIndex] const button = $(".upscale", this) const status = $(".upscale-status", this) button.disabled = true status.textContent = "Upscaling…" try { await downloadUpscale({ path: image.path, name: image.name }) status.textContent = "Downloaded" } catch (error) { status.textContent = error.message } finally { button.disabled = false } } setupLightbox() { const dialog = $(".lightbox", this) const viewer = $(".viewer", dialog) const image = $("img", viewer) $(".close", dialog).addEventListener("click", () => dialog.close()) $(".upscale", dialog).addEventListener("click", () => this.upscale()) $(".expanded-star", dialog).addEventListener("click", () => { this.toggleStar(this.images[this.imageIndex]) }) $(".zoom-out", dialog).addEventListener("click", () => this.zoom(1 / ZOOM_STEP)) $(".reset", dialog).addEventListener("click", () => this.resetView()) $(".zoom-in", dialog).addEventListener("click", () => this.zoom(ZOOM_STEP)) dialog.addEventListener("click", event => { if (event.target === dialog) dialog.close() }) dialog.addEventListener("keydown", event => { if (event.key === "ArrowLeft") this.moveImage(-1) else if (event.key === "ArrowRight") this.moveImage(1) else if (event.key === "+" || event.key === "=") this.zoom(ZOOM_STEP) else if (event.key === "-") this.zoom(1 / ZOOM_STEP) else if (event.key === "0") this.resetView() else return event.preventDefault() }) dialog.addEventListener("close", () => image.removeAttribute("src")) image.addEventListener("load", () => this.resetView()) viewer.addEventListener("wheel", event => this.zoomWheel(event), { passive: false, }) viewer.addEventListener("pointerdown", event => this.startPan(event)) viewer.addEventListener("pointermove", event => this.pan(event)) viewer.addEventListener("pointerup", event => this.stopPan(event)) viewer.addEventListener("pointercancel", event => this.stopPan(event)) viewer.addEventListener("dblclick", () => this.resetView()) } openImage(image) { this.imageIndex = this.images.indexOf(image) this.showImage(image) $(".lightbox", this).showModal() } showImage(image) { const dialog = $(".lightbox", this) $("img", dialog).src = original(image) $("img", dialog).alt = image.name $(".prompt", dialog).textContent = this.formatPrompt(image) $(".upscale-status", dialog).textContent = "" const star = $(".expanded-star", dialog) star.dataset.path = image.path this.renderStar(star, image.starred) } async moveImage(step) { const controller = this.controller const current = this.images[this.imageIndex] if (step > 0 && this.imageIndex === this.images.length - 1 && this.nextOffset !== null) await this.loadMore() if (controller !== this.controller) return const images = this.images.filter(image => !this.favoritesOnly || image.starred) if (!images.length) { $(".lightbox", this).close() return } const index = images.indexOf(current) const image = images[(index + step + images.length) % images.length] this.imageIndex = this.images.indexOf(image) this.showImage(image) } formatPrompt(image) { if (!image.prompt) return "Prompt unavailable" const lines = [`1:${image.prompt}`] if (image.second_prompt) { lines.push(`2:${image.second_prompt}`) } return lines.join("\n") } resetView() { const viewer = $(".viewer", this) const image = $("img", viewer) this.baseZoom = Math.min( viewer.clientWidth / image.naturalWidth, viewer.clientHeight / image.naturalHeight, MIN_ZOOM, ) this.zoomLevel = this.baseZoom this.panX = 0 this.panY = 0 this.renderView() } zoomWheel(event) { event.preventDefault() const viewer = $(".viewer", this) const box = viewer.getBoundingClientRect() const x = event.clientX - box.left - box.width / 2 const y = event.clientY - box.top - box.height / 2 this.zoom(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, x, y) } zoom(step, x = 0, y = 0) { const level = Math.min( Math.max(this.zoomLevel * step, this.baseZoom), MAX_ZOOM, ) const ratio = level / this.zoomLevel this.panX = x - (x - this.panX) * ratio this.panY = y - (y - this.panY) * ratio this.zoomLevel = level this.renderView() } startPan(event) { event.currentTarget.setPointerCapture(event.pointerId) this.dragX = event.clientX this.dragY = event.clientY } pan(event) { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return this.panX += event.clientX - this.dragX this.panY += event.clientY - this.dragY this.dragX = event.clientX this.dragY = event.clientY this.renderView() } stopPan(event) { if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId) } } renderView() { $("img", $(".viewer", this)).style.transform = ` translate(-50%, -50%) translate(${this.panX}px, ${this.panY}px) scale(${this.zoomLevel}) ` } } customElements.define("image-explorer", ImageExplorer)