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">♥</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">×</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">×</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">♥</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">← 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 ? '♥ Favorited' : '♡ 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">×</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.