feat(photos): add Photos timeline view with lightbox and infinite scroll

Backend: new GET /api/photos endpoint with cursor-based pagination that
queries image/video files sorted by EXIF captured_at (falling back to
created_at), joining file_metadata for sort dates.

Frontend: dense photo grid grouped by day with lazy-loaded thumbnails,
IntersectionObserver infinite scroll, multi-select with batch
download/delete, and a full-screen lightbox with prev/next navigation,
EXIF metadata display, and download/favorite/delete toolbar.

Includes navigation wiring, CSS (with dark theme), and i18n translations
for all 9 locales.
This commit is contained in:
Jared Wolff
2026-03-05 13:40:02 -05:00
parent 69fe3a8b07
commit 53e4f5afe6
23 changed files with 1318 additions and 3 deletions
+8 -2
View File
@@ -271,7 +271,7 @@ function cacheElements() {
elements.pageTitle = document.querySelector('.page-title');
elements.actionsBar = document.querySelector('.actions-bar');
elements.navItems = document.querySelectorAll('.nav-item');
elements.trashBtn = document.querySelector('.nav-item:nth-child(5)'); // The trash nav item
elements.trashBtn = document.querySelector('.nav-item:nth-child(6)'); // The trash nav item (after Photos)
elements.searchInput = document.querySelector('.search-container input');
}
@@ -436,7 +436,13 @@ function setupEventListeners() {
switchToRecentFilesView();
return;
}
// Check if this is the photos item
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.photos') {
switchToPhotosView();
return;
}
// Check if this is the trash item
if (item === elements.trashBtn) {
// Hide shared view if active
+30 -1
View File
@@ -73,7 +73,8 @@ const VIEW_FLAGS = {
'shared': 'isSharedView',
'recent': 'isRecentView',
'favorites': 'isFavoritesView',
'trash': 'isTrashView'
'trash': 'isTrashView',
'photos': 'isPhotosView'
};
/**
@@ -114,6 +115,11 @@ function setCurrentSection(section) {
if (section !== 'shared' && window.sharedView) {
window.sharedView.hide();
}
// Hide photosView when switching to any other section
if (section !== 'photos' && window.photosView) {
window.photosView.hide();
}
}
function switchToSharedView() {
@@ -223,7 +229,30 @@ function switchToRecentFilesView() {
}
}
function switchToPhotosView() {
setCurrentSection('photos');
// Hide breadcrumb
const breadcrumb = document.querySelector('.breadcrumb');
if (breadcrumb) breadcrumb.style.display = 'none';
// Hide actions-bar (photos has its own upload via selection bar)
window.setActionsBarMode('hidden');
// Hide file containers
const filesGrid = document.getElementById('files-grid');
const filesListView = document.getElementById('files-list-view');
if (filesGrid) filesGrid.style.display = 'none';
if (filesListView) filesListView.style.display = 'none';
// Show photos view
if (window.photosView) {
window.photosView.show();
}
}
window.switchToFilesView = switchToFilesView;
window.switchToSharedView = switchToSharedView;
window.switchToFavoritesView = switchToFavoritesView;
window.switchToRecentFilesView = switchToRecentFilesView;
window.switchToPhotosView = switchToPhotosView;
+1
View File
@@ -16,6 +16,7 @@ window.app = {
isSharedView: false,
isFavoritesView: false,
isRecentView: false,
isPhotosView: false,
currentSection: 'files',
isSearchMode: false,
shareDialogItem: null,
+328
View File
@@ -0,0 +1,328 @@
/**
* OxiCloud - Photos Timeline View
* Dense photo grid grouped by day, 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<string>} Selected item IDs */
selected: new Set(),
/** @type {IntersectionObserver|null} */
_observer: null,
/** @type {HTMLElement|null} */
_container: null,
/** @type {boolean} */
_initialized: false,
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._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._render();
this._loadPage();
},
/** Hide the photos view */
hide() {
if (this._container) {
this._container.classList.remove('active');
}
this._destroyObserver();
this._hideSelectionBar();
},
/** Fetch a page of photos from the API */
async _loadPage() {
if (this.loading || this.exhausted) return;
this.loading = true;
this._showLoading(true);
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);
// Read cursor from header
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);
this._render();
}
},
/** Render the full timeline from this.items */
_render() {
if (!this._container) return;
this._destroyObserver();
if (this.items.length === 0 && this.exhausted) {
this._renderEmpty();
return;
}
if (this.items.length === 0) return;
// Group by day
const groups = this._groupByDay(this.items);
let html = '';
for (const [dayLabel, files] of groups) {
html += `<div class="photos-day-header">${this._escHtml(dayLabel)}<span class="photos-day-count">${files.length}</span></div>`;
html += '<div class="photos-grid">';
for (const file of files) {
const isVideo = file.mime_type && file.mime_type.startsWith('video/');
const selected = this.selected.has(file.id) ? ' selected' : '';
const thumbUrl = `/api/files/${file.id}/thumbnail/preview`;
html += `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}">`;
html += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
html += `<img src="${thumbUrl}" loading="lazy" alt="${this._escAttr(file.name)}">`;
if (isVideo) {
html += `<div class="video-badge"><i class="fas fa-play"></i></div>`;
}
html += `</div>`;
}
html += '</div>';
}
// Sentinel for infinite scroll
html += '<div class="photos-sentinel"></div>';
this._container.innerHTML = html;
// Attach click handlers via delegation
this._container.onclick = (e) => this._handleClick(e);
// Observe sentinel for infinite scroll
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);
}
},
/** Render empty state */
_renderEmpty() {
const t = (k, d) => window.i18n ? window.i18n.t(k) : d;
this._container.innerHTML = `
<div class="photos-empty">
<i class="fas fa-images"></i>
<p class="photos-empty-title">${t('photos.empty_state', 'No photos yet')}</p>
<p>${t('photos.empty_hint', 'Upload images or videos to see them here')}</p>
</div>`;
},
/** Group items by day using sort_date */
_groupByDay(items) {
const map = new Map();
for (const item of items) {
const ts = (item.sort_date || item.created_at) * 1000;
const d = new Date(ts);
const key = d.toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
if (!map.has(key)) map.set(key, []);
map.get(key).push(item);
}
return map;
},
/** Handle click on photo tile */
_handleClick(e) {
const tile = e.target.closest('.photo-tile');
if (!tile) return;
const id = tile.dataset.id;
const check = e.target.closest('.photo-check');
// If clicking checkbox or in selection mode, toggle select
if (check || this.selected.size > 0) {
this._toggleSelect(id, tile);
return;
}
// Otherwise open lightbox
const idx = this.items.findIndex(f => f.id === id);
if (idx >= 0 && window.photosLightbox) {
window.photosLightbox.open(this.items, idx);
}
},
/** Toggle selection of an item */
_toggleSelect(id, tile) {
if (this.selected.has(id)) {
this.selected.delete(id);
tile.classList.remove('selected');
} else {
this.selected.add(id);
tile.classList.add('selected');
}
this._updateSelectionBar();
},
/** Show/update selection bar */
_updateSelectionBar() {
let bar = document.getElementById('photos-selection-bar');
if (this.selected.size === 0) {
this._hideSelectionBar();
return;
}
if (!bar) {
bar = document.createElement('div');
bar.id = 'photos-selection-bar';
bar.className = 'photos-selection-bar';
document.body.appendChild(bar);
}
const t = (k, d) => window.i18n ? window.i18n.t(k) : d;
const count = this.selected.size;
bar.innerHTML = `
<span class="selection-count">${count} ${t('photos.items_selected', 'selected')}</span>
<button id="photos-sel-download" title="Download"><i class="fas fa-download"></i></button>
<button id="photos-sel-delete" title="Delete"><i class="fas fa-trash"></i></button>
<button id="photos-sel-clear" title="Clear"><i class="fas fa-times"></i></button>
`;
bar.querySelector('#photos-sel-clear').onclick = () => {
this.selected.clear();
this._container.querySelectorAll('.photo-tile.selected').forEach(t => t.classList.remove('selected'));
this._hideSelectionBar();
};
bar.querySelector('#photos-sel-delete').onclick = async () => {
if (!confirm('Delete selected items?')) return;
for (const fid of this.selected) {
try {
await fetch(`/api/files/${fid}`, {
method: 'DELETE',
credentials: 'include',
headers: this._headers()
});
} catch (err) {
console.error('Delete failed:', fid, err);
}
}
// Remove from items and re-render
this.items = this.items.filter(f => !this.selected.has(f.id));
this.selected.clear();
this._hideSelectionBar();
this._render();
};
bar.querySelector('#photos-sel-download').onclick = async () => {
for (const fid of this.selected) {
const a = document.createElement('a');
a.href = `/api/files/${fid}`;
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}
};
bar.style.display = 'flex';
},
_hideSelectionBar() {
const bar = document.getElementById('photos-selection-bar');
if (bar) bar.style.display = 'none';
},
_showLoading(show) {
if (!this._container) return;
let loader = this._container.querySelector('.photos-loading');
if (show && !loader) {
loader = document.createElement('div');
loader.className = 'photos-loading';
loader.innerHTML = '<i class="fas fa-spinner"></i> Loading...';
this._container.appendChild(loader);
} else if (!show && loader) {
loader.remove();
}
},
_destroyObserver() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
},
_escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
_escAttr(s) {
return String(s || '').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
};
window.photosView = photosView;
@@ -0,0 +1,281 @@
/**
* OxiCloud - Photos Lightbox
* Full-screen image/video viewer with prev/next navigation.
*/
const photosLightbox = {
/** @type {Array} Items array reference */
items: [],
/** @type {number} Current index */
index: -1,
/** @type {HTMLElement|null} */
_overlay: null,
/** @type {string|null} Current blob URL to revoke */
_blobUrl: null,
/** @type {Function|null} */
_keyHandler: null,
/** Auth headers */
_headers() {
return typeof getCsrfHeaders === 'function' ? { ...getCsrfHeaders() } : {};
},
/** Open lightbox at given index */
open(items, index) {
this.items = items;
this.index = index;
this._createOverlay();
this._show();
this._bindKeys();
},
/** Close lightbox */
close() {
if (this._overlay) {
this._overlay.classList.remove('active');
setTimeout(() => {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}, 200);
}
this._revokeBlob();
this._unbindKeys();
},
/** Navigate to previous */
prev() {
if (this.index > 0) {
this.index--;
this._show();
}
},
/** Navigate to next */
next() {
if (this.index < this.items.length - 1) {
this.index++;
this._show();
}
},
/** Create the overlay DOM structure */
_createOverlay() {
if (this._overlay) this._overlay.remove();
const el = document.createElement('div');
el.className = 'photos-lightbox';
el.innerHTML = `
<div class="lightbox-info">
<div class="lightbox-filename"></div>
<div class="lightbox-meta"></div>
</div>
<button class="lightbox-close"><i class="fas fa-times"></i></button>
<button class="lightbox-nav lightbox-prev"><i class="fas fa-chevron-left"></i></button>
<div class="lightbox-content"></div>
<button class="lightbox-nav lightbox-next"><i class="fas fa-chevron-right"></i></button>
<div class="lightbox-toolbar">
<button class="lb-download" title="Download"><i class="fas fa-download"></i></button>
<button class="lb-favorite" title="Favorite"><i class="far fa-star"></i></button>
<button class="lb-delete" title="Delete"><i class="fas fa-trash"></i></button>
</div>
<div class="lightbox-counter"></div>
`;
document.body.appendChild(el);
this._overlay = el;
// Event listeners
el.querySelector('.lightbox-close').onclick = () => this.close();
el.querySelector('.lightbox-prev').onclick = () => this.prev();
el.querySelector('.lightbox-next').onclick = () => this.next();
// Click backdrop to close
el.addEventListener('click', (e) => {
if (e.target === el || e.target.classList.contains('lightbox-content')) {
this.close();
}
});
// Toolbar actions
el.querySelector('.lb-download').onclick = () => this._download();
el.querySelector('.lb-favorite').onclick = () => this._toggleFavorite();
el.querySelector('.lb-delete').onclick = () => this._delete();
// Animate in
requestAnimationFrame(() => el.classList.add('active'));
},
/** Display the current item */
async _show() {
if (!this._overlay || this.index < 0) return;
const item = this.items[this.index];
const content = this._overlay.querySelector('.lightbox-content');
const filename = this._overlay.querySelector('.lightbox-filename');
const meta = this._overlay.querySelector('.lightbox-meta');
const counter = this._overlay.querySelector('.lightbox-counter');
filename.textContent = item.name;
counter.textContent = `${this.index + 1} / ${this.items.length}`;
// Format date
const ts = (item.sort_date || item.created_at) * 1000;
const dateStr = new Date(ts).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
meta.textContent = `${dateStr} · ${item.size_formatted || ''}`;
// Update nav button visibility
this._overlay.querySelector('.lightbox-prev').style.visibility = this.index > 0 ? 'visible' : 'hidden';
this._overlay.querySelector('.lightbox-next').style.visibility = this.index < this.items.length - 1 ? 'visible' : 'hidden';
// Load content
this._revokeBlob();
content.innerHTML = '<div class="photos-loading"><i class="fas fa-spinner"></i></div>';
try {
const isVideo = item.mime_type && item.mime_type.startsWith('video/');
const res = await fetch(`/api/files/${item.id}`, {
credentials: 'include',
headers: this._headers()
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
this._blobUrl = URL.createObjectURL(blob);
if (isVideo) {
content.innerHTML = `<video src="${this._blobUrl}" controls autoplay></video>`;
} else {
content.innerHTML = `<img src="${this._blobUrl}" alt="${this._escAttr(item.name)}">`;
}
} catch (err) {
console.error('Lightbox load error:', err);
content.innerHTML = '<div class="photos-loading">Failed to load</div>';
}
// Load EXIF metadata
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
},
/** Load EXIF metadata for info bar */
async _loadMetadata(fileId, metaEl, dateStr, sizeStr) {
try {
const res = await fetch(`/api/files/${fileId}/metadata`, {
credentials: 'include',
headers: this._headers()
});
if (res.ok) {
const data = await res.json();
let parts = [dateStr];
if (sizeStr) parts.push(sizeStr);
if (data.camera_make || data.camera_model) {
parts.push([data.camera_make, data.camera_model].filter(Boolean).join(' '));
}
if (data.width && data.height) {
parts.push(`${data.width}×${data.height}`);
}
metaEl.textContent = parts.join(' · ');
}
} catch (err) {
// Non-critical, keep existing meta
}
},
/** Download current item */
_download() {
const item = this.items[this.index];
if (!item) return;
const a = document.createElement('a');
a.href = `/api/files/${item.id}`;
a.download = item.name;
document.body.appendChild(a);
a.click();
a.remove();
},
/** Toggle favorite on current item */
async _toggleFavorite() {
const item = this.items[this.index];
if (!item || !window.favorites) return;
try {
await fetch(`/api/favorites/file/${item.id}`, {
method: 'POST',
credentials: 'include',
headers: this._headers(true)
});
const btn = this._overlay.querySelector('.lb-favorite');
if (btn) {
btn.classList.toggle('active');
const icon = btn.querySelector('i');
if (icon) {
icon.className = btn.classList.contains('active') ? 'fas fa-star' : 'far fa-star';
}
}
} catch (err) {
console.error('Favorite toggle failed:', err);
}
},
/** Delete current item */
async _delete() {
const item = this.items[this.index];
if (!item) return;
if (!confirm(`Delete ${item.name}?`)) return;
try {
await fetch(`/api/files/${item.id}`, {
method: 'DELETE',
credentials: 'include',
headers: this._headers()
});
// Remove from photosView items too
if (window.photosView) {
window.photosView.items = window.photosView.items.filter(f => f.id !== item.id);
}
this.items.splice(this.index, 1);
if (this.items.length === 0) {
this.close();
if (window.photosView) window.photosView._render();
} else {
if (this.index >= this.items.length) this.index = this.items.length - 1;
this._show();
if (window.photosView) window.photosView._render();
}
} catch (err) {
console.error('Delete failed:', err);
}
},
/** Keyboard navigation */
_bindKeys() {
this._keyHandler = (e) => {
if (e.key === 'Escape') this.close();
else if (e.key === 'ArrowLeft') this.prev();
else if (e.key === 'ArrowRight') this.next();
};
document.addEventListener('keydown', this._keyHandler);
},
_unbindKeys() {
if (this._keyHandler) {
document.removeEventListener('keydown', this._keyHandler);
this._keyHandler = null;
}
},
_revokeBlob() {
if (this._blobUrl) {
URL.revokeObjectURL(this._blobUrl);
this._blobUrl = null;
}
},
_escAttr(s) {
return String(s || '').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
};
window.photosLightbox = photosLightbox;