/** * OxiCloud - Photos Timeline View * Photo grid grouped by day/month/year, with infinite scroll and multi-select. */ import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { thumbnail } from '../thumbnail.js'; import { photosLightbox } from './photosLightbox.js'; /** @import {FileItem} from '../../core/types.js' */ /** * @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum */ /** * @typedef {Object} PhotoGroup * @property {string} label * @property {FileItem[]} files * @property {HTMLElement} section * @property {boolean} materialized */ const photosView = { /** @type {Array} All loaded photo items */ items: [], /** @type {string|null} Cursor for next page */ nextCursor: null, /** @type {boolean} Currently fetching */ loading: false, /** @type {boolean} All items loaded */ exhausted: false, /** @type {Set} Selected item IDs */ selected: new Set(), /** @type {IntersectionObserver|null} Materializes/dematerializes group tiles by viewport proximity */ _materializeObserver: null, /** @type {IntersectionObserver|null} Infinite-scroll trigger on the sentinel */ _sentinelObserver: null, /** @type {HTMLElement|null} */ _container: null, /** @type {HTMLElement|null} The infinite-scroll sentinel element */ _sentinelEl: null, /** @type {boolean} */ _initialized: false, /** @type {PhotoModeEnum} */ groupMode: 'monthly', /** @type {Map} fileId → thumbnail URL (persists across re-renders) */ _videoThumbCache: new Map(), /** @type {Map} group label → group record (DOM + data) */ _groupData: new Map(), /** @type {string[]} Ordered group labels (timeline order) */ _groupOrder: [], /** @type {(() => void)|null} Debounced window resize handler */ _resizeHandler: null, /** @type {number} */ _resizeTimer: 0, PAGE_SIZE: 200, /** Auth headers (HttpOnly cookies) */ _headers(json = false) { const h = getCsrfHeaders(); if (json) h['Content-Type'] = 'application/json'; return h; }, /** Initialize / re-initialize the photos view */ init() { if (!this._container) { const contentArea = document.querySelector('.content-area'); if (!contentArea) return; const el = document.createElement('div'); el.id = 'photos-container'; el.className = 'photos-container'; contentArea.appendChild(el); this._container = el; } if (!this._initialized) { this.groupMode = /** @type {'daily'|'monthly'|'yearly'} */ (localStorage.getItem('oxicloud-photos-group')) || 'monthly'; this._initialized = true; } }, /** Show the photos view and load data */ show() { this.init(); if (!this._container) return; this._container.classList.add('active'); this.items = []; this.nextCursor = null; this.exhausted = false; this.selected.clear(); this._groupData = new Map(); this._groupOrder = []; this._destroyObserver(); this._container.innerHTML = ''; this._loadPage(); }, /** Hide the photos view */ hide() { if (this._container) { this._container.classList.remove('active'); } this._destroyObserver(); this._unbindResize(); this._hideSelectionBar(); }, /** Switch grouping mode */ /** * * @param {PhotoModeEnum} mode * @returns */ setGroupMode(mode) { if (this.groupMode === mode) return; this.groupMode = mode; localStorage.setItem('oxicloud-photos-group', mode); this._renderFull(); }, /** Fetch a page of photos from the API */ async _loadPage() { if (this.loading || this.exhausted) return; this.loading = true; this._showLoading(true); const prevCount = this.items.length; try { let url = `/api/photos?limit=${this.PAGE_SIZE}`; if (this.nextCursor) { url += `&before=${this.nextCursor}`; } const res = await fetch(url, { credentials: 'include', headers: this._headers() }); if (!res.ok) throw new Error(`HTTP ${res.status}`); /** @type {FileItem[]} */ const data = await res.json(); if (!data || data.length === 0) { this.exhausted = true; } else { this.items.push(...data); const cursor = res.headers.get('X-Next-Cursor'); if (cursor && data.length >= this.PAGE_SIZE) { this.nextCursor = cursor; } else { this.exhausted = true; } } } catch (err) { console.error('Error loading photos:', err); this.exhausted = true; } finally { this.loading = false; this._showLoading(false); if (prevCount === 0) { this._renderFull(); } else { this._appendBatch(prevCount); } } }, // ── Virtualized rendering ─────────────────────────────────────── // The timeline can hold tens of thousands of items, so we never keep // every tile in the DOM. Each date-group is a
with a header // (always present, cheap) and a grid that is *materialized* (tiles in // the DOM) only while near the viewport, and *dematerialized* (emptied, // its height frozen as a spacer) once it scrolls far away. An // IntersectionObserver rooted on the scroll container drives the swap, // so the DOM node count stays bounded by a few screens regardless of // library size. // _renderFull() — rebuild the group skeleton (first load, mode switch, delete) // _appendBatch(n) — append new groups for infinite-scroll pages /** Rebuild the group skeleton — first load, group-mode switch, or deletions. */ _renderFull() { if (!this._container) return; this._destroyObserver(); this._groupData = new Map(); this._groupOrder = []; this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly'); this._container.classList.add(`photos-group-${this.groupMode}`); if (this.items.length === 0 && this.exhausted) { this._renderEmpty(); return; } if (this.items.length === 0) return; // Toolbar via innerHTML, then append group
s + sentinel as // real elements so we keep references for the observer. this._container.innerHTML = this._renderToolbar(); this._container.onclick = (e) => this._handleClick(e); const groups = this._groupItems(this.items); for (const [label, files] of groups) { /** @type {PhotoGroup} */ const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false }; this._groupData.set(label, rec); this._groupOrder.push(label); this._container.appendChild(rec.section); } const sentinel = document.createElement('div'); sentinel.className = 'photos-sentinel'; this._container.appendChild(sentinel); this._sentinelEl = sentinel; this._setupObservers(); this._eagerMaterialize(); this._bindResize(); }, /** Append new groups for an infinite-scroll page without rebuilding the * existing skeleton. The first new group may continue the previous tail * label, in which case we merge into it. Complexity: O(new groups). * @param {number} startIndex */ _appendBatch(startIndex) { if (!this._container || !this._sentinelEl) { this._renderFull(); return; } const newItems = this.items.slice(startIndex); if (newItems.length === 0) return; const newGroups = this._groupItems(newItems); for (const [label, files] of newGroups) { const existing = this._groupData.get(label); if (existing) { // Continuation of a group already in the timeline. existing.files = existing.files.concat(files); const countEl = existing.section.querySelector('.photos-day-count'); if (countEl) countEl.textContent = String(existing.files.length); const grid = /** @type {HTMLElement|null} */ (existing.section.querySelector('.photos-grid')); if (grid) { if (existing.materialized) { let tilesHtml = ''; for (const file of files) tilesHtml += this._renderTile(file); grid.insertAdjacentHTML('beforeend', tilesHtml); this._setupVideoThumbnails(grid); this._fadeInTiles(grid); } else { grid.style.minHeight = `${this._estimateHeight(existing.files.length)}px`; } } } else { /** @type {PhotoGroup} */ const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false }; this._groupData.set(label, rec); this._groupOrder.push(label); this._container.insertBefore(rec.section, this._sentinelEl); this._materializeObserver?.observe(rec.section); } } }, /** Build a dematerialized group section (header + empty grid spacer). * @param {string} label * @param {FileItem[]} files * @returns {HTMLElement} */ _buildGroupEl(label, files) { const section = document.createElement('section'); section.className = 'photos-group'; section.dataset.group = label; section.innerHTML = `
${this._escHtml(label)}${files.length}
` + `
`; return section; }, /** Wire the two IntersectionObservers (materialization + infinite scroll). */ _setupObservers() { const root = this._container?.parentElement || null; if (!('IntersectionObserver' in window)) { // Degrade gracefully: render every group (legacy behaviour). for (const label of this._groupOrder) { const rec = this._groupData.get(label); if (rec) this._materializeGroup(rec.section); } return; } this._materializeObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { const section = /** @type {HTMLElement} */ (entry.target); if (entry.isIntersecting) this._materializeGroup(section); else this._dematerializeGroup(section); } }, { root, rootMargin: '1200px 0px' } ); for (const label of this._groupOrder) { const rec = this._groupData.get(label); if (rec) this._materializeObserver.observe(rec.section); } if (this._sentinelEl) { this._sentinelObserver = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) this._loadPage(); }, { root, rootMargin: '600px 0px' } ); this._sentinelObserver.observe(this._sentinelEl); } }, /** Synchronously materialize the first groups within ~1.5 viewports so * the initial paint has tiles before the observer's first callback. */ _eagerMaterialize() { const budget = (this._container?.parentElement?.clientHeight || window.innerHeight) * 1.5; let acc = 0; for (const label of this._groupOrder) { const rec = this._groupData.get(label); if (!rec) continue; this._materializeGroup(rec.section); acc += rec.section.offsetHeight; if (acc > budget) break; } }, /** Fill a group's grid with tiles (idempotent). * @param {HTMLElement} section */ _materializeGroup(section) { const rec = this._groupData.get(section.dataset.group || ''); if (!rec || rec.materialized) return; rec.materialized = true; const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid')); if (!grid) return; let html = ''; for (const file of rec.files) html += this._renderTile(file); grid.innerHTML = html; grid.style.minHeight = ''; this._setupVideoThumbnails(grid); this._fadeInTiles(grid); }, /** Empty a group's grid, freezing its current height as a spacer. * @param {HTMLElement} section */ _dematerializeGroup(section) { const rec = this._groupData.get(section.dataset.group || ''); if (!rec?.materialized) return; rec.materialized = false; const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid')); if (!grid) return; grid.style.minHeight = `${grid.offsetHeight}px`; grid.innerHTML = ''; }, /** Current grid geometry (columns / gap / square tile px) for the active * mode, used to estimate off-screen group heights. * @returns {{cols: number, gap: number, tile: number}} */ _gridMetrics() { const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid')); const width = sample?.clientWidth || (this._container?.clientWidth || 1200) - 16; const mobile = window.matchMedia('(max-width: 768px)').matches; let min; let gap; if (this.groupMode === 'yearly') { min = mobile ? 80 : 120; gap = mobile ? 4 : 10; } else if (this.groupMode === 'monthly') { min = mobile ? 110 : 180; gap = mobile ? 2 : 14; } else { min = mobile ? 100 : 150; gap = mobile ? 2 : 12; } const cols = Math.max(1, Math.floor((width + gap) / (min + gap))); const tile = (width - (cols - 1) * gap) / cols; return { cols, gap, tile }; }, /** Estimated pixel height of a grid holding `count` square tiles. * @param {number} count * @returns {number} */ _estimateHeight(count) { const { cols, gap, tile } = this._gridMetrics(); const rows = Math.max(1, Math.ceil(count / cols)); return Math.round(rows * tile + (rows - 1) * gap); }, /** Re-estimate spacer heights for dematerialized groups after a resize. */ _bindResize() { if (this._resizeHandler) return; this._resizeHandler = () => { clearTimeout(this._resizeTimer); this._resizeTimer = window.setTimeout(() => this._onResize(), 150); }; window.addEventListener('resize', this._resizeHandler); }, _onResize() { if (!this._container?.classList.contains('active')) return; for (const label of this._groupOrder) { const rec = this._groupData.get(label); if (!rec || rec.materialized) continue; const grid = /** @type {HTMLElement|null} */ (rec.section.querySelector('.photos-grid')); if (grid) grid.style.minHeight = `${this._estimateHeight(rec.files.length)}px`; } }, _unbindResize() { if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler = null; } clearTimeout(this._resizeTimer); }, /** * Generate HTML for a single photo/video tile * @param {FileItem} file */ _renderTile(file) { const isVideo = file.mime_type?.startsWith('video/'); const selected = this.selected.has(file.id) ? ' selected' : ''; const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null; const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`; let h = `
`; h += `
`; const srcset = cachedThumb ? '' : ` srcset="/api/files/${file.id}/thumbnail/icon 150w, /api/files/${file.id}/thumbnail/preview 400w, /api/files/${file.id}/thumbnail/large 800w" sizes="(max-width: 768px) 33vw, 200px"`; h += `${this._escAttr(file.name)}`; if (isVideo) h += `
`; h += `
`; return h; }, /** * Fade tiles in as their thumbnails finish loading (kills the pop-in). * Idempotent — only wires images not already marked loaded. * @param {ParentNode} [scope] Limit to a subtree (a group grid); defaults to the whole container. */ _fadeInTiles(scope) { const root = scope || this._container; root?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => { const img = /** @type {HTMLImageElement} */ (el); if (img.complete) { img.classList.add('is-loaded'); } else { const done = () => img.classList.add('is-loaded'); img.addEventListener('load', done, { once: true }); img.addEventListener('error', done, { once: true }); } }); }, // ── Client-side video thumbnail generation ────────────────────── // Uses the browser's native video decoder (