/** * OxiCloud - Photos Timeline View * Photo grid grouped by day/month/year, with infinite scroll and multi-select. */ import { Modal } from '../../components/modal.js'; 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 {'square'|'justified'} */ layoutMode: 'square', /** @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, /** @type {string|null} Anchor id for shift-range selection */ _selectAnchorId: null, 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.layoutMode = /** @type {'square'|'justified'} */ (localStorage.getItem('oxicloud-photos-layout')) || 'square'; 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(); }, /** * Switch tile layout (square crop vs justified aspect-preserving rows). * @param {'square'|'justified'} mode */ setLayoutMode(mode) { if (this.layoutMode === mode) return; this.layoutMode = mode; localStorage.setItem('oxicloud-photos-layout', 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}`); this._container.classList.remove('photos-layout-square', 'photos-layout-justified'); this._container.classList.add(`photos-layout-${this.layoutMode}`); 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); this._container.onkeydown = (e) => this._handleKeydown(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) { if (this.layoutMode === 'justified') { // Justified rows must repack against the whole group. grid.innerHTML = this._renderGroupTiles(existing.files); } else { 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; grid.innerHTML = this._renderGroupTiles(rec.files); 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 width = this._gridWidth(); 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) { if (this.layoutMode === 'justified') { const width = this._gridWidth(); const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200; const perRow = Math.max(1, Math.round(width / (target * 1.4))); const rows = Math.max(1, Math.ceil(count / perRow)); return Math.round(rows * target + (rows - 1) * 8); } 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 * @param {string} [sizeStyle] Inline `width:..;height:..` for justified rows. */ _renderTile(file, sizeStyle) { 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`; const styleAttr = sizeStyle ? ` style="${sizeStyle}"` : ''; 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; }, /** * Inner HTML for a group's grid in the current layout mode. * @param {FileItem[]} files * @returns {string} */ _renderGroupTiles(files) { if (this.layoutMode !== 'justified') { let html = ''; for (const file of files) html += this._renderTile(file); return html; } const rows = this._justifiedRows(files, this._gridWidth()); let html = ''; for (const row of rows) { html += `
`; for (const t of row.tiles) { html += this._renderTile(t.file, `width:${t.w}px;height:${t.h}px`); } html += '
'; } return html; }, /** * Pack files into justified rows (Flickr-style): each full row is scaled so * it fills the container width while preserving every tile's aspect ratio. * Missing dimensions fall back to a 1:1 aspect. * @param {FileItem[]} files * @param {number} width Available content width in px. * @returns {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>} */ _justifiedRows(files, width) { const gap = 8; const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200; /** @type {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>} */ const rows = []; /** @type {Array<{file: FileItem, aspect: number}>} */ let cur = []; let aspectSum = 0; for (const file of files) { let aspect = file.width && file.height ? file.width / file.height : 1; if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1; aspect = Math.min(Math.max(aspect, 0.4), 3); cur.push({ file, aspect }); aspectSum += aspect; const rowWidth = aspectSum * target + (cur.length - 1) * gap; if (rowWidth >= width) { const h = (width - (cur.length - 1) * gap) / aspectSum; rows.push({ height: Math.round(h), tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * h)), h: Math.round(h) })) }); cur = []; aspectSum = 0; } } if (cur.length) { rows.push({ height: target, tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * target)), h: target })) }); } return rows; }, /** Current grid content width in px (for layout / height estimates). */ _gridWidth() { const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid')); return sample?.clientWidth || (this._container?.clientWidth || 1200) - 16; }, /** * 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 (