Files
Oxicloud/static/js/features/library/photosLightbox.js
T
Edouard Vanbelle fac184ccfe style(front/js): apply types on all objects
- reduce amount of warnings in IDE
    - maximize API type mapping with static/js/core/types.js
2026-05-19 10:18:33 +02:00

314 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OxiCloud - Photos Lightbox
* Full-screen image/video viewer with prev/next navigation.
*/
import { getCsrfHeaders } from '../../core/csrf.js';
import { favorites } from '../library/favorites.js';
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
/** @typedef {typeof import('./photos.js').photosView} PhotosView */
export const photosLightbox = {
/** @type {Array<FileItem>} 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 {(ev: KeyboardEvent) => any|null} */
_keyHandler: null,
/** @type {PhotosView|null} Reference to photosView, set after both modules load */
_photosView: null,
/**
* Register the photosView reference (called from photos.js to avoid circular imports).
* @param {any} pv
*/
setPhotosView(pv) {
this._photosView = pv;
},
/** Auth headers */
_headers() {
return getCsrfHeaders();
},
/**
* Open lightbox at given index
* @param {FileItem[]} items
* @param {number} 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
/** @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();
// Click backdrop to close
el.addEventListener('click', (e) => {
if (e.target === el || /** @type {HTMLElement} */ (e.target).classList.contains('lightbox-content')) {
this.close();
}
});
// Toolbar actions
/** @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();
// 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
/** @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));
// Load content
this._revokeBlob();
content.innerHTML = '<div class="photos-loading"><i class="fas fa-spinner"></i></div>';
try {
const isVideo = 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
* @param {string} fileId
* @param {Element} metaEl
* @param {string} dateStr
* @param {string} sizeStr
*/
async _loadMetadata(fileId, metaEl, dateStr, sizeStr) {
try {
const res = await fetch(`/api/files/${fileId}/metadata`, {
credentials: 'include',
headers: this._headers()
});
if (res.ok) {
const metadata = /** @type {FileMetadata} */ (await res.json());
const parts = [dateStr];
if (sizeStr) parts.push(sizeStr);
if (metadata.camera_make || metadata.camera_model) {
parts.push([metadata.camera_make, metadata.camera_model].filter(Boolean).join(' '));
}
if (metadata.width && metadata.height) {
parts.push(`${metadata.width}×${metadata.height}`);
}
metaEl.textContent = parts.join(' · ');
//TODO: add geoloc pointer to openstreetmap ?
}
} 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 || !favorites) return;
try {
await fetch(`/api/favorites/file/${item.id}`, {
method: 'POST',
credentials: 'include',
headers: this._headers()
});
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 (this._photosView) {
this._photosView.items = this._photosView.items.filter((f) => f.id !== item.id);
}
this.items.splice(this.index, 1);
if (this.items.length === 0) {
this.close();
if (this._photosView) this._photosView._renderFull(); // will call renderEmpty() on this case
} else {
if (this.index >= this.items.length) this.index = this.items.length - 1;
this._show();
if (this._photosView) this._photosView._renderFull();
}
} 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;
}
},
/** @param {any} s */
_escAttr(s) {
return String(s || '')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
};