Speed up explorer scrolling and add Favorites view
Browse files- app.py +126 -62
- explorer.css +7 -2
- explorer.js +201 -83
app.py
CHANGED
|
@@ -21,6 +21,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|
| 21 |
from datetime import datetime
|
| 22 |
from functools import lru_cache
|
| 23 |
from io import BytesIO
|
|
|
|
| 24 |
from pathlib import Path
|
| 25 |
from urllib.parse import quote, unquote, urlparse
|
| 26 |
from zoneinfo import ZoneInfo
|
|
@@ -112,6 +113,9 @@ IMAGE_SUFFIXES = (".epng",)
|
|
| 112 |
PREVIEW_CACHE_SIZE = 256
|
| 113 |
PREVIEW_QUALITY = 70
|
| 114 |
PREVIEW_SIZE = 320
|
|
|
|
|
|
|
|
|
|
| 115 |
IMAGE_KEY_CACHE_SIZE = 512
|
| 116 |
DUPLICATE_HASH_SIZE = 16
|
| 117 |
DUPLICATE_HASH_DISTANCE = 24
|
|
@@ -2418,13 +2422,13 @@ def upload_image(image):
|
|
| 2418 |
|
| 2419 |
|
| 2420 |
def encrypted_image_bytes(image):
|
| 2421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2422 |
nonce = os.urandom(NONCE_SIZE)
|
| 2423 |
-
encrypted = AESGCM(image_key(salt)).encrypt(
|
| 2424 |
-
nonce,
|
| 2425 |
-
image_png_bytes(image),
|
| 2426 |
-
FILE_MAGIC,
|
| 2427 |
-
)
|
| 2428 |
return FILE_MAGIC + salt + nonce + encrypted
|
| 2429 |
|
| 2430 |
|
|
@@ -2463,7 +2467,8 @@ def natural_key(path):
|
|
| 2463 |
|
| 2464 |
def star_database():
|
| 2465 |
STAR_DB.parent.mkdir(parents=True, exist_ok=True)
|
| 2466 |
-
database = sqlite3.connect(STAR_DB)
|
|
|
|
| 2467 |
database.execute(
|
| 2468 |
"CREATE TABLE IF NOT EXISTS stars (path TEXT PRIMARY KEY)"
|
| 2469 |
)
|
|
@@ -2477,6 +2482,14 @@ def star_database():
|
|
| 2477 |
artists TEXT NOT NULL
|
| 2478 |
)"""
|
| 2479 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2480 |
return database
|
| 2481 |
|
| 2482 |
|
|
@@ -2542,7 +2555,10 @@ def set_star(path, starred):
|
|
| 2542 |
|
| 2543 |
|
| 2544 |
def stored_bytes(path):
|
| 2545 |
-
|
|
|
|
|
|
|
|
|
|
| 2546 |
if not data.startswith(FILE_MAGIC):
|
| 2547 |
raise HTTPException(500, "invalid encrypted image")
|
| 2548 |
salt_start = len(FILE_MAGIC)
|
|
@@ -2557,16 +2573,36 @@ def stored_bytes(path):
|
|
| 2557 |
|
| 2558 |
@lru_cache(maxsize=PREVIEW_CACHE_SIZE)
|
| 2559 |
def stored_preview(path, modified, size):
|
| 2560 |
-
|
| 2561 |
-
|
| 2562 |
-
|
| 2563 |
-
|
| 2564 |
-
data
|
| 2565 |
-
|
| 2566 |
-
|
| 2567 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2568 |
)
|
| 2569 |
-
|
|
|
|
|
|
|
|
|
|
| 2570 |
|
| 2571 |
|
| 2572 |
def reserve_matrix_grid(folder):
|
|
@@ -3906,28 +3942,72 @@ def explorer_folder_has_images(path):
|
|
| 3906 |
)
|
| 3907 |
|
| 3908 |
|
| 3909 |
-
def
|
| 3910 |
path = (IMAGE_DIR / folder).resolve()
|
| 3911 |
if path.parent != IMAGE_DIR.resolve() or not path.is_dir():
|
| 3912 |
raise HTTPException(404, "folder not found")
|
| 3913 |
-
|
| 3914 |
-
|
| 3915 |
-
|
| 3916 |
-
|
| 3917 |
-
|
| 3918 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3919 |
for item in sorted(entries, key=natural_key, reverse=True)
|
| 3920 |
-
if
|
| 3921 |
-
and item.
|
| 3922 |
]
|
| 3923 |
-
|
| 3924 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3925 |
prompt, second_prompt, artists = stored_prompt(
|
| 3926 |
-
|
| 3927 |
)
|
|
|
|
| 3928 |
images.append({
|
| 3929 |
-
"name": Path(
|
| 3930 |
"path": relative,
|
|
|
|
| 3931 |
"starred": relative in starred,
|
| 3932 |
"prompt": prompt,
|
| 3933 |
"second_prompt": second_prompt,
|
|
@@ -3936,34 +4016,17 @@ def explorer_images(folder, starred):
|
|
| 3936 |
database.commit()
|
| 3937 |
finally:
|
| 3938 |
database.close()
|
| 3939 |
-
|
| 3940 |
-
|
| 3941 |
-
|
| 3942 |
-
|
| 3943 |
-
|
| 3944 |
-
|
| 3945 |
-
|
| 3946 |
-
|
| 3947 |
-
|
| 3948 |
-
|
| 3949 |
-
|
| 3950 |
-
folders = [
|
| 3951 |
-
item.name
|
| 3952 |
-
for item in sorted(entries, key=natural_key, reverse=True)
|
| 3953 |
-
if item.is_dir(follow_symlinks=False)
|
| 3954 |
-
and explorer_folder_has_images(item.path)
|
| 3955 |
-
]
|
| 3956 |
-
if folder is None and not infinite:
|
| 3957 |
-
return {"folders": [{"name": name} for name in folders]}
|
| 3958 |
-
starred = starred_paths()
|
| 3959 |
-
if infinite:
|
| 3960 |
-
return {
|
| 3961 |
-
"groups": [
|
| 3962 |
-
{"name": name, "images": explorer_images(name, starred)}
|
| 3963 |
-
for name in folders
|
| 3964 |
-
]
|
| 3965 |
-
}
|
| 3966 |
-
return {"images": explorer_images(folder, starred)}
|
| 3967 |
|
| 3968 |
|
| 3969 |
@api.post("/api/images/star", dependencies=[Depends(require_pass)])
|
|
@@ -4041,10 +4104,11 @@ def image_delete(body: DownloadRequest):
|
|
| 4041 |
path.unlink()
|
| 4042 |
database = star_database()
|
| 4043 |
try:
|
| 4044 |
-
|
| 4045 |
-
|
| 4046 |
-
|
| 4047 |
-
|
|
|
|
| 4048 |
database.commit()
|
| 4049 |
finally:
|
| 4050 |
database.close()
|
|
|
|
| 21 |
from datetime import datetime
|
| 22 |
from functools import lru_cache
|
| 23 |
from io import BytesIO
|
| 24 |
+
from itertools import islice
|
| 25 |
from pathlib import Path
|
| 26 |
from urllib.parse import quote, unquote, urlparse
|
| 27 |
from zoneinfo import ZoneInfo
|
|
|
|
| 113 |
PREVIEW_CACHE_SIZE = 256
|
| 114 |
PREVIEW_QUALITY = 70
|
| 115 |
PREVIEW_SIZE = 320
|
| 116 |
+
EXPLORER_PAGE_SIZE = 80
|
| 117 |
+
EXPLORER_MAX_PAGE_SIZE = 200
|
| 118 |
+
EXPLORER_DB_TIMEOUT = 30
|
| 119 |
IMAGE_KEY_CACHE_SIZE = 512
|
| 120 |
DUPLICATE_HASH_SIZE = 16
|
| 121 |
DUPLICATE_HASH_DISTANCE = 24
|
|
|
|
| 2422 |
|
| 2423 |
|
| 2424 |
def encrypted_image_bytes(image):
|
| 2425 |
+
return encrypt_image_data(image_png_bytes(image))
|
| 2426 |
+
|
| 2427 |
+
|
| 2428 |
+
def encrypt_image_data(data, salt=None):
|
| 2429 |
+
salt = salt if salt is not None else os.urandom(SALT_SIZE)
|
| 2430 |
nonce = os.urandom(NONCE_SIZE)
|
| 2431 |
+
encrypted = AESGCM(image_key(salt)).encrypt(nonce, data, FILE_MAGIC)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2432 |
return FILE_MAGIC + salt + nonce + encrypted
|
| 2433 |
|
| 2434 |
|
|
|
|
| 2467 |
|
| 2468 |
def star_database():
|
| 2469 |
STAR_DB.parent.mkdir(parents=True, exist_ok=True)
|
| 2470 |
+
database = sqlite3.connect(STAR_DB, timeout=EXPLORER_DB_TIMEOUT)
|
| 2471 |
+
database.execute("PRAGMA journal_mode=WAL")
|
| 2472 |
database.execute(
|
| 2473 |
"CREATE TABLE IF NOT EXISTS stars (path TEXT PRIMARY KEY)"
|
| 2474 |
)
|
|
|
|
| 2482 |
artists TEXT NOT NULL
|
| 2483 |
)"""
|
| 2484 |
)
|
| 2485 |
+
database.execute(
|
| 2486 |
+
"""CREATE TABLE IF NOT EXISTS image_previews (
|
| 2487 |
+
path TEXT PRIMARY KEY,
|
| 2488 |
+
modified INTEGER NOT NULL,
|
| 2489 |
+
size INTEGER NOT NULL,
|
| 2490 |
+
data BLOB NOT NULL
|
| 2491 |
+
)"""
|
| 2492 |
+
)
|
| 2493 |
return database
|
| 2494 |
|
| 2495 |
|
|
|
|
| 2555 |
|
| 2556 |
|
| 2557 |
def stored_bytes(path):
|
| 2558 |
+
return decrypt_image_data(path.read_bytes())
|
| 2559 |
+
|
| 2560 |
+
|
| 2561 |
+
def decrypt_image_data(data):
|
| 2562 |
if not data.startswith(FILE_MAGIC):
|
| 2563 |
raise HTTPException(500, "invalid encrypted image")
|
| 2564 |
salt_start = len(FILE_MAGIC)
|
|
|
|
| 2573 |
|
| 2574 |
@lru_cache(maxsize=PREVIEW_CACHE_SIZE)
|
| 2575 |
def stored_preview(path, modified, size):
|
| 2576 |
+
relative = path.relative_to(IMAGE_DIR.resolve()).as_posix()
|
| 2577 |
+
database = star_database()
|
| 2578 |
+
try:
|
| 2579 |
+
cached = database.execute(
|
| 2580 |
+
"""SELECT data FROM image_previews
|
| 2581 |
+
WHERE path = ? AND modified = ? AND size = ?""",
|
| 2582 |
+
(relative, modified, size),
|
| 2583 |
+
).fetchone()
|
| 2584 |
+
if cached is not None:
|
| 2585 |
+
return decrypt_image_data(cached[0])
|
| 2586 |
+
source = path.read_bytes()
|
| 2587 |
+
with Image.open(BytesIO(decrypt_image_data(source))) as image:
|
| 2588 |
+
image.thumbnail((PREVIEW_SIZE, PREVIEW_SIZE))
|
| 2589 |
+
data = BytesIO()
|
| 2590 |
+
image.save(
|
| 2591 |
+
data,
|
| 2592 |
+
format="WEBP",
|
| 2593 |
+
quality=PREVIEW_QUALITY,
|
| 2594 |
+
method=0,
|
| 2595 |
+
)
|
| 2596 |
+
preview = data.getvalue()
|
| 2597 |
+
salt = source[len(FILE_MAGIC):len(FILE_MAGIC) + SALT_SIZE]
|
| 2598 |
+
database.execute(
|
| 2599 |
+
"INSERT OR REPLACE INTO image_previews VALUES (?, ?, ?, ?)",
|
| 2600 |
+
(relative, modified, size, encrypt_image_data(preview, salt)),
|
| 2601 |
)
|
| 2602 |
+
database.commit()
|
| 2603 |
+
return preview
|
| 2604 |
+
finally:
|
| 2605 |
+
database.close()
|
| 2606 |
|
| 2607 |
|
| 2608 |
def reserve_matrix_grid(folder):
|
|
|
|
| 3942 |
)
|
| 3943 |
|
| 3944 |
|
| 3945 |
+
def explorer_files(folder):
|
| 3946 |
path = (IMAGE_DIR / folder).resolve()
|
| 3947 |
if path.parent != IMAGE_DIR.resolve() or not path.is_dir():
|
| 3948 |
raise HTTPException(404, "folder not found")
|
| 3949 |
+
with os.scandir(path) as entries:
|
| 3950 |
+
return [
|
| 3951 |
+
(Path(folder) / item.name).as_posix()
|
| 3952 |
+
for item in sorted(entries, key=natural_key, reverse=True)
|
| 3953 |
+
if Path(item.name).suffix.casefold() in IMAGE_SUFFIXES
|
| 3954 |
+
and item.is_file()
|
| 3955 |
+
]
|
| 3956 |
+
|
| 3957 |
+
|
| 3958 |
+
@api.get("/api/images", dependencies=[Depends(require_pass)])
|
| 3959 |
+
def image_list(
|
| 3960 |
+
folder: str = Query(None),
|
| 3961 |
+
infinite: bool = Query(False),
|
| 3962 |
+
favorites: bool = Query(False),
|
| 3963 |
+
search: str = Query(""),
|
| 3964 |
+
offset: int = Query(0, ge=0),
|
| 3965 |
+
limit: int = Query(EXPLORER_PAGE_SIZE, ge=1, le=EXPLORER_MAX_PAGE_SIZE),
|
| 3966 |
+
):
|
| 3967 |
+
grouped = infinite or (favorites and folder is None)
|
| 3968 |
+
root = IMAGE_DIR
|
| 3969 |
+
if not root.is_dir():
|
| 3970 |
+
key = "groups" if grouped else "folders" if folder is None else "images"
|
| 3971 |
+
return {key: [], "next_offset": None}
|
| 3972 |
+
if grouped or folder is None:
|
| 3973 |
+
with os.scandir(root) as entries:
|
| 3974 |
+
folders = [
|
| 3975 |
+
item.name
|
| 3976 |
for item in sorted(entries, key=natural_key, reverse=True)
|
| 3977 |
+
if item.is_dir(follow_symlinks=False)
|
| 3978 |
+
and (grouped or explorer_folder_has_images(item.path))
|
| 3979 |
]
|
| 3980 |
+
if not grouped:
|
| 3981 |
+
return {"folders": [{"name": name} for name in folders]}
|
| 3982 |
+
else:
|
| 3983 |
+
folders = [folder]
|
| 3984 |
+
starred = starred_paths()
|
| 3985 |
+
if favorites:
|
| 3986 |
+
favorite_folders = {relative.split("/", 1)[0] for relative in starred}
|
| 3987 |
+
folders = [name for name in folders if name in favorite_folders]
|
| 3988 |
+
files = (relative for name in folders for relative in explorer_files(name)
|
| 3989 |
+
if not favorites or relative in starred)
|
| 3990 |
+
database = star_database()
|
| 3991 |
+
try:
|
| 3992 |
+
query = search.strip().casefold()
|
| 3993 |
+
if query:
|
| 3994 |
+
files = (relative for relative in files if (
|
| 3995 |
+
query in Path(relative).with_suffix(".png").name.casefold()
|
| 3996 |
+
or any(query in prompt.casefold() for prompt in stored_prompt(
|
| 3997 |
+
root / relative, relative, database
|
| 3998 |
+
)[:2])
|
| 3999 |
+
))
|
| 4000 |
+
page = list(islice(files, offset, offset + limit + 1))
|
| 4001 |
+
images = []
|
| 4002 |
+
for relative in page[:limit]:
|
| 4003 |
prompt, second_prompt, artists = stored_prompt(
|
| 4004 |
+
root / relative, relative, database
|
| 4005 |
)
|
| 4006 |
+
stat = (root / relative).stat()
|
| 4007 |
images.append({
|
| 4008 |
+
"name": Path(relative).with_suffix(".png").name,
|
| 4009 |
"path": relative,
|
| 4010 |
+
"version": f"{stat.st_mtime_ns}-{stat.st_size}",
|
| 4011 |
"starred": relative in starred,
|
| 4012 |
"prompt": prompt,
|
| 4013 |
"second_prompt": second_prompt,
|
|
|
|
| 4016 |
database.commit()
|
| 4017 |
finally:
|
| 4018 |
database.close()
|
| 4019 |
+
result = {"next_offset": offset + limit if len(page) > limit else None}
|
| 4020 |
+
if grouped:
|
| 4021 |
+
groups = {}
|
| 4022 |
+
for image in images:
|
| 4023 |
+
name = image["path"].split("/", 1)[0]
|
| 4024 |
+
groups.setdefault(name, []).append(image)
|
| 4025 |
+
result["groups"] = [{"name": name, "images": items}
|
| 4026 |
+
for name, items in groups.items()]
|
| 4027 |
+
else:
|
| 4028 |
+
result["images"] = images
|
| 4029 |
+
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4030 |
|
| 4031 |
|
| 4032 |
@api.post("/api/images/star", dependencies=[Depends(require_pass)])
|
|
|
|
| 4104 |
path.unlink()
|
| 4105 |
database = star_database()
|
| 4106 |
try:
|
| 4107 |
+
for table in ("image_prompts", "image_previews"):
|
| 4108 |
+
database.executemany(
|
| 4109 |
+
f"DELETE FROM {table} WHERE path = ?",
|
| 4110 |
+
((relative,) for relative in relatives),
|
| 4111 |
+
)
|
| 4112 |
database.commit()
|
| 4113 |
finally:
|
| 4114 |
database.close()
|
explorer.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
@import url('https://fonts.googleapis.com/css2?family=
|
| 2 |
|
| 3 |
:root {
|
| 4 |
--bg: #111;
|
|
@@ -20,7 +20,7 @@
|
|
| 20 |
body {
|
| 21 |
background: var(--bg);
|
| 22 |
color: var(--text);
|
| 23 |
-
font-family:
|
| 24 |
font-weight: 450;
|
| 25 |
margin: 0;
|
| 26 |
}
|
|
@@ -170,6 +170,11 @@ button:disabled {
|
|
| 170 |
margin-top: 2rem;
|
| 171 |
}
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
.preview {
|
| 174 |
background: var(--bg);
|
| 175 |
border: 0;
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Spline+Sans:wght@450;600&display=swap');
|
| 2 |
|
| 3 |
:root {
|
| 4 |
--bg: #111;
|
|
|
|
| 20 |
body {
|
| 21 |
background: var(--bg);
|
| 22 |
color: var(--text);
|
| 23 |
+
font-family: 'Spline Sans', sans-serif;
|
| 24 |
font-weight: 450;
|
| 25 |
margin: 0;
|
| 26 |
}
|
|
|
|
| 170 |
margin-top: 2rem;
|
| 171 |
}
|
| 172 |
|
| 173 |
+
.load-more {
|
| 174 |
+
display: block;
|
| 175 |
+
margin: var(--gap) auto;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
.preview {
|
| 179 |
background: var(--bg);
|
| 180 |
border: 0;
|
explorer.js
CHANGED
|
@@ -4,12 +4,16 @@ const $ = (selector, root = document) => root.querySelector(selector)
|
|
| 4 |
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]
|
| 5 |
const p = new URLSearchParams(location.search).get("p")
|
| 6 |
const api = (path, query = {}) => `${path}?${new URLSearchParams({ p, ...query })}`
|
| 7 |
-
const preview =
|
| 8 |
-
const original =
|
| 9 |
const MIN_ZOOM = 1
|
| 10 |
const MAX_ZOOM = 8
|
| 11 |
const ZOOM_STEP = 1.2
|
| 12 |
const INFINITE = "*"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class ImageExplorer extends HTMLElement {
|
| 15 |
connectedCallback() {
|
|
@@ -49,6 +53,9 @@ class ImageExplorer extends HTMLElement {
|
|
| 49 |
</dialog>`
|
| 50 |
this.cache = new Map()
|
| 51 |
this.setupPreviews()
|
|
|
|
|
|
|
|
|
|
| 52 |
this.load()
|
| 53 |
$(".back", this).addEventListener("click", () => this.load())
|
| 54 |
$(".date-prev", this).addEventListener("click", () => this.navigateDate(1))
|
|
@@ -64,42 +71,109 @@ class ImageExplorer extends HTMLElement {
|
|
| 64 |
this.setupLightbox()
|
| 65 |
}
|
| 66 |
|
| 67 |
-
async load(folder) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
$(".status", this).textContent = "Loading"
|
| 69 |
this.previewObserver.disconnect()
|
|
|
|
| 70 |
$(".content", this).replaceChildren()
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
const query = folder === INFINITE
|
| 74 |
-
? { infinite: true }
|
| 75 |
-
: folder ? { folder } : {}
|
| 76 |
-
const response = await fetch(api("/api/images", query))
|
| 77 |
-
if (!response.ok) {
|
| 78 |
-
$(".status", this).textContent = "Unable to load images"
|
| 79 |
-
return
|
| 80 |
-
}
|
| 81 |
-
data = await response.json()
|
| 82 |
-
this.cache.set(folder, data)
|
| 83 |
-
}
|
| 84 |
-
this.folder = folder
|
| 85 |
-
if (!folder) this.folders = data.folders.map(item => item.name)
|
| 86 |
-
$("h1", this).textContent = folder === INFINITE
|
| 87 |
-
? "Infinite"
|
| 88 |
-
: folder || "Image folders"
|
| 89 |
$(".back", this).hidden = !folder
|
|
|
|
| 90 |
const favorites = $(".favorites", this)
|
| 91 |
-
favorites.hidden = !folder
|
| 92 |
-
favorites.setAttribute("aria-pressed",
|
| 93 |
-
this.favoritesOnly = false
|
| 94 |
this.updateDateNav()
|
| 95 |
const search = $("input[type=search]", this)
|
| 96 |
-
search.value =
|
| 97 |
search.placeholder = folder ? "Search positive prompts" : "Filter folders"
|
| 98 |
search.setAttribute("aria-label", search.placeholder)
|
| 99 |
-
if (folder
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
|
| 105 |
updateDateNav() {
|
|
@@ -129,24 +203,26 @@ class ImageExplorer extends HTMLElement {
|
|
| 129 |
delete image.dataset.src
|
| 130 |
this.previewObserver.unobserve(image)
|
| 131 |
}
|
| 132 |
-
}, { rootMargin:
|
| 133 |
}
|
| 134 |
|
| 135 |
addFolders(folders) {
|
| 136 |
const grid = document.createElement("div")
|
| 137 |
grid.className = "grid folders"
|
| 138 |
-
const
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
| 150 |
for (const folder of folders) {
|
| 151 |
const card = document.createElement("div")
|
| 152 |
card.className = "folder entry"
|
|
@@ -165,27 +241,44 @@ class ImageExplorer extends HTMLElement {
|
|
| 165 |
$(".content", this).append(grid)
|
| 166 |
}
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
addImages(images) {
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
addGroups(groups) {
|
| 177 |
-
this.images = groups.flatMap(group => group.images)
|
| 178 |
const content = $(".content", this)
|
| 179 |
for (const group of groups) {
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
}
|
| 190 |
}
|
| 191 |
|
|
@@ -212,7 +305,8 @@ class ImageExplorer extends HTMLElement {
|
|
| 212 |
open.addEventListener("click", () => this.openImage(image))
|
| 213 |
const picture = document.createElement("img")
|
| 214 |
picture.alt = image.name
|
| 215 |
-
picture.
|
|
|
|
| 216 |
this.previewObserver.observe(picture)
|
| 217 |
open.append(picture)
|
| 218 |
const star = this.starButton(image, "card-star")
|
|
@@ -244,49 +338,63 @@ class ImageExplorer extends HTMLElement {
|
|
| 244 |
}
|
| 245 |
|
| 246 |
setStar(path, starred) {
|
| 247 |
-
for (const
|
| 248 |
-
|
| 249 |
-
for (const image of images) {
|
| 250 |
-
if (image.path === path) image.starred = starred
|
| 251 |
-
}
|
| 252 |
}
|
| 253 |
for (const button of $$(".star", this)) {
|
| 254 |
if (button.dataset.path === path) this.renderStar(button, starred)
|
| 255 |
}
|
| 256 |
for (const card of $$(".image", this)) {
|
| 257 |
-
if (card.dataset.path ==
|
|
|
|
|
|
|
|
|
|
| 258 |
}
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
|
| 262 |
async toggleStar(image) {
|
| 263 |
this.pendingStars ||= new Set()
|
| 264 |
if (this.pendingStars.has(image.path)) return
|
| 265 |
this.pendingStars.add(image.path)
|
|
|
|
| 266 |
const buttons = $$(".star", this).filter(button => (
|
| 267 |
button.dataset.path === image.path
|
| 268 |
))
|
| 269 |
for (const button of buttons) button.disabled = true
|
| 270 |
const starred = !image.starred
|
| 271 |
-
this.setStar(image.path, starred)
|
| 272 |
try {
|
|
|
|
|
|
|
|
|
|
| 273 |
const response = await fetch(api("/api/images/star"), {
|
| 274 |
method: "POST",
|
| 275 |
headers: { "Content-Type": "application/json" },
|
| 276 |
body: JSON.stringify({ path: image.path, starred }),
|
| 277 |
})
|
| 278 |
if (!response.ok) throw new Error("Unable to update star")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
} catch (error) {
|
| 280 |
this.setStar(image.path, !starred)
|
| 281 |
$(".status", this).textContent = error.message
|
| 282 |
} finally {
|
| 283 |
this.pendingStars.delete(image.path)
|
| 284 |
for (const button of buttons) button.disabled = false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
}
|
| 286 |
}
|
| 287 |
|
| 288 |
select(checked) {
|
| 289 |
-
for (const input of $$(".item", this)) input.checked = checked
|
| 290 |
this.update()
|
| 291 |
}
|
| 292 |
|
|
@@ -296,21 +404,20 @@ class ImageExplorer extends HTMLElement {
|
|
| 296 |
}
|
| 297 |
|
| 298 |
toggleFavorites() {
|
| 299 |
-
this.favoritesOnly =
|
| 300 |
-
$(".favorites", this).setAttribute("aria-pressed", this.favoritesOnly)
|
| 301 |
-
this.filter($("input[type=search]", this).value)
|
| 302 |
}
|
| 303 |
|
| 304 |
filter(value) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
const query = value.trim().toLowerCase()
|
| 306 |
for (const entry of $$(".entry", this)) {
|
| 307 |
-
|
| 308 |
-
|| entry.dataset.prompt?.includes(query)
|
| 309 |
-
const favoriteMatch = !this.favoritesOnly || entry.classList.contains("favorite")
|
| 310 |
-
entry.hidden = !nameMatch || !favoriteMatch
|
| 311 |
-
}
|
| 312 |
-
for (const group of $$(".date-group", this)) {
|
| 313 |
-
group.hidden = !$(".entry:not([hidden])", group)
|
| 314 |
}
|
| 315 |
}
|
| 316 |
|
|
@@ -356,7 +463,7 @@ class ImageExplorer extends HTMLElement {
|
|
| 356 |
})
|
| 357 |
if (!response.ok) throw new Error("Delete failed")
|
| 358 |
this.cache.clear()
|
| 359 |
-
await this.load(this.folder)
|
| 360 |
} catch (error) {
|
| 361 |
$(".status", this).textContent = error.message
|
| 362 |
} finally {
|
|
@@ -424,7 +531,7 @@ class ImageExplorer extends HTMLElement {
|
|
| 424 |
|
| 425 |
showImage(image) {
|
| 426 |
const dialog = $(".lightbox", this)
|
| 427 |
-
$("img", dialog).src = original(image
|
| 428 |
$("img", dialog).alt = image.name
|
| 429 |
$(".prompt", dialog).textContent = this.formatPrompt(image)
|
| 430 |
$(".upscale-status", dialog).textContent = ""
|
|
@@ -433,10 +540,21 @@ class ImageExplorer extends HTMLElement {
|
|
| 433 |
this.renderStar(star, image.starred)
|
| 434 |
}
|
| 435 |
|
| 436 |
-
moveImage(step) {
|
| 437 |
-
const
|
| 438 |
-
|
| 439 |
-
this.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
}
|
| 441 |
|
| 442 |
formatPrompt(image) {
|
|
|
|
| 4 |
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]
|
| 5 |
const p = new URLSearchParams(location.search).get("p")
|
| 6 |
const api = (path, query = {}) => `${path}?${new URLSearchParams({ p, ...query })}`
|
| 7 |
+
const preview = image => api("/api/images/preview", { path: image.path, v: image.version })
|
| 8 |
+
const original = image => api("/api/images/original", { path: image.path, v: image.version })
|
| 9 |
const MIN_ZOOM = 1
|
| 10 |
const MAX_ZOOM = 8
|
| 11 |
const ZOOM_STEP = 1.2
|
| 12 |
const INFINITE = "*"
|
| 13 |
+
const FAVORITES = "favorites"
|
| 14 |
+
const SEARCH_DELAY = 250
|
| 15 |
+
const LOAD_MARGIN = "640px"
|
| 16 |
+
const PREVIEW_MARGIN = "320px"
|
| 17 |
|
| 18 |
class ImageExplorer extends HTMLElement {
|
| 19 |
connectedCallback() {
|
|
|
|
| 53 |
</dialog>`
|
| 54 |
this.cache = new Map()
|
| 55 |
this.setupPreviews()
|
| 56 |
+
this.pageObserver = new IntersectionObserver(entries => {
|
| 57 |
+
if (entries.some(entry => entry.isIntersecting)) this.loadMore()
|
| 58 |
+
}, { rootMargin: LOAD_MARGIN })
|
| 59 |
this.load()
|
| 60 |
$(".back", this).addEventListener("click", () => this.load())
|
| 61 |
$(".date-prev", this).addEventListener("click", () => this.navigateDate(1))
|
|
|
|
| 71 |
this.setupLightbox()
|
| 72 |
}
|
| 73 |
|
| 74 |
+
async load(folder, favoritesOnly = false, query = "") {
|
| 75 |
+
clearTimeout(this.searchTimer)
|
| 76 |
+
this.controller?.abort()
|
| 77 |
+
const controller = new AbortController()
|
| 78 |
+
this.controller = controller
|
| 79 |
+
this.folder = folder
|
| 80 |
+
$(".lightbox", this).close()
|
| 81 |
+
this.favoritesOnly = folder === FAVORITES || favoritesOnly
|
| 82 |
+
this.searchQuery = query
|
| 83 |
+
this.images = []
|
| 84 |
+
this.imagePaths = new Set()
|
| 85 |
+
this.groupGrids = new Map()
|
| 86 |
+
this.nextOffset = folder ? 0 : null
|
| 87 |
+
this.loadingPage = false
|
| 88 |
+
this.more = null
|
| 89 |
$(".status", this).textContent = "Loading"
|
| 90 |
this.previewObserver.disconnect()
|
| 91 |
+
this.pageObserver.disconnect()
|
| 92 |
$(".content", this).replaceChildren()
|
| 93 |
+
$("h1", this).textContent = folder === FAVORITES ? "Favorites"
|
| 94 |
+
: folder === INFINITE ? "Infinite" : folder || "Image folders"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
$(".back", this).hidden = !folder
|
| 96 |
+
$(".all", this).textContent = folder ? "Select loaded" : "Select all"
|
| 97 |
const favorites = $(".favorites", this)
|
| 98 |
+
favorites.hidden = !folder || folder === FAVORITES
|
| 99 |
+
favorites.setAttribute("aria-pressed", this.favoritesOnly)
|
|
|
|
| 100 |
this.updateDateNav()
|
| 101 |
const search = $("input[type=search]", this)
|
| 102 |
+
search.value = query
|
| 103 |
search.placeholder = folder ? "Search positive prompts" : "Filter folders"
|
| 104 |
search.setAttribute("aria-label", search.placeholder)
|
| 105 |
+
if (folder) {
|
| 106 |
+
if (folder !== INFINITE && folder !== FAVORITES) this.addImages([])
|
| 107 |
+
this.more = document.createElement("button")
|
| 108 |
+
this.more.className = "load-more"
|
| 109 |
+
this.more.type = "button"
|
| 110 |
+
this.more.textContent = "Load more"
|
| 111 |
+
this.more.addEventListener("click", () => this.loadMore())
|
| 112 |
+
$(".content", this).append(this.more)
|
| 113 |
+
this.pageObserver.observe(this.more)
|
| 114 |
+
await this.loadMore()
|
| 115 |
+
return
|
| 116 |
+
}
|
| 117 |
+
try {
|
| 118 |
+
let data = this.cache.get(folder)
|
| 119 |
+
if (!data) {
|
| 120 |
+
const response = await fetch(api("/api/images"), { signal: controller.signal })
|
| 121 |
+
if (!response.ok) throw new Error("Unable to load folders")
|
| 122 |
+
data = await response.json()
|
| 123 |
+
}
|
| 124 |
+
if (controller !== this.controller) return
|
| 125 |
+
this.cache.set(folder, data)
|
| 126 |
+
this.folders = [FAVORITES, INFINITE, ...data.folders.map(item => item.name)]
|
| 127 |
+
this.addFolders(data.folders)
|
| 128 |
+
this.update()
|
| 129 |
+
} catch (error) {
|
| 130 |
+
if (controller === this.controller) $(".status", this).textContent = error.message
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
loadMore() {
|
| 135 |
+
if (this.loadingPage) return this.pageTask
|
| 136 |
+
if (this.nextOffset === null || !this.folder || this.pendingStars?.size) return
|
| 137 |
+
this.pageTask = this.loadPage()
|
| 138 |
+
return this.pageTask
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
async loadPage() {
|
| 142 |
+
const controller = this.controller
|
| 143 |
+
const grouped = this.folder === INFINITE || this.folder === FAVORITES
|
| 144 |
+
const query = {
|
| 145 |
+
offset: this.nextOffset,
|
| 146 |
+
favorites: this.favoritesOnly,
|
| 147 |
+
search: this.searchQuery,
|
| 148 |
+
...(grouped ? { infinite: true } : { folder: this.folder }),
|
| 149 |
+
}
|
| 150 |
+
this.loadingPage = true
|
| 151 |
+
this.more.disabled = true
|
| 152 |
+
this.more.textContent = "Loading"
|
| 153 |
+
try {
|
| 154 |
+
const response = await fetch(api("/api/images", query), { signal: controller.signal })
|
| 155 |
+
if (!response.ok) throw new Error("Unable to load images")
|
| 156 |
+
const data = await response.json()
|
| 157 |
+
if (controller !== this.controller) return
|
| 158 |
+
if (grouped) this.addGroups(data.groups)
|
| 159 |
+
else this.addImages(data.images)
|
| 160 |
+
this.nextOffset = data.next_offset
|
| 161 |
+
this.more.hidden = this.nextOffset === null
|
| 162 |
+
this.more.textContent = "Load more"
|
| 163 |
+
this.update()
|
| 164 |
+
this.pageObserver.unobserve(this.more)
|
| 165 |
+
if (this.nextOffset !== null) this.pageObserver.observe(this.more)
|
| 166 |
+
} catch (error) {
|
| 167 |
+
if (controller !== this.controller) return
|
| 168 |
+
this.pageObserver.unobserve(this.more)
|
| 169 |
+
this.more.textContent = "Retry loading images"
|
| 170 |
+
$(".status", this).textContent = error.message
|
| 171 |
+
} finally {
|
| 172 |
+
if (controller === this.controller) {
|
| 173 |
+
this.loadingPage = false
|
| 174 |
+
this.more.disabled = false
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
}
|
| 178 |
|
| 179 |
updateDateNav() {
|
|
|
|
| 203 |
delete image.dataset.src
|
| 204 |
this.previewObserver.unobserve(image)
|
| 205 |
}
|
| 206 |
+
}, { rootMargin: PREVIEW_MARGIN })
|
| 207 |
}
|
| 208 |
|
| 209 |
addFolders(folders) {
|
| 210 |
const grid = document.createElement("div")
|
| 211 |
grid.className = "grid folders"
|
| 212 |
+
for (const [folder, label] of [[FAVORITES, "Favorites"], [INFINITE, "Infinite"]]) {
|
| 213 |
+
const card = document.createElement("div")
|
| 214 |
+
card.className = "folder entry"
|
| 215 |
+
card.dataset.name = label.toLowerCase()
|
| 216 |
+
const open = document.createElement("button")
|
| 217 |
+
open.className = "folder-open"
|
| 218 |
+
open.type = "button"
|
| 219 |
+
open.addEventListener("click", () => this.load(folder))
|
| 220 |
+
const name = document.createElement("strong")
|
| 221 |
+
name.textContent = label
|
| 222 |
+
open.append(name)
|
| 223 |
+
card.append(open)
|
| 224 |
+
grid.append(card)
|
| 225 |
+
}
|
| 226 |
for (const folder of folders) {
|
| 227 |
const card = document.createElement("div")
|
| 228 |
card.className = "folder entry"
|
|
|
|
| 241 |
$(".content", this).append(grid)
|
| 242 |
}
|
| 243 |
|
| 244 |
+
appendImages(grid, images) {
|
| 245 |
+
const cards = document.createDocumentFragment()
|
| 246 |
+
for (const image of images) {
|
| 247 |
+
if (this.imagePaths.has(image.path)) continue
|
| 248 |
+
this.imagePaths.add(image.path)
|
| 249 |
+
this.images.push(image)
|
| 250 |
+
cards.append(this.card(image))
|
| 251 |
+
}
|
| 252 |
+
grid.append(cards)
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
addImages(images) {
|
| 256 |
+
let grid = $(".content > .grid", this)
|
| 257 |
+
if (!grid) {
|
| 258 |
+
grid = document.createElement("div")
|
| 259 |
+
grid.className = "grid"
|
| 260 |
+
$(".content", this).append(grid)
|
| 261 |
+
}
|
| 262 |
+
this.appendImages(grid, images)
|
| 263 |
}
|
| 264 |
|
| 265 |
addGroups(groups) {
|
|
|
|
| 266 |
const content = $(".content", this)
|
| 267 |
for (const group of groups) {
|
| 268 |
+
let grid = this.groupGrids.get(group.name)
|
| 269 |
+
if (!grid) {
|
| 270 |
+
const section = document.createElement("section")
|
| 271 |
+
section.className = "date-group"
|
| 272 |
+
const heading = document.createElement("h2")
|
| 273 |
+
heading.textContent = group.name
|
| 274 |
+
grid = document.createElement("div")
|
| 275 |
+
grid.className = "grid"
|
| 276 |
+
section.append(heading, grid)
|
| 277 |
+
content.insertBefore(section, this.more)
|
| 278 |
+
this.groupGrids.set(group.name, grid)
|
| 279 |
+
}
|
| 280 |
+
this.appendImages(grid, group.images)
|
| 281 |
+
grid.parentElement.hidden = !$(".entry:not([hidden])", grid)
|
| 282 |
}
|
| 283 |
}
|
| 284 |
|
|
|
|
| 305 |
open.addEventListener("click", () => this.openImage(image))
|
| 306 |
const picture = document.createElement("img")
|
| 307 |
picture.alt = image.name
|
| 308 |
+
picture.decoding = "async"
|
| 309 |
+
picture.dataset.src = preview(image)
|
| 310 |
this.previewObserver.observe(picture)
|
| 311 |
open.append(picture)
|
| 312 |
const star = this.starButton(image, "card-star")
|
|
|
|
| 338 |
}
|
| 339 |
|
| 340 |
setStar(path, starred) {
|
| 341 |
+
for (const image of this.images) {
|
| 342 |
+
if (image.path === path) image.starred = starred
|
|
|
|
|
|
|
|
|
|
| 343 |
}
|
| 344 |
for (const button of $$(".star", this)) {
|
| 345 |
if (button.dataset.path === path) this.renderStar(button, starred)
|
| 346 |
}
|
| 347 |
for (const card of $$(".image", this)) {
|
| 348 |
+
if (card.dataset.path !== path) continue
|
| 349 |
+
card.classList.toggle("favorite", starred)
|
| 350 |
+
card.hidden = this.favoritesOnly && !starred
|
| 351 |
+
if (card.hidden) $(".item", card).checked = false
|
| 352 |
}
|
| 353 |
+
for (const group of $$(".date-group", this)) {
|
| 354 |
+
group.hidden = !$(".entry:not([hidden])", group)
|
| 355 |
+
}
|
| 356 |
+
this.update()
|
| 357 |
}
|
| 358 |
|
| 359 |
async toggleStar(image) {
|
| 360 |
this.pendingStars ||= new Set()
|
| 361 |
if (this.pendingStars.has(image.path)) return
|
| 362 |
this.pendingStars.add(image.path)
|
| 363 |
+
const controller = this.controller
|
| 364 |
const buttons = $$(".star", this).filter(button => (
|
| 365 |
button.dataset.path === image.path
|
| 366 |
))
|
| 367 |
for (const button of buttons) button.disabled = true
|
| 368 |
const starred = !image.starred
|
|
|
|
| 369 |
try {
|
| 370 |
+
await this.pageTask
|
| 371 |
+
if (controller !== this.controller) return
|
| 372 |
+
this.setStar(image.path, starred)
|
| 373 |
const response = await fetch(api("/api/images/star"), {
|
| 374 |
method: "POST",
|
| 375 |
headers: { "Content-Type": "application/json" },
|
| 376 |
body: JSON.stringify({ path: image.path, starred }),
|
| 377 |
})
|
| 378 |
if (!response.ok) throw new Error("Unable to update star")
|
| 379 |
+
if (controller === this.controller && this.favoritesOnly && !starred
|
| 380 |
+
&& this.nextOffset !== null) {
|
| 381 |
+
this.nextOffset = Math.max(0, this.nextOffset - 1)
|
| 382 |
+
}
|
| 383 |
} catch (error) {
|
| 384 |
this.setStar(image.path, !starred)
|
| 385 |
$(".status", this).textContent = error.message
|
| 386 |
} finally {
|
| 387 |
this.pendingStars.delete(image.path)
|
| 388 |
for (const button of buttons) button.disabled = false
|
| 389 |
+
if (this.more && this.nextOffset !== null) {
|
| 390 |
+
this.pageObserver.unobserve(this.more)
|
| 391 |
+
this.pageObserver.observe(this.more)
|
| 392 |
+
}
|
| 393 |
}
|
| 394 |
}
|
| 395 |
|
| 396 |
select(checked) {
|
| 397 |
+
for (const input of $$(".entry:not([hidden]) .item", this)) input.checked = checked
|
| 398 |
this.update()
|
| 399 |
}
|
| 400 |
|
|
|
|
| 404 |
}
|
| 405 |
|
| 406 |
toggleFavorites() {
|
| 407 |
+
this.load(this.folder, !this.favoritesOnly, $("input[type=search]", this).value)
|
|
|
|
|
|
|
| 408 |
}
|
| 409 |
|
| 410 |
filter(value) {
|
| 411 |
+
clearTimeout(this.searchTimer)
|
| 412 |
+
if (this.folder) {
|
| 413 |
+
this.searchTimer = setTimeout(() => {
|
| 414 |
+
this.load(this.folder, this.favoritesOnly, value)
|
| 415 |
+
}, SEARCH_DELAY)
|
| 416 |
+
return
|
| 417 |
+
}
|
| 418 |
const query = value.trim().toLowerCase()
|
| 419 |
for (const entry of $$(".entry", this)) {
|
| 420 |
+
entry.hidden = !entry.dataset.name.includes(query)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
}
|
| 422 |
}
|
| 423 |
|
|
|
|
| 463 |
})
|
| 464 |
if (!response.ok) throw new Error("Delete failed")
|
| 465 |
this.cache.clear()
|
| 466 |
+
await this.load(this.folder, this.favoritesOnly, $("input[type=search]", this).value)
|
| 467 |
} catch (error) {
|
| 468 |
$(".status", this).textContent = error.message
|
| 469 |
} finally {
|
|
|
|
| 531 |
|
| 532 |
showImage(image) {
|
| 533 |
const dialog = $(".lightbox", this)
|
| 534 |
+
$("img", dialog).src = original(image)
|
| 535 |
$("img", dialog).alt = image.name
|
| 536 |
$(".prompt", dialog).textContent = this.formatPrompt(image)
|
| 537 |
$(".upscale-status", dialog).textContent = ""
|
|
|
|
| 540 |
this.renderStar(star, image.starred)
|
| 541 |
}
|
| 542 |
|
| 543 |
+
async moveImage(step) {
|
| 544 |
+
const controller = this.controller
|
| 545 |
+
const current = this.images[this.imageIndex]
|
| 546 |
+
if (step > 0 && this.imageIndex === this.images.length - 1
|
| 547 |
+
&& this.nextOffset !== null) await this.loadMore()
|
| 548 |
+
if (controller !== this.controller) return
|
| 549 |
+
const images = this.images.filter(image => !this.favoritesOnly || image.starred)
|
| 550 |
+
if (!images.length) {
|
| 551 |
+
$(".lightbox", this).close()
|
| 552 |
+
return
|
| 553 |
+
}
|
| 554 |
+
const index = images.indexOf(current)
|
| 555 |
+
const image = images[(index + step + images.length) % images.length]
|
| 556 |
+
this.imageIndex = this.images.indexOf(image)
|
| 557 |
+
this.showImage(image)
|
| 558 |
}
|
| 559 |
|
| 560 |
formatPrompt(image) {
|