2026-03-05 13:40:02 -05:00
|
|
|
|
/**
|
|
|
|
|
|
* OxiCloud - Photos Lightbox
|
|
|
|
|
|
* Full-screen image/video viewer with prev/next navigation.
|
2026-06-10 14:53:55 +02:00
|
|
|
|
*
|
|
|
|
|
|
* Media is never buffered in page memory: videos stream straight from the
|
|
|
|
|
|
* API (the element's `src` is same-origin, so auth cookies travel
|
|
|
|
|
|
* automatically and the browser issues Range requests — playback starts
|
|
|
|
|
|
* progressively and seeking works without downloading the whole file).
|
|
|
|
|
|
* Photos open with the server-cached `large` thumbnail; the full-resolution
|
|
|
|
|
|
* original streams in only on demand via the toolbar expand button.
|
2026-03-05 13:40:02 -05:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-06-19 09:58:01 +00:00
|
|
|
|
import { Modal } from '../../components/modal.js';
|
2026-04-13 15:09:10 +02:00
|
|
|
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
2026-06-19 09:58:01 +00:00
|
|
|
|
import { i18n } from '../../core/i18n.js';
|
2026-04-13 15:09:10 +02:00
|
|
|
|
import { favorites } from '../library/favorites.js';
|
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
|
|
|
|
|
|
/** @typedef {typeof import('./photos.js').photosView} PhotosView */
|
|
|
|
|
|
|
2026-04-13 15:09:10 +02:00
|
|
|
|
export const photosLightbox = {
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/** @type {Array<FileItem>} Items array reference */
|
2026-03-05 13:40:02 -05:00
|
|
|
|
items: [],
|
|
|
|
|
|
/** @type {number} Current index */
|
|
|
|
|
|
index: -1,
|
|
|
|
|
|
/** @type {HTMLElement|null} */
|
|
|
|
|
|
_overlay: null,
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/** @type {(ev: KeyboardEvent) => any|null} */
|
2026-03-05 13:40:02 -05:00
|
|
|
|
_keyHandler: null,
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/** @type {PhotosView|null} Reference to photosView, set after both modules load */
|
2026-04-13 15:09:10 +02:00
|
|
|
|
_photosView: null,
|
2026-06-10 14:53:55 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Monotonic token identifying the most recent {@link photosLightbox._show}
|
|
|
|
|
|
* call. Image load/error callbacks fire asynchronously, so a rapid
|
|
|
|
|
|
* prev/next must not let a superseded item commit its (stale) content
|
|
|
|
|
|
* over the newer one.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_showGeneration: 0,
|
2026-04-13 15:09:10 +02:00
|
|
|
|
|
2026-06-19 10:25:33 +00:00
|
|
|
|
/** @type {number} Current zoom factor (1 = fit) */
|
|
|
|
|
|
_zoom: 1,
|
|
|
|
|
|
/** @type {number} */
|
|
|
|
|
|
_panX: 0,
|
|
|
|
|
|
/** @type {number} */
|
|
|
|
|
|
_panY: 0,
|
|
|
|
|
|
/** @type {Map<number, {x: number, y: number}>} Active pointers (for pinch) */
|
|
|
|
|
|
_pointers: new Map(),
|
|
|
|
|
|
/** @type {number} */
|
|
|
|
|
|
_pinchStartDist: 0,
|
|
|
|
|
|
/** @type {number} */
|
|
|
|
|
|
_pinchStartZoom: 1,
|
|
|
|
|
|
/** @type {{x: number, y: number, panX: number, panY: number}|null} */
|
|
|
|
|
|
_dragStart: null,
|
|
|
|
|
|
/** @type {{x: number, y: number, t: number}|null} */
|
|
|
|
|
|
_swipeStart: null,
|
|
|
|
|
|
|
2026-04-13 15:09:10 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Register the photosView reference (called from photos.js to avoid circular imports).
|
2026-05-07 23:40:02 +02:00
|
|
|
|
* @param {any} pv
|
2026-04-13 15:09:10 +02:00
|
|
|
|
*/
|
|
|
|
|
|
setPhotosView(pv) {
|
|
|
|
|
|
this._photosView = pv;
|
|
|
|
|
|
},
|
2026-03-05 13:40:02 -05:00
|
|
|
|
|
|
|
|
|
|
/** Auth headers */
|
|
|
|
|
|
_headers() {
|
2026-04-13 15:09:10 +02:00
|
|
|
|
return getCsrfHeaders();
|
2026-03-05 13:40:02 -05:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-10 14:53:55 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Streaming URL of the original file. Same-origin, so media elements
|
|
|
|
|
|
* send the auth cookie automatically and the browser handles Range.
|
|
|
|
|
|
* @param {FileItem} item
|
|
|
|
|
|
* @returns {string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
_originalUrl(item) {
|
|
|
|
|
|
return `/api/files/${item.id}?inline=true`;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* URL of the server-cached `large` thumbnail (immutable, browser-cached).
|
|
|
|
|
|
* @param {FileItem} item
|
|
|
|
|
|
* @returns {string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
_thumbUrl(item) {
|
|
|
|
|
|
return `/api/files/${item.id}/thumbnail/large`;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Open lightbox at given index
|
|
|
|
|
|
* @param {FileItem[]} items
|
|
|
|
|
|
* @param {number} index
|
|
|
|
|
|
*/
|
2026-03-05 13:40:02 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-06-19 10:25:33 +00:00
|
|
|
|
this._resetZoom();
|
2026-03-05 13:40:02 -05:00
|
|
|
|
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">
|
2026-06-10 14:53:55 +02:00
|
|
|
|
<button class="lb-fullres hidden" title="Full resolution"><i class="fas fa-expand"></i></button>
|
2026-06-19 10:25:33 +00:00
|
|
|
|
<button class="lb-info" title="Info"><i class="fas fa-circle-info"></i></button>
|
2026-03-05 13:40:02 -05:00
|
|
|
|
<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>
|
2026-06-19 10:25:33 +00:00
|
|
|
|
<div class="lightbox-infopanel hidden"></div>
|
2026-03-05 13:40:02 -05:00
|
|
|
|
`;
|
|
|
|
|
|
document.body.appendChild(el);
|
|
|
|
|
|
this._overlay = el;
|
|
|
|
|
|
|
|
|
|
|
|
// Event listeners
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-close')).onclick = () => this.close();
|
|
|
|
|
|
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-prev')).onclick = () => this.prev();
|
|
|
|
|
|
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-next')).onclick = () => this.next();
|
2026-03-05 13:40:02 -05:00
|
|
|
|
|
|
|
|
|
|
// Click backdrop to close
|
|
|
|
|
|
el.addEventListener('click', (e) => {
|
2026-05-07 23:40:02 +02:00
|
|
|
|
if (e.target === el || /** @type {HTMLElement} */ (e.target).classList.contains('lightbox-content')) {
|
2026-03-05 13:40:02 -05:00
|
|
|
|
this.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-10 14:53:55 +02:00
|
|
|
|
// Toolbar actions (`.lb-fullres` is wired per-item in `_show`)
|
2026-06-19 10:25:33 +00:00
|
|
|
|
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-info')).onclick = () => this._toggleInfoPanel();
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
|
|
|
|
|
|
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
|
|
|
|
|
|
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
|
2026-03-05 13:40:02 -05:00
|
|
|
|
|
|
|
|
|
|
// Animate in
|
|
|
|
|
|
requestAnimationFrame(() => el.classList.add('active'));
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 00:17:17 +02:00
|
|
|
|
/** Preload the immediate prev/next thumbnails so navigation is instant. */
|
|
|
|
|
|
_preloadNeighbors() {
|
|
|
|
|
|
[this.index - 1, this.index + 1].forEach((i) => {
|
|
|
|
|
|
const it = this.items[i];
|
|
|
|
|
|
if (it && !it.mime_type?.startsWith('video/')) {
|
|
|
|
|
|
const pre = new Image();
|
|
|
|
|
|
pre.src = this._thumbUrl(it);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-05 13:40:02 -05:00
|
|
|
|
/** Display the current item */
|
2026-06-10 14:53:55 +02:00
|
|
|
|
_show() {
|
2026-03-05 13:40:02 -05:00
|
|
|
|
if (!this._overlay || this.index < 0) return;
|
2026-06-10 14:53:55 +02:00
|
|
|
|
const generation = ++this._showGeneration;
|
2026-06-19 10:25:33 +00:00
|
|
|
|
this._resetZoom();
|
2026-03-05 13:40:02 -05:00
|
|
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
|
|
2026-06-19 09:58:01 +00:00
|
|
|
|
// Reflect the current favorite state on the toolbar star.
|
|
|
|
|
|
const favBtn = this._overlay.querySelector('.lb-favorite');
|
|
|
|
|
|
if (favBtn) {
|
|
|
|
|
|
const isFav = favorites.isFavorite(item.id, 'file');
|
|
|
|
|
|
favBtn.classList.toggle('active', isFav);
|
|
|
|
|
|
const favIcon = favBtn.querySelector('i');
|
|
|
|
|
|
if (favIcon) favIcon.className = isFav ? 'fas fa-star' : 'far fa-star';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 13:40:02 -05:00
|
|
|
|
// Format date
|
|
|
|
|
|
const ts = (item.sort_date || item.created_at) * 1000;
|
|
|
|
|
|
const dateStr = new Date(ts).toLocaleDateString(undefined, {
|
2026-04-07 22:48:59 +02:00
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: 'short',
|
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit'
|
2026-03-05 13:40:02 -05:00
|
|
|
|
});
|
|
|
|
|
|
meta.textContent = `${dateStr} · ${item.size_formatted || ''}`;
|
|
|
|
|
|
|
|
|
|
|
|
// Update nav button visibility
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-prev')).classList.toggle('hidden', !(this.index > 0));
|
|
|
|
|
|
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-next')).classList.toggle('hidden', !(this.index < this.items.length - 1));
|
2026-03-05 13:40:02 -05:00
|
|
|
|
|
2026-06-10 14:53:55 +02:00
|
|
|
|
// Reset the full-resolution button for the new item
|
|
|
|
|
|
const fullResBtn = /** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lb-fullres'));
|
|
|
|
|
|
fullResBtn.classList.add('hidden');
|
|
|
|
|
|
fullResBtn.disabled = false;
|
|
|
|
|
|
const fullResIcon = fullResBtn.querySelector('i');
|
|
|
|
|
|
if (fullResIcon) fullResIcon.className = 'fas fa-expand';
|
|
|
|
|
|
|
2026-06-15 00:17:17 +02:00
|
|
|
|
// Preload neighbours so prev/next is instant.
|
|
|
|
|
|
this._preloadNeighbors();
|
|
|
|
|
|
|
2026-03-05 13:40:02 -05:00
|
|
|
|
// Load content
|
|
|
|
|
|
content.innerHTML = '<div class="photos-loading"><i class="fas fa-spinner"></i></div>';
|
|
|
|
|
|
|
2026-06-10 14:53:55 +02:00
|
|
|
|
if (item.mime_type?.startsWith('video/')) {
|
|
|
|
|
|
const video = document.createElement('video');
|
|
|
|
|
|
video.controls = true;
|
|
|
|
|
|
video.autoplay = true;
|
|
|
|
|
|
// Instant first frame while metadata loads; a 204 (no cached
|
|
|
|
|
|
// thumbnail yet) simply leaves the poster blank.
|
|
|
|
|
|
video.poster = this._thumbUrl(item);
|
|
|
|
|
|
video.src = this._originalUrl(item);
|
|
|
|
|
|
video.addEventListener('error', () => {
|
|
|
|
|
|
if (generation !== this._showGeneration) return;
|
|
|
|
|
|
content.innerHTML = '<div class="photos-loading">Failed to load</div>';
|
2026-03-05 13:40:02 -05:00
|
|
|
|
});
|
2026-06-10 14:53:55 +02:00
|
|
|
|
// The native player has its own buffering UI — drop the spinner now.
|
|
|
|
|
|
content.replaceChildren(video);
|
|
|
|
|
|
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-05 13:40:02 -05:00
|
|
|
|
|
2026-06-10 14:53:55 +02:00
|
|
|
|
// Photo: thumbnail first, original on demand. GIFs go straight to
|
|
|
|
|
|
// the original — the thumbnail is a static JPEG and would lose the
|
|
|
|
|
|
// animation.
|
|
|
|
|
|
const isGif = item.mime_type === 'image/gif';
|
|
|
|
|
|
let showingOriginal = isGif;
|
|
|
|
|
|
const img = document.createElement('img');
|
|
|
|
|
|
img.alt = item.name;
|
2026-06-19 10:25:33 +00:00
|
|
|
|
this._wireZoomPan(img);
|
2026-06-10 14:53:55 +02:00
|
|
|
|
|
|
|
|
|
|
img.addEventListener('load', () => {
|
|
|
|
|
|
if (generation !== this._showGeneration) return;
|
|
|
|
|
|
// First load replaces the spinner; the on-demand swap reuses the
|
|
|
|
|
|
// already-attached element.
|
|
|
|
|
|
if (!img.isConnected) content.replaceChildren(img);
|
|
|
|
|
|
fullResBtn.classList.toggle('hidden', showingOriginal);
|
|
|
|
|
|
fullResBtn.disabled = false;
|
|
|
|
|
|
if (fullResIcon) fullResIcon.className = 'fas fa-expand';
|
|
|
|
|
|
});
|
2026-03-05 13:40:02 -05:00
|
|
|
|
|
2026-06-10 14:53:55 +02:00
|
|
|
|
img.addEventListener('error', () => {
|
|
|
|
|
|
if (generation !== this._showGeneration) return;
|
|
|
|
|
|
if (!showingOriginal) {
|
|
|
|
|
|
// No server thumbnail (unsupported format or generation
|
|
|
|
|
|
// failed) — fall back to the original.
|
|
|
|
|
|
showingOriginal = true;
|
|
|
|
|
|
img.src = this._originalUrl(item);
|
2026-03-05 13:40:02 -05:00
|
|
|
|
} else {
|
2026-06-10 14:53:55 +02:00
|
|
|
|
content.innerHTML = '<div class="photos-loading">Failed to load</div>';
|
|
|
|
|
|
fullResBtn.classList.add('hidden');
|
2026-03-05 13:40:02 -05:00
|
|
|
|
}
|
2026-06-10 14:53:55 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
fullResBtn.onclick = () => {
|
|
|
|
|
|
if (generation !== this._showGeneration || showingOriginal) return;
|
|
|
|
|
|
showingOriginal = true;
|
|
|
|
|
|
fullResBtn.disabled = true;
|
|
|
|
|
|
if (fullResIcon) fullResIcon.className = 'fas fa-spinner fa-spin';
|
|
|
|
|
|
img.src = this._originalUrl(item);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
img.src = showingOriginal ? this._originalUrl(item) : this._thumbUrl(item);
|
2026-03-05 13:40:02 -05:00
|
|
|
|
|
|
|
|
|
|
// Load EXIF metadata
|
|
|
|
|
|
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Load EXIF metadata for info bar
|
|
|
|
|
|
* @param {string} fileId
|
|
|
|
|
|
* @param {Element} metaEl
|
|
|
|
|
|
* @param {string} dateStr
|
|
|
|
|
|
* @param {string} sizeStr
|
|
|
|
|
|
*/
|
2026-03-05 13:40:02 -05:00
|
|
|
|
async _loadMetadata(fileId, metaEl, dateStr, sizeStr) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/api/files/${fileId}/metadata`, {
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
headers: this._headers()
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
2026-05-07 23:40:02 +02:00
|
|
|
|
const metadata = /** @type {FileMetadata} */ (await res.json());
|
2026-04-07 22:50:42 +02:00
|
|
|
|
const parts = [dateStr];
|
2026-03-05 13:40:02 -05:00
|
|
|
|
if (sizeStr) parts.push(sizeStr);
|
2026-05-07 23:40:02 +02:00
|
|
|
|
if (metadata.camera_make || metadata.camera_model) {
|
|
|
|
|
|
parts.push([metadata.camera_make, metadata.camera_model].filter(Boolean).join(' '));
|
2026-03-05 13:40:02 -05:00
|
|
|
|
}
|
2026-05-07 23:40:02 +02:00
|
|
|
|
if (metadata.width && metadata.height) {
|
|
|
|
|
|
parts.push(`${metadata.width}×${metadata.height}`);
|
2026-03-05 13:40:02 -05:00
|
|
|
|
}
|
|
|
|
|
|
metaEl.textContent = parts.join(' · ');
|
2026-06-19 10:25:33 +00:00
|
|
|
|
this._fillInfoPanel(metadata, dateStr, sizeStr);
|
2026-03-05 13:40:02 -05:00
|
|
|
|
}
|
2026-04-07 22:50:42 +02:00
|
|
|
|
} catch (_err) {
|
2026-03-05 13:40:02 -05:00
|
|
|
|
// 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();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-19 09:58:01 +00:00
|
|
|
|
/** Toggle favorite on current item (via the favorites module so its
|
|
|
|
|
|
* cache stays in sync — the lightbox can then show the right initial
|
|
|
|
|
|
* star next time the item is opened). */
|
2026-03-05 13:40:02 -05:00
|
|
|
|
async _toggleFavorite() {
|
|
|
|
|
|
const item = this.items[this.index];
|
2026-06-19 09:58:01 +00:00
|
|
|
|
if (!item) return;
|
|
|
|
|
|
const isFav = favorites.isFavorite(item.id, 'file');
|
2026-03-05 13:40:02 -05:00
|
|
|
|
try {
|
2026-06-19 09:58:01 +00:00
|
|
|
|
if (isFav) {
|
|
|
|
|
|
await favorites.removeFromFavorites(item.id, 'file', item.name);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await favorites.addToFavorites(item.id, item.name, 'file', null);
|
|
|
|
|
|
}
|
|
|
|
|
|
const btn = this._overlay?.querySelector('.lb-favorite');
|
2026-03-05 13:40:02 -05:00
|
|
|
|
if (btn) {
|
2026-06-19 09:58:01 +00:00
|
|
|
|
const nowFav = !isFav;
|
|
|
|
|
|
btn.classList.toggle('active', nowFav);
|
2026-03-05 13:40:02 -05:00
|
|
|
|
const icon = btn.querySelector('i');
|
2026-06-19 09:58:01 +00:00
|
|
|
|
if (icon) icon.className = nowFav ? 'fas fa-star' : 'far fa-star';
|
2026-03-05 13:40:02 -05:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Favorite toggle failed:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** Delete current item */
|
|
|
|
|
|
async _delete() {
|
|
|
|
|
|
const item = this.items[this.index];
|
|
|
|
|
|
if (!item) return;
|
2026-06-19 09:58:01 +00:00
|
|
|
|
const ok = await Modal.confirmDialog({
|
|
|
|
|
|
title: i18n.t('photos.delete_title'),
|
|
|
|
|
|
message: i18n.t('photos.delete_one_confirm', { name: item.name }),
|
|
|
|
|
|
confirmText: i18n.t('actions.delete'),
|
|
|
|
|
|
icon: 'fa-trash'
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!ok) return;
|
2026-03-05 13:40:02 -05:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch(`/api/files/${item.id}`, {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
headers: this._headers()
|
|
|
|
|
|
});
|
|
|
|
|
|
// Remove from photosView items too
|
2026-04-13 15:09:10 +02:00
|
|
|
|
if (this._photosView) {
|
|
|
|
|
|
this._photosView.items = this._photosView.items.filter((f) => f.id !== item.id);
|
2026-03-05 13:40:02 -05:00
|
|
|
|
}
|
|
|
|
|
|
this.items.splice(this.index, 1);
|
|
|
|
|
|
if (this.items.length === 0) {
|
|
|
|
|
|
this.close();
|
2026-05-07 23:40:02 +02:00
|
|
|
|
if (this._photosView) this._photosView._renderFull(); // will call renderEmpty() on this case
|
2026-03-05 13:40:02 -05:00
|
|
|
|
} else {
|
|
|
|
|
|
if (this.index >= this.items.length) this.index = this.items.length - 1;
|
|
|
|
|
|
this._show();
|
2026-05-07 23:40:02 +02:00
|
|
|
|
if (this._photosView) this._photosView._renderFull();
|
2026-03-05 13:40:02 -05:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Delete failed:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-19 10:25:33 +00:00
|
|
|
|
// ── Zoom / pan / swipe ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** @returns {HTMLImageElement|null} The image element currently shown. */
|
|
|
|
|
|
_currentImg() {
|
|
|
|
|
|
return /** @type {HTMLImageElement|null} */ (this._overlay?.querySelector('.lightbox-content img') || null);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** Reset zoom/pan state (per item and on close). */
|
|
|
|
|
|
_resetZoom() {
|
|
|
|
|
|
this._zoom = 1;
|
|
|
|
|
|
this._panX = 0;
|
|
|
|
|
|
this._panY = 0;
|
|
|
|
|
|
this._pointers.clear();
|
|
|
|
|
|
this._pinchStartDist = 0;
|
|
|
|
|
|
this._dragStart = null;
|
|
|
|
|
|
this._swipeStart = null;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_applyTransform() {
|
|
|
|
|
|
const img = this._currentImg();
|
|
|
|
|
|
if (img) img.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${this._zoom})`;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Set the zoom factor (clamped 1–5), centered. Resets pan at 1.
|
|
|
|
|
|
* @param {number} z
|
|
|
|
|
|
*/
|
|
|
|
|
|
_setZoom(z) {
|
|
|
|
|
|
z = Math.min(Math.max(z, 1), 5);
|
|
|
|
|
|
if (z === 1) {
|
|
|
|
|
|
this._panX = 0;
|
|
|
|
|
|
this._panY = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._zoom = z;
|
|
|
|
|
|
this._applyTransform();
|
|
|
|
|
|
const img = this._currentImg();
|
|
|
|
|
|
if (img) img.classList.toggle('is-zoomed', z > 1);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Wire wheel-zoom, double-click zoom, drag-pan, pinch-zoom and (when not
|
|
|
|
|
|
* zoomed) touch swipe-to-navigate onto a photo element.
|
|
|
|
|
|
* @param {HTMLImageElement} img
|
|
|
|
|
|
*/
|
|
|
|
|
|
_wireZoomPan(img) {
|
|
|
|
|
|
img.style.transformOrigin = 'center center';
|
|
|
|
|
|
img.style.touchAction = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
img.addEventListener(
|
|
|
|
|
|
'wheel',
|
|
|
|
|
|
(e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this._setZoom(this._zoom * (e.deltaY < 0 ? 1.2 : 1 / 1.2));
|
|
|
|
|
|
},
|
|
|
|
|
|
{ passive: false }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
img.addEventListener('dblclick', (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this._setZoom(this._zoom > 1 ? 1 : 2.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
img.addEventListener('pointerdown', (e) => {
|
|
|
|
|
|
img.setPointerCapture?.(e.pointerId);
|
|
|
|
|
|
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
|
|
|
|
if (this._pointers.size === 2) {
|
|
|
|
|
|
const pts = [...this._pointers.values()];
|
|
|
|
|
|
this._pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
|
|
|
|
|
this._pinchStartZoom = this._zoom;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this._dragStart = { x: e.clientX, y: e.clientY, panX: this._panX, panY: this._panY };
|
|
|
|
|
|
this._swipeStart = { x: e.clientX, y: e.clientY, t: Date.now() };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
img.addEventListener('pointermove', (e) => {
|
|
|
|
|
|
if (!this._pointers.has(e.pointerId)) return;
|
|
|
|
|
|
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
|
|
|
|
if (this._pointers.size === 2 && this._pinchStartDist > 0) {
|
|
|
|
|
|
const pts = [...this._pointers.values()];
|
|
|
|
|
|
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
|
|
|
|
|
this._setZoom(this._pinchStartZoom * (dist / this._pinchStartDist));
|
|
|
|
|
|
} else if (this._zoom > 1 && this._dragStart) {
|
|
|
|
|
|
this._panX = this._dragStart.panX + (e.clientX - this._dragStart.x);
|
|
|
|
|
|
this._panY = this._dragStart.panY + (e.clientY - this._dragStart.y);
|
|
|
|
|
|
this._applyTransform();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const endPointer = (/** @type {PointerEvent} */ e) => {
|
|
|
|
|
|
const wasPinch = this._pointers.size === 2;
|
|
|
|
|
|
this._pointers.delete(e.pointerId);
|
|
|
|
|
|
if (!wasPinch && this._zoom === 1 && this._swipeStart && e.pointerType === 'touch') {
|
|
|
|
|
|
const dx = e.clientX - this._swipeStart.x;
|
|
|
|
|
|
const dy = e.clientY - this._swipeStart.y;
|
|
|
|
|
|
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
|
|
|
|
|
if (dx > 0) this.prev();
|
|
|
|
|
|
else this.next();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (wasPinch) this._pinchStartDist = 0;
|
|
|
|
|
|
this._dragStart = null;
|
|
|
|
|
|
this._swipeStart = null;
|
|
|
|
|
|
};
|
|
|
|
|
|
img.addEventListener('pointerup', endPointer);
|
|
|
|
|
|
img.addEventListener('pointercancel', endPointer);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// ── Info panel ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** Toggle the EXIF info panel. */
|
|
|
|
|
|
_toggleInfoPanel() {
|
|
|
|
|
|
this._overlay?.querySelector('.lightbox-infopanel')?.classList.toggle('hidden');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Populate the info panel from fetched EXIF metadata.
|
|
|
|
|
|
* @param {FileMetadata} metadata
|
|
|
|
|
|
* @param {string} dateStr
|
|
|
|
|
|
* @param {string} sizeStr
|
|
|
|
|
|
*/
|
|
|
|
|
|
_fillInfoPanel(metadata, dateStr, sizeStr) {
|
|
|
|
|
|
const panel = this._overlay?.querySelector('.lightbox-infopanel');
|
|
|
|
|
|
if (!panel) return;
|
|
|
|
|
|
const item = this.items[this.index];
|
|
|
|
|
|
const rows = [this._infoRow('fa-image', item?.name || ''), this._infoRow('fa-calendar', dateStr)];
|
|
|
|
|
|
if (sizeStr) rows.push(this._infoRow('fa-hard-drive', sizeStr));
|
|
|
|
|
|
if (metadata.width && metadata.height) {
|
|
|
|
|
|
rows.push(this._infoRow('fa-ruler-combined', `${metadata.width} × ${metadata.height}`));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (metadata.camera_make || metadata.camera_model) {
|
|
|
|
|
|
rows.push(this._infoRow('fa-camera', [metadata.camera_make, metadata.camera_model].filter(Boolean).join(' ')));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (metadata.latitude != null && metadata.longitude != null) {
|
|
|
|
|
|
rows.push(this._infoRow('fa-location-dot', `${metadata.latitude.toFixed(5)}, ${metadata.longitude.toFixed(5)}`));
|
|
|
|
|
|
}
|
|
|
|
|
|
panel.innerHTML = rows.join('');
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param {string} icon FontAwesome class
|
|
|
|
|
|
* @param {string} text
|
|
|
|
|
|
* @returns {string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
_infoRow(icon, text) {
|
|
|
|
|
|
const d = document.createElement('div');
|
|
|
|
|
|
d.textContent = text;
|
|
|
|
|
|
return `<div class="lb-info-row"><i class="fas ${icon}"></i><span>${d.innerHTML}</span></div>`;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-05 13:40:02 -05:00
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|