/** * Gallery Module — photo backup + AI-generated image library. */ import uiModule from './ui.js'; import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js'; import spinnerModule from './spinner.js'; import { makeWindowDraggable } from './windowDrag.js'; const API_BASE = window.location.origin; let _open = false; let _galleryResizeHandler = null; // Auto-refresh gallery when new image is generated window.addEventListener('gallery-refresh', () => { if (_open) _fetchLibrary(false); }); let _items = []; let _total = 0; let _totalTagged = 0; // Update the "X/Y tagged" badge in the AI-tagging settings header. function _updateTagCount() { const el = document.getElementById('gallery-tag-count'); if (el) el.textContent = _total ? `${_totalTagged}/${_total} tagged` : ''; } let _search = ''; // Stack of active tag filters. Multiple tags AND together — the user // builds this up by clicking tag chips or by hitting Enter in the // search box, and tears it down with the × on each pill. let _activeTags = []; let _activeModel = null; let _activeAlbum = null; let _galleryCascaded = false; // play the domino-in cascade once per open let _favoritesOnly = false; let _sort = 'shuffle'; let _shuffleSeed = Math.floor(Math.random() * 2 ** 31); let _offset = 0; // Page size — computed from the grid's visible area so taller / wider // windows (fullscreen) fetch enough photos to fill the screen instead // of leaving blank space below a fixed 24-photo page. Capped at the // backend's max (100). let _limit = 24; function _computeFetchLimit() { const grid = document.getElementById('gallery-grid'); const COL_W = 168; // 160px min column + 8px gap const ROW_H = 200; // ~160px image + caption + gap const gridW = (grid && grid.clientWidth) || Math.min(window.innerWidth * 0.9, 1100); const cols = Math.max(2, Math.floor(gridW / COL_W)); // The grid scroll viewport is max-height:60vh. const gridH = window.innerHeight * 0.6; const rows = Math.ceil(gridH / ROW_H) + 2; // +2 buffer rows for scroll return Math.min(100, Math.max(24, cols * rows)); } let _searchDebounce = null; let _escHandler = null; let _albums = []; // Albums tab — search filter + multi-select state. Mirrors what the // Photos tab does (_search, _selectMode) but scoped to the albums grid. let _albumSearch = ''; let _albumSelectMode = false; const _albumSelected = new Set(); // ---- API helpers ---- async function _fetchLibrary(append) { // Recompute the page size each fetch so resizing / fullscreening the // window between loads pulls the right number of photos. _limit = _computeFetchLimit(); // First load with nothing on screen → show skeleton tiles instead of a blank // grid that then snaps to full. BUT: if the last successful load returned // zero items, skip the skeleton entirely — otherwise empty accounts flash // 8-20 placeholder tiles for ~200ms before snapping to the "No photos yet" // message, which read as glitchy. if (!append && _items.length === 0) { let _knownEmpty = false; try { _knownEmpty = localStorage.getItem('gallery-known-empty') === '1'; } catch (_) {} if (!_knownEmpty) _renderSkeletons(_limit); } if (!append) { _offset = 0; // Leave _items untouched until the response arrives — that's the // stale-while-revalidate trick that lets the gallery feel instant on // re-open. The new list replaces _items on success below; if the fetch // fails, the previous photos stay visible. } const params = new URLSearchParams({ sort: _sort, offset: _offset, limit: _limit }); if (_sort === 'shuffle') params.set('seed', String(_shuffleSeed)); if (_search) params.set('search', _search); if (_activeTags.length) params.set('tag', _activeTags.join(',')); if (_activeModel) params.set('model', _activeModel); if (_activeAlbum) params.set('album', _activeAlbum); if (_favoritesOnly) params.set('favorites', 'true'); try { const res = await fetch(`${API_BASE}/api/gallery/library?${params}`, { credentials: 'same-origin' }); const data = await res.json(); if (append) { _items = _items.concat(data.items || []); } else { _items = data.items || []; } // Cache an "empty" verdict so the next open of an empty gallery doesn't // flash skeleton tiles before the real "No photos yet" message. try { const _noFilters = !_search && !_activeTags.length && !_activeModel && !_activeAlbum && !_favoritesOnly; if (_noFilters) { if (_items.length === 0) localStorage.setItem('gallery-known-empty', '1'); else localStorage.removeItem('gallery-known-empty'); } } catch (_) {} _total = data.total || 0; if (typeof data.total_tagged === 'number') _totalTagged = data.total_tagged; _updateTagCount(); _renderGrid(); _renderTags(data.tags || []); _renderModels(data.models || []); _renderStats(); } catch (e) { console.error('Gallery fetch error:', e); } } async function _fetchAlbums() { try { const res = await fetch(`${API_BASE}/api/gallery/albums`, { credentials: 'same-origin' }); const data = await res.json(); _albums = data.albums || []; _renderAlbums(); } catch (e) { console.error('Albums fetch error:', e); } } // v2 review HIGH-7: return a boolean so callers can stop showing // "Tags saved" / "Photo deleted" toasts when the server actually // returned 4xx/5xx. The previous swallow-and-return-undefined caused // silent UI lies on permission failures. async function _patchImage(id, patch) { try { const r = await fetch(`${API_BASE}/api/gallery/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(patch), }); if (!r.ok) { console.warn('Gallery patch returned', r.status); return false; } return true; } catch (e) { console.error('Gallery patch error:', e); return false; } } async function _deleteImage(id) { try { const r = await fetch(`${API_BASE}/api/gallery/${id}`, { method: 'DELETE', credentials: 'same-origin', }); if (!r.ok) { console.warn('Gallery delete returned', r.status); return false; } return true; } catch (e) { console.error('Gallery delete error:', e); return false; } } // ---- Bulk upload with progress ---- // Accepts either File[] (uploads all into fallbackAlbumId) or // {file, albumId}[] (per-file album targeting — used for folder drops). async function _bulkUpload(filesOrItems, fallbackAlbumId) { const bar = document.getElementById('gallery-upload-bar'); const progress = document.getElementById('gallery-upload-progress'); const status = document.getElementById('gallery-upload-status'); if (!bar) return; const items = filesOrItems.map(it => it instanceof File ? { file: it, albumId: fallbackAlbumId } : it ); bar.style.display = ''; let done = 0, dupes = 0, errors = 0; const total = items.length; // Concurrency pool — N workers pulling from the queue. 4 is a reasonable // default for a local server: enough to overlap network + EXIF + disk // without flooding SQLite (which serializes writes anyway). Videos in // particular benefit because they're large enough to be I/O-bound. const CONCURRENCY = 4; let cursor = 0; async function worker() { while (true) { const idx = cursor++; if (idx >= items.length) return; const it = items[idx]; const fd = new FormData(); fd.append('file', it.file); if (it.albumId) fd.append('album_id', it.albumId); try { const res = await fetch(`${API_BASE}/api/gallery/upload`, { method: 'POST', body: fd, credentials: 'same-origin', }); const data = await res.json(); if (data.duplicate) dupes++; else if (!data.ok) errors++; } catch (e) { errors++; } done++; if (progress) progress.style.width = `${(done / total) * 100}%`; if (status) status.textContent = `${done}/${total}${dupes ? ` (${dupes} duplicates)` : ''}`; } } await Promise.all(Array.from({ length: Math.min(CONCURRENCY, total) }, worker)); const msg = `${done - dupes - errors} imported` + (dupes ? `, ${dupes} duplicates skipped` : '') + (errors ? `, ${errors} errors` : ''); if (status) status.textContent = msg; uiModule.showToast(msg); setTimeout(() => { bar.style.display = 'none'; }, 3000); // Auto-switch to Recent so the just-uploaded photos are immediately // visible at the top (otherwise Shuffle would scatter them). if (done - dupes - errors > 0 && _sort !== 'recent') { _sort = 'recent'; const sortSel = document.getElementById('gallery-sort'); if (sortSel) sortSel.value = 'recent'; } _fetchLibrary(false); _fetchAlbums(); } // True if this File / filename should be uploaded — images and common videos. function _isMediaFile(f) { const t = (f?.type || '').toLowerCase(); if (t.startsWith('image/') || t.startsWith('video/')) return true; // Some Linux file managers and older browsers leave .type blank; fall // back to the extension. const ext = (f?.name || '').toLowerCase().split('.').pop() || ''; return ['png','jpg','jpeg','webp','gif','mp4','mov','webm','mkv','m4v'].includes(ext); } // True if a URL/filename refers to a video — used to pick