e8520e485f
- Add Modal.confirmDialog() (Promise<boolean>, built on openPanel so it inherits the overlay, animation, focus-trap and Escape handling) and use it to replace native confirm() in the photos batch-delete and lightbox single-delete flows. - The lightbox now reflects the real favorite state when an item opens (previously the star always started empty) and toggles favorites through the favorites module so its cache stays in sync. - Add photos.delete_* i18n keys (English; other locales fall back to en). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
780 lines
29 KiB
JavaScript
780 lines
29 KiB
JavaScript
/**
|
|
* 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<FileItem>} 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} 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<string, string>} fileId → thumbnail URL (persists across re-renders) */
|
|
_videoThumbCache: new Map(),
|
|
/** @type {Map<string, PhotoGroup>} 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 <section> 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 <section>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 =
|
|
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
|
|
`<div class="photos-grid" style="min-height:${this._estimateHeight(files.length)}px"></div>`;
|
|
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 = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}">`;
|
|
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
|
|
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 += `<img src="${thumbUrl}"${srcset} loading="lazy" decoding="async" alt="${this._escAttr(file.name)}">`;
|
|
if (isVideo) h += `<div class="video-badge"><i class="fas fa-play"></i></div>`;
|
|
h += `</div>`;
|
|
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 (<video> + <canvas>) to
|
|
// extract a frame, show it immediately, and upload to the server
|
|
// for permanent caching. Zero server-side dependencies (no ffmpeg).
|
|
|
|
/** Attach error handlers to video tile images within a freshly
|
|
* materialized grid; on failure, extract a frame from the video using
|
|
* the browser's built-in codec.
|
|
* @param {ParentNode} [scope] Subtree to scan; defaults to the whole container.
|
|
*/
|
|
_setupVideoThumbnails(scope) {
|
|
const root = scope || this._container;
|
|
const tiles = /** @type {NodeListOf<HTMLDivElement>|undefined} */ (root?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
|
|
|
|
if (!tiles) return;
|
|
|
|
for (const tile of tiles) {
|
|
const fileId = tile.dataset.id;
|
|
if (!fileId) continue;
|
|
if (this._videoThumbCache.has(fileId)) continue;
|
|
|
|
const img = tile.querySelector('img');
|
|
if (!img) continue;
|
|
|
|
img.addEventListener(
|
|
'error',
|
|
() => {
|
|
this._generateVideoThumbnail(tile, img);
|
|
},
|
|
{ once: true }
|
|
);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate().
|
|
* @param {HTMLDivElement} tile
|
|
* @param {HTMLImageElement} img
|
|
*/
|
|
async _generateVideoThumbnail(tile, img) {
|
|
const fileId = tile.dataset.id;
|
|
// TODO: remove this HACK, this is not evolutive...
|
|
const file = /** @type {FileItem} */ ({ id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime });
|
|
|
|
try {
|
|
await thumbnail.queueGenerate(file, null, (previewDataUrl) => {
|
|
img.src = previewDataUrl;
|
|
this._videoThumbCache.set(fileId, previewDataUrl);
|
|
});
|
|
// Switch to permanent server URL so the data URL can be GC'd
|
|
this._videoThumbCache.set(fileId, `/api/files/${fileId}/thumbnail/preview?v=1`);
|
|
} catch {
|
|
// Keep generic play badge on error
|
|
}
|
|
},
|
|
|
|
/** Render the group mode toolbar */
|
|
_renderToolbar() {
|
|
const modes = [
|
|
['daily', i18n.t('photos.view_daily')],
|
|
['monthly', i18n.t('photos.view_monthly')],
|
|
['yearly', i18n.t('photos.view_yearly')]
|
|
];
|
|
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() {
|
|
if (!this._container) return;
|
|
this._container.innerHTML = `
|
|
<div class="photos-empty">
|
|
<i class="fas fa-images"></i>
|
|
<p class="photos-empty-title">${i18n.t('photos.empty_state')}</p>
|
|
<p>${i18n.t('photos.empty_hint')}</p>
|
|
</div>`;
|
|
},
|
|
|
|
/**
|
|
* Group items by the current groupMode
|
|
* @param {FileItem[]} items
|
|
*/
|
|
_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);
|
|
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 or toolbar
|
|
* @param {MouseEvent} e
|
|
*/
|
|
_handleClick(e) {
|
|
// Handle group mode toggle
|
|
const target = /** @type {Element} */ (e.target);
|
|
const modeBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-group-mode]'));
|
|
if (modeBtn) {
|
|
this.setGroupMode(/** @type {PhotoModeEnum} */ (modeBtn.dataset.groupMode));
|
|
return;
|
|
}
|
|
|
|
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
|
|
if (!tile) return;
|
|
|
|
const id = tile.dataset.id;
|
|
const check = 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) {
|
|
photosLightbox.open(this.items, idx);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Toggle selection of an item
|
|
* @param {string} id
|
|
* @param {HTMLDivElement} tile
|
|
*/
|
|
_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 count = this.selected.size;
|
|
bar.innerHTML = `
|
|
<span class="selection-count">${count} ${i18n.t('photos.items_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>
|
|
`;
|
|
|
|
const bar_clear = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-clear'));
|
|
if (bar_clear) {
|
|
bar_clear.onclick = () => {
|
|
this.selected.clear();
|
|
this._container.querySelectorAll('.photo-tile.selected').forEach((t) => {
|
|
t.classList.remove('selected');
|
|
});
|
|
this._hideSelectionBar();
|
|
};
|
|
}
|
|
|
|
const bar_delete = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-delete'));
|
|
if (bar_delete) {
|
|
bar_delete.onclick = async () => {
|
|
const ok = await Modal.confirmDialog({
|
|
title: i18n.t('photos.delete_title'),
|
|
message: i18n.t('photos.delete_selected_confirm'),
|
|
confirmText: i18n.t('actions.delete'),
|
|
icon: 'fa-trash'
|
|
});
|
|
if (!ok) return;
|
|
|
|
// One batch request per chunk instead of one DELETE per photo.
|
|
// The photos view is files-only, so every id is a file id.
|
|
const ids = [...this.selected];
|
|
const CHUNK_SIZE = 1000; // backend MAX_BATCH_SIZE
|
|
const trashed = new Set();
|
|
|
|
try {
|
|
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
|
|
const chunk = ids.slice(i, i + CHUNK_SIZE);
|
|
const response = await fetch('/api/batch/trash', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: this._headers(true),
|
|
body: JSON.stringify({ file_ids: chunk, folder_ids: [] })
|
|
});
|
|
// 200 = all trashed, 206 = partial; both carry `successful`.
|
|
if (!response.ok && response.status !== 206) {
|
|
console.error('Batch trash failed:', response.status);
|
|
continue;
|
|
}
|
|
const data = await response.json();
|
|
const ok = Array.isArray(data?.successful) ? data.successful : chunk;
|
|
for (const id of ok) trashed.add(id);
|
|
}
|
|
} catch (err) {
|
|
console.error('Batch trash error:', err);
|
|
}
|
|
|
|
if (trashed.size > 0) {
|
|
this.items = this.items.filter((f) => !trashed.has(f.id));
|
|
for (const id of trashed) this.selected.delete(id);
|
|
this._renderFull();
|
|
}
|
|
// Refresh (or hide) the bar to reflect any items left selected.
|
|
this._updateSelectionBar();
|
|
};
|
|
}
|
|
|
|
const bar_download = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-download'));
|
|
if (bar_download) {
|
|
bar_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';
|
|
},
|
|
|
|
/** @param {boolean} show */
|
|
_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._materializeObserver) {
|
|
this._materializeObserver.disconnect();
|
|
this._materializeObserver = null;
|
|
}
|
|
if (this._sentinelObserver) {
|
|
this._sentinelObserver.disconnect();
|
|
this._sentinelObserver = null;
|
|
}
|
|
},
|
|
|
|
/** @param {any} s */
|
|
_escHtml(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
},
|
|
|
|
/** @param {any} s */
|
|
_escAttr(s) {
|
|
return String(s || '')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<');
|
|
}
|
|
};
|
|
|
|
photosLightbox.setPhotosView(photosView);
|
|
|
|
export { photosView };
|