PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/gallery.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:17 UTC 137,676 bytes, 2,835 lines download raw bytes

1/**
2 * Gallery Module — photo backup + AI-generated image library.
3 */
4
5import uiModule from './ui.js';
6import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
7import spinnerModule from './spinner.js';
8import { makeWindowDraggable } from './windowDrag.js';
9
10const API_BASE = window.location.origin;
11let _open = false;
12let _galleryResizeHandler = null;
13
14// Auto-refresh gallery when new image is generated
15window.addEventListener('gallery-refresh', () => {
16  if (_open) _fetchLibrary(false);
17});
18let _items = [];
19let _total = 0;
20let _totalTagged = 0;
21
22// Update the "X/Y tagged" badge in the AI-tagging settings header.
23function _updateTagCount() {
24  const el = document.getElementById('gallery-tag-count');
25  if (el) el.textContent = _total ? `${_totalTagged}/${_total} tagged` : '';
26}
27let _search = '';
28// Stack of active tag filters. Multiple tags AND together — the user
29// builds this up by clicking tag chips or by hitting Enter in the
30// search box, and tears it down with the × on each pill.
31let _activeTags = [];
32let _activeModel = null;
33let _activeAlbum = null;
34let _galleryCascaded = false;   // play the domino-in cascade once per open
35let _favoritesOnly = false;
36let _sort = 'shuffle';
37let _shuffleSeed = Math.floor(Math.random() * 2 ** 31);
38let _offset = 0;
39// Page size — computed from the grid's visible area so taller / wider
40// windows (fullscreen) fetch enough photos to fill the screen instead
41// of leaving blank space below a fixed 24-photo page. Capped at the
42// backend's max (100).
43let _limit = 24;
44function _computeFetchLimit() {
45  const grid = document.getElementById('gallery-grid');
46  const COL_W = 168; // 160px min column + 8px gap
47  const ROW_H = 200; // ~160px image + caption + gap
48  const gridW = (grid && grid.clientWidth) || Math.min(window.innerWidth * 0.9, 1100);
49  const cols = Math.max(2, Math.floor(gridW / COL_W));
50  // The grid scroll viewport is max-height:60vh.
51  const gridH = window.innerHeight * 0.6;
52  const rows = Math.ceil(gridH / ROW_H) + 2; // +2 buffer rows for scroll
53  return Math.min(100, Math.max(24, cols * rows));
54}
55let _searchDebounce = null;
56let _escHandler = null;
57let _albums = [];
58// Albums tab — search filter + multi-select state. Mirrors what the
59// Photos tab does (_search, _selectMode) but scoped to the albums grid.
60let _albumSearch = '';
61let _albumSelectMode = false;
62const _albumSelected = new Set();
63
64// ---- API helpers ----
65
66async function _fetchLibrary(append) {
67  // Recompute the page size each fetch so resizing / fullscreening the
68  // window between loads pulls the right number of photos.
69  _limit = _computeFetchLimit();
70  // First load with nothing on screen → show skeleton tiles instead of a blank
71  // grid that then snaps to full. BUT: if the last successful load returned
72  // zero items, skip the skeleton entirely — otherwise empty accounts flash
73  // 8-20 placeholder tiles for ~200ms before snapping to the "No photos yet"
74  // message, which read as glitchy.
75  if (!append && _items.length === 0) {
76    let _knownEmpty = false;
77    try { _knownEmpty = localStorage.getItem('gallery-known-empty') === '1'; } catch (_) {}
78    if (!_knownEmpty) _renderSkeletons(_limit);
79  }
80  if (!append) {
81    _offset = 0;
82    // Leave _items untouched until the response arrives — that's the
83    // stale-while-revalidate trick that lets the gallery feel instant on
84    // re-open. The new list replaces _items on success below; if the fetch
85    // fails, the previous photos stay visible.
86  }
87  const params = new URLSearchParams({ sort: _sort, offset: _offset, limit: _limit });
88  if (_sort === 'shuffle') params.set('seed', String(_shuffleSeed));
89  if (_search) params.set('search', _search);
90  if (_activeTags.length) params.set('tag', _activeTags.join(','));
91  if (_activeModel) params.set('model', _activeModel);
92  if (_activeAlbum) params.set('album', _activeAlbum);
93  if (_favoritesOnly) params.set('favorites', 'true');
94  try {
95    const res = await fetch(`${API_BASE}/api/gallery/library?${params}`, { credentials: 'same-origin' });
96    const data = await res.json();
97    if (append) {
98      _items = _items.concat(data.items || []);
99    } else {
100      _items = data.items || [];
101    }
102    // Cache an "empty" verdict so the next open of an empty gallery doesn't
103    // flash skeleton tiles before the real "No photos yet" message.
104    try {
105      const _noFilters = !_search && !_activeTags.length && !_activeModel && !_activeAlbum && !_favoritesOnly;
106      if (_noFilters) {
107        if (_items.length === 0) localStorage.setItem('gallery-known-empty', '1');
108        else localStorage.removeItem('gallery-known-empty');
109      }
110    } catch (_) {}
111    _total = data.total || 0;
112    if (typeof data.total_tagged === 'number') _totalTagged = data.total_tagged;
113    _updateTagCount();
114    _renderGrid();
115    _renderTags(data.tags || []);
116    _renderModels(data.models || []);
117    _renderStats();
118  } catch (e) {
119    console.error('Gallery fetch error:', e);
120  }
121}
122
123async function _fetchAlbums() {
124  try {
125    const res = await fetch(`${API_BASE}/api/gallery/albums`, { credentials: 'same-origin' });
126    const data = await res.json();
127    _albums = data.albums || [];
128    _renderAlbums();
129  } catch (e) { console.error('Albums fetch error:', e); }
130}
131
132
133// v2 review HIGH-7: return a boolean so callers can stop showing
134// "Tags saved" / "Photo deleted" toasts when the server actually
135// returned 4xx/5xx. The previous swallow-and-return-undefined caused
136// silent UI lies on permission failures.
137async function _patchImage(id, patch) {
138  try {
139    const r = await fetch(`${API_BASE}/api/gallery/${id}`, {
140      method: 'PATCH',
141      headers: { 'Content-Type': 'application/json' },
142      credentials: 'same-origin',
143      body: JSON.stringify(patch),
144    });
145    if (!r.ok) {
146      console.warn('Gallery patch returned', r.status);
147      return false;
148    }
149    return true;
150  } catch (e) {
151    console.error('Gallery patch error:', e);
152    return false;
153  }
154}
155
156async function _deleteImage(id) {
157  try {
158    const r = await fetch(`${API_BASE}/api/gallery/${id}`, {
159      method: 'DELETE',
160      credentials: 'same-origin',
161    });
162    if (!r.ok) {
163      console.warn('Gallery delete returned', r.status);
164      return false;
165    }
166    return true;
167  } catch (e) {
168    console.error('Gallery delete error:', e);
169    return false;
170  }
171}
172
173// ---- Bulk upload with progress ----
174
175// Accepts either File[] (uploads all into fallbackAlbumId) or
176// {file, albumId}[] (per-file album targeting — used for folder drops).
177async function _bulkUpload(filesOrItems, fallbackAlbumId) {
178  const bar = document.getElementById('gallery-upload-bar');
179  const progress = document.getElementById('gallery-upload-progress');
180  const status = document.getElementById('gallery-upload-status');
181  if (!bar) return;
182
183  const items = filesOrItems.map(it =>
184    it instanceof File ? { file: it, albumId: fallbackAlbumId } : it
185  );
186
187  bar.style.display = '';
188  let done = 0, dupes = 0, errors = 0;
189  const total = items.length;
190
191  // Concurrency pool — N workers pulling from the queue. 4 is a reasonable
192  // default for a local server: enough to overlap network + EXIF + disk
193  // without flooding SQLite (which serializes writes anyway). Videos in
194  // particular benefit because they're large enough to be I/O-bound.
195  const CONCURRENCY = 4;
196  let cursor = 0;
197  async function worker() {
198    while (true) {
199      const idx = cursor++;
200      if (idx >= items.length) return;
201      const it = items[idx];
202      const fd = new FormData();
203      fd.append('file', it.file);
204      if (it.albumId) fd.append('album_id', it.albumId);
205      try {
206        const res = await fetch(`${API_BASE}/api/gallery/upload`, {
207          method: 'POST', body: fd, credentials: 'same-origin',
208        });
209        const data = await res.json();
210        if (data.duplicate) dupes++;
211        else if (!data.ok) errors++;
212      } catch (e) { errors++; }
213      done++;
214      if (progress) progress.style.width = `${(done / total) * 100}%`;
215      if (status) status.textContent = `${done}/${total}${dupes ? ` (${dupes} duplicates)` : ''}`;
216    }
217  }
218  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, total) }, worker));
219
220  const msg = `${done - dupes - errors} imported` +
221    (dupes ? `, ${dupes} duplicates skipped` : '') +
222    (errors ? `, ${errors} errors` : '');
223  if (status) status.textContent = msg;
224  uiModule.showToast(msg);
225  setTimeout(() => { bar.style.display = 'none'; }, 3000);
226  // Auto-switch to Recent so the just-uploaded photos are immediately
227  // visible at the top (otherwise Shuffle would scatter them).
228  if (done - dupes - errors > 0 && _sort !== 'recent') {
229    _sort = 'recent';
230    const sortSel = document.getElementById('gallery-sort');
231    if (sortSel) sortSel.value = 'recent';
232  }
233  _fetchLibrary(false);
234  _fetchAlbums();
235}
236
237// True if this File / filename should be uploaded — images and common videos.
238function _isMediaFile(f) {
239  const t = (f?.type || '').toLowerCase();
240  if (t.startsWith('image/') || t.startsWith('video/')) return true;
241  // Some Linux file managers and older browsers leave .type blank; fall
242  // back to the extension.
243  const ext = (f?.name || '').toLowerCase().split('.').pop() || '';
244  return ['png','jpg','jpeg','webp','gif','mp4','mov','webm','mkv','m4v'].includes(ext);
245}
246
247// True if a URL/filename refers to a video — used to pick <video> vs <img>.
248function _isVideoUrl(url) {
249  const ext = (url || '').toLowerCase().split('?')[0].split('.').pop();
250  return ['mp4','mov','webm','mkv','m4v'].includes(ext);
251}
252
253// Recursively walk a webkit FileSystemEntry, returning all media Files under it.
254async function _walkEntryForImages(entry) {
255  if (entry.isFile) {
256    return new Promise(res => {
257      entry.file(
258        f => res(_isMediaFile(f) ? [f] : []),
259        () => res([])
260      );
261    });
262  }
263  if (!entry.isDirectory) return [];
264  const reader = entry.createReader();
265  const out = [];
266  while (true) {
267    const batch = await new Promise(res => reader.readEntries(res, () => res([])));
268    if (!batch.length) break;
269    const subs = await Promise.all(batch.map(_walkEntryForImages));
270    subs.forEach(s => out.push(...s));
271  }
272  return out;
273}
274
275// Handle a native drop: split into folders (→ new/existing albums) and loose files
276// (→ current album). Returns when the whole upload is complete.
277async function _handleGalleryDrop(e) {
278  const dtItems = [...(e.dataTransfer?.items || [])];
279  const entries = dtItems
280    .map(it => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null))
281    .filter(Boolean);
282
283  const uploadItems = [];
284  let sawFolderEntry = false;
285
286  for (const entry of entries) {
287    if (entry.isDirectory) {
288      sawFolderEntry = true;
289      let album = _albums.find(a => a.name === entry.name);
290      if (!album) {
291        try {
292          const res = await fetch(`${API_BASE}/api/gallery/albums`, {
293            method: 'POST',
294            headers: { 'Content-Type': 'application/json' },
295            credentials: 'same-origin',
296            body: JSON.stringify({ name: entry.name }),
297          });
298          const data = await res.json();
299          if (data && data.id) album = { id: data.id, name: data.name || entry.name };
300        } catch (err) { console.error('Failed to create album for', entry.name, err); }
301      }
302      if (!album) continue;
303      const files = await _walkEntryForImages(entry);
304      files.forEach(f => uploadItems.push({ file: f, albumId: album.id }));
305    } else if (entry.isFile) {
306      const f = await new Promise(res => entry.file(res, () => res(null)));
307      if (f && _isMediaFile(f)) {
308        uploadItems.push({ file: f, albumId: _activeAlbum });
309      }
310    }
311  }
312
313  // Fallback: some drag sources (Linux file managers like Thunar/Nautilus,
314  // or older browsers) don't populate FileSystemEntry but DO populate
315  // dataTransfer.files for loose files. Pick those up too.
316  if (!uploadItems.length) {
317    const files = [...(e.dataTransfer?.files || [])].filter(_isMediaFile);
318    files.forEach(f => uploadItems.push({ file: f, albumId: _activeAlbum }));
319  }
320
321  if (uploadItems.length) {
322    await _bulkUpload(uploadItems);
323    return;
324  }
325
326  // Nothing usable — either an empty folder, an unreadable folder URI, or a
327  // non-image drop. If the dataTransfer types hint at a folder/URI drop,
328  // explain the limitation and point at the Upload album button.
329  const types = [...(e.dataTransfer?.types || [])];
330  const looksLikeFolderUri = !sawFolderEntry && (
331    types.includes('text/uri-list') ||
332    types.includes('text/x-moz-url') ||
333    dtItems.some(it => it.kind === 'string')
334  );
335  if (looksLikeFolderUri) {
336    uiModule.showError('Browsers can’t read folders dropped from native file managers (Thunar/Nautilus). Use the "Upload album" tile in the Albums tab instead.');
337  } else if (entries.length || dtItems.length) {
338    uiModule.showToast('No images found in that drop');
339  }
340}
341
342// ---- Render helpers ----
343
344function _renderStats() {
345  const el = document.getElementById('gallery-stats');
346  if (el) el.textContent = `${_total} photo${_total !== 1 ? 's' : ''}`;
347}
348
349function _renderTags(tags) {
350  // The global "every tag in the gallery" chip row under the search is gone —
351  // it just piled up every user-added tag with no way to remove it. Filter by
352  // tapping a tag on a photo (→ a removable pill in the header) or via search.
353  const container = document.getElementById('gallery-tag-chips');
354  if (!container) return;
355  container.innerHTML = '';
356  container.style.display = 'none';
357}
358
359function _renderModels(models) {
360  const sel = document.getElementById('gallery-model-filter');
361  if (!sel) return;
362  let html = '<option value="">All sources</option>';
363  models.forEach(m => {
364    const selected = _activeModel === m ? ' selected' : '';
365    html += `<option value="${_esc(m)}"${selected}>${_esc(m)}</option>`;
366  });
367  sel.innerHTML = html;
368}
369
370function _renderAlbums() {
371  const container = document.getElementById('gallery-album-chips');   // above search: active-filter indicators
372  const filterC = document.getElementById('gallery-filter-chips');    // below search: All / Favorites
373  if (!container && !filterC) return;
374  // Below the search bar: the All / Favorites filters PLUS any active tag chips
375  // (so a tag you searched/clicked sits right next to All and the heart).
376  if (filterC) {
377    // Order: All, then the heart, then any active tag chips (to the right of
378    // both), then the active-album chip. No favorites-within-an-album view, so
379    // the heart is hidden while an album is active.
380    let fhtml = `<button class="gallery-chip${!_activeAlbum && !_favoritesOnly ? ' active' : ''}" data-album="">All</button>`;
381    if (!_activeAlbum) {
382      fhtml += `<button class="gallery-chip gallery-chip-fav${_favoritesOnly ? ' active' : ''}" data-fav="true" title="Favorites">&#9829;</button>`;
383    }
384    _activeTags.forEach(t => {
385      fhtml += `<span class="gallery-chip gallery-chip-active-album" title="Filtered to tag — click × to remove"><span>#${_esc(t)}</span><button class="gallery-chip-clear" data-clear-tag="${_esc(t)}" aria-label="Remove tag filter">&times;</button></span>`;
386    });
387    if (_activeAlbum) {
388      const a = _albums.find(x => x.id === _activeAlbum);
389      if (a) {
390        fhtml += `<span class="gallery-chip gallery-chip-active-album" title="Currently showing this album — click X to clear"><span>${_esc(a.name)}</span><button class="gallery-chip-clear" data-clear="album" aria-label="Clear album filter">&times;</button></span>`;
391      }
392    }
393    filterC.innerHTML = fhtml;
394    filterC.querySelector('.gallery-chip[data-album=""]')?.addEventListener('click', () => {
395      _favoritesOnly = false;
396      _activeAlbum = null;
397      _activeTags = [];
398      _fetchLibrary(false);
399      _renderAlbums();
400    });
401    filterC.querySelector('.gallery-chip-fav')?.addEventListener('click', () => {
402      _favoritesOnly = !_favoritesOnly;
403      _activeAlbum = null;
404      _fetchLibrary(false);
405      _renderAlbums();
406    });
407    filterC.querySelector('.gallery-chip-clear[data-clear="album"]')?.addEventListener('click', (e) => {
408      e.stopPropagation();
409      _activeAlbum = null;
410      _fetchLibrary(false);
411      _renderAlbums();
412    });
413    filterC.querySelectorAll('.gallery-chip-clear[data-clear-tag]').forEach(x => {
414      x.addEventListener('click', (e) => {
415        e.stopPropagation();
416        const tag = x.dataset.clearTag;
417        _activeTags = _activeTags.filter(t => t !== tag);
418        _fetchLibrary(false);
419        _renderAlbums();
420      });
421    });
422  }
423  // The above-search row is no longer used — all filter chips live below now.
424  if (container) container.innerHTML = '';
425}
426
427// Albums tab — renders the album list as a grid of cover-thumbnailed cards.
428// Clicking an album switches to the Photos tab filtered by that album.
429//
430// Structure mirrors the Photos tab: persistent toolbar (search + Select)
431// and bulk bar built once, only the inner #gallery-albums-grid-wrap
432// re-renders so the search input keeps focus while typing.
433function _renderAlbumsTab() {
434  const container = document.getElementById('gallery-albums-container');
435  if (!container) return;
436  _ensureAlbumsToolbar(container);
437  _renderAlbumsGrid();
438}
439
440function _filteredAlbums() {
441  const q = _albumSearch.trim().toLowerCase();
442  if (!q) return _albums;
443  return _albums.filter(a => (a.name || '').toLowerCase().includes(q));
444}
445
446function _ensureAlbumsToolbar(container) {
447  if (container.querySelector('#gallery-albums-toolbar')) return;
448  container.innerHTML = `
449    <div class="gallery-toolbar" id="gallery-albums-toolbar">
450      <div class="gallery-search-wrap">
451        <input type="text" class="gallery-search" id="gallery-albums-search" placeholder="Search albums..." />
452      </div>
453      <button class="gallery-select-btn gallery-toolbar-action" id="gallery-albums-select-btn" title="Select for bulk actions" style="position:relative;top:2px;"><span style="position:relative;top:1px;">Select</span></button>
454    </div>
455    <div class="memory-bulk-bar hidden" id="gallery-albums-bulk-bar">
456      <label class="memory-bulk-check-all" style="position:relative;top:-1px;"><input type="checkbox" id="gallery-albums-bulk-all"> All</label>
457      <span id="gallery-albums-bulk-count" style="position:relative;top:-1px;">0 selected</span>
458      <button class="memory-toolbar-btn" id="gallery-albums-bulk-delete" title="Delete selected" style="margin-left:auto;color:var(--color-error, #f44);position:relative;top:-3px;">
458<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
459      <button class="memory-toolbar-btn" id="gallery-albums-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
460    </div>
461    <div id="gallery-albums-grid-wrap"></div>
462  `;
463
464  // Wire search — debounced re-render, same pattern as Photos.
465  const searchInput = container.querySelector('#gallery-albums-search');
466  let _albumSearchDebounce = null;
467  searchInput.addEventListener('input', () => {
468    clearTimeout(_albumSearchDebounce);
469    _albumSearchDebounce = setTimeout(() => {
470      _albumSearch = searchInput.value;
471      _renderAlbumsGrid();
472    }, 150);
473  });
474
475  // Wire Select + bulk bar — Cancel restores the normal click-to-open
476  // behavior; Actions opens a dropdown anchored on the button.
477  container.querySelector('#gallery-albums-select-btn').addEventListener('click', () => {
478    _setAlbumSelectMode(!_albumSelectMode);
479  });
480  container.querySelector('#gallery-albums-bulk-cancel').addEventListener('click', () => {
481    _setAlbumSelectMode(false);
482  });
483  container.querySelector('#gallery-albums-bulk-all').addEventListener('change', (e) => {
484    const on = e.target.checked;
485    const list = _filteredAlbums();
486    if (on) list.forEach(a => _albumSelected.add(a.id));
487    else _albumSelected.clear();
488    _renderAlbumsGrid();
489  });
490  container.querySelector('#gallery-albums-bulk-delete').addEventListener('click', (e) => {
491    e.stopPropagation();
492    if (!_albumSelected.size) { uiModule.showToast('Select albums first'); return; }
493    _bulkDeleteAlbums([..._albumSelected]);
494  });
495}
496
497function _setAlbumSelectMode(on) {
498  _albumSelectMode = on;
499  if (!on) _albumSelected.clear();
500  const container = document.getElementById('gallery-albums-container');
501  container.querySelector('#gallery-albums-select-btn span').textContent = on ? 'Cancel' : 'Select';
502  container.querySelector('#gallery-albums-select-btn').classList.toggle('active', on);
503  container.querySelector('#gallery-albums-bulk-bar').classList.toggle('hidden', !on);
504  _renderAlbumsGrid();
505}
506
507function _updateAlbumBulkCount() {
508  const container = document.getElementById('gallery-albums-container');
509  if (!container) return;
510  const sel = _albumSelected.size;
511  const cnt = container.querySelector('#gallery-albums-bulk-count');
512  if (cnt) cnt.textContent = sel + ' selected';
513  const all = container.querySelector('#gallery-albums-bulk-all');
514  const total = _filteredAlbums().length;
515  if (all) { all.checked = total > 0 && sel === total; all.indeterminate = sel > 0 && sel < total; }
516  const del = container.querySelector('#gallery-albums-bulk-delete');
517  if (del) del.style.opacity = sel > 0 ? '1' : '0.5';
518}
519
520function _renderAlbumsGrid() {
521  const container = document.getElementById('gallery-albums-container');
522  if (!container) return;
523  const wrap = container.querySelector('#gallery-albums-grid-wrap');
524  if (!wrap) return;
525
526  const albums = _filteredAlbums();
527  if (!_albums.length) {
528    wrap.innerHTML = `
529      <div class="gallery-albums-empty">
530        <p>No albums yet.</p>
531        <button class="gallery-select-btn" id="gallery-albums-new">+ New album</button>
532      </div>`;
533    _wireAlbumsEvents(wrap);
534    return;
535  }
536  if (!albums.length) {
537    wrap.innerHTML = `<div class="gallery-albums-empty"><p>No albums match "${_esc(_albumSearch)}".</p></div>`;
538    return;
539  }
540
541  let html = '<div class="gallery-albums-grid">';
542  // Action tiles (New / Upload) — hidden in select mode so they don't
543  // visually compete with the selection dots and can't be accidentally
544  // toggled like real albums.
545  if (!_albumSelectMode) {
546    html += `
547      <div class="gallery-album-card gallery-album-card-add" id="gallery-albums-new">
548        <div class="gallery-album-cover">
549          <div class="gallery-album-placeholder">+</div>
550        </div>
551        <div class="gallery-album-info">
552          <div class="gallery-album-name">New album</div>
553        </div>
554      </div>
555      <div class="gallery-album-card gallery-album-card-add" id="gallery-albums-upload">
556        <div class="gallery-album-cover">
557          <div class="gallery-album-placeholder">
558            <svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
559          </div>
560        </div>
561        <div class="gallery-album-info">
562          <div class="gallery-album-name">Upload album</div>
563          <div class="gallery-album-count">Pick a folder</div>
564        </div>
565      </div>`;
566  }
567  albums.forEach(a => {
568    // Empty albums get the placeholder icon even if cover_url is set —
569    // a stale cover from before the album was emptied looks like the
570    // album still has photos in it.
571    const cover = (a.cover_url && a.count > 0)
572      ? `<img src="${_esc(a.cover_url)}" alt="" loading="lazy" />`
573      : `<div class="gallery-album-placeholder">
574           <svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
575         </div>`;
576    const isSel = _albumSelected.has(a.id);
577    const dot = _albumSelectMode
578      ? `<span class="gallery-select-dot${isSel ? ' selected' : ''}" style="display:flex;"></span>`
579      : '';
580    const cls = 'gallery-album-card' + (_albumSelectMode ? ' gallery-card-selectable' : '') + (isSel ? ' selected' : '');
581    html += `
582      <div class="${cls}" data-album="${_esc(a.id)}">
583        ${dot}
584        <button class="gallery-album-menu-btn" data-album="${_esc(a.id)}" title="Options" aria-label="Album options"${_albumSelectMode ? ' style="display:none"' : ''}>
585          <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="position:relative;top:2px;"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
586        </button>
587        <div class="gallery-album-menu-pop dropdown" data-album="${_esc(a.id)}" hidden>
588          <div class="dropdown-item-compact" data-action="upload">
589            <span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></span>
590            <span>Upload here</span>
591          </div>
592          <div class="dropdown-item-compact" data-action="rename">
593            <span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span>
594            <span>Rename</span>
595          </div>
596          <div class="dropdown-item-compact dropdown-item-danger" data-action="delete">
597            <span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></span>
598            <span>Delete</span>
599          </div>
600        </div>
601        <div class="gallery-album-cover">${cover}</div>
602        <div class="gallery-album-info">
603          <div class="gallery-album-name">${_esc(a.name)}</div>
604          <div class="gallery-album-count">${a.count} photo${a.count === 1 ? '' : 's'}</div>
605        </div>
606      </div>`;
607  });
608  html += '</div>';
609  wrap.innerHTML = html;
610  _updateAlbumBulkCount();
611  _wireAlbumsEvents(wrap);
612}
613
614// Per-card / per-popmenu event wiring — extracted so both the empty
615// state and the real grid can reuse it.
616function _wireAlbumsEvents(scope) {
617  const container = document.getElementById('gallery-albums-container');
618  if (!container) return;
619
620  container.querySelectorAll('.gallery-album-card[data-album]').forEach(card => {
621    card.addEventListener('click', (e) => {
622      // Clicks on the menu button or any pop-menu item are handled below;
623      // don't navigate into the album in that case.
624      if (e.target.closest('.gallery-album-menu-btn')) return;
625      if (e.target.closest('.gallery-album-menu-pop')) return;
626      // In select mode, clicking a card toggles its selection instead
627      // of opening it. Mirrors the Photos tab's behaviour.
628      if (_albumSelectMode) {
629        const id = card.dataset.album;
630        if (_albumSelected.has(id)) _albumSelected.delete(id);
631        else _albumSelected.add(id);
632        const dot = card.querySelector('.gallery-select-dot');
633        if (dot) dot.classList.toggle('selected', _albumSelected.has(id));
634        card.classList.toggle('selected', _albumSelected.has(id));
635        _updateAlbumBulkCount();
636        return;
637      }
638      _activeAlbum = card.dataset.album || null;
639      _favoritesOnly = false;
640      // Hide any open photo detail before swapping context — otherwise the
641      // previously-viewed photo lingers on top when the user lands back on
642      // the Photos tab.
643      const _detail = document.getElementById('gallery-detail');
644      if (_detail) _detail.style.display = 'none';
645      _renderAlbums();
646      _fetchLibrary(false);
647      // Switch back to the Photos tab so they immediately see the contents.
648      const modal = document.getElementById('gallery-modal');
649      const photosTab = modal?.querySelector('.gallery-tab[data-tab="images"]');
650      photosTab?.click();
651    });
652  });
653
654  // Hover menu: toggle the per-card pop on ⋯ click, close any others.
655  container.querySelectorAll('.gallery-album-menu-btn').forEach(btn => {
656    btn.addEventListener('click', (e) => {
657      e.stopPropagation();
658      const id = btn.dataset.album;
659      const pop = container.querySelector(`.gallery-album-menu-pop[data-album="${CSS.escape(id)}"]`);
660      const wasOpen = pop && !pop.hidden;
661      container.querySelectorAll('.gallery-album-menu-pop').forEach(p => { p.hidden = true; });
662      if (pop && !wasOpen) pop.hidden = false;
663    });
664  });
665  // Click anywhere else closes any open pop.
666  if (!container._popDismissWired) {
667    document.addEventListener('click', (e) => {
668      if (e.target.closest('.gallery-album-menu-btn')) return;
669      if (e.target.closest('.gallery-album-menu-pop')) return;
670      container.querySelectorAll('.gallery-album-menu-pop').forEach(p => { p.hidden = true; });
671    });
672    container._popDismissWired = true;
673  }
674
675  container.querySelectorAll('.gallery-album-menu-pop').forEach(pop => {
676    const id = pop.dataset.album;
677    pop.querySelector('[data-action="upload"]')?.addEventListener('click', (e) => {
678      e.stopPropagation();
679      pop.hidden = true;
680      // Spawn an ephemeral file picker scoped to this album.
681      const picker = document.createElement('input');
682      picker.type = 'file';
683      picker.accept = 'image/*,video/*';
684      picker.multiple = true;
685      picker.style.display = 'none';
686      picker.addEventListener('change', async () => {
687        const files = [...(picker.files || [])];
688        if (files.length) await _bulkUpload(files, id);
689        picker.remove();
690      });
691      document.body.appendChild(picker);
692      picker.click();
693    });
694    pop.querySelector('[data-action="rename"]')?.addEventListener('click', async (e) => {
695      e.stopPropagation();
696      pop.hidden = true;
697      const album = _albums.find(a => a.id === id);
698      const newName = prompt('Rename album:', album?.name || '');
699      if (!newName || !newName.trim() || newName.trim() === album?.name) return;
700      const r = await fetch(`${API_BASE}/api/gallery/albums/${id}`, {
701        method: 'PUT', headers: { 'Content-Type': 'application/json' },
702        credentials: 'same-origin', body: JSON.stringify({ name: newName.trim() }),
703      });
704      if (r.ok) {
705        await _fetchAlbums();
706        _renderAlbumsTab();
707        if (uiModule) uiModule.showToast('Album renamed');
708      } else if (uiModule) {
709        uiModule.showError('Rename failed');
710      }
711    });
712    pop.querySelector('[data-action="delete"]')?.addEventListener('click', async (e) => {
713      e.stopPropagation();
714      pop.hidden = true;
715      const album = _albums.find(a => a.id === id);
716      const ok = await uiModule.styledConfirm(
717        `Delete album "${album?.name || ''}"? Photos inside will stay in your library.`,
718        { confirmText: 'Delete', danger: true },
719      );
720      if (!ok) return;
721      const r = await fetch(`${API_BASE}/api/gallery/albums/${id}`, {
722        method: 'DELETE', credentials: 'same-origin',
723      });
724      if (r.ok) {
725        if (_activeAlbum === id) _activeAlbum = null;
726        await _fetchAlbums();
727        _renderAlbumsTab();
728        _renderAlbums();
729        if (uiModule) uiModule.showToast('Album deleted');
730      } else if (uiModule) {
731        uiModule.showError('Delete failed');
732      }
733    });
734  });
735
736  document.getElementById('gallery-albums-new')?.addEventListener('click', async () => {
737    const name = (uiModule.styledPrompt
738      ? await uiModule.styledPrompt('Name your new album.', { title: 'New album', placeholder: 'e.g. Vacation 2026', confirmText: 'Create' })
739      : prompt('Album name:'));
740    if (!name?.trim()) return;
741    await fetch(`${API_BASE}/api/gallery/albums`, {
742      method: 'POST', headers: { 'Content-Type': 'application/json' },
743      credentials: 'same-origin', body: JSON.stringify({ name: name.trim() }),
744    });
745    await _fetchAlbums();
746    _renderAlbumsTab();
747  });
748
749  document.getElementById('gallery-albums-upload')?.addEventListener('click', () => {
750    // <input webkitdirectory> picks a folder; we create an album with the
751    // folder name and upload every image inside.
752    const picker = document.createElement('input');
753    picker.type = 'file';
754    picker.multiple = true;
755    picker.webkitdirectory = true;
756    picker.style.display = 'none';
757    picker.addEventListener('change', async () => {
758      const all = [...(picker.files || [])];
759      const images = all.filter(_isMediaFile);
760      picker.remove();
761      if (!images.length) {
762        if (uiModule) uiModule.showToast('No images or videos in that folder');
763        return;
764      }
765      // Derive folder name from the first file's relative path (e.g.
766      // "MyTrip/photo.jpg" → "MyTrip"). Fall back to a prompt.
767      const rel = images[0].webkitRelativePath || '';
768      let folderName = rel.split('/')[0] || '';
769      if (!folderName) {
770        folderName = prompt('Album name for these photos:') || '';
771        if (!folderName.trim()) return;
772      }
773      // Reuse an existing album with the same name; otherwise create one.
774      let album = _albums.find(a => a.name === folderName);
775      if (!album) {
776        const r = await fetch(`${API_BASE}/api/gallery/albums`, {
777          method: 'POST', headers: { 'Content-Type': 'application/json' },
778          credentials: 'same-origin', body: JSON.stringify({ name: folderName }),
779        });
780        const data = await r.json().catch(() => ({}));
781        if (data?.id) {
782          album = { id: data.id, name: folderName, count: 0 };
783          _albums.push(album);
784        }
785      }
786      if (!album) {
787        if (uiModule) uiModule.showError('Could not create album');
788        return;
789      }
790      await _bulkUpload(images, album.id);
791      await _fetchAlbums();
792      _renderAlbumsTab();
793    });
794    document.body.appendChild(picker);
795    picker.click();
796  });
797}
798
799async function _bulkDeleteAlbums(ids) {
800  if (!ids.length) return;
801  const ok = await uiModule.styledConfirm(
802    `Delete ${ids.length} album${ids.length > 1 ? 's' : ''}? Photos inside will stay in your library.`,
803    { confirmText: 'Delete', danger: true },
804  );
805  if (!ok) return;
806  let failed = 0;
807  for (const id of ids) {
808    const r = await fetch(`${API_BASE}/api/gallery/albums/${id}`, {
809      method: 'DELETE', credentials: 'same-origin',
810    });
811    if (!r.ok) failed++;
812    else if (_activeAlbum === id) _activeAlbum = null;
813  }
814  if (failed) uiModule.showError(`Failed to delete ${failed} of ${ids.length} albums`);
815  else if (uiModule) uiModule.showToast(`Deleted ${ids.length} album${ids.length > 1 ? 's' : ''}`);
816  _setAlbumSelectMode(false);
817  await _fetchAlbums();
818  _renderAlbumsTab();
819  _renderAlbums();
820}
821
822// Fetch the user's persisted editor drafts and render them as a thumbnail
823// grid under the new-canvas / browse buttons. Each card resumes the draft
824// in the editor on click; the × trashes it server-side.
825// Frosted whirlpool overlay over the drafts area while fetching the
826// list. Lives inside the drafts section so it sits above the grid.
827let _draftsSpinner = null;
828function _draftsShowLoading(section) {
829  if (!section) return;
830  let ov = section.querySelector('.gallery-editor-drafts-loading');
831  if (!ov) {
832    ov = document.createElement('div');
833    ov.className = 'gallery-editor-drafts-loading';
834    try {
835      _draftsSpinner = spinnerModule.createWhirlpool(28);
836      _draftsSpinner.element.style.cssText = 'width:28px;height:28px;margin:0;';
837      ov.appendChild(_draftsSpinner.element);
838    } catch (_) {
839      ov.textContent = 'Loading…';
840    }
841    section.appendChild(ov);
842  }
843  // Start the overlay exactly at the grid's top so it covers ONLY the projects
844  // list — not the header's search/select above it (the old fixed 30px offset
845  // assumed a short header and ended up covering half the search/select).
846  const _grid = section.querySelector('.gallery-editor-drafts-grid');
847  const _hdr = section.querySelector('.gallery-editor-drafts-header');
848  const _top = _grid ? _grid.offsetTop : (_hdr ? _hdr.offsetHeight : 30);
849  ov.style.top = _top + 'px';
850  ov.style.display = '';
851}
852function _draftsHideLoading(section) {
853  if (!section) return;
854  const ov = section.querySelector('.gallery-editor-drafts-loading');
855  if (ov) ov.style.display = 'none';
856}
857
858// Held between renders so search + select state survive a re-render.
859let _draftsCache = [];
860let _draftsSearch = '';
861let _draftsSelectMode = false;
862let _draftsSelected = new Set();
863
864async function _renderEditorDrafts() {
865  const section = document.getElementById('gallery-editor-drafts');
866  const grid = document.getElementById('gallery-editor-drafts-grid');
867  if (!section || !grid) return;
868  // Show a frosted whirlpool overlay over the drafts area while the
869  // list is fetching. The section becomes visible BEFORE the fetch so
870  // the user sees the loading indicator instead of a blank space.
871  section.hidden = false;
872  _draftsShowLoading(section);
873  try {
874    const res = await fetch(`${API_BASE}/api/editor-drafts`, { credentials: 'same-origin' });
875    if (res.ok) {
876      const out = await res.json();
877      _draftsCache = Array.isArray(out.drafts) ? out.drafts : [];
878    }
879  } catch (_) {
880    _draftsCache = [];
881  }
882  _draftsHideLoading(section);
883  if (!_draftsCache.length) {
884    section.hidden = true;
885    grid.innerHTML = '';
886    _draftsSelected.clear();
887    _draftsSelectMode = false;
888    _draftsSyncBulkBar();
889    return;
890  }
891  section.hidden = false;
892  // Drop selections for drafts that no longer exist.
893  const present = new Set(_draftsCache.map(d => d.id));
894  for (const id of [..._draftsSelected]) if (!present.has(id)) _draftsSelected.delete(id);
895  _draftsPaint();
896  _draftsWireOnce();
897}
898
899// Re-render only the grid (and bulk bar) from cached drafts + search +
900// selection state. Used by search/select-mode/checkbox updates.
901function _draftsPaint() {
902  const grid = document.getElementById('gallery-editor-drafts-grid');
903  if (!grid) return;
904  const q = _draftsSearch.trim().toLowerCase();
905  const filtered = _draftsCache.filter(d => {
906    if (!q) return true;
907    const name = String(d.name || '').toLowerCase();
908    return name.includes(q);
909  });
910  grid.innerHTML = filtered.map(d => {
911    const updated = d.updated_at ? _humanRelativeDate(new Date(d.updated_at)) : '';
912    const dims = (d.width && d.height) ? `${d.width}×${d.height}` : '';
913    const thumb = d.thumbnail
914      ? `<img class="gallery-editor-draft-thumb" src="${_esc(d.thumbnail)}" alt="" />`
915      : '<div class="gallery-editor-draft-thumb gallery-editor-draft-thumb-empty"></div>';
916    const checked = _draftsSelected.has(d.id);
917    const checkbox = _draftsSelectMode
918      ? `<span class="gallery-select-dot${checked ? ' selected' : ''}" data-draft-id="${_esc(d.id)}"></span>`
919      : '';
920    return `
921      <div class="gallery-editor-draft-card${checked ? ' selected' : ''}${_draftsSelectMode ? ' select-mode' : ''}" data-draft-id="${_esc(d.id)}" tabindex="0" title="Resume ${_esc(d.name || 'project')}">
922        ${checkbox}
923        ${thumb}
924        <div class="gallery-editor-draft-info">
925          <div class="gallery-editor-draft-name">${_esc(d.name || 'Untitled')}</div>
926          <div class="gallery-editor-draft-meta">
926${_esc([dims, updated].filter(Boolean).join(' · '))}</div>
927        </div>
928        <button class="gallery-editor-draft-delete" data-draft-id="${_esc(d.id)}" title="Delete project" aria-label="Delete project">×</button>
929      </div>`;
930  }).join('');
931  grid.querySelectorAll('.gallery-editor-draft-card').forEach(card => {
932    card.addEventListener('click', (e) => {
933      if (e.target.closest('.gallery-editor-draft-delete')) return;
934      const id = card.dataset.draftId;
935      if (!id) return;
936      if (_draftsSelectMode) {
937        if (_draftsSelected.has(id)) _draftsSelected.delete(id);
938        else _draftsSelected.add(id);
939        _draftsPaint();
940        _draftsSyncBulkBar();
941        return;
942      }
943      // Pass the cached dims as the preset size so the editor can show a
944      // correctly-proportioned placeholder while the draft loads.
945      const draft = _draftsCache.find(d => d.id === id);
946      const presetSize = (draft && draft.width && draft.height)
947        ? { w: draft.width, h: draft.height }
948        : null;
949      openEditor(null, null, presetSize, draft?.name || null, id);
950    });
951  });
952  grid.querySelectorAll('.gallery-editor-draft-delete').forEach(btn => {
953    btn.addEventListener('click', async (e) => {
954      e.stopPropagation();
955      const id = btn.dataset.draftId;
956      if (!id) return;
957      const ok = await uiModule.styledConfirm('Delete this project?', {
958        confirmText: 'Delete', cancelText: 'Cancel', danger: true,
959      });
960      if (!ok) return;
961      // Graceful exit: fade + shrink the card before the grid re-renders.
962      const card = btn.closest('.gallery-editor-draft-card');
963      if (card) card.classList.add('gallery-draft-removing');
964      try {
965        await fetch(`${API_BASE}/api/editor-drafts/${encodeURIComponent(id)}`, {
966          method: 'DELETE', credentials: 'same-origin',
967        });
968      } catch (_) { /* swallow — refresh below */ }
969      await new Promise(r => setTimeout(r, 240));   // let the animation finish
970      _draftsSelected.delete(id);
971      _renderEditorDrafts();
972    });
973  });
974  _draftsSyncBulkBar();
975}
976
977function _draftsSyncBulkBar() {
978  const bar = document.getElementById('gallery-editor-drafts-bulk');
979  const countEl = document.getElementById('gallery-editor-drafts-bulk-count');
980  const selectBtn = document.getElementById('gallery-editor-drafts-select');
981  if (bar) bar.classList.toggle('hidden', !_draftsSelectMode);
982  if (countEl) countEl.textContent = `${_draftsSelected.size} selected`;
983  if (selectBtn) {
984    selectBtn.textContent = _draftsSelectMode ? 'Cancel' : 'Select';
985    selectBtn.classList.toggle('active', _draftsSelectMode);
986  }
987  // "All" checkbox state — checked when all visible drafts are selected,
988  // indeterminate when only some (matches the Photos tab).
989  const all = document.getElementById('gallery-editor-drafts-select-all');
990  if (all) {
991    const q = _draftsSearch.trim().toLowerCase();
992    const visible = _draftsCache.filter(d => !q || String(d.name || '').toLowerCase().includes(q));
993    const selVis = visible.filter(d => _draftsSelected.has(d.id)).length;
994    all.checked = visible.length > 0 && selVis === visible.length;
995    all.indeterminate = selVis > 0 && selVis < visible.length;
996  }
997}
998
999let _draftsWired = false;
1000function _draftsWireOnce() {
1001  if (_draftsWired) return;
1002  _draftsWired = true;
1003  document.getElementById('gallery-editor-drafts-search')?.addEventListener('input', (e) => {
1004    _draftsSearch = e.target.value || '';
1005    _draftsPaint();
1006  });
1007  document.getElementById('gallery-editor-drafts-select')?.addEventListener('click', () => {
1008    _draftsSelectMode = !_draftsSelectMode;
1009    if (!_draftsSelectMode) _draftsSelected.clear();
1010    _draftsPaint();
1011  });
1012  document.getElementById('gallery-editor-drafts-select-all')?.addEventListener('change', (e) => {
1013    // Same "All" checkbox behavior as Photos: checked selects every visible
1014    // draft, unchecked clears them (respects the search filter).
1015    const q = _draftsSearch.trim().toLowerCase();
1016    const visible = _draftsCache.filter(d => !q || String(d.name || '').toLowerCase().includes(q));
1017    if (e.target.checked) for (const d of visible) _draftsSelected.add(d.id);
1018    else for (const d of visible) _draftsSelected.delete(d.id);
1019    _draftsPaint();
1020  });
1021  document.getElementById('gallery-editor-drafts-bulk-cancel')?.addEventListener('click', () => {
1022    _draftsSelectMode = false;
1023    _draftsSelected.clear();
1024    _draftsPaint();
1025  });
1026  document.getElementById('gallery-editor-drafts-bulk-delete')?.addEventListener('click', async () => {
1027    if (!_draftsSelected.size) return;
1028    const n = _draftsSelected.size;
1029    const ok = await uiModule.styledConfirm(`Delete ${n} project${n === 1 ? '' : 's'}?`, {
1030      confirmText: 'Delete', cancelText: 'Cancel', danger: true,
1031    });
1032    if (!ok) return;
1033    const ids = [..._draftsSelected];
1034    // Graceful exit on the selected cards before they're removed.
1035    const grid = document.getElementById('gallery-editor-drafts-grid');
1036    if (grid) ids.forEach(id => grid.querySelector(`.gallery-editor-draft-card[data-draft-id="${(window.CSS && CSS.escape) ? CSS.escape(id) : id}"]`)?.classList.add('gallery-draft-removing'));
1037    await new Promise(r => setTimeout(r, 240));
1038    await Promise.allSettled(ids.map(id =>
1039      fetch(`${API_BASE}/api/editor-drafts/${encodeURIComponent(id)}`, {
1040        method: 'DELETE', credentials: 'same-origin',
1041      })
1042    ));
1043    _draftsSelected.clear();
1044    _draftsSelectMode = false;
1045    _renderEditorDrafts();
1046  });
1047}
1048
1049// Human-readable "x minutes ago" / "y days ago" for the drafts list.
1050function _humanRelativeDate(when) {
1051  const diff = (Date.now() - when.getTime()) / 1000;
1052  if (diff < 60) return 'just now';
1053  if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
1054  if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
1055  if (diff < 86400 * 30) return Math.floor(diff / 86400) + 'd ago';
1056  return when.toLocaleDateString();
1057}
1058
1059// Edit tab empty state — shown when the user clicks the tab without a photo
1060// loaded. Lets them start a blank canvas or jump back to pick a photo.
1061function _renderEditorLanding() {
1062  const container = document.getElementById('gallery-editor-container');
1063  if (!container) return;
1064  // openEditor()/closeEditor() may have left the container hidden; the Edit
1065  // tab is still active so make sure the landing is actually visible.
1066  container.style.display = 'flex';
1067  // Templates rendered as a native <select>. Browsers handle all the layout
1068  // and styling natively — no custom flex grid, no clipping, no empty boxes.
1069  // Picking an option fires `change` and goes straight into the editor.
1070  const presets = [
1071    { w: 1024, h: 1024, label: 'Square HD — 1024 × 1024' },
1072    { w: 1920, h: 1080, label: 'Widescreen — 1920 × 1080' },
1073    { w: 1080, h: 1920, label: 'Portrait — 1080 × 1920' },
1074    { w: 1080, h: 1080, label: 'Instagram — 1080 × 1080' },
1075    { w: 1500, h: 1050, label: 'Postcard — 1500 × 1050' },
1076    { w: 2480, h: 3508, label: 'A4 (300dpi) — 2480 × 3508' },
1077    { w: 2550, h: 3300, label: 'Letter (300dpi) — 2550 × 3300' },
1078    { w: 3840, h: 2160, label: '4K — 3840 × 2160' },
1079  ];
1080  const optionsHtml = presets
1081    .map((p, i) => `<option value="${i}">${p.label}</option>`)
1082    .join('');
1083  container.innerHTML = `
1084    <div class="gallery-editor-landing">
1085      <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>
1086      <h3>Image Editor <span class="ge-alpha-tag">Alpha</span></h3>
1087      <p>Start a blank canvas, or open a photo from your gallery to edit it.</p>
1088      <div class="gallery-editor-landing-actions">
1089        <button class="gallery-select-btn" id="gallery-editor-new">New canvas...</button>
1090        <button class="gallery-select-btn" id="gallery-editor-pick">Browse photos</button>
1091      </div>
1092      <label class="gallery-editor-template-label">
1093        Or pick a template
1094        <select class="gallery-editor-template-select" id="gallery-editor-template">
1095          <option value="">Select a size…</option>
1096          ${optionsHtml}
1097        </select>
1098      </label>
1099      <div class="gallery-editor-drafts" id="gallery-editor-drafts" hidden>
1100        <div class="gallery-editor-drafts-header">
1101          <h4 class="gallery-editor-drafts-title">Saved projects</h4>
1102          <input type="search" class="gallery-editor-drafts-search" id="gallery-editor-drafts-search" placeholder="Search projects…" autocomplete="off" />
1103          <button class="gallery-select-btn" id="gallery-editor-drafts-select" title="Toggle multi-select">Select</button>
1104        </div>
1105        <div class="gallery-bulk-bar hidden" id="gallery-editor-drafts-bulk">
1106          <label class="memory-bulk-check-all"><input type="checkbox" id="gallery-editor-drafts-select-all"> All</label>
1107          <span class="gallery-bulk-count" id="gallery-editor-drafts-bulk-count">0 selected</span>
1108          <button class="gallery-bulk-delete" id="gallery-editor-drafts-bulk-delete">
1108<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete selected</button>
1109          <button class="memory-toolbar-btn" id="gallery-editor-drafts-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
1110        </div>
1111        <div class="gallery-editor-drafts-grid" id="gallery-editor-drafts-grid"></div>
1112      </div>
1113    </div>`;
1114  // Each remount of the editor landing rebuilds the drafts header
1115  // markup, so the cached event listener references are stale. Reset.
1116  _draftsWired = false;
1117  _renderEditorDrafts();
1118  document.getElementById('gallery-editor-template')?.addEventListener('change', (e) => {
1119    const idx = parseInt(e.target.value, 10);
1120    if (Number.isNaN(idx)) return;
1121    const p = presets[idx];
1122    if (p) openEditor(null, null, { w: p.w, h: p.h }, `${p.w}×${p.h}`);
1123  });
1124  document.getElementById('gallery-editor-new')?.addEventListener('click', async () => {
1125    // openEditor() now returns a Promise — it's async because the size
1126    // prompt is a styled modal. Await it before checking whether the
1127    // editor actually opened (the user may have cancelled).
1128    await openEditor(null, null, null, 'New canvas');
1129    if (!isEditorOpen()) _renderEditorLanding();
1130  });
1131  document.getElementById('gallery-editor-pick')?.addEventListener('click', () => {
1132    document.querySelector('#gallery-modal .gallery-tab[data-tab="images"]')?.click();
1133  });
1134}
1135
1136// Wire the first-tile Upload affordance in the Photos grid. Opens the same
1137// multi-file picker the old Import button used.
1138function _wireUploadTile() {
1139  const tile = document.getElementById('gallery-upload-tile');
1140  if (!tile) return;
1141  tile.addEventListener('click', () => {
1142    const input = document.createElement('input');
1143    input.type = 'file';
1144    input.accept = 'image/*,video/*';
1145    input.multiple = true;
1146    input.addEventListener('change', () => {
1147      if (input.files.length) _bulkUpload([...input.files], _activeAlbum);
1148    });
1149    input.click();
1150  });
1151}
1152
1153// Shimmer placeholder tiles shown while the FIRST page loads, so the grid
1154// doesn't pop from empty → full (re-opens keep the old photos via
1155// stale-while-revalidate, so skeletons only show when there's nothing yet).
1156function _renderSkeletons(n) {
1157  const grid = document.getElementById('gallery-grid');
1158  if (!grid) return;
1159  const count = Math.max(8, Math.min(n || 12, 20));
1160  let html = '';
1161  for (let i = 0; i < count; i++) html += '<div class="gallery-card gallery-card-skeleton" aria-hidden="true"></div>';
1162  grid.innerHTML = html;
1163  const lm = document.getElementById('gallery-load-more');
1164  if (lm) lm.style.display = 'none';
1165}
1166
1167function _renderGrid() {
1168  const grid = document.getElementById('gallery-grid');
1169  const loadMore = document.getElementById('gallery-load-more');
1170  if (!grid) return;
1171
1172  // First tile: always-visible "Upload" affordance. Mirrors the Upload album
1173  // tile in the Albums tab so the upload entry point is consistent across
1174  // both grids.
1175  const uploadTile = `
1176    <div class="gallery-card gallery-card-upload" id="gallery-upload-tile" title="Upload photos or videos">
1177      <div class="gallery-card-upload-inner">
1178        <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
1179        <div class="gallery-card-upload-label">Upload</div>
1180      </div>
1181    </div>`;
1182
1183  if (_items.length === 0) {
1184    grid.innerHTML = uploadTile + '<div class="gallery-empty">No photos yet. Click Upload or drag-and-drop to get started!</div>';
1185    _wireUploadTile();
1186    if (loadMore) loadMore.style.display = 'none';
1187    return;
1188  }
1189
1190  let html = uploadTile;
1191  _items.forEach(img => {
1192    const date = img.taken_at
1193      ? new Date(img.taken_at).toLocaleDateString()
1194      : (img.created_at ? new Date(img.created_at).toLocaleDateString() : '');
1195    // Card label: prefer the prompt (which doubles as the user-editable
1196    // name for uploaded photos). Fall back to a cleaned filename so
1197    // imported photos with empty prompts still show something useful
1198    // instead of a blank row.
1199    const fallbackName = (img.filename || '')
1200      .replace(/^\d{4,}[_-]/, '')   // drop date-prefix on uploads
1201      .replace(/\.[^.]+$/, '')       // drop extension
1202      .replace(/[_-]+/g, ' ')
1203      .trim();
1204    const labelText = (img.prompt || '').trim() || fallbackName || 'Photo';
1205    const promptPreview = labelText.length > 60 ? labelText.substring(0, 58) + '...' : labelText;
1206    const favCls = img.favorite ? ' gallery-fav-active' : '';
1207    html += `
1208      <div class="gallery-card" data-id="${_esc(img.id)}">
1209        <span class="gallery-select-dot" style="display:none;"></span>
1210        <button class="gallery-fav-btn${favCls}" data-id="${_esc(img.id)}" title="Favorite">&#9829;</button>
1211        <button class="gallery-dl-btn" data-id="${_esc(img.id)}" data-url="${_esc(img.url)}" data-filename="${_esc(img.filename || '')}" title="Download">
1212          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
1213        </button>
1214        ${_isVideoUrl(img.url)
1215          ? `<video src="${_esc(img.url)}" preload="metadata" muted playsinline></video>
1216             <span class="gallery-card-play" aria-hidden="true">
1217               <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
1218             </span>`
1219          : `<img src="${_esc(img.url)}" alt="${_esc(img.prompt)}" loading="lazy" />`}
1220        <div class="gallery-card-info">
1221          <div class="gallery-card-prompt">${_esc(promptPreview)}</div>
1222          <div class="gallery-card-meta">
1223            ${img.model ? `<span class="gallery-card-model">${_esc(img.model)}</span>` : ''}
1224            <span class="gallery-card-date">${date}</span>
1225          </div>
1226        </div>
1227      </div>`;
1228  });
1229  grid.innerHTML = html;
1230  _wireUploadTile();
1231
1232  // Domino-in cascade the first render after opening (not on filter/sort/
1233  // load-more re-renders) — mirrors the document library.
1234  if (!_galleryCascaded) {
1235    _galleryCascaded = true;
1236    grid.classList.add('gallery-just-opened');
1237    setTimeout(() => grid.classList.remove('gallery-just-opened'), 900);
1238  }
1239
1240  if (loadMore) {
1241    loadMore.style.display = _items.length < _total ? 'block' : 'none';
1242  }
1243
1244  // Card click → detail (skip the upload tile, it has its own handler)
1245  grid.querySelectorAll('.gallery-card[data-id]').forEach(card => {
1246    card.addEventListener('click', (e) => {
1247      if (e.target.closest('.gallery-fav-btn')) return;
1248      if (e.target.closest('.gallery-dl-btn')) return;
1249      const selectBtn = document.getElementById('gallery-select-btn');
1250      if (selectBtn && selectBtn.classList.contains('active')) return;
1251      const img = _items.find(i => i.id === card.dataset.id);
1252      if (img) _openDetail(img);
1253    });
1254  });
1255
1256  // Download buttons
1257  grid.querySelectorAll('.gallery-dl-btn').forEach(btn => {
1258    btn.addEventListener('click', async (e) => {
1259      e.stopPropagation();
1260      const url = btn.dataset.url;
1261      const filename = btn.dataset.filename || `image-${btn.dataset.id}.png`;
1262      try {
1263        const res = await fetch(url, { credentials: 'same-origin' });
1264        const blob = await res.blob();
1265        const objUrl = URL.createObjectURL(blob);
1266        const a = document.createElement('a');
1267        a.href = objUrl;
1268        a.download = filename;
1269        document.body.appendChild(a);
1270        a.click();
1271        a.remove();
1272        setTimeout(() => URL.revokeObjectURL(objUrl), 1000);
1273      } catch (_) {
1274        const a = document.createElement('a');
1275        a.href = url;
1276        a.download = filename;
1277        document.body.appendChild(a);
1278        a.click();
1279        a.remove();
1280      }
1281    });
1282  });
1283
1284  // Favorite buttons
1285  grid.querySelectorAll('.gallery-fav-btn').forEach(btn => {
1286    btn.addEventListener('click', async (e) => {
1287      e.stopPropagation();
1288      const id = btn.dataset.id;
1289      const res = await fetch(`${API_BASE}/api/gallery/${id}/favorite`, {
1290        method: 'POST', credentials: 'same-origin',
1291      });
1292      const data = await res.json();
1293      if (data.ok) {
1294        btn.classList.toggle('gallery-fav-active', data.favorite);
1295        const item = _items.find(i => i.id === id);
1296        if (item) item.favorite = data.favorite;
1297      }
1298    });
1299  });
1300}
1301
1302// ---- Detail overlay ----
1303
1304function _openDetail(img) {
1305  const detail = document.getElementById('gallery-detail');
1306  if (!detail) return;
1307  // Drop any face-overlay resize listener from the previous photo
1308  // before the new render attaches its own.
1309
1310  const _dateSrc = img.taken_at || img.created_at || null;
1311  const _dateObj = _dateSrc ? new Date(_dateSrc) : null;
1312  const _relAgo = (d) => {
1313    if (!d || isNaN(d.getTime())) return '';
1314    const secs = (Date.now() - d.getTime()) / 1000;
1315    if (secs < 0) return '';
1316    if (secs < 60) return 'just now';
1317    if (secs < 3600) { const m = Math.floor(secs / 60); return `${m} minute${m !== 1 ? 's' : ''} ago`; }
1318    if (secs < 86400) { const h = Math.floor(secs / 3600); return `${h} hour${h !== 1 ? 's' : ''} ago`; }
1319    if (secs < 86400 * 7) { const d2 = Math.floor(secs / 86400); return `${d2} day${d2 !== 1 ? 's' : ''} ago`; }
1320    if (secs < 86400 * 30) { const w = Math.floor(secs / (86400 * 7)); return `${w} week${w !== 1 ? 's' : ''} ago`; }
1321    if (secs < 86400 * 365) { const mo = Math.floor(secs / (86400 * 30)); return `${mo} month${mo !== 1 ? 's' : ''} ago`; }
1322    const y = Math.floor(secs / (86400 * 365));
1323    return `${y} year${y !== 1 ? 's' : ''} ago`;
1324  };
1325  const date = _dateObj
1326    ? `${_dateObj.toLocaleString()}<span class="gallery-date-rel"> (${_relAgo(_dateObj)})</span>`
1327    : 'Unknown';
1328  const userTags = img.user_tags || img.tags || '';
1329  const aiTags = img.ai_tags || '';
1330  const dims = img.width && img.height ? `${img.width} x ${img.height}` : (img.size || 'Unknown');
1331  const fileSize = img.file_size ? _humanSize(img.file_size) : '';
1332  // "Edited" row: only show when updated_at is meaningfully later than
1333  // created_at (>10s). Every photo bumps updated_at on insert via the
1334  // ORM timestamp mixin, so the gap filters out the trivial case.
1335  let editedHtml = '';
1336  if (img.updated_at && img.created_at) {
1337    const u = new Date(img.updated_at);
1338    const c = new Date(img.created_at);
1339    if (!isNaN(u) && !isNaN(c) && (u.getTime() - c.getTime() > 10000)) {
1340      editedHtml = `<div class="gallery-detail-section"><label>Edited</label><div>${u.toLocaleString()}<span class="gallery-date-rel"> (${_relAgo(u)})</span></div></div>`;
1341    }
1342  }
1343
1344  detail.innerHTML = `
1345    <div class="gallery-detail-header">
1346      <button class="gallery-detail-back" id="gallery-detail-back">&larr; Back</button>
1347      <div style="flex:1"></div>
1348      <button class="gallery-detail-back" id="gallery-edit-direct-btn" title="Edit (E)" aria-label="Edit photo" style="display:inline-flex;align-items:center;gap:4px;">
1349        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
1350        Edit
1351      </button>
1352      <button class="gallery-detail-back gallery-detail-fav-header${img.favorite ? ' active' : ''}" id="gallery-detail-fav-header" title="${img.favorite ? 'Unfavorite' : 'Favorite'}" aria-label="Favorite" aria-pressed="${img.favorite ? 'true' : 'false'}" style="display:inline-flex;align-items:center;justify-content:center;padding:4px 8px;">
1353        <svg width="14" height="14" viewBox="0 0 24 24" fill="${img.favorite ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
1354      </button>
1355      <div class="gallery-detail-menu-wrap">
1356        <button class="gallery-detail-action gallery-detail-menu-btn" id="gallery-detail-menu-btn" title="Actions" aria-label="Photo actions">
1357          <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
1358        </button>
1359        <div class="gallery-detail-menu dropdown" id="gallery-detail-menu" hidden>
1360          <button class="dropdown-item-compact" id="gallery-fav-detail">
1361            <span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="${img.favorite ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></span>
1362            ${img.favorite ? 'Favorited' : 'Favorite'}
1363          </button>
1364          <button class="dropdown-item-compact" id="gallery-ai-tag-btn" data-mode="${aiTags ? 'clear' : 'tag'}">
1365            <span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg></span>
1366            ${aiTags ? 'Clear AI tags' : 'AI Tag'}
1367          </button>
1368          <button class="dropdown-item-compact" id="gallery-download-btn">
1369            <span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
1370            Download
1371          </button>
1372          ${img.album_id ? `<button class="dropdown-item-compact" id="gallery-set-cover-btn">
1373            <span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></span>
1374            Set as album cover
1375          </button>` : ''}
1376          <button class="dropdown-item-compact dropdown-item-danger" id="gallery-delete-btn">
1377            <span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></span>
1378            Delete
1379          </button>
1380        </div>
1381      </div>
1382    </div>
1383    <div class="gallery-detail-body">
1384      <div class="gallery-detail-image" id="gallery-detail-image-wrap" style="position:relative">
1385        <button class="gallery-detail-rotate gallery-detail-rotate-ccw" id="gallery-rotate-ccw-btn" title="Rotate 90° counter-clockwise" aria-label="Rotate left">
1386          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
1387        </button>
1388        <button class="gallery-detail-rotate gallery-detail-rotate-cw" id="gallery-rotate-btn" title="Rotate 90° clockwise" aria-label="Rotate right">
1389          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
1390        </button>
1391        <button class="gallery-detail-nav gallery-detail-nav-prev" id="gallery-detail-prev" title="Previous (←)" aria-label="Previous">
1392          <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
1393        </button>
1394        <div class="gallery-detail-img-frame">
1395          ${_isVideoUrl(img.url)
1396            ? `<video id="gallery-detail-img" src="${_esc(img.url)}" controls preload="metadata" playsinline></video>`
1397            : `<img id="gallery-detail-img" src="${_esc(img.url)}" alt="${_esc(img.prompt)}" />`}
1398          <div id="gallery-detail-face-overlay" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none"></div>
1399        </div>
1400        <button class="gallery-detail-nav gallery-detail-nav-next" id="gallery-detail-next" title="Next (→)" aria-label="Next">
1401          <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
1402        </button>
1403      </div>
1404      <div class="gallery-detail-sidebar">
1405        <div class="gallery-detail-section">
1406          <label>Name</label>
1407          <div class="gallery-name-wrap">
1408            <input type="text" class="gallery-detail-name-input" id="gallery-detail-name-input"
1409              value="${_esc(img.prompt || '')}" placeholder="Untitled photo (press Enter to save)" />
1410            <svg class="gallery-name-enter" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg>
1411          </div>
1412        </div>
1413        ${img.prompt && img.model !== 'imported' ? `<div class="gallery-detail-section"><label>Prompt</label><div class="gallery-detail-prompt">${_esc(img.prompt)}</div></div>` : ''}
1414        <div class="gallery-detail-section gallery-detail-section-date">
1415          <label>Date</label>
1416          <div>${date}</div>
1417        </div>
1418        ${editedHtml}
1419        <div class="gallery-detail-section">
1420          <label>Dimensions</label>
1421          <div>${dims}${fileSize ? ` (${fileSize})` : ''}</div>
1422        </div>
1423        ${img.camera ? `<div class="gallery-detail-section"><label>Camera</label><div>${_esc(img.camera)}</div></div>` : ''}
1424        ${img.gps ? `<div class="gallery-detail-section"><label>Location</label><div>${img.gps.lat}, ${img.gps.lng}</div></div>` : ''}
1425        ${img.model ? `<div class="gallery-detail-section"><label>Source</label><div>${_esc(img.model)}
1425</div></div>` : ''}
1426        ${img.session_name ? `<div class="gallery-detail-section"><label>Session</label><div>${_esc(img.session_name)}</div></div>` : ''}
1427        ${aiTags ? `<div class="gallery-detail-section"><label>AI Tags</label><div class="gallery-ai-tags">${aiTags.split(',').map(t => t.trim()).filter(Boolean).map(t => `<button class="gallery-ai-chip gallery-aitag-chip" data-tag-filter="${_esc(t)}" title="AI-generated tag — click to filter to photos tagged “${_esc(t)}”"><span class="gallery-aitag-mark" aria-hidden="true">✦</span>${_esc(t)}</button>`).join('')}</div></div>` : ''}
1428        <div class="gallery-detail-section">
1429          <label>Tags</label>
1430          <div class="gallery-ai-tags" id="gallery-user-tag-chips">${userTags.split(',').map(t => t.trim()).filter(Boolean).map(t => `<button class="gallery-ai-chip gallery-user-chip" data-tag-filter="${_esc(t)}" title="Filter to photos tagged “${_esc(t)}”">${_esc(t)}<span class="gallery-tag-x" title="Remove tag" aria-label="Remove tag">×</span></button>`).join('')}</div>
1431          <div class="gallery-tag-input-wrap">
1432            <input type="text" class="gallery-tag-input" id="gallery-tag-input"
1433              value="" placeholder="Add a tag" title="Type a tag and press Enter to add it" />
1434            <span class="gallery-tag-enter-hint" aria-hidden="true">↵</span>
1435          </div>
1436        </div>
1437        <div class="gallery-detail-section">
1438          <label>Album</label>
1439          <select id="gallery-detail-album" class="gallery-tag-input" style="padding:4px 6px;">
1440            <option value="">None</option>
1441            ${_albums.map(a => `<option value="${a.id}" ${img.album_id === a.id ? 'selected' : ''}>${_esc(a.name)}</option>`).join('')}
1442          </select>
1443        </div>
1444        <div class="gallery-detail-section" id="gallery-detail-people-section" style="display:none">
1445          <label>People in this photo</label>
1446          <div id="gallery-detail-people-list" class="gallery-detail-people"></div>
1447        </div>
1448      </div>
1449    </div>
1450  `;
1451  detail.style.display = 'flex';
1452
1453  document.getElementById('gallery-detail-back').addEventListener('click', () => {
1454    detail.style.display = 'none';
1455  });
1456
1457  // Clickable tag chips — both AI Tags and User Tags. Clicking a chip
1458  // closes the detail, sets the tag filter on the main grid, and
1459  // re-fetches so the user sees other photos with that tag.
1460  // Remove a user tag from this photo (the × on a tag chip).
1461  const _removeUserTag = async (tag, chip) => {
1462    const existing = (img.user_tags || img.tags || '').split(',').map(t => t.trim()).filter(Boolean);
1463    const remaining = existing.filter(e => e.toLowerCase() !== String(tag).toLowerCase());
1464    const cleaned = remaining.join(', ');
1465    const ok = await _patchImage(img.id, { tags: cleaned });
1466    if (!ok) { if (uiModule) uiModule.showError('Failed to remove tag'); return; }
1467    img.tags = cleaned;
1468    img.user_tags = cleaned;
1469    chip.remove();
1470  };
1471  detail.querySelectorAll('[data-tag-filter]').forEach(chip => {
1472    chip.addEventListener('click', (e) => {
1473      e.stopPropagation();
1474      // × removes the tag (user chips only) instead of filtering.
1475      if (e.target.closest('.gallery-tag-x')) { _removeUserTag(chip.dataset.tagFilter, chip); return; }
1476      const tag = chip.dataset.tagFilter;
1477      if (!tag) return;
1478      if (!_activeTags.includes(tag)) _activeTags.push(tag);
1479      _activeAlbum = null;
1480      _favoritesOnly = false;
1481      detail.style.display = 'none';
1482      // Ensure we're looking at the Photos tab.
1483      const photosTab = document.querySelector('#gallery-modal .gallery-tab[data-tab="images"]');
1484      photosTab?.click();
1485      _fetchLibrary(false);
1486      _renderAlbums();
1487    });
1488  });
1489
1490  // Overflow menu — single ⋮ button on the right that hosts all the action
1491  // items. Clicking any item closes the menu (per-item handlers also fire).
1492  const menuBtn = document.getElementById('gallery-detail-menu-btn');
1493  const menu = document.getElementById('gallery-detail-menu');
1494  if (menuBtn && menu) {
1495    // `.dropdown { display:none }` isn't tied to [hidden] — set inline display.
1496    const _setMenu = (show) => { menu.hidden = !show; menu.style.display = show ? 'block' : 'none'; };
1497    _setMenu(false);
1498    menuBtn.addEventListener('click', (e) => {
1499      e.stopPropagation();
1500      _setMenu(menu.hidden);
1501    });
1502    menu.addEventListener('click', () => { _setMenu(false); });
1503    // Click outside closes the menu.
1504    document.addEventListener('click', (e) => {
1505      if (!menu.hidden && !menu.contains(e.target) && e.target !== menuBtn) _setMenu(false);
1506    });
1507  }
1508
1509  const _toggleDetailFavorite = async () => {
1510    const res = await fetch(`${API_BASE}/api/gallery/${img.id}/favorite`, {
1511      method: 'POST', credentials: 'same-origin',
1512    });
1513    const data = await res.json();
1514    if (!data.ok) return;
1515    img.favorite = data.favorite;
1516    const menuItem = document.getElementById('gallery-fav-detail');
1517    if (menuItem) menuItem.innerHTML = data.favorite ? '&#9829; Favorited' : '&#9825; Favorite';
1518    const headerBtn = document.getElementById('gallery-detail-fav-header');
1519    if (headerBtn) {
1520      headerBtn.setAttribute('aria-pressed', data.favorite ? 'true' : 'false');
1521      headerBtn.setAttribute('title', data.favorite ? 'Unfavorite' : 'Favorite');
1522      const svg = headerBtn.querySelector('svg');
1523      if (svg) svg.setAttribute('fill', data.favorite ? 'currentColor' : 'none');
1524    }
1525  };
1526  document.getElementById('gallery-fav-detail').addEventListener('click', _toggleDetailFavorite);
1527  document.getElementById('gallery-detail-fav-header')?.addEventListener('click', _toggleDetailFavorite);
1528
1529  document.getElementById('gallery-ai-tag-btn').addEventListener('click', async (e) => {
1530    // When the photo already has AI tags this button is "Clear AI tags".
1531    const clearMode = e.currentTarget.dataset.mode === 'clear';
1532    // The button lives in the ⋮ menu which closes on click, so its text never
1533    // shows — surface a whirlpool overlay on the image instead.
1534    const stage = document.getElementById('gallery-detail-image-wrap') || document.getElementById('gallery-detail-img')?.parentElement;
1535    let overlay = null, spinner = null;
1536    if (stage) {
1537      overlay = document.createElement('div');
1538      overlay.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;align-items:center;justify-content:center;background:color-mix(in srgb, var(--bg) 55%, transparent);z-index:5;';
1539      try {
1540        spinner = spinnerModule.createWhirlpool(36);
1541        spinner.element.style.cssText = 'width:36px;height:36px;margin:0;';
1542        overlay.appendChild(spinner.element);
1543        const label = document.createElement('div');
1544        label.textContent = clearMode ? 'Clearing…' : 'AI tagging…';
1545        label.style.cssText = 'font-size:11px;opacity:0.7;';
1546        overlay.appendChild(label);
1547      } catch (_) { overlay.textContent = clearMode ? 'Clearing…' : 'AI tagging…'; }
1548      if (getComputedStyle(stage).position === 'static') stage.style.position = 'relative';
1549      stage.appendChild(overlay);
1550    }
1551    const cleanup = () => { try { spinner?.destroy?.(); } catch {} overlay?.remove(); };
1552    try {
1553      const url = clearMode
1554        ? `${API_BASE}/api/gallery/clear-ai-tags?image_id=${encodeURIComponent(img.id)}`
1555        : `${API_BASE}/api/gallery/${img.id}/ai-tag`;
1556      const res = await fetch(url, { method: 'POST', credentials: 'same-origin' });
1557      const data = await res.json();
1558      cleanup();
1559      if (data.ok) {
1560        img.ai_tags = clearMode ? '' : data.ai_tags;
1561        uiModule.showToast(clearMode ? 'AI tags cleared' : 'AI tags added');
1562        _openDetail(img); // re-render detail
1563      } else {
1564        uiModule.showError(data.error || (clearMode ? 'Clear failed' : 'AI tagging failed'));
1565      }
1566    } catch (e2) {
1567      cleanup();
1568      uiModule.showError(clearMode ? 'Clear failed' : 'AI tagging failed');
1569    }
1570  });
1571
1572  document.getElementById('gallery-download-btn').addEventListener('click', async () => {
1573    try {
1574      const res = await fetch(img.url, { credentials: 'same-origin' });
1575      const blob = await res.blob();
1576      const objUrl = URL.createObjectURL(blob);
1577      const a = document.createElement('a');
1578      a.href = objUrl;
1579      a.download = img.filename || `image-${img.id}.png`;
1580      document.body.appendChild(a);
1581      a.click();
1582      a.remove();
1583      setTimeout(() => URL.revokeObjectURL(objUrl), 1000);
1584    } catch (e) {
1585      // Fallback: direct link
1586      const a = document.createElement('a');
1587      a.href = img.url;
1588      a.download = img.filename || `image-${img.id}.png`;
1589      document.body.appendChild(a);
1590      a.click();
1591      a.remove();
1592    }
1593  });
1594
1595  // Whirlpool while the (newly opened/navigated) image loads — cached images
1596  // report `complete` immediately, so no spinner flash for those.
1597  const _imgEl = document.getElementById('gallery-detail-img');
1598  const _frame = detail.querySelector('.gallery-detail-img-frame');
1599  if (_imgEl && _frame && _imgEl.tagName === 'IMG' && !_imgEl.complete) {
1600    const ld = document.createElement('div');
1601    ld.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb, var(--bg) 40%, transparent);z-index:4;pointer-events:none;border-radius:6px;';
1602    let _sp = null;
1603    try { _sp = spinnerModule.createWhirlpool(34); _sp.element.style.cssText = 'width:34px;height:34px;margin:0;'; ld.appendChild(_sp.element); } catch (_) {}
1604    _frame.appendChild(ld);
1605    const _done = () => { try { _sp?.destroy?.(); } catch {} ld.remove(); };
1606    _imgEl.addEventListener('load', _done, { once: true });
1607    _imgEl.addEventListener('error', _done, { once: true });
1608  }
1609
1610  // Prev/Next navigation
1611  const curIdx = _items.findIndex(i => i.id === img.id);
1612  const prevBtn = document.getElementById('gallery-detail-prev');
1613  const nextBtn = document.getElementById('gallery-detail-next');
1614  if (curIdx <= 0) prevBtn.classList.add('gallery-detail-nav-disabled');
1615  if (curIdx < 0 || curIdx >= _items.length - 1) nextBtn.classList.add('gallery-detail-nav-disabled');
1616
1617  prevBtn.addEventListener('click', (e) => {
1618    e.stopPropagation();
1619    if (curIdx > 0) _openDetail(_items[curIdx - 1]);
1620  });
1621  nextBtn.addEventListener('click', (e) => {
1622    e.stopPropagation();
1623    if (curIdx >= 0 && curIdx < _items.length - 1) _openDetail(_items[curIdx + 1]);
1624  });
1625
1626  // Mobile swipe — horizontal one-finger swipe across the image wrap moves
1627  // between photos. Skips multi-touch (pinch-zoom) and lets the video
1628  // controls handle their own touches.
1629  const wrap = document.getElementById('gallery-detail-image-wrap');
1630  if (wrap) {
1631    let sx = 0, sy = 0, st = 0, tracking = false;
1632    wrap.addEventListener('touchstart', (e) => {
1633      if (e.touches.length !== 1) { tracking = false; return; }
1634      if (e.target.closest('video, button')) { tracking = false; return; }
1635      const t = e.touches[0];
1636      sx = t.clientX; sy = t.clientY; st = Date.now();
1637      tracking = true;
1638    }, { passive: true });
1639    wrap.addEventListener('touchend', (e) => {
1640      if (!tracking) return;
1641      tracking = false;
1642      const t = e.changedTouches[0];
1643      const dx = t.clientX - sx;
1644      const dy = t.clientY - sy;
1645      const dt = Date.now() - st;
1646      // Horizontal flick: > 40px, dominantly horizontal, under 800ms.
1647      if (dt > 800) return;
1648      if (Math.abs(dx) < 40) return;
1649      if (Math.abs(dx) < Math.abs(dy) * 1.2) return;
1650      if (dx < 0 && curIdx < _items.length - 1) _openDetail(_items[curIdx + 1]);
1651      else if (dx > 0 && curIdx > 0) _openDetail(_items[curIdx - 1]);
1652    }, { passive: true });
1653  }
1654
1655  const _openInEditor = () => {
1656    try {
1657      detail.style.display = 'none';
1658      const modal = document.getElementById('gallery-modal');
1659      if (modal) {
1660        modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active'));
1661        modal.querySelector('.gallery-tab[data-tab="editor"]')?.classList.add('active');
1662      }
1663      const imagesContainer = document.getElementById('gallery-images-container');
1664      const albumsContainer = document.getElementById('gallery-albums-container');
1665      if (imagesContainer) imagesContainer.style.display = 'none';
1666      if (albumsContainer) albumsContainer.style.display = 'none';
1667      const editorContainer = document.getElementById('gallery-editor-container');
1668      if (editorContainer) editorContainer.style.display = 'flex';
1669      const baseFilename = (img.filename || '').replace(/\.[^.]+$/, '');
1670      const label = img.prompt?.trim() || baseFilename || 'Photo';
1671      openEditor(img.url, img.id, null, label);
1672    } catch (e) {
1673      console.error('[edit] failed:', e);
1674      if (uiModule) uiModule.showError('Failed to open editor: ' + (e?.message || 'unknown'));
1675    }
1676  };
1677  document.getElementById('gallery-edit-btn')?.addEventListener('click', _openInEditor);
1678  document.getElementById('gallery-edit-direct-btn')?.addEventListener('click', _openInEditor);
1679
1680  // Rotate — server-side image rotation. Forces a fresh URL afterwards
1681  // so the browser doesn't show the old cached version. Shows a
1682  // whirlpool over the detail image while the request + reload are in
1683  // flight so the user sees the action is processing.
1684  const _rotate = async (angle) => {
1685    const stage = document.querySelector('.gallery-detail-img-stage') || document.getElementById('gallery-detail-img')?.parentElement;
1686    let overlay = null;
1687    let spinner = null;
1688    if (stage) {
1689      overlay = document.createElement('div');
1690      overlay.className = 'gallery-detail-rotate-loading';
1691      overlay.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb, var(--bg) 55%, transparent);z-index:5;pointer-events:none;';
1692      try {
1693        spinner = spinnerModule.createWhirlpool(36);
1694        spinner.element.style.cssText = 'width:36px;height:36px;margin:0;';
1695        overlay.appendChild(spinner.element);
1696      } catch (_) { overlay.textContent = 'Rotating…'; }
1697      if (getComputedStyle(stage).position === 'static') stage.style.position = 'relative';
1698      stage.appendChild(overlay);
1699    }
1700    const cleanup = () => {
1701      try { spinner?.destroy?.(); } catch {}
1702      overlay?.remove();
1703    };
1704    try {
1705      const r = await fetch(`${API_BASE}/api/gallery/${img.id}/rotate`, {
1706        method: 'POST',
1707        headers: { 'Content-Type': 'application/json' },
1708        credentials: 'same-origin',
1709        body: JSON.stringify({ angle }),
1710      });
1711      if (!r.ok) { cleanup(); uiModule.showError('Rotate failed'); return; }
1712      // Cache-bust the image in the detail view, then wait for the new
1713      // image to actually load before clearing the spinner so the user
1714      // doesn't see a flash of the old/blank image.
1715      const imgEl = document.getElementById('gallery-detail-img');
1716      if (imgEl) {
1717        const newSrc = img.url + (img.url.includes('?') ? '&' : '?') + 't=' + Date.now();
1718        await new Promise((resolve) => {
1719          imgEl.onload = imgEl.onerror = () => { imgEl.onload = null; imgEl.onerror = null; resolve(); };
1720          imgEl.src = newSrc;
1721        });
1722      }
1723      cleanup();
1724      uiModule.showToast('Rotated');
1725      _fetchLibrary(false);
1726    } catch (e) {
1727      cleanup();
1728      uiModule.showError('Rotate failed');
1729    }
1730  };
1731  document.getElementById('gallery-rotate-btn')?.addEventListener('click', () => _rotate(90));
1732  document.getElementById('gallery-rotate-ccw-btn')?.addEventListener('click', () => _rotate(-90));
1733
1734  // Set as album cover — only present if the photo is currently in an album.
1735  document.getElementById('gallery-set-cover-btn')?.addEventListener('click', async () => {
1736    if (!img.album_id) return;
1737    try {
1738      const r = await fetch(`${API_BASE}/api/gallery/albums/${img.album_id}`, {
1739        method: 'PUT',
1740        headers: { 'Content-Type': 'application/json' },
1741        credentials: 'same-origin',
1742        body: JSON.stringify({ cover_id: img.id }),
1743      });
1744      if (r.ok) {
1745        uiModule.showToast('Album cover updated');
1746        await _fetchAlbums();
1747      } else {
1748        uiModule.showError('Failed to set cover');
1749      }
1750    } catch (e) {
1751      uiModule.showError('Failed to set cover');
1752    }
1753  });
1754
1755  document.getElementById('gallery-delete-btn').addEventListener('click', async () => {
1756    if (!await uiModule.styledConfirm('Delete this photo? This cannot be undone.', { confirmText: 'Delete', danger: true })) return;
1757    const ok = await _deleteImage(img.id);
1758    if (!ok) {
1759      uiModule.showError('Failed to delete photo');
1760      return;
1761    }
1762    detail.style.display = 'none';
1763    _items = _items.filter(i => i.id !== img.id);
1764    _total = Math.max(0, _total - 1);
1765    _renderGrid();
1766    _renderStats();
1767    if (uiModule) uiModule.showToast('Photo deleted');
1768  });
1769
1770  // Tag input — Enter saves; also strips a leading '#' from each tag so
1771  // typing "#person, #beach" stores as "person, beach".
1772  // Rename input — saves to the prompt column on Enter/blur via the
1773  // dedicated rename endpoint.
1774  const _nameInput = document.getElementById('gallery-detail-name-input');
1775  if (_nameInput) {
1776    const _saveName = async () => {
1777      const newName = _nameInput.value.trim();
1778      if (newName === (img.prompt || '')) return;
1779      try {
1780        const r = await fetch(`${API_BASE}/api/gallery/${img.id}/rename`, {
1781          method: 'POST',
1782          headers: { 'Content-Type': 'application/json' },
1783          credentials: 'same-origin',
1784          body: JSON.stringify({ name: newName }),
1785        });
1786        if (!r.ok) throw new Error('Failed');
1787        img.prompt = newName;
1788        if (uiModule) uiModule.showToast('Renamed');
1789        window.dispatchEvent(new CustomEvent('gallery-refresh'));
1790      } catch {
1791        if (uiModule) uiModule.showError('Failed to rename');
1792      }
1793    };
1794    _nameInput.addEventListener('keydown', (e) => {
1795      if (e.key === 'Enter') { e.preventDefault(); _saveName(); _nameInput.blur(); }
1796    });
1797    _nameInput.addEventListener('blur', _saveName);
1798  }
1799  const _tagInput = document.getElementById('gallery-tag-input');
1800  if (_tagInput) {
1801    // Wire a tag chip's click-to-filter (same behavior as the chips rendered at
1802    // open) so chips we add live still work.
1803    const _wireTagChip = (chip) => {
1804      chip.addEventListener('click', (e) => {
1805        e.stopPropagation();
1806        if (e.target.closest('.gallery-tag-x')) { _removeUserTag(chip.dataset.tagFilter, chip); return; }
1807        const tag = chip.dataset.tagFilter;
1808        if (!tag) return;
1809        if (!_activeTags.includes(tag)) _activeTags.push(tag);
1810        _activeAlbum = null;
1811        _favoritesOnly = false;
1812        detail.style.display = 'none';
1813        document.querySelector('#gallery-modal .gallery-tab[data-tab="images"]')?.click();
1814        _fetchLibrary(false);
1815        _renderAlbums();
1816      });
1817    };
1818    // The input is an ADD field: type a tag, press Enter → it's appended to the
1819    // photo's tags, the field clears, and a chip appears immediately. No re-render.
1820    const _addTags = async () => {
1821      const newTags = _tagInput.value.split(',').map(t => t.trim().replace(/^#+/, '').trim()).filter(Boolean);
1822      _tagInput.value = '';
1823      if (!newTags.length) return;
1824      const existing = (img.user_tags || img.tags || '').split(',').map(t => t.trim()).filter(Boolean);
1825      const merged = existing.slice();
1826      const added = [];
1827      for (const t of newTags) {
1828        if (!merged.some(e => e.toLowerCase() === t.toLowerCase())) { merged.push(t); added.push(t); }
1829      }
1830      if (!added.length) return;
1831      const cleaned = merged.join(', ');
1832      const ok = await _patchImage(img.id, { tags: cleaned });
1833      if (!ok) { if (uiModule) uiModule.showError('Failed to save tags'); return; }
1834      img.tags = cleaned;
1835      img.user_tags = cleaned;
1836      const chips = document.getElementById('gallery-user-tag-chips');
1837      if (chips) {
1838        added.forEach(t => {
1839          const b = document.createElement('button');
1840          b.className = 'gallery-ai-chip gallery-user-chip';
1841          b.dataset.tagFilter = t;
1842          b.title = `Filter to photos tagged “${t}”`;
1843          b.textContent = t;
1844          const x = document.createElement('span');
1845          x.className = 'gallery-tag-x';
1846          x.title = 'Remove tag';
1847          x.setAttribute('aria-label', 'Remove tag');
1848          x.textContent = '×';
1849          b.appendChild(x);
1850          chips.appendChild(b);
1851          _wireTagChip(b);
1852        });
1853      }
1854    };
1855    _tagInput.addEventListener('keydown', (e) => {
1856      if (e.key === 'Enter') { e.preventDefault(); _addTags(); }
1857    });
1858    // Tap-away on mobile still adds whatever's typed.
1859    _tagInput.addEventListener('blur', () => { if (_tagInput.value.trim()) _addTags(); });
1860  }
1861
1862  document.getElementById('gallery-detail-album').addEventListener('change', async (e) => {
1863    const albumId = e.target.value;
1864    const ok = await _patchImage(img.id, { album_id: albumId || '' });
1865    if (!ok) { uiModule.showError('Failed to update album'); return; }
1866    img.album_id = albumId || null;
1867    uiModule.showToast(albumId ? 'Added to album' : 'Removed from album');
1868  });
1869}
1870
1871
1872function _makeGalleryDraggable(content) {
1873  if (!content) return;
1874  const header = content.querySelector('.modal-header');
1875  if (!header) return;
1876  const modal = content.closest('.modal') || content;
1877  makeWindowDraggable(modal, { content, header });
1878}
1879
1880// ---- Open / Close ----
1881
1882// Re-export the manager for the rail click handler
1883import * as Modals from './modalManager.js';
1884
1885export function openGallery() {
1886  // If already minimized — restore in place, preserve all state
1887  if (Modals.isRegistered('gallery-modal') && Modals.isMinimized('gallery-modal')) {
1888    Modals.restore('gallery-modal');
1889    return;
1890  }
1891  if (_open) return;
1892  _open = true;
1893  _galleryCascaded = false;   // replay the domino-in cascade on each open
1894  // State is preserved across close/reopen — filters, album, sort, items,
1895  // albums, people — so reopening the gallery feels instant. Use the search
1896  // input or "All" chip to clear the active filter.
1897  // Exception: when sort is shuffle, regenerate the seed every open so the
1898  // user gets a fresh order each visit (the whole point of shuffle). Also
1899  // CLEAR the cached items so the user doesn't see the stale random order
1900  // flash up and then swap to the new order when the fetch resolves —
1901  // skeletons during the brief refetch read as intentional, the swap doesn't.
1902  if (_sort === 'shuffle') {
1903    _shuffleSeed = Math.floor(Math.random() * 2 ** 31);
1904    _items = [];
1905  }
1906
1907  const modal = document.createElement('div');
1908  modal.className = 'modal';
1909  modal.id = 'gallery-modal';
1910  modal.innerHTML = `
1911    <div class="modal-content gallery-modal-content">
1912      <div class="modal-header">
1913        <h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Gallery <span id="gallery-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal;margin-left:8px"></span></h4>
1914        <button class="modal-close" id="gallery-close">&times;</button>
1915      </div>
1916      <div class="gallery-tabs">
1917        <button class="gallery-tab active" data-tab="images">
1918          <span class="gallery-tab-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></span>
1919          <span class="gallery-tab-label">Photos</span>
1920        </button>
1921        <button class="gallery-tab" data-tab="albums">
1922          <span class="gallery-tab-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg></span>
1923          <span class="gallery-tab-label">Albums</span>
1924        </button>
1925        <button class="gallery-tab" data-tab="editor" id="gallery-editor-tab">
1926          <span class="gallery-tab-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span>
1927          <span class="gallery-tab-label">Edit</span>
1928          <span class="gallery-tab-close" id="gallery-editor-tab-close" title="Close edit" aria-label="Close edit">×</span>
1929        </button>
1930        <button class="gallery-tab" data-tab="settings">
1931          <span class="gallery-tab-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
1932          <span class="gallery-tab-label">Settings</span>
1933        </button>
1934      </div>
1935      <div class="modal-body">
1936        <div id="gallery-upload-bar" style="display:none;padding:4px 8px 0;">
1937          <div style="background:var(--border);border-radius:4px;overflow:hidden;height:6px;">
1938            <div id="gallery-upload-progress" style="height:100%;background:var(--accent, var(--red));width:0%;transition:width 0.2s;"></div>
1939          </div>
1940          <div id="gallery-upload-status" style="font-size:10px;opacity:0.5;margin-top:2px;"></div>
1941        </div>
1942        <div class="gallery-images-container" id="gallery-images-container" style="margin-top:2px">
1943        <div class="gallery-album-chips" id="gallery-album-chips"></div>
1944        <div class="gallery-album-chips gallery-people-chips" id="gallery-people-chips" style="display:none"></div>
1945        <div class="gallery-toolbar">
1946          <div class="gallery-search-wrap">
1947            <input type="text" class="gallery-search" id="gallery-search" placeholder="Search photos, tags..." />
1948            <span class="gallery-search-enter-hint" aria-hidden="true"><svg class="gallery-enter-key" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg>to tag</span>
1949          </div>
1950          <span class="gallery-toolbar-break" aria-hidden="true"></span>
1951          <select class="gallery-model-filter" id="gallery-model-filter">
1952            <option value="">All sources</option>
1953          </select>
1954          <select class="gallery-sort" id="gallery-sort">
1955            <option value="shuffle">Random</option>
1956            <option value="recent">Recent</option>
1957            <option value="oldest">
1957Oldest</option>
1958          </select>
1959          <button class="gallery-select-btn gallery-toolbar-action" id="gallery-select-btn" title="Select for bulk actions"><span style="position:relative;top:1px;">Select</span></button>
1960        </div>
1961        <div class="gallery-album-chips" id="gallery-filter-chips" style="margin-top:0;"></div>
1962        <div class="memory-bulk-bar hidden" id="gallery-bulk-bar" style="margin-bottom:4px;">
1963          <label class="memory-bulk-check-all" style="position:relative;top:-1px;"><input type="checkbox" id="gallery-bulk-select-all"> All</label>
1964          <span id="gallery-bulk-count" style="position:relative;top:-1px;">0 selected</span>
1965          <button class="memory-toolbar-btn" id="gallery-bulk-actions" style="position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Actions <span style="opacity:0.55;font-size:9px;">▼</span></button>
1966          <button class="memory-toolbar-btn" id="gallery-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-3px;">
1966<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
1967        </div>
1968        <div class="gallery-tag-chips" id="gallery-tag-chips"></div>
1969        <div class="gallery-grid" id="gallery-grid"></div>
1970        <button class="gallery-load-more" id="gallery-load-more" style="display:none">Load more</button>
1971        <div class="gallery-detail" id="gallery-detail" style="display:none"></div>
1972        </div>
1973        <div class="gallery-albums-container" id="gallery-albums-container" style="display:none;"></div>
1974        <div class="gallery-editor-container" id="gallery-editor-container" style="display:none;"></div>
1975        <div class="gallery-settings-container" id="gallery-settings-container" style="display:none;">
1976          <div class="admin-card">
1977            <h2>AI Tagging <span id="gallery-tag-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal;"></span></h2>
1978            <p class="memory-desc doclib-desc">Auto-tag photos by content with your <a href="#" id="gallery-vision-link" class="ge-vision-link">vision model</a>. Your own tags are kept.</p>
1979            <div id="gallery-tag-bar" style="display:none;padding:8px 0 0;">
1980              <div style="background:var(--border);border-radius:4px;overflow:hidden;height:6px;">
1981                <div id="gallery-tag-progress" style="height:100%;background:var(--accent, var(--red));width:0%;transition:width 0.2s;"></div>
1982              </div>
1983              <div style="display:flex;justify-content:space-between;align-items:center;margin-top:4px;">
1984                <div id="gallery-tag-status" style="font-size:10px;opacity:0.5;"></div>
1985                <button id="gallery-tag-cancel" class="gallery-select-btn" style="font-size:10px;padding:1px 6px;">Cancel</button>
1986              </div>
1987            </div>
1988            <div class="memory-toolbar" style="display:flex;flex-direction:row;gap:6px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-top:32px;">
1989              <button class="memory-toolbar-btn" id="gallery-clear-ai-tags-btn" title="Remove all AI-generated tags from every photo">Clear AI tags</button>
1990              <button class="memory-toolbar-btn" id="gallery-tag-all-btn" title="AI-tag all untagged photos (in the current album, if any)">
1991                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-2px;margin-right:5px;"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
1992                Start AI tag
1993              </button>
1994            </div>
1995          </div>
1996        </div>
1997      </div>
1998    </div>
1999  `;
2000  document.body.appendChild(modal);
2001  Modals.register('gallery-modal', {
2002    railBtnId: 'rail-gallery',
2003    sidebarBtnId: 'tool-gallery-btn',
2004    closeFn: () => _doCloseGallery(),
2005    restoreFn: () => {},
2006  });
2007
2008  // Allow dragging the modal by its header — same pattern as Email Library,
2009  // Sessions, etc. The tileManager (corner/edge snap-tiling) listens on
2010  // pointer events too; it only shows a ghost on move and snaps on release,
2011  // so the two coexist.
2012  _makeGalleryDraggable(modal.querySelector('.modal-content'));
2013
2014  document.getElementById('gallery-close').addEventListener('click', async () => {
2015    if (isEditorOpen()) {
2016      const ok = await uiModule.styledConfirm(
2017        'Close Gallery and the active edit?',
2018        { confirmText: 'Close', danger: true },
2019      );
2020      if (!ok) return;
2021      window.__galleryAllowCloseEditor = true;
2022    }
2023    closeGallery();
2024  });
2025
2026  // Double-click the Edit tab to rename what's being edited. The label
2027  // shows up everywhere it's referenced by id (#gallery-editor-tab), so a
2028  // simple inline contenteditable swap is enough.
2029  const editorTab = modal.querySelector('.gallery-tab[data-tab="editor"]');
2030  // Close × on the Edit tab — appears on hover. Confirms if the editor
2031  // has an open session (any in-progress edit), otherwise just closes.
2032  const editorTabClose = modal.querySelector('#gallery-editor-tab-close');
2033  if (editorTabClose) {
2034    editorTabClose.addEventListener('click', async (e) => {
2035      e.stopPropagation();
2036      if (isEditorOpen()) {
2037        const ok = await uiModule.styledConfirm(
2038          'Close the edit? Any unsaved changes will be lost.',
2039          { confirmText: 'Close', danger: true },
2040        );
2041        if (!ok) return;
2042      }
2043      window.__galleryAllowCloseEditor = true;
2044      closeEditor();
2045      window.__galleryAllowCloseEditor = false;
2046      // If user is currently on the Edit tab, swap back to Photos.
2047      const activeTab = modal.querySelector('.gallery-tab.active');
2048      if (activeTab?.dataset.tab === 'editor') {
2049        modal.querySelector('.gallery-tab[data-tab="images"]')?.click();
2050      }
2051    });
2052  }
2053  if (editorTab) {
2054    editorTab.addEventListener('dblclick', (e) => {
2055      e.stopPropagation();
2056      const labelEl = editorTab.querySelector('.gallery-tab-label') || editorTab;
2057      const current = labelEl.textContent.replace(/^Edit:\s*/, '');
2058      const oldText = labelEl.textContent;
2059      const input = document.createElement('input');
2060      input.type = 'text';
2061      input.value = current === 'Edit' ? '' : current;
2062      input.placeholder = 'Edit name';
2063      input.className = 'gallery-tab-rename-input';
2064      // Replace only the label span's contents so the icon SVG next to
2065      // it stays visible during the rename.
2066      labelEl.textContent = '';
2067      labelEl.appendChild(input);
2068      input.focus();
2069      input.select();
2070      const finish = (commit) => {
2071        if (commit && input.value.trim()) {
2072          labelEl.textContent = `Edit: ${input.value.trim().slice(0, 24)}`;
2073        } else {
2074          labelEl.textContent = oldText;
2075        }
2076      };
2077      input.addEventListener('blur', () => finish(true));
2078      input.addEventListener('keydown', (ev) => {
2079        if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
2080        if (ev.key === 'Escape') { ev.preventDefault(); finish(false); }
2081      });
2082    });
2083  }
2084
2085  // ── Tab switching ──
2086  modal.querySelectorAll('.gallery-tab').forEach(tab => {
2087    tab.addEventListener('click', () => {
2088      modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active'));
2089      tab.classList.add('active');
2090      const target = tab.dataset.tab;
2091      // Always close the photo detail when changing tabs — leaving it open
2092      // means it pops back the next time the user returns to Photos.
2093      const _detail = document.getElementById('gallery-detail');
2094      if (_detail) _detail.style.display = 'none';
2095      const imagesContainer = document.getElementById('gallery-images-container');
2096      const albumsContainer = document.getElementById('gallery-albums-container');
2097      const editorContainer = document.getElementById('gallery-editor-container');
2098      const settingsContainer = document.getElementById('gallery-settings-container');
2099      if (imagesContainer) imagesContainer.style.display = target === 'images' ? '' : 'none';
2100      if (albumsContainer) albumsContainer.style.display = target === 'albums' ? '' : 'none';
2101      if (editorContainer) editorContainer.style.display = target === 'editor' ? 'flex' : 'none';
2102      if (settingsContainer) settingsContainer.style.display = target === 'settings' ? '' : 'none';
2103      if (target === 'images') {
2104        // Keep active edits alive when leaving the Edit tab. The edit
2105        // session is only torn down by the explicit Edit-tab close.
2106      } else if (target === 'albums') {
2107        _renderAlbumsTab();
2108      } else if (target === 'editor') {
2109        // If the editor isn't already holding an image, render a chooser so the
2110        // tab does something useful instead of opening an empty grey pane.
2111        if (!isEditorOpen()) _renderEditorLanding();
2112      }
2113    });
2114  });
2115
2116  const searchInput = document.getElementById('gallery-search');
2117  searchInput.addEventListener('input', () => {
2118    clearTimeout(_searchDebounce);
2119    _searchDebounce = setTimeout(() => {
2120      _search = searchInput.value.trim();
2121      _fetchLibrary(false);
2122    }, 300);
2123  });
2124  // Pressing Enter in the search box converts the current query into a
2125  // stacked tag-filter pill (strips a leading "#"), clears the box, and
2126  // re-fetches. Lets the user keep narrowing by tag without having to
2127  // click chips.
2128  searchInput.addEventListener('keydown', (e) => {
2129    if (e.key !== 'Enter') return;
2130    e.preventDefault();
2131    const raw = searchInput.value.trim().replace(/^#/, '');
2132    if (!raw) return;
2133    if (!_activeTags.includes(raw)) _activeTags.push(raw);
2134    // Searching a tag swaps the active filter to All (clear Favorites/album) so
2135    // the heart deactivates and All lights up.
2136    _favoritesOnly = false;
2137    _activeAlbum = null;
2138    // Filtering by a tag reads best newest-first — switch the sort to Recent.
2139    if (_sort !== 'recent') {
2140      _sort = 'recent';
2141      const sortSel = document.getElementById('gallery-sort');
2142      if (sortSel) sortSel.value = 'recent';
2143    }
2144    searchInput.value = '';
2145    _search = '';
2146    clearTimeout(_searchDebounce);
2147    _fetchLibrary(false);
2148    _renderAlbums();
2149  });
2150
2151  document.getElementById('gallery-sort').addEventListener('change', (e) => {
2152    _sort = e.target.value;
2153    // New shuffle session each time the user picks Shuffle so it actually
2154    // re-shuffles, not just re-renders the same seeded order.
2155    if (_sort === 'shuffle') _shuffleSeed = Math.floor(Math.random() * 2 ** 31);
2156    _fetchLibrary(false);
2157  });
2158
2159  document.getElementById('gallery-model-filter').addEventListener('change', (e) => {
2160    _activeModel = e.target.value || null;
2161    _fetchLibrary(false);
2162  });
2163
2164  document.getElementById('gallery-load-more').addEventListener('click', () => {
2165    _offset = _items.length;
2166    _fetchLibrary(true);
2167  });
2168
2169  // Infinite scroll: auto-load the next page when the Load-more button nears the
2170  // bottom of the gallery's scroll area. The button stays as a manual fallback.
2171  // Infinite scroll. A capture-phase scroll listener on document catches scroll
2172  // from WHICHEVER element actually scrolls (desktop modal-body or the mobile
2173  // scroll wrapper — IntersectionObserver's root was unreliable across the two).
2174  // We just test the Load-more button against the viewport bottom.
2175  let _loadingMore = false;
2176  let _scrollTick = false;
2177  const _maybeAutoLoad = () => {
2178    _scrollTick = false;
2179    if (!_open || _loadingMore || _items.length >= _total) return;
2180    const btn = document.getElementById('gallery-load-more');
2181    if (!btn || btn.style.display === 'none' || !btn.offsetParent) return;  // hidden / nothing more
2182    const r = btn.getBoundingClientRect();
2183    if (r.top <= window.innerHeight + 600) {   // within 600px of the viewport bottom
2184      _loadingMore = true;
2185      _offset = _items.length;
2186      Promise.resolve(_fetchLibrary(true)).finally(() => { _loadingMore = false; });
2187    }
2188  };
2189  document.addEventListener('scroll', () => {
2190    if (_scrollTick) return;
2191    _scrollTick = true;
2192    requestAnimationFrame(_maybeAutoLoad);
2193  }, true);
2194
2195  // When the window grows (e.g. entering fullscreen), the visible grid
2196  // can hold more photos than the last page fetched — top up so there's
2197  // no blank space. Debounced; only fires when the freshly-computed
2198  // page size exceeds what's loaded and the server has more to give.
2199  let _resizeTopUpTimer = null;
2200  const _onGalleryResize = () => {
2201    clearTimeout(_resizeTopUpTimer);
2202    _resizeTopUpTimer = setTimeout(() => {
2203      if (!_open) return;
2204      if (_items.length >= _total) return;        // already have everything
2205      if (_computeFetchLimit() <= _items.length) return; // viewport not bigger than current load
2206      _offset = _items.length;
2207      _fetchLibrary(true);
2208    }, 300);
2209  };
2210  window.addEventListener('resize', _onGalleryResize);
2211  // Remember the handler so closeGallery can detach it.
2212  _galleryResizeHandler = _onGalleryResize;
2213
2214  // ── Import images ──
2215
2216  // "vision model" link → open Settings on the AI tab (where the vision model
2217  // is configured).
2218  const visionLink = document.getElementById('gallery-vision-link');
2219  if (visionLink) {
2220    visionLink.addEventListener('click', (e) => {
2221      e.preventDefault();
2222      import('./settings.js').then(m => {
2223        m.open('ai');
2224        // The gallery modal gets a bumped z-index from modalManager; settings
2225        // opens with its lower static z-index and lands BEHIND it. Raise it above.
2226        const sm = document.getElementById('settings-modal');
2227        const gm = document.getElementById('gallery-modal');
2228        if (sm) {
2229          const gz = gm ? (parseInt(getComputedStyle(gm).zIndex) || 0) : 0;
2230          sm.style.setProperty('z-index', String(Math.max(gz + 1, 10050)), 'important');
2231        }
2232      }).catch(() => {});
2233    });
2234  }
2235
2236  // ── Tag All Untagged ──
2237  let _tagCancelRequested = false;
2238  let _tagging = false;
2239  const tagAllBtn = document.getElementById('gallery-tag-all-btn');
2240  const _tagAllOrigHTML = tagAllBtn ? tagAllBtn.innerHTML : '';
2241  if (tagAllBtn) {
2242    tagAllBtn.addEventListener('click', async () => {
2243      // While a run is active this button acts as Cancel.
2244      if (_tagging) {
2245        _tagCancelRequested = true;
2246        const _se = document.getElementById('gallery-tag-status');
2247        if (_se) _se.textContent = 'Cancelling…';
2248        tagAllBtn.textContent = 'Cancelling…';
2249        tagAllBtn.disabled = true;
2250        return;
2251      }
2252      if (tagAllBtn.disabled) return;
2253      const scope = _activeAlbum
2254        ? (_albums.find(a => a.id === _activeAlbum)?.name || 'this album')
2255        : 'entire gallery';
2256      const params = new URLSearchParams();
2257      if (_activeAlbum) params.set('album_id', _activeAlbum);
2258      let listRes;
2259      try {
2260        const r = await fetch(`${API_BASE}/api/gallery/ai-tag-batch?${params.toString()}`, {
2261          method: 'POST', credentials: 'same-origin',
2262        });
2263        listRes = await r.json();
2264      } catch (e) { uiModule.showError('Failed to fetch tag queue'); return; }
2265      if (!listRes.ok || !Array.isArray(listRes.image_ids) || listRes.image_ids.length === 0) {
2266        uiModule.showToast(`No untagged photos in ${scope}`);
2267        return;
2268      }
2269      const total = listRes.image_ids.length;
2270      const untagged = listRes.total_untagged || total;
2271      if (!await uiModule.styledConfirm(
2272        `Tag ${total} of ${untagged} untagged photo${total > 1 ? 's' : ''} in ${scope}?`,
2273        { confirmText: 'Tag All' }
2274      )) return;
2275
2276      const bar = document.getElementById('gallery-tag-bar');
2277      const progEl = document.getElementById('gallery-tag-progress');
2278      const statusEl = document.getElementById('gallery-tag-status');
2279      const cancelBtn = document.getElementById('gallery-tag-cancel');
2280      bar.style.display = '';
2281      progEl.style.width = '0%';
2282      // The Start button becomes the Cancel control for the run (kept enabled so
2283      // it's clickable; the click handler above routes to cancel via _tagging).
2284      _tagging = true;
2285      _tagCancelRequested = false;
2286      tagAllBtn.classList.add('active', 'gallery-tag-cancelling');
2287      tagAllBtn.textContent = 'Cancel';
2288      if (cancelBtn) cancelBtn.style.display = 'none';   // start button covers it now
2289      cancelBtn.onclick = () => { _tagCancelRequested = true; statusEl.textContent = 'Cancelling...'; };
2290
2291      let done = 0, failed = 0;
2292      for (const id of listRes.image_ids) {
2293        if (_tagCancelRequested) break;
2294        try {
2295          const r = await fetch(`${API_BASE}/api/gallery/${id}/ai-tag`, {
2296            method: 'POST', credentials: 'same-origin',
2297          });
2298          const d = await r.json();
2299          if (!d.ok) failed++;
2300        } catch (_) { failed++; }
2301        done++;
2302        progEl.style.width = `${Math.round((done / total) * 100)}%`;
2303        statusEl.textContent = `Tagging ${done}/${total}${failed ? ` — ${failed} failed` : ''}`;
2304      }
2305
2306      statusEl.textContent = _tagCancelRequested
2307        ? `Cancelled after ${done}/${total}${failed ? ` (${failed} failed)` : ''}`
2308        : `Done — tagged ${done - failed}/${total}${failed ? ` (${failed} failed)` : ''}`;
2309      // Restore the Start button.
2310      _tagging = false;
2311      tagAllBtn.disabled = false;
2312      tagAllBtn.classList.remove('active', 'gallery-tag-cancelling');
2313      tagAllBtn.innerHTML = _tagAllOrigHTML;
2314      if (cancelBtn) cancelBtn.style.display = '';
2315      setTimeout(() => { bar.style.display = 'none'; }, 3000);
2316      await _fetchLibrary(false);
2317      if (uiModule) uiModule.showToast(`Tagged ${done - failed} photo${(done - failed) !== 1 ? 's' : ''}`);
2318    });
2319  }
2320
2321  // ── Toolbar overflow (⋮) ──
2322  const moreBtn = document.getElementById('gallery-toolbar-more-btn');
2323  const moreMenu = document.getElementById('gallery-toolbar-more-menu');
2324  if (moreBtn && moreMenu) {
2325    // `.dropdown { display:none }` isn't tied to [hidden], so toggling the
2326    // attribute alone won't reveal it — set inline display too (inline wins).
2327    const _setMore = (show) => { moreMenu.hidden = !show; moreMenu.style.display = show ? 'block' : 'none'; };
2328    _setMore(false);
2329    moreBtn.addEventListener('click', (e) => {
2330      e.stopPropagation();
2331      _setMore(moreMenu.hidden);
2332    });
2333    document.addEventListener('click', (e) => {
2334      if (!moreMenu.hidden && !moreMenu.contains(e.target) && e.target !== moreBtn) _setMore(false);
2335    });
2336  }
2337
2338  // ── Clear AI Tags ──
2339  const clearAiTagsBtn = document.getElementById('gallery-clear-ai-tags-btn');
2340  if (clearAiTagsBtn) {
2341    clearAiTagsBtn.addEventListener('click', async () => {
2342      if (clearAiTagsBtn.disabled) return;
2343      if (moreMenu) { moreMenu.hidden = true; moreMenu.style.display = 'none'; }
2344      if (!await uiModule.styledConfirm(
2345        'Remove all AI-generated tags from every photo? Your own tags are kept.',
2346        { confirmText: 'Clear AI Tags', danger: true }
2347      )) return;
2348      clearAiTagsBtn.disabled = true;
2349      try {
2350        const r = await fetch(`${API_BASE}/api/gallery/clear-ai-tags`, {
2351          method: 'POST', credentials: 'same-origin',
2352        });
2353        const d = await r.json();
2354        if (!d.ok) throw new Error(d.error || 'Clear failed');
2355        uiModule.showToast(`Cleared AI tags on ${d.cleared} photo${d.cleared === 1 ? '' : 's'}`);
2356        await _fetchLibrary(false);
2357      } catch (e) {
2358        uiModule.showError(`Failed to clear AI tags: ${e.message || e}`);
2359      } finally {
2360        clearAiTagsBtn.disabled = false;
2361      }
2362    });
2363  }
2364
2365
2366  // ── Select mode + bulk delete ──
2367  let _selectMode = false;
2368  const selectBtn = document.getElementById('gallery-select-btn');
2369  const bulkBar = document.getElementById('gallery-bulk-bar');
2370
2371  const _selectedDots = () => [...document.querySelectorAll('.gallery-select-dot.selected')];
2372  const _selectedIds = () => _selectedDots().map(d => d.closest('.gallery-card')?.dataset.id).filter(Boolean);
2373
2374  function _updateBulkCount() {
2375    const sel = document.querySelectorAll('.gallery-select-dot.selected').length;
2376    const total = document.querySelectorAll('.gallery-select-dot').length;
2377    const el = document.getElementById('gallery-bulk-count');
2378    if (el) el.textContent = sel + ' selected';
2379    const all = document.getElementById('gallery-bulk-select-all');
2380    if (all) { all.checked = total > 0 && sel === total; all.indeterminate = sel > 0 && sel < total; }
2381    // When something's selected, brighten Actions to the same full --fg color as
2382    // the "N selected" count (the button is a dimmer 60% --fg by default).
2383    const actions = document.getElementById('gallery-bulk-actions');
2384    if (actions) actions.style.color = sel > 0 ? 'var(--fg)' : '';
2385  }
2386
2387  function _setSelectMode(on) {
2388    _selectMode = on;
2389    selectBtn.classList.toggle('active', on);
2390    // The Select button doubles as Cancel while active (mirrors the library).
2391    selectBtn.textContent = on ? 'Cancel' : 'Select';
2392    bulkBar.classList.toggle('hidden', !on);
2393    // Body-level signal so the CSS rule that hides per-thumbnail overlay
2394    // buttons (favorite/download) applies to every card — including cards
2395    // rendered after select mode was already on (load-more etc).
2396    document.body.classList.toggle('gallery-selecting', on);
2397    document.querySelectorAll('.gallery-select-dot').forEach(d => {
2398      d.style.display = on ? '' : 'none';
2399      if (!on) d.classList.remove('selected');
2400      d.closest('.gallery-card')?.classList.toggle('gallery-card-selectable', on);
2401    });
2402    if (!on) document.querySelectorAll('.gallery-bulk-menu').forEach(m => m.remove());
2403    _updateBulkCount();
2404  }
2405
2406  function _exitSelectMode() { _setSelectMode(false); }
2407
2408  selectBtn.addEventListener('click', () => _setSelectMode(!_selectMode));
2409  document.getElementById('gallery-bulk-cancel')?.addEventListener('click', () => _exitSelectMode());
2410
2411  // Select all / none.
2412  document.getElementById('gallery-bulk-select-all')?.addEventListener('change', (e) => {
2413    const on = e.target.checked;
2414    document.querySelectorAll('.gallery-select-dot').forEach(d => d.classList.toggle('selected', on));
2415    _updateBulkCount();
2416  });
2417
2418  document.getElementById('gallery-grid').addEventListener('click', (e) => {
2419    if (!_selectMode) return;
2420    const card = e.target.closest('.gallery-card');
2421    if (!card) return;
2422    e.stopPropagation();
2423    const dot = card.querySelector('.gallery-select-dot');
2424    if (dot) { dot.classList.toggle('selected'); _updateBulkCount(); }
2425  });
2426
2427  // Mobile: long-press on a thumbnail enters select mode and marks that
2428  // thumbnail as the first selection. Cancelled by movement (so vertical
2429  // scrolling still works) or by lifting before the timer fires.
2430  if ('ontouchstart' in window) {
2431    const gridEl = document.getElementById('gallery-grid');
2432    let lpTimer = null;
2433    let lpCard = null;
2434    let lpStartX = 0, lpStartY = 0;
2435    let lpFired = false;
2436    const LONG_PRESS_MS = 380;
2437    const MOVE_CANCEL_PX = 10;
2438    const cancel = () => { clearTimeout(lpTimer); lpTimer = null; lpCard = null; };
2439    gridEl.addEventListener('touchstart', (e) => {
2440      const card = e.target.closest('.gallery-card');
2441      if (!card) return;
2442      const t = e.touches[0];
2443      lpCard = card;
2444      lpStartX = t.clientX; lpStartY = t.clientY;
2445      lpFired = false;
2446      lpTimer = setTimeout(() => {
2447        if (!lpCard) return;
2448        lpFired = true;
2449        try { if (navigator.vibrate) navigator.vibrate(15); } catch {}
2450        if (!_selectMode) _setSelectMode(true);
2451        const dot = lpCard.querySelector('.gallery-select-dot');
2452        if (dot && !dot.classList.contains('selected')) {
2453          dot.classList.add('selected');
2454          _updateBulkCount();
2455        }
2456      }, LONG_PRESS_MS);
2457    }, { passive: true });
2458    gridEl.addEventListener('touchmove', (e) => {
2459      if (!lpCard) return;
2460      const t = e.touches[0];
2461      if (Math.abs(t.clientX - lpStartX) > MOVE_CANCEL_PX
2462        || Math.abs(t.clientY - lpStartY) > MOVE_CANCEL_PX) cancel();
2463    }, { passive: true });
2464    gridEl.addEventListener('touchend', (e) => {
2465      if (lpFired) {
2466        // Swallow the synthesized click that follows the long-press so the
2467        // tap doesn't immediately toggle the same dot back off.
2468        e.preventDefault();
2469      }
2470      cancel();
2471    });
2472    gridEl.addEventListener('touchcancel', cancel);
2473  }
2474
2475  // Drag-and-drop import
2476  const grid = document.getElementById('gallery-grid');
2477  const imagesContainer = document.getElementById('gallery-images-container');
2478  ['dragenter', 'dragover'].forEach(ev => {
2479    imagesContainer.addEventListener(ev, (e) => { e.preventDefault(); e.stopPropagation(); grid.classList.add('gallery-dragover'); });
2480  });
2481  ['dragleave', 'drop'].forEach(ev => {
2482    imagesContainer.addEventListener(ev, (e) => { e.preventDefault(); e.stopPropagation(); grid.classList.remove('gallery-dragover'); });
2483  });
2484  imagesContainer.addEventListener('drop', (e) => {
2485    _handleGalleryDrop(e).catch(err => console.error('Gallery drop error:', err));
2486  });
2487
2488  // Same drop handling on the Albums tab: dropped folders become new albums,
2489  // loose files go into whatever album is currently active (or stay loose).
2490  const albumsContainer = document.getElementById('gallery-albums-container');
2491  if (albumsContainer) {
2492    ['dragenter', 'dragover'].forEach(ev => {
2493      albumsContainer.addEventListener(ev, (e) => {
2494        e.preventDefault(); e.stopPropagation();
2495        albumsContainer.classList.add('gallery-dragover');
2496      });
2497    });
2498    ['dragleave', 'drop'].forEach(ev => {
2499      albumsContainer.addEventListener(ev, (e) => {
2500        e.preventDefault(); e.stopPropagation();
2501        albumsContainer.classList.remove('gallery-dragover');
2502      });
2503    });
2504    albumsContainer.addEventListener('drop', (e) => {
2505      _handleGalleryDrop(e)
2506        .then(() => _renderAlbumsTab())
2507        .catch(err => console.error('Gallery drop error:', err));
2508    });
2509  }
2510
2511
2512  // ── Bulk actions menu (Favorite / Add tag / Delete on the selection) ──
2513  // Built dynamically like the email library's _showBulkActionsMenu so it
2514  // shares the exact same dropdown style/behaviour.
2515  const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
2516  function _showGalleryBulkMenu(anchor) {
2517    document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove());
2518    // Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
2519    // matches every other menu in the app. Positioned fixed at the button.
2520    const dropdown = document.createElement('div');
2521    dropdown.className = 'dropdown gallery-bulk-menu';
2522    const rect = anchor.getBoundingClientRect();
2523    const left = Math.min(rect.left, window.innerWidth - 200);
2524    // Inline the standard dropdown look so it renders correctly even where the
2525    // `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile).
2526    dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
2527    const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
2528    const _tagIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
2529    const _dlIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
2530    const _delIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>';
2531    const _cancelIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
2532    const items = [
2533      { label: 'Favorite', icon: _favIco, action: () => _bulkFavorite(_selectedIds()) },
2534      { label: 'Add tag…', icon: _tagIco, action: () => _bulkTag(_selectedIds()) },
2535      { label: 'Download', icon: _dlIco, action: () => _bulkDownload(_selectedIds()) },
2536      { label: 'Delete', icon: _delIco, danger: true, action: () => _bulkDelete(_selectedIds()) },
2537      { separator: true },
2538      { label: 'Cancel', icon: _cancelIco, action: () => _exitSelectMode() },
2539    ];
2540    for (const a of items) {
2541      if (a.separator) {
2542        const sep = document.createElement('div');
2543        sep.className = 'dropdown-divider';
2544        sep.style.cssText = 'height:1px;background:var(--border);margin:4px 4px;';
2545        dropdown.appendChild(sep);
2546        continue;
2547      }
2548      const it = document.createElement('div');
2549      it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
2550      it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
2551      it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
2552      dropdown.appendChild(it);
2553    }
2554    document.body.appendChild(dropdown);
2555    const close = (ev) => {
2556      if (!dropdown.contains(ev.target) && ev.target !== anchor) {
2557        dropdown.remove();
2558        document.removeEventListener('click', close, true);
2559      }
2560    };
2561    setTimeout(() => document.addEventListener('click', close, true), 10);
2562  }
2563
2564  _bulkActionsBtn?.addEventListener('click', (e) => {
2565    e.stopPropagation();
2566    // Toggle: clicking the Actions button while its dropdown is already open
2567    // should close it. The outside-click handler explicitly skips clicks on
2568    // the anchor, so the button itself has to do its own dismiss.
2569    const existing = document.querySelector('.gallery-bulk-menu');
2570    if (existing) { existing.remove(); return; }
2571    if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
2572    _showGalleryBulkMenu(e.currentTarget);
2573  });
2574
2575  async function _bulkDelete(ids) {
2576    if (!ids.length) return;
2577    if (!await uiModule.styledConfirm(`Delete ${ids.length} photo${ids.length > 1 ? 's' : ''}? This cannot be undone.`, { confirmText: 'Delete', danger: true })) return;
2578    const deleted = [], failed = [];
2579    for (const id of ids) { const ok = await _deleteImage(id); (ok ? deleted : failed).push(id); }
2580    if (failed.length) uiModule.showError(`Failed to delete ${failed.length} of ${ids.length} photos`);
2581    _items = _items.filter(i => !deleted.includes(i.id));
2582    _total = Math.max(0, _total - deleted.length);
2583    _exitSelectMode();
2584    if (uiModule) uiModule.showToast(`${deleted.length} photo${deleted.length > 1 ? 's' : ''} deleted`);
2585    // If we just emptied a FILTERED view (e.g. deleted every photo under a tag),
2586    // drop the filters and reload the full library so the user isn't stranded on
2587    // a blank screen with a now-empty tag/album/favorites filter still active.
2588    if (_items.length === 0 && (_activeTags.length || _activeAlbum || _favoritesOnly)) {
2589      _activeTags = [];
2590      _activeAlbum = null;
2591      _favoritesOnly = false;
2592      _fetchLibrary(false);
2593      _renderAlbums();
2594      return;
2595    }
2596    _renderGrid(); _renderStats();
2597  }
2598
2599  async function _bulkDownload(ids) {
2600    if (!ids.length) return;
2601    // More than 5 → bundle into a single .zip server-side instead of firing a
2602    // flood of individual downloads.
2603    if (ids.length > 5) {
2604      try {
2605        if (uiModule) uiModule.showToast(`Zipping ${ids.length} photos…`);
2606        const res = await fetch(`${API_BASE}/api/gallery/download-zip`, {
2607          method: 'POST', credentials: 'same-origin',
2608          headers: { 'Content-Type': 'application/json' },
2609          body: JSON.stringify({ ids }),
2610        });
2611        if (!res.ok) throw new Error('zip failed');
2612        const blob = await res.blob();
2613        const objUrl = URL.createObjectURL(blob);
2614        const a = document.createElement('a');
2615        a.href = objUrl;
2616        a.download = 'gallery-photos.zip';
2617        document.body.appendChild(a);
2618        a.click();
2619        a.remove();
2620        setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
2621        _exitSelectMode();
2622        if (uiModule) uiModule.showToast(`Downloaded ${ids.length} photos (zip)`);
2623      } catch (e) {
2624        if (uiModule) uiModule.showError('Failed to create zip');
2625      }
2626      return;
2627    }
2628    // 5 or fewer → individual downloads.
2629    let n = 0;
2630    for (const id of ids) {
2631      const it = _items.find(i => i.id === id);
2632      if (!it) continue;
2633      try {
2634        const res = await fetch(it.url, { credentials: 'same-origin' });
2635        const blob = await res.blob();
2636        const objUrl = URL.createObjectURL(blob);
2637        const a = document.createElement('a');
2638        a.href = objUrl;
2639        a.download = it.filename || `image-${it.id}.png`;
2640        document.body.appendChild(a);
2641        a.click();
2642        a.remove();
2643        setTimeout(() => URL.revokeObjectURL(objUrl), 1000);
2644        n++;
2645        // Stagger so the browser doesn't drop simultaneous downloads.
2646        await new Promise(r => setTimeout(r, 250));
2647      } catch (_) { /* skip failures */ }
2648    }
2649    _exitSelectMode();
2650    if (uiModule) uiModule.showToast(`Downloading ${n} photo${n === 1 ? '' : 's'}`);
2651  }
2652
2653  async function _bulkFavorite(ids) {
2654    let n = 0;
2655    for (const id of ids) {
2656      if (await _patchImage(id, { favorite: true })) {
2657        n++;
2658        const it = _items.find(i => i.id === id); if (it) it.favorite = true;
2659      }
2660    }
2661    _renderGrid(); _exitSelectMode();
2662    if (uiModule) uiModule.showToast(`Favorited ${n} photo${n > 1 ? 's' : ''}`);
2663  }
2664
2665  async function _bulkTag(ids) {
2666    const tag = (await uiModule.styledPrompt('', { title: 'Add tag to selected', placeholder: 'tag', confirmText: 'Add', maxLength: 60 }) || '').trim().replace(/^#+/, '').trim();
2667    if (!tag) return;
2668    let n = 0;
2669    for (const id of ids) {
2670      const it = _items.find(i => i.id === id);
2671      const existing = (it?.user_tags || it?.tags || '').split(',').map(t =>
2671 t.trim()).filter(Boolean);
2672      if (existing.includes(tag)) { continue; }
2673      const merged = [...existing, tag].join(', ');
2674      if (await _patchImage(id, { tags: merged })) {
2675        n++;
2676        if (it) { it.tags = merged; it.user_tags = merged; }
2677      }
2678    }
2679    _exitSelectMode();
2680    if (uiModule) uiModule.showToast(`Tagged ${n} photo${n === 1 ? '' : 's'} “${tag}”`);
2681  }
2682
2683  modal.addEventListener('click', (e) => {
2684    if (uiModule.isTouchInsideModal()) return;
2685    if (e.target === modal) closeGallery();
2686  });
2687
2688  _escHandler = (e) => {
2689    if (e.key === 'Escape') {
2690      // While the image editor is visible, Escape is reserved for the
2691      // editor (cancel transform/lasso/crop, dismiss size prompt, etc.).
2692      // Don't close the gallery — users would lose their in-progress edit.
2693      // We check the editor container's visibility AND the isEditorOpen()
2694      // flag so a crop popup, transform handles, etc. all keep Esc.
2695      const editorContainer = document.getElementById('gallery-editor-container');
2696      const editorVisible = !!(
2697        editorContainer &&
2698        getComputedStyle(editorContainer).display !== 'none' &&
2699        editorContainer.querySelector('.gallery-editor')
2700      );
2701      if (editorVisible || isEditorOpen()) {
2702        e.preventDefault();
2703        e.stopImmediatePropagation();
2704        return;
2705      }
2706      const detail = document.getElementById('gallery-detail');
2707      if (detail && detail.style.display !== 'none') {
2708        // Click Back so Esc and the visible button always do the same thing —
2709        // future tweaks to Back's teardown automatically apply to Esc too.
2710        // stopImmediatePropagation blocks app.js's generic dynamic-modal Esc
2711        // handler that would otherwise close the whole gallery underneath us.
2712        e.preventDefault();
2713        e.stopImmediatePropagation();
2714        const back = document.getElementById('gallery-detail-back');
2715        if (back) back.click(); else detail.style.display = 'none';
2716      } else {
2717        closeGallery();
2718      }
2719      return;
2720    }
2721    // Arrow-key navigation inside detail view (ignore when typing in inputs)
2722    if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
2723    const detail = document.getElementById('gallery-detail');
2724    if (!detail || detail.style.display === 'none') return;
2725    const tag = (e.target?.tagName || '').toLowerCase();
2726    if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target?.isContentEditable) return;
2727    const btn = document.getElementById(e.key === 'ArrowLeft' ? 'gallery-detail-prev' : 'gallery-detail-next');
2728    if (btn && !btn.classList.contains('gallery-detail-nav-disabled')) {
2729      e.preventDefault();
2730      btn.click();
2731    }
2732  };
2733  // Capture phase + stopImmediatePropagation (see _escHandler) so app.js's
2734  // generic dynamic-modal Esc dismiss doesn't close the whole gallery before
2735  // we get a chance to close just the photo detail.
2736  document.addEventListener('keydown', _escHandler, true);
2737
2738  const btn = document.getElementById('tool-gallery-btn');
2739  if (btn) btn.classList.add('active');
2740
2741  // 1) Paint cached state immediately so the user sees photos instantly.
2742  //    Filters, sort, search box, album chips, and the grid all come from
2743  //    module-scope state that we no longer reset on open.
2744  if (_items.length || _albums.length) {
2745    if (_search) searchInput.value = _search;
2746    const sortSel = document.getElementById('gallery-sort');
2747    if (sortSel) sortSel.value = _sort;
2748    _renderAlbums();
2749    _renderGrid();
2750    _renderStats();
2751  }
2752  // 2) Refresh in the background so the cache stays close-to-fresh. If the
2753  //    fetch fails or takes a moment, the cached view sticks around.
2754  _fetchAlbums();
2755  _fetchLibrary(false);
2756  searchInput.focus();
2757}
2758
2759function _doCloseGallery() {
2760  const editorMounted = !!document.querySelector('#gallery-editor-container .gallery-editor');
2761  if ((window.__galleryEditLive || isEditorOpen() || editorMounted) && !window.__galleryAllowCloseEditor) {
2762    if (uiModule) uiModule.showToast('Close the edit tab first');
2763    return;
2764  }
2765  _open = false;
2766  clearTimeout(_searchDebounce);
2767  if (_galleryResizeHandler) {
2768    window.removeEventListener('resize', _galleryResizeHandler);
2769    _galleryResizeHandler = null;
2770  }
2771  // Detach the face-overlay resize listener so we don't leak a
2772  // handler past close (v2 review HIGH-9).
2773  closeEditor();
2774  window.__galleryAllowCloseEditor = false;
2775
2776  const modal = document.getElementById('gallery-modal');
2777  if (modal) {
2778    const content = modal.querySelector('.modal-content, .gallery-modal-content');
2779    if (content) {
2780      content.classList.add('modal-closing');
2781      content.addEventListener('animationend', () => modal.remove(), { once: true });
2782      setTimeout(() => { if (modal.parentElement) modal.remove(); }, 250);
2783    } else {
2784      modal.remove();
2785    }
2786  }
2787
2788  if (_escHandler) {
2789    document.removeEventListener('keydown', _escHandler, true);
2790    _escHandler = null;
2791  }
2792
2793  const btn = document.getElementById('tool-gallery-btn');
2794  if (btn) btn.classList.remove('active');
2795}
2796
2797export function closeGallery() {
2798  if (!_open && !Modals.isMinimized('gallery-modal')) return;
2799  if (Modals.isRegistered('gallery-modal')) {
2800    Modals.close('gallery-modal');
2801  } else {
2802    _doCloseGallery();
2803  }
2804}
2805
2806export function isGalleryOpen() {
2807  if (Modals.isMinimized('gallery-modal')) return false;
2808  return _open;
2809}
2810
2811// ---- Utilities ----
2812
2813function _esc(str) {
2814  if (!str) return '';
2815  const d = document.createElement('div');
2816  d.textContent = str;
2817  return d.innerHTML;
2818}
2819
2820function _humanSize(bytes) {
2821  if (!bytes) return '';
2822  const units = ['B', 'KB', 'MB', 'GB'];
2823  let i = 0;
2824  let size = bytes;
2825  while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
2826  return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
2827}
2828
2829const galleryModule = {
2830  openGallery,
2831  closeGallery,
2832  isGalleryOpen,
2833};
2834
2835export default galleryModule;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.