q6 commited on
Commit
b4e7e0f
·
1 Parent(s): 51ee26e

Speed up explorer scrolling and add Favorites view

Browse files
Files changed (3) hide show
  1. app.py +126 -62
  2. explorer.css +7 -2
  3. 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
- salt = os.urandom(SALT_SIZE)
 
 
 
 
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
- data = path.read_bytes()
 
 
 
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
- with Image.open(BytesIO(stored_bytes(path))) as image:
2561
- image.thumbnail((PREVIEW_SIZE, PREVIEW_SIZE))
2562
- data = BytesIO()
2563
- image.save(
2564
- data,
2565
- format="WEBP",
2566
- quality=PREVIEW_QUALITY,
2567
- method=0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2568
  )
2569
- return data.getvalue()
 
 
 
2570
 
2571
 
2572
  def reserve_matrix_grid(folder):
@@ -3906,28 +3942,72 @@ def explorer_folder_has_images(path):
3906
  )
3907
 
3908
 
3909
- def explorer_images(folder, starred):
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
- database = star_database()
3914
- images = []
3915
- try:
3916
- with os.scandir(path) as entries:
3917
- files = [
3918
- item
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3919
  for item in sorted(entries, key=natural_key, reverse=True)
3920
- if Path(item.name).suffix.casefold() in IMAGE_SUFFIXES
3921
- and item.is_file()
3922
  ]
3923
- for item in files:
3924
- relative = (Path(folder) / item.name).as_posix()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3925
  prompt, second_prompt, artists = stored_prompt(
3926
- Path(item.path), relative, database
3927
  )
 
3928
  images.append({
3929
- "name": Path(item.name).with_suffix(".png").name,
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
- return images
3940
-
3941
-
3942
- @api.get("/api/images", dependencies=[Depends(require_pass)])
3943
- def image_list(folder: str = Query(None), infinite: bool = Query(False)):
3944
- root = IMAGE_DIR
3945
- if not root.is_dir():
3946
- return {"groups": []} if infinite else (
3947
- {"folders": []} if folder is None else {"images": []}
3948
- )
3949
- with os.scandir(root) as entries:
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
- database.executemany(
4045
- "DELETE FROM image_prompts WHERE path = ?",
4046
- ((relative,) for relative in relatives),
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=Inter:wght@450;600&display=swap');
2
 
3
  :root {
4
  --bg: #111;
@@ -20,7 +20,7 @@
20
  body {
21
  background: var(--bg);
22
  color: var(--text);
23
- font-family: Inter, sans-serif;
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 = path => api("/api/images/preview", { path })
8
- const original = path => api("/api/images/original", { path })
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
- let data = this.cache.get(folder)
72
- if (!data) {
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", "false")
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 === INFINITE) this.addGroups(data.groups)
100
- else if (folder) this.addImages(data.images)
101
- else this.addFolders(data.folders)
102
- this.update()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: "320px" })
133
  }
134
 
135
  addFolders(folders) {
136
  const grid = document.createElement("div")
137
  grid.className = "grid folders"
138
- const infinite = document.createElement("div")
139
- infinite.className = "folder entry"
140
- infinite.dataset.name = "infinite"
141
- const infiniteOpen = document.createElement("button")
142
- infiniteOpen.className = "folder-open"
143
- infiniteOpen.type = "button"
144
- infiniteOpen.addEventListener("click", () => this.load(INFINITE))
145
- const infiniteName = document.createElement("strong")
146
- infiniteName.textContent = "∞ Infinite"
147
- infiniteOpen.append(infiniteName)
148
- infinite.append(infiniteOpen)
149
- grid.append(infinite)
 
 
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
- this.images = images
170
- const grid = document.createElement("div")
171
- grid.className = "grid"
172
- for (const image of images) grid.append(this.card(image))
173
- $(".content", this).append(grid)
 
 
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
- const section = document.createElement("section")
181
- section.className = "date-group"
182
- const heading = document.createElement("h2")
183
- heading.textContent = group.name
184
- const grid = document.createElement("div")
185
- grid.className = "grid"
186
- for (const image of group.images) grid.append(this.card(image))
187
- section.append(heading, grid)
188
- content.append(section)
 
 
 
 
 
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.dataset.src = preview(image.path)
 
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 data of this.cache.values()) {
248
- const images = data.images || data.groups?.flatMap(group => group.images) || []
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 === path) card.classList.toggle("favorite", starred)
 
 
 
258
  }
259
- this.filter($("input[type=search]", this).value)
 
 
 
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 = !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
- const nameMatch = !query || entry.dataset.name.includes(query)
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.path)
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 count = this.images.length
438
- this.imageIndex = (this.imageIndex + step + count) % count
439
- this.showImage(this.images[this.imageIndex])
 
 
 
 
 
 
 
 
 
 
 
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) {