let isPinching = false;
let pinchZoomOccurred = false;
let initialPinchDistance = null;
let initialScale = 1;
let currentScale = 1;
let currentTranslateX = 0;
let currentTranslateY = 0;
let isPanning = false;
let panStartX = 0;
let panStartY = 0;
let panOriginX = 0;
let panOriginY = 0;
let pinchStartMidX = 0;
let pinchStartMidY = 0;
let pinchStartTranslateX = 0;
let pinchStartTranslateY = 0;
let exifAbortController = null;
let isGalleryLoading = false;
let autoPlayTimer = null;
let isAutoPlay = false;
let navHideTimer = null;
const AUTO_PLAY_INTERVAL_MS = 5000;
let pendingAutoAdvance = false;
// Assume that currentGalleryImages is already declared in app.js.
// Only declare currentGalleryIndex if it isn't defined already.
if (typeof currentGalleryIndex === "undefined") {
var currentGalleryIndex = -1;
}
let currentExifRequestId = 0;
function encodeGalleryPath(relUrl) {
if (!relUrl) {
return '';
}
return relUrl
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, (char) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[char]));
}
function renderExifData(exif) {
const exifPanel = document.getElementById('gallery-exif');
if (!exifPanel) {
return;
}
const entries = exif && Object.keys(exif).length > 0 ? exif : null;
if (!entries) {
exifPanel.innerHTML = '
No EXIF data
';
return;
}
const importantKeys = [
'Model',
'Make',
'LensModel',
'DateTimeOriginal',
'ExposureTime',
'FNumber',
'ISOSpeedRatings',
'FocalLength',
'FocalLengthIn35mmFilm',
'ExposureBiasValue',
'WhiteBalance',
'Software'
];
const rows = [];
importantKeys.forEach(key => {
const value = exif[key];
if (value !== undefined && value !== null && value !== '') {
rows.push([key, value]);
}
});
if (rows.length === 0) {
exifPanel.innerHTML = 'No EXIF data
';
return;
}
exifPanel.innerHTML = rows.map(([key, value]) => {
const safeKey = escapeHtml(key);
const safeValue = escapeHtml(Array.isArray(value) ? value.join(', ') : value);
return `${safeKey}${safeValue}
`;
}).join('');
}
function showExifPlaceholder(message) {
const exifPanel = document.getElementById('gallery-exif');
if (!exifPanel) {
return;
}
exifPanel.innerHTML = `${message}
`;
}
function loadExifData(relUrl) {
const exifPanel = document.getElementById('gallery-exif');
const infoButton = document.getElementById('gallery-info');
if (!exifPanel || !infoButton) {
return;
}
const requestId = ++currentExifRequestId;
if (exifAbortController) {
try {
exifAbortController.abort();
} catch (e) {
/* ignore */
}
}
exifAbortController = typeof AbortController !== 'undefined' ? new AbortController() : null;
infoButton.classList.add('loading');
showExifPlaceholder('Loading EXIF...');
fetch(`/media_exif/${encodeGalleryPath(relUrl)}`, exifAbortController ? { signal: exifAbortController.signal } : {})
.then(res => {
if (!res.ok) {
throw new Error('EXIF fetch failed');
}
return res.json();
})
.then(data => {
if (requestId !== currentExifRequestId) {
return;
}
infoButton.classList.remove('loading');
renderExifData((data && data.exif) || {});
})
.catch((err) => {
if (err && err.name === 'AbortError') {
return;
}
if (requestId !== currentExifRequestId) {
return;
}
infoButton.classList.remove('loading');
showExifPlaceholder('EXIF not available');
});
}
function toggleExifPanel() {
const exifPanel = document.getElementById('gallery-exif');
const infoButton = document.getElementById('gallery-info');
if (!exifPanel || !infoButton) {
return;
}
const isOpen = exifPanel.classList.toggle('open');
infoButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
}
function resetExifPanel(clearOpen = true) {
const exifPanel = document.getElementById('gallery-exif');
const infoButton = document.getElementById('gallery-info');
currentExifRequestId++;
if (exifAbortController) {
try {
exifAbortController.abort();
} catch (e) {
/* ignore */
}
exifAbortController = null;
}
if (exifPanel) {
exifPanel.innerHTML = '';
if (clearOpen) {
exifPanel.classList.remove('open');
}
}
if (infoButton && clearOpen) {
infoButton.setAttribute('aria-expanded', 'false');
infoButton.classList.remove('loading');
}
}
function updateFilenameDisplay(relUrl) {
const filenameEl = document.getElementById('gallery-filename');
if (!filenameEl) {
return;
}
const filenameTextEl = filenameEl.querySelector('.filename-text');
if (!relUrl) {
if (filenameTextEl) {
filenameTextEl.textContent = '';
} else {
filenameEl.textContent = '';
}
filenameEl.dataset.filename = '';
return;
}
const parts = relUrl.split('/');
const filename = parts[parts.length - 1] || relUrl;
if (filenameTextEl) {
filenameTextEl.textContent = filename;
} else {
filenameEl.textContent = filename;
}
filenameEl.dataset.filename = filename;
}
function updateFilenameProgress() {
const filenameEl = document.getElementById('gallery-filename');
if (!filenameEl) {
return;
}
const total = currentGalleryImages.length;
const hasValidIndex = currentGalleryIndex >= 0 && currentGalleryIndex < total;
const progress = total > 0 && hasValidIndex
? ((currentGalleryIndex + 1) / total) * 100
: 0;
filenameEl.style.setProperty('--filename-progress', `${progress}%`);
}
function updateGalleryPosition() {
const positionEl = document.getElementById('gallery-position');
if (!positionEl) {
return;
}
const total = currentGalleryImages.length;
if (total <= 0 || currentGalleryIndex < 0 || currentGalleryIndex >= total) {
positionEl.textContent = '';
return;
}
positionEl.textContent = `${currentGalleryIndex + 1} / ${total}`;
}
function updateDownloadLink(relUrl) {
const downloadBtn = document.getElementById('gallery-download');
if (!downloadBtn) {
return;
}
if (!relUrl) {
downloadBtn.removeAttribute('data-relurl');
return;
}
// Store relUrl for the click handler (which fetches a short-lived token)
downloadBtn.dataset.relurl = relUrl;
}
function updateNextButtonState() {
const nextButton = document.querySelector('.gallery-next');
if (!nextButton) {
return;
}
const atLastImage = currentGalleryIndex >= currentGalleryImages.length - 1;
nextButton.textContent = atLastImage ? '\u00D7' : '\u203A';
nextButton.setAttribute('aria-label', atLastImage ? 'Close gallery' : 'Next image');
nextButton.classList.toggle('is-close', atLastImage);
}
function showGalleryNav() {
clearTimeout(navHideTimer);
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden'));
if (currentScale > 1.01) {
return;
}
navHideTimer = setTimeout(() => {
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.add('nav-hidden'));
}, 1000);
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function getPanBounds(image, scale) {
const stage = document.getElementById('gallery-stage');
if (!stage || !image) {
return { maxX: 0, maxY: 0 };
}
const baseWidth = image.clientWidth || 0;
const baseHeight = image.clientHeight || 0;
const scaledWidth = baseWidth * scale;
const scaledHeight = baseHeight * scale;
return {
maxX: Math.max(0, (scaledWidth - stage.clientWidth) / 2),
maxY: Math.max(0, (scaledHeight - stage.clientHeight) / 2)
};
}
function applyImageTransform(image) {
if (!image) {
return;
}
const { maxX, maxY } = getPanBounds(image, currentScale);
currentTranslateX = clamp(currentTranslateX, -maxX, maxX);
currentTranslateY = clamp(currentTranslateY, -maxY, maxY);
image.style.transform = `translate(-50%, -50%) translate(${currentTranslateX}px, ${currentTranslateY}px) scale(${currentScale})`;
}
function resetImageTransformState(image = null) {
isPinching = false;
isPanning = false;
pinchZoomOccurred = false;
initialPinchDistance = null;
initialScale = 1;
currentScale = 1;
currentTranslateX = 0;
currentTranslateY = 0;
panStartX = 0;
panStartY = 0;
panOriginX = 0;
panOriginY = 0;
pinchStartMidX = 0;
pinchStartMidY = 0;
pinchStartTranslateX = 0;
pinchStartTranslateY = 0;
if (image) {
applyImageTransform(image);
}
}
function openGalleryModal(relUrl) {
const modal = document.getElementById('gallery-modal');
if (!modal) {
return;
}
document.body.style.overflow = 'hidden'; // Disable background scrolling.
currentGalleryIndex = currentGalleryImages.indexOf(relUrl);
if (currentGalleryIndex < 0) {
currentGalleryIndex = 0;
}
modal.setAttribute('aria-hidden', 'false');
showGalleryImage(relUrl);
modal.style.display = 'flex';
showGalleryNav();
}
function showGalleryImage(relUrl) {
if (isGalleryLoading) {
return;
}
isGalleryLoading = true;
clearAutoPlayTimer();
updateNextButtonState();
updateGalleryPosition();
updateFilenameProgress();
loadExifData(relUrl);
const fullUrl = '/media/' + relUrl;
const stage = document.getElementById('gallery-stage');
const loader = document.getElementById('loader-container');
if (!stage || !loader) {
isGalleryLoading = false;
return;
}
updateDownloadLink(relUrl);
// Set a timeout for showing the loader after 500ms.
let loaderTimeout = setTimeout(() => {
loader.style.display = 'flex';
}, 500);
// Preload the new image.
const tempImg = new Image();
tempImg.src = fullUrl;
tempImg.onload = function () {
clearTimeout(loaderTimeout);
loader.style.display = 'none';
updateFilenameDisplay(relUrl);
// Select all current images in the gallery stage
const existingImages = stage.querySelectorAll('img');
// Create a new image element for the transition
const newImage = document.createElement('img');
newImage.src = fullUrl;
newImage.style.position = 'absolute';
newImage.style.top = '50%'; // Center vertically
newImage.style.left = '50%'; // Center horizontally
newImage.style.transform = 'translate(-50%, -50%)'; // Ensure centering
newImage.style.transition = 'opacity 0.5s ease-in-out';
newImage.style.opacity = 0; // Start fully transparent
newImage.id = 'gallery-modal-content';
newImage.alt = '';
// Add pinch-to-zoom event listeners
newImage.addEventListener('touchstart', handlePinchStart, { passive: false });
newImage.addEventListener('touchmove', handlePinchMove, { passive: false });
newImage.addEventListener('touchend', handlePinchEnd, { passive: false });
newImage.addEventListener('touchcancel', handlePinchEnd, { passive: false });
// Reset pinch variables for the new image
resetImageTransformState(newImage);
// Append new image on top of existing ones
stage.appendChild(newImage);
// Fade out all old images and fade in the new one
setTimeout(() => {
existingImages.forEach(img => {
img.style.opacity = 0; // Apply fade-out
});
newImage.style.opacity = 1; // Fade in the new image
}, 10);
// Wait for fade-out to finish, then remove all old images
setTimeout(() => {
existingImages.forEach(img => {
if (img.parentNode) {
img.parentNode.removeChild(img); // Remove old images
}
});
isGalleryLoading = false;
if (pendingAutoAdvance && isAutoPlay) {
pendingAutoAdvance = false;
attemptAutoAdvance();
return;
}
if (isAutoPlay) {
scheduleAutoPlayCycle();
}
}, 510); // 500ms for fade-out + small buffer
// Preload the next image
preloadNextImage();
};
tempImg.onerror = function() {
clearTimeout(loaderTimeout);
loader.style.display = 'none';
isGalleryLoading = false;
pendingAutoAdvance = false;
if (isAutoPlay) {
scheduleAutoPlayCycle();
}
console.error('Error loading image:', fullUrl);
};
}
function preloadNextImage() {
if (currentGalleryIndex < currentGalleryImages.length - 1) {
const nextImage = new Image();
nextImage.src = '/media/' + currentGalleryImages[currentGalleryIndex + 1];
}
}
function closeGalleryModal() {
clearTimeout(navHideTimer);
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden'));
const modal = document.getElementById('gallery-modal');
const stage = document.getElementById('gallery-stage');
if (modal) {
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
}
isGalleryLoading = false;
setAutoPlay(false);
updateDownloadLink(null);
// remove all images
if (stage) {
const existingImages = stage.querySelectorAll('img');
existingImages.forEach(img => {
if (img.parentNode) {
img.parentNode.removeChild(img);
}
});
}
const filenameEl = document.getElementById('gallery-filename');
if (filenameEl) {
const filenameTextEl = filenameEl.querySelector('.filename-text');
if (filenameTextEl) {
filenameTextEl.textContent = '';
} else {
filenameEl.textContent = '';
}
filenameEl.dataset.filename = '';
filenameEl.style.setProperty('--filename-progress', '0%');
}
const positionEl = document.getElementById('gallery-position');
if (positionEl) {
positionEl.textContent = '';
}
resetExifPanel(true);
resetImageTransformState();
// Restore scrolling.
document.body.style.overflow = '';
}
function navigateByScreenHalf(clientX) {
if (isGalleryLoading) {
return;
}
const midpoint = window.innerWidth / 2;
if (clientX < midpoint) {
if (currentGalleryIndex > 0) {
currentGalleryIndex--;
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
}
return;
}
if (currentGalleryIndex < currentGalleryImages.length - 1) {
currentGalleryIndex++;
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
} else {
closeGalleryModal();
}
}
function handleGalleryScreenClick(e) {
// If a pinch zoom occurred, don't change the image.
if (pinchZoomOccurred) {
pinchZoomOccurred = false;
return;
}
if (currentScale > 1.01 || isPinching || isPanning) {
return;
}
const target = e.target;
if (!target) {
return;
}
// Do not interpret clicks on controls/panels as navigation.
if (target.closest('.gallery-control') || target.closest('.gallery-nav') || target.closest('#gallery-exif')) {
return;
}
navigateByScreenHalf(e.clientX);
}
function initGallerySwipe() {
let touchStartX = 0;
let touchEndX = 0;
const galleryModal = document.getElementById('gallery-modal');
if (!galleryModal) {
return;
}
galleryModal.addEventListener('touchstart', function(e) {
// If more than one finger is on screen, ignore swipe tracking.
if (e.touches.length > 1) {
return;
}
if (currentScale > 1.01 || isPinching || isPanning) {
return;
}
touchStartX = e.changedTouches[0].screenX;
}, false);
galleryModal.addEventListener('touchend', function(e) {
// If the event was part of a multi-touch (pinch) gesture or a pinch was detected, skip swipe.
if (e.changedTouches.length > 1 || isPinching || isPanning || pinchZoomOccurred || currentScale > 1.01) {
return;
}
touchEndX = e.changedTouches[0].screenX;
handleSwipe();
}, false);
function handleSwipe() {
const deltaX = touchEndX - touchStartX;
if (Math.abs(deltaX) > 50) { // Swipe threshold.
if (deltaX > 0) { // Swipe right: previous image.
if (!isGalleryLoading && currentGalleryIndex > 0) {
currentGalleryIndex--;
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
}
} else { // Swipe left: next image.
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) {
currentGalleryIndex++;
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
}
}
}
}
}
function clearAutoPlayTimer() {
if (autoPlayTimer) {
clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
}
function setAutoPlay(active) {
const playButton = document.getElementById('gallery-play');
isAutoPlay = active;
if (playButton) {
playButton.textContent = active ? '\u2225' : '\u25B6';
playButton.setAttribute('aria-pressed', active ? 'true' : 'false');
playButton.setAttribute('aria-label', active ? 'Pause slideshow' : 'Start slideshow');
playButton.classList.toggle('playing', active);
}
pendingAutoAdvance = false;
clearAutoPlayTimer();
if (active) {
scheduleAutoPlayCycle();
} else {
setPlayRingProgress(0);
}
}
function setPlayRingProgress(deg) {
const playButton = document.getElementById('gallery-play');
if (playButton) {
playButton.style.setProperty('--play-progress', `${deg}deg`);
}
}
function restartPlayAnimation() {
const playButton = document.getElementById('gallery-play');
if (!playButton) {
return;
}
playButton.classList.remove('playing-anim');
// force reflow to restart animation
void playButton.offsetWidth;
setPlayRingProgress(0);
playButton.classList.add('playing-anim');
}
function completePlayRing() {
const playButton = document.getElementById('gallery-play');
if (!playButton) {
return;
}
playButton.classList.remove('playing-anim');
setPlayRingProgress(360);
}
function scheduleAutoPlayCycle() {
if (!isAutoPlay) {
return;
}
clearAutoPlayTimer();
restartPlayAnimation();
autoPlayTimer = setTimeout(() => {
triggerAutoAdvance();
}, AUTO_PLAY_INTERVAL_MS);
}
function triggerAutoAdvance() {
completePlayRing();
if (isGalleryLoading) {
pendingAutoAdvance = true;
return;
}
attemptAutoAdvance();
}
function attemptAutoAdvance() {
pendingAutoAdvance = false;
if (!isAutoPlay) {
return;
}
if (currentGalleryIndex >= currentGalleryImages.length - 1) {
setAutoPlay(false);
return;
}
const nextUrl = currentGalleryImages[currentGalleryIndex + 1];
const img = new Image();
img.onload = function() {
if (!isAutoPlay) {
return;
}
currentGalleryIndex++;
showGalleryImage(nextUrl);
};
img.onerror = function() {
if (isAutoPlay) {
scheduleAutoPlayCycle();
}
};
img.src = '/media/' + nextUrl;
}
function handlePinchStart(e) {
if (e.touches.length === 2) {
e.preventDefault();
isPinching = true;
isPanning = false;
pinchZoomOccurred = false; // Reset at the start of a pinch gesture.
const dx = e.touches[0].pageX - e.touches[1].pageX;
const dy = e.touches[0].pageY - e.touches[1].pageY;
initialPinchDistance = Math.sqrt(dx * dx + dy * dy);
initialScale = currentScale;
pinchStartMidX = (e.touches[0].pageX + e.touches[1].pageX) / 2;
pinchStartMidY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
pinchStartTranslateX = currentTranslateX;
pinchStartTranslateY = currentTranslateY;
showGalleryNav();
return;
}
if (e.touches.length === 1 && currentScale > 1.01) {
e.preventDefault();
isPanning = true;
panStartX = e.touches[0].pageX;
panStartY = e.touches[0].pageY;
panOriginX = currentTranslateX;
panOriginY = currentTranslateY;
showGalleryNav();
}
}
function handlePinchMove(e) {
if (e.touches.length === 2 && initialPinchDistance) {
e.preventDefault();
const dx = e.touches[0].pageX - e.touches[1].pageX;
const dy = e.touches[0].pageY - e.touches[1].pageY;
const currentDistance = Math.sqrt(dx * dx + dy * dy);
let scale = currentDistance / initialPinchDistance;
scale = Math.max(1, Math.min(scale * initialScale, 4));
// If the scale has changed enough, mark that a pinch zoom occurred.
if (Math.abs(scale - currentScale) > 0.01) {
pinchZoomOccurred = true;
}
currentScale = scale;
const midX = (e.touches[0].pageX + e.touches[1].pageX) / 2;
const midY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
currentTranslateX = pinchStartTranslateX + (midX - pinchStartMidX);
currentTranslateY = pinchStartTranslateY + (midY - pinchStartMidY);
applyImageTransform(e.currentTarget);
showGalleryNav();
return;
}
if (e.touches.length === 1 && isPanning && currentScale > 1.01) {
e.preventDefault();
const deltaX = e.touches[0].pageX - panStartX;
const deltaY = e.touches[0].pageY - panStartY;
currentTranslateX = panOriginX + deltaX;
currentTranslateY = panOriginY + deltaY;
pinchZoomOccurred = true;
applyImageTransform(e.currentTarget);
showGalleryNav();
}
}
function handlePinchEnd(e) {
if (e.touches.length === 1 && currentScale > 1.01) {
// Transition from pinch to one-finger pan.
isPinching = false;
isPanning = true;
initialPinchDistance = null;
panStartX = e.touches[0].pageX;
panStartY = e.touches[0].pageY;
panOriginX = currentTranslateX;
panOriginY = currentTranslateY;
showGalleryNav();
return;
}
if (e.touches.length < 1) {
isPinching = false;
isPanning = false;
initialPinchDistance = null;
initialScale = currentScale;
const image = e.currentTarget;
if (currentScale <= 1.01) {
currentScale = 1;
currentTranslateX = 0;
currentTranslateY = 0;
}
applyImageTransform(image);
showGalleryNav();
}
}
function handleGalleryKeydown(e) {
const modal = document.getElementById('gallery-modal');
if (!modal || modal.style.display !== 'flex') {
return;
}
if (e.key === 'Escape') {
closeGalleryModal();
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
if (!isGalleryLoading && currentGalleryIndex > 0) {
currentGalleryIndex--;
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
}
return;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) {
currentGalleryIndex++;
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
} else if (!isGalleryLoading) {
closeGalleryModal();
}
return;
}
if (e.key === ' ') {
e.preventDefault();
setAutoPlay(!isAutoPlay);
}
}
function initGalleryControls() {
const nextButton = document.querySelector('.gallery-next');
const prevButton = document.querySelector('.gallery-prev');
const closeButton = document.getElementById('gallery-close');
const infoButton = document.getElementById('gallery-info');
const playButton = document.getElementById('gallery-play');
if (nextButton) {
nextButton.addEventListener('click', function () {
if (isGalleryLoading) {
return;
}
if (currentGalleryIndex < currentGalleryImages.length - 1) {
currentGalleryIndex++;
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
} else {
closeGalleryModal();
}
});
}
if (prevButton) {
prevButton.addEventListener('click', function () {
if (isGalleryLoading) {
return;
}
if (currentGalleryIndex > 0) {
currentGalleryIndex--;
showGalleryImage(currentGalleryImages[currentGalleryIndex]);
}
});
}
if (closeButton) {
closeButton.addEventListener('click', closeGalleryModal);
}
const downloadButton = document.getElementById('gallery-download');
if (downloadButton) {
downloadButton.addEventListener('click', async function (e) {
e.preventDefault();
e.stopPropagation();
const relUrl = downloadButton.dataset.relurl;
if (!relUrl) return;
const encodedSubpath = relUrl
.replace(/^\/+/, '')
.split('/')
.map(segment => encodeURIComponent(decodeURIComponent(segment)))
.join('/');
// Fetch a short-lived download token (works in Telegram's internal browser)
let tokenizedUrl;
try {
const resp = await fetch(`/create_dltoken/${encodedSubpath}`);
if (!resp.ok) return;
tokenizedUrl = (await resp.text()).trim();
} catch {
return;
}
if (!tokenizedUrl) return;
// Append download=true so the server returns the full-size original as attachment
const sep = tokenizedUrl.includes('?') ? '&' : '?';
const downloadUrl = new URL(tokenizedUrl + sep + 'download=true', window.location.href);
const a = document.createElement('a');
a.href = downloadUrl.toString();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
}
if (infoButton) {
infoButton.addEventListener('click', function (e) {
e.stopPropagation();
toggleExifPanel();
});
}
if (playButton) {
playButton.addEventListener('click', function (e) {
e.stopPropagation();
setAutoPlay(!isAutoPlay);
});
}
}
function initGallery() {
const galleryModal = document.getElementById('gallery-modal');
if (!galleryModal || galleryModal.dataset.galleryBound) return;
initGallerySwipe();
initGalleryControls();
galleryModal.addEventListener('click', handleGalleryScreenClick);
document.addEventListener('keydown', handleGalleryKeydown);
galleryModal.dataset.galleryBound = '1';
}
window.initGallery = initGallery;