Download explorer.js from HyperHail/C: direct link, hf CLI and curl.
- Browser
- Download file 26.2 kB
-
https://huggingface.co/spaces/HyperHail/C/resolve/main/explorer.js
- Command line
-
hf download hf://spaces/HyperHail/C/explorer.js
-
curl -L -o explorer.js https://huggingface.co/spaces/HyperHail/C/resolve/main/explorer.js
26.2 kB
| 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 = ` | |
| <main> | |
| <div class="date-heading"> | |
| <button class="date-prev" type="button" aria-label="Previous date" hidden>←</button> | |
| <h1>Image folders</h1> | |
| <button class="date-next" type="button" aria-label="Next date" hidden>→</button> | |
| </div> | |
| <div class="tools"> | |
| <button class="back" type="button" hidden>Folders</button> | |
| <input type="search" placeholder="Filter folders" aria-label="Filter folders"> | |
| <button class="favorites" type="button" aria-pressed="false" hidden>Favorites</button> | |
| <button class="all" type="button">Select all</button> | |
| <button class="clear" type="button">Clear</button> | |
| <button class="download primary" type="button">Download 7z</button> | |
| <button class="delete" type="button">Delete</button> | |
| </div> | |
| <div class="status">Loading</div> | |
| <div class="content"></div> | |
| </main> | |
| <dialog class="lightbox"> | |
| <div class="image-actions"> | |
| <button class="upscale primary" type="button">Upscale</button> | |
| <button class="star expanded-star" type="button" aria-label="Star image"></button> | |
| <span class="upscale-status" role="status" aria-live="polite"></span> | |
| </div> | |
| <button class="close" type="button" aria-label="Close">Close</button> | |
| <div class="zoom-tools"> | |
| <button class="zoom-out" type="button" aria-label="Zoom out">−</button> | |
| <button class="reset" type="button">Reset</button> | |
| <button class="zoom-in" type="button" aria-label="Zoom in">+</button> | |
| </div> | |
| <pre class="prompt"></pre> | |
| <div class="viewer"><img alt="" draggable="false"></div> | |
| </dialog>` | |
| 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) | |