summaryrefslogtreecommitdiff
path: root/web/js/lightbox.js
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-03 19:17:04 +0300
committerPaul Buetow <paul@buetow.org>2026-05-03 19:17:04 +0300
commit30c2b0fe8232cc748ab2bded6ab4d76febe32425 (patch)
tree985578b326a1ff8fe7af395bf08471857376b8fc /web/js/lightbox.js
parent687b86cdeef2192272f0945ddd496322a9113f9e (diff)
fix(admin): fix rescan goroutine lifecycle and race on shared ScanProgress
- Protect adminService scan state (cancel func + progress pointer) with sync.Mutex. - Allocate fresh ScanProgress per trigger and pass it to the scanner, eliminating races on the previously shared progress struct. - Cancel previous scan context before starting a new one. - Add tests for cancellation, fresh progress per scan, concurrent triggers, and empty progress when never started. - Fix race-prone tests by polling Running==true before waiting for completion.
Diffstat (limited to 'web/js/lightbox.js')
-rw-r--r--web/js/lightbox.js181
1 files changed, 181 insertions, 0 deletions
diff --git a/web/js/lightbox.js b/web/js/lightbox.js
new file mode 100644
index 0000000..876f456
--- /dev/null
+++ b/web/js/lightbox.js
@@ -0,0 +1,181 @@
+let currentMediaList = [];
+let currentIndex = -1;
+let zoomScale = 1;
+let panX = 0;
+let panY = 0;
+let isPanning = false;
+let panStart = { x: 0, y: 0 };
+let slideshowTimer = null;
+let slideshowPausedUntil = 0;
+let onNavigateCallback = null;
+
+export function initLightbox({ onNavigate }) {
+ onNavigateCallback = onNavigate;
+
+ document.getElementById('lb-close')?.addEventListener('click', close);
+ document.getElementById('lb-prev')?.addEventListener('click', prev);
+ document.getElementById('lb-next')?.addEventListener('click', next);
+ document.getElementById('lb-zoom-in')?.addEventListener('click', zoomIn);
+ document.getElementById('lb-zoom-out')?.addEventListener('click', zoomOut);
+ document.getElementById('lb-slideshow')?.addEventListener('click', toggleSlideshow);
+
+ // Close on backdrop click (clicking outside the image/toolbar)
+ document.getElementById('image-lightbox')?.addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) close();
+ });
+
+ const img = document.getElementById('lightbox-image');
+ if (img) {
+ img.addEventListener('mousedown', (e) => {
+ if (zoomScale > 1) {
+ isPanning = true;
+ panStart = { x: e.clientX - panX, y: e.clientY - panY };
+ img.style.cursor = 'grabbing';
+ e.preventDefault();
+ }
+ });
+ img.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ if (e.deltaY < 0) zoomIn();
+ else zoomOut();
+ }, { passive: false });
+ }
+ window.addEventListener('mousemove', (e) => {
+ if (!isPanning) return;
+ panX = e.clientX - panStart.x;
+ panY = e.clientY - panStart.y;
+ applyTransform();
+ });
+ window.addEventListener('mouseup', () => {
+ if (isPanning) {
+ isPanning = false;
+ const img = document.getElementById('lightbox-image');
+ if (img) img.style.cursor = zoomScale > 1 ? 'grab' : 'zoom-in';
+ }
+ });
+}
+
+export function open(mediaArray, startMediaId) {
+ currentMediaList = mediaArray.filter((m) => m.type === 'image');
+ currentIndex = currentMediaList.findIndex((m) => m.id === startMediaId);
+ if (currentIndex === -1) currentIndex = 0;
+ render();
+ document.getElementById('image-lightbox')?.classList.add('open');
+}
+
+export function close() {
+ stopSlideshow();
+ document.getElementById('image-lightbox')?.classList.remove('open');
+ resetZoom();
+ currentMediaList = [];
+ currentIndex = -1;
+}
+
+export function isOpen() {
+ return document.getElementById('image-lightbox')?.classList.contains('open');
+}
+
+export function next() {
+ if (!currentMediaList.length) return;
+ currentIndex = (currentIndex + 1) % currentMediaList.length;
+ render();
+ pauseSlideshow();
+}
+
+export function prev() {
+ if (!currentMediaList.length) return;
+ currentIndex = (currentIndex - 1 + currentMediaList.length) % currentMediaList.length;
+ render();
+ pauseSlideshow();
+}
+
+export function zoomIn() {
+ setZoom(zoomScale * 1.25);
+ pauseSlideshow();
+}
+
+export function zoomOut() {
+ setZoom(zoomScale / 1.25);
+ pauseSlideshow();
+}
+
+export function toggleSlideshow() {
+ if (slideshowTimer) stopSlideshow();
+ else startSlideshow();
+}
+
+export function isSlideshowActive() {
+ return !!slideshowTimer;
+}
+
+function render() {
+ const media = currentMediaList[currentIndex];
+ const img = document.getElementById('lightbox-image');
+ const meta = document.getElementById('lb-meta');
+ const counter = document.getElementById('lb-counter');
+ if (!media || !img) return;
+ img.src = `/api/media/${media.id}/stream`;
+ resetZoom();
+ if (meta) {
+ const size = media.file_size_bytes ? fmtSize(media.file_size_bytes) : '';
+ const res = media.resolution || '';
+ meta.textContent = `${media.file_name}${res ? ' — ' + res : ''}${size ? ' — ' + size : ''}`;
+ }
+ if (counter) {
+ counter.textContent = `${currentIndex + 1} / ${currentMediaList.length}`;
+ }
+}
+
+function setZoom(s) {
+ zoomScale = Math.max(0.5, Math.min(s, 5));
+ applyTransform();
+}
+
+function applyTransform() {
+ const img = document.getElementById('lightbox-image');
+ if (!img) return;
+ img.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomScale})`;
+ img.style.cursor = zoomScale > 1 ? 'grab' : 'zoom-in';
+}
+
+function resetZoom() {
+ zoomScale = 1;
+ panX = 0;
+ panY = 0;
+ applyTransform();
+}
+
+function startSlideshow() {
+ if (slideshowTimer) clearInterval(slideshowTimer);
+ slideshowTimer = setInterval(() => {
+ if (Date.now() < slideshowPausedUntil) return;
+ if (!isOpen()) { stopSlideshow(); return; }
+ next();
+ }, 5000);
+ const btn = document.getElementById('lb-slideshow');
+ if (btn) btn.textContent = '⏸';
+}
+
+function stopSlideshow() {
+ clearInterval(slideshowTimer);
+ slideshowTimer = null;
+ const btn = document.getElementById('lb-slideshow');
+ if (btn) btn.textContent = '⏵';
+}
+
+function pauseSlideshow() {
+ slideshowPausedUntil = Date.now() + 10000;
+}
+
+function fmtSize(bytes) {
+ if (!bytes || bytes <= 0) return '';
+ const kb = bytes / 1024;
+ if (kb < 1024) return Math.round(kb) + ' KB';
+ const mb = kb / 1024;
+ if (mb < 1024) return Math.round(mb * 10) / 10 + ' MB';
+ return Math.round((mb / 1024) * 10) / 10 + ' GB';
+}
+
+function escapeHtml(s) {
+ return (s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+}