fix(photos): fix SQL type mismatch, pagination panic, missing icons, and add day/month/year grouping

- Fix photos endpoint 500: remove ::uuid cast on user_id WHERE clause (VARCHAR column)
- Fix pagination underflow panic when total_pages is 0
- Add missing 'images' and 'play' icons to SVG icon registry
- Add day/month/year grouping toggle with localStorage persistence
- Improve grid spacing and group header styling per mode
- Add i18n translations for grouping labels (all 9 locales)
This commit is contained in:
Jared Wolff
2026-03-05 14:46:30 -05:00
parent 53e4f5afe6
commit 6a84a5c44e
22 changed files with 220 additions and 64 deletions
+58 -14
View File
@@ -1,6 +1,6 @@
/**
* OxiCloud - Photos Timeline View
* Dense photo grid grouped by day, with infinite scroll and multi-select.
* Photo grid grouped by day/month/year, with infinite scroll and multi-select.
*/
const photosView = {
@@ -20,6 +20,8 @@ const photosView = {
_container: null,
/** @type {boolean} */
_initialized: false,
/** @type {'daily'|'monthly'|'yearly'} */
groupMode: 'monthly',
PAGE_SIZE: 200,
@@ -42,6 +44,7 @@ const photosView = {
this._container = el;
}
if (!this._initialized) {
this.groupMode = localStorage.getItem('oxicloud-photos-group') || 'monthly';
this._initialized = true;
}
},
@@ -68,6 +71,14 @@ const photosView = {
this._hideSelectionBar();
},
/** Switch grouping mode */
setGroupMode(mode) {
if (this.groupMode === mode) return;
this.groupMode = mode;
localStorage.setItem('oxicloud-photos-group', mode);
this._render();
},
/** Fetch a page of photos from the API */
async _loadPage() {
if (this.loading || this.exhausted) return;
@@ -93,7 +104,6 @@ const photosView = {
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;
@@ -116,6 +126,10 @@ const photosView = {
if (!this._container) return;
this._destroyObserver();
// Set group mode class on container
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;
@@ -123,12 +137,12 @@ const photosView = {
if (this.items.length === 0) return;
// Group by day
const groups = this._groupByDay(this.items);
let html = '';
// Group by selected mode
const groups = this._groupItems(this.items);
let html = this._renderToolbar();
for (const [dayLabel, files] of groups) {
html += `<div class="photos-day-header">${this._escHtml(dayLabel)}<span class="photos-day-count">${files.length}</span></div>`;
for (const [label, files] of groups) {
html += `<div class="photos-day-header">${this._escHtml(label)}<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/');
@@ -165,6 +179,23 @@ const photosView = {
}
},
/** Render the group mode toolbar */
_renderToolbar() {
const t = (k, d) => window.i18n ? window.i18n.t(k) : d;
const modes = [
['daily', t('photos.view_daily', 'Day')],
['monthly', t('photos.view_monthly', 'Month')],
['yearly', t('photos.view_yearly', 'Year')]
];
let html = '<div class="photos-toolbar"><div class="view-toggle">';
for (const [mode, label] of modes) {
const active = this.groupMode === mode ? ' active' : '';
html += `<button class="toggle-btn${active}" data-group-mode="${mode}">${this._escHtml(label)}</button>`;
}
html += '</div></div>';
return html;
},
/** Render empty state */
_renderEmpty() {
const t = (k, d) => window.i18n ? window.i18n.t(k) : d;
@@ -176,23 +207,37 @@ const photosView = {
</div>`;
},
/** Group items by day using sort_date */
_groupByDay(items) {
/** Group items by the current groupMode */
_groupItems(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'
});
let key;
if (this.groupMode === 'yearly') {
key = String(d.getFullYear());
} else if (this.groupMode === 'monthly') {
key = d.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
} else {
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 */
/** Handle click on photo tile or toolbar */
_handleClick(e) {
// Handle group mode toggle
const modeBtn = e.target.closest('[data-group-mode]');
if (modeBtn) {
this.setGroupMode(modeBtn.dataset.groupMode);
return;
}
const tile = e.target.closest('.photo-tile');
if (!tile) return;
@@ -268,7 +313,6 @@ const photosView = {
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();