/** * OxiCloud - Photos Timeline View * Photo grid grouped by day/month/year, with infinite scroll and multi-select. */ 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} */ _observer: null, /** @type {HTMLElement|null} */ _container: null, /** @type {boolean} */ _initialized: false, /** @type {'daily'|'monthly'|'yearly'} */ groupMode: 'monthly', /** @type {Map} fileId → thumbnail URL (persists across re-renders) */ _videoThumbCache: new Map(), /** @type {number} Max concurrent video thumbnail extractions */ _maxConcurrentDecodes: 3, /** @type {number} Currently running video decodes */ _activeDecodes: 0, /** @type {Array} Pending video decode queue */ _decodeQueue: [], /** @type {number} Items already rendered in the DOM */ _renderedCount: 0, PAGE_SIZE: 200, /** Auth headers (HttpOnly cookies) */ _headers(json = false) { const h = typeof getCsrfHeaders === 'function' ? { ...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 = 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._renderedCount = 0; this._container.innerHTML = ''; this._loadPage(); }, /** Hide the photos view */ hide() { if (this._container) { this._container.classList.remove('active'); } this._destroyObserver(); this._hideSelectionBar(); }, /** Switch grouping mode */ setGroupMode(mode) { if (this.groupMode === mode) return; this.groupMode = mode; localStorage.setItem('oxicloud-photos-group', mode); this._renderedCount = 0; 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}`); 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); } } }, // ── Rendering ─────────────────────────────────────────────────── // Two render paths: // _renderFull() — full DOM rebuild (first load, group-mode change, delete) // _appendBatch(n) — append-only for infinite-scroll pages (O(batch)) /** Full DOM rebuild — first load, group-mode switch, or after deletions. */ _renderFull() { if (!this._container) return; this._destroyObserver(); 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; const groups = this._groupItems(this.items); let html = this._renderToolbar(); for (const [label, files] of groups) { html += `
${this._escHtml(label)}${files.length}
`; html += '
'; for (const file of files) html += this._renderTile(file); html += '
'; } html += '
'; this._container.innerHTML = html; this._container.onclick = (e) => this._handleClick(e); this._renderedCount = this.items.length; this._observeSentinel(); this._setupVideoThumbnails(); }, /** Append-only render for infinite scroll — inserts only the items * from this.items[startIndex..] without destroying existing DOM. * Complexity: O(batch) instead of O(total_items). */ _appendBatch(startIndex) { if (!this._container) return; this._destroyObserver(); const newItems = this.items.slice(startIndex); if (newItems.length === 0) { this._observeSentinel(); return; } const newGroups = this._groupItems(newItems); const sentinel = this._container.querySelector('.photos-sentinel'); if (!sentinel) { // Fallback: sentinel missing — full rebuild this._renderedCount = 0; this._renderFull(); return; } for (const [label, files] of newGroups) { let tilesHtml = ''; for (const file of files) tilesHtml += this._renderTile(file); // Does this date-group already exist in the DOM? const existingHeader = this._container.querySelector(`.photos-day-header[data-group="${CSS.escape(label)}"]`); if (existingHeader) { // Append tiles to existing grid and update count badge const grid = existingHeader.nextElementSibling; if (grid?.classList.contains('photos-grid')) { grid.insertAdjacentHTML('beforeend', tilesHtml); const countSpan = existingHeader.querySelector('.photos-day-count'); if (countSpan) countSpan.textContent = grid.children.length; } } else { // New group — insert header + grid before sentinel const sectionHtml = `
${this._escHtml(label)}${files.length}
` + `
${tilesHtml}
`; sentinel.insertAdjacentHTML('beforebegin', sectionHtml); } } this._renderedCount = this.items.length; this._observeSentinel(); this._setupVideoThumbnails(startIndex); }, /** Generate HTML for a single photo/video tile */ _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 += `
`; h += `${this._escAttr(file.name)}`; if (isVideo) h += `
`; h += `
`; return h; }, /** (Re-)observe the sentinel element for infinite scroll */ _observeSentinel() { this._destroyObserver(); const sentinel = this._container?.querySelector('.photos-sentinel'); if (sentinel && !this.exhausted) { this._observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) this._loadPage(); }, { rootMargin: '400px' } ); this._observer.observe(sentinel); } }, // ── Client-side video thumbnail generation ────────────────────── // Uses the browser's native video decoder (