Merge remote-tracking branch 'origin/development'

This commit is contained in:
lelo 2026-02-27 20:23:01 +00:00
commit 3c7cb11f02
3 changed files with 823 additions and 298 deletions

View File

@ -4,9 +4,11 @@
.gallery-nav:active, .gallery-nav:active,
#gallery-modal-content, #gallery-modal-content,
#gallery-close, #gallery-close,
#gallery-download { #gallery-download,
-webkit-tap-highlight-color: transparent; /* Disable blue highlight on Android/iOS/Chrome/Safari */ #gallery-info,
outline: none; /* Disable outline on click/focus */ #gallery-play {
-webkit-tap-highlight-color: transparent;
outline: none;
} }
@property --play-progress { @property --play-progress {
@ -15,160 +17,196 @@
initial-value: 0deg; initial-value: 0deg;
} }
/* Gallery Modal Styles */
#gallery-modal { #gallery-modal {
display: none; /* Hidden by default */ display: none;
position: fixed; position: fixed;
z-index: 3000; z-index: 3000;
top: 0; inset: 0;
left: 0; background-color: rgba(0, 0, 0, 0.82);
width: 100%; background-image:
height: 100vh; radial-gradient(circle at 50% 15%, rgba(42, 42, 42, 0.52), rgba(8, 8, 8, 0.95) 56%, #000 100%);
background-color: rgba(0,0,0,1);
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overflow: hidden;
cursor: default;
}
#gallery-stage {
position: absolute;
inset: 82px 22px 96px;
cursor: pointer; cursor: pointer;
} }
#gallery-modal-content { #gallery-modal-content {
max-width: 95%; position: absolute;
max-height: 95vh; top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: min(96vw, calc(100vw - 80px));
max-height: calc(100vh - 190px);
object-fit: contain; object-fit: contain;
margin: auto; margin: auto;
display: block; display: block;
cursor: pointer; cursor: pointer;
border-radius: 12px;
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.58);
} }
/* Close Button */ .gallery-top-bar {
#gallery-close { position: absolute;
position: fixed;
top: 20px;
right: 20px;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.6);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
z-index: 120;
font-size: 1.1rem;
padding: 0;
margin: 0;
}
#gallery-close:hover {
background: rgba(40, 40, 40, 0.85);
}
/* Loader for gallery */
#loader-container {
position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; right: 0;
height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: space-between;
align-items: center; align-items: center;
z-index: 9999; /* Ensure it's on top */ gap: 12px;
pointer-events: none; /* Allow interacting with close button while loader shows */ padding: 14px 18px;
z-index: 130;
pointer-events: none;
} }
#gallery-loader { .gallery-actions-left,
border: 8px solid #f3f3f3; .gallery-actions-right {
border-top: 8px solid #525252; display: flex;
gap: 10px;
pointer-events: auto;
}
.gallery-control {
width: 48px;
height: 48px;
min-width: 48px;
min-height: 48px;
border-radius: 50%; border-radius: 50%;
width: 30px; border: 1px solid rgba(255, 255, 255, 0.27);
height: 30px; background: rgba(0, 0, 0, 0.56);
animation: spin 3s linear infinite;
}
/* Keyframes for spinner animation */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.gallery-nav {
position: fixed;
top: 50%;
transform: translateY(-50%);
font-size: 3rem;
color: white;
background: rgba(0,0,0,0.1);
border: none;
padding: 1rem;
cursor: pointer;
z-index: 10;
transition: opacity 0.4s ease;
}
.gallery-prev { left: 20px; }
.gallery-next { right: 20px; }
.gallery-nav.nav-hidden { opacity: 0; }
#gallery-info {
position: fixed;
top: 20px;
left: 20px;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.6);
color: #fff; color: #fff;
display: grid; display: inline-flex;
place-items: center; align-items: center;
justify-content: center;
cursor: pointer; cursor: pointer;
z-index: 120; z-index: 132;
font-size: 1rem; font-size: 1.45rem;
} font-weight: 500;
#gallery-download {
position: fixed;
top: 20px;
left: 64px;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.6);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
z-index: 120;
font-size: 1.25rem;
text-decoration: none;
line-height: 1; line-height: 1;
padding: 0;
text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
} }
#gallery-download:hover { .gallery-control:hover,
background: rgba(40, 40, 40, 0.85); .gallery-control:focus-visible {
background: rgba(38, 38, 38, 0.88);
border-color: rgba(255, 255, 255, 0.65);
transform: translateY(-1px);
} }
#gallery-info.loading { #gallery-info.loading {
opacity: 0.65; opacity: 0.62;
}
#gallery-info {
font-size: 1.7rem;
font-weight: 700;
}
#gallery-download,
#gallery-close {
font-size: 1.6rem;
}
#loader-container {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 3500;
pointer-events: none;
}
#gallery-loader {
border: 4px solid rgba(255, 255, 255, 0.22);
border-top: 4px solid #fff;
border-radius: 50%;
width: 34px;
height: 34px;
animation: gallery-spin 0.9s linear infinite;
}
@keyframes gallery-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.gallery-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 56px;
height: 56px;
min-width: 56px;
min-height: 56px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
color: #fff;
background: rgba(0, 0, 0, 0.54);
display: grid;
place-items: center;
padding: 0;
cursor: pointer;
z-index: 120;
font-size: 2.2rem;
font-weight: 400;
line-height: 1;
transition: opacity 0.25s ease, transform 0.25s ease, background 0.2s ease;
}
.gallery-nav:hover,
.gallery-nav:focus-visible {
background: rgba(30, 30, 30, 0.9);
}
.gallery-prev {
left: 18px;
}
.gallery-next {
right: 18px;
}
.gallery-next.is-close {
background: #fff;
border-color: #fff;
color: #101010;
}
.gallery-nav.nav-hidden {
opacity: 0;
transform: translateY(-50%) scale(0.97);
pointer-events: none;
} }
#gallery-exif { #gallery-exif {
position: fixed; position: absolute;
top: 60px; top: 68px;
left: 20px; left: 18px;
background: rgba(0, 0, 0, 0.82); background: rgba(0, 0, 0, 0.85);
color: #fff; color: #fff;
padding: 0.75rem 0.85rem; padding: 0.85rem 0.92rem;
border-radius: 8px; border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); border: 1px solid rgba(255, 255, 255, 0.16);
max-width: 320px; box-shadow: 0 14px 36px rgba(0, 0, 0, 0.5);
max-height: 50vh; width: min(360px, calc(100vw - 36px));
max-height: calc(100vh - 228px);
overflow: auto; overflow: auto;
display: none; display: none;
z-index: 120; z-index: 140;
} }
#gallery-exif.open { #gallery-exif.open {
@ -179,9 +217,9 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
font-size: 0.9rem; font-size: 0.82rem;
margin-bottom: 6px; margin-bottom: 8px;
line-height: 1.3; line-height: 1.28;
} }
.exif-row:last-child { .exif-row:last-child {
@ -190,76 +228,127 @@
.exif-key { .exif-key {
font-weight: 600; font-weight: 600;
color: #e5e5e5; color: rgba(255, 255, 255, 0.88);
} }
.exif-value { .exif-value {
text-align: right; text-align: right;
color: #fff; color: rgba(255, 255, 255, 0.98);
word-break: break-word;
} }
.exif-placeholder { .exif-placeholder {
font-style: italic; font-style: italic;
color: #d0d0d0; color: rgba(255, 255, 255, 0.76);
} }
#gallery-filename { .gallery-meta-bar {
position: fixed; position: absolute;
bottom: 20px; bottom: 16px;
left: 50%; left: 18px;
transform: translateX(-50%); right: 18px;
color: #fff; display: flex;
background: rgba(0, 0, 0, 0.55); align-items: center;
padding: 0.4rem 0.75rem; justify-content: space-between;
border-radius: 6px; gap: 10px;
font-size: 0.95rem; z-index: 120;
max-width: 90%;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 20;
pointer-events: none; pointer-events: none;
} }
#gallery-play { #gallery-filename,
position: fixed; #gallery-position {
bottom: 20px;
right: 20px;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.6);
color: #fff; color: #fff;
display: grid; background: rgba(0, 0, 0, 0.6);
place-items: center; border: 1px solid rgba(255, 255, 255, 0.16);
cursor: pointer; border-radius: 8px;
z-index: 120; padding: 0.45rem 0.72rem;
font-size: 0.82rem;
line-height: 1.2;
white-space: nowrap;
}
#gallery-filename {
flex: 1;
min-width: 0;
position: relative;
isolation: isolate;
text-align: left;
overflow: hidden;
--filename-progress: 0%;
}
#gallery-filename::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(
90deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 255, 255, 0.95) var(--filename-progress),
rgba(0, 0, 0, 0.62) var(--filename-progress),
rgba(0, 0, 0, 0.62) 100%
);
z-index: 0;
}
#gallery-filename .filename-text {
position: relative;
z-index: 1;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #fff;
}
#gallery-filename::after {
content: attr(data-filename);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 0.45rem 0.72rem;
box-sizing: border-box;
z-index: 2;
overflow: hidden;
clip-path: inset(0 calc(100% - var(--filename-progress)) 0 0);
text-overflow: ellipsis;
white-space: nowrap;
color: #0a0a0a;
pointer-events: none;
}
#gallery-position {
min-width: 72px;
text-align: center;
}
#gallery-play {
position: relative;
font-size: 1.2rem; font-size: 1.2rem;
line-height: 1; font-weight: 600;
padding: 0 0 0 2px; /* optical compensation for ▶ glyph whitespace */
--autoplay-duration: 5s; --autoplay-duration: 5s;
--play-progress: 0deg; --play-progress: 0deg;
overflow: visible;
} }
#gallery-play.playing::after { #gallery-play.playing::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: -8px; inset: -4px;
border-radius: 50%; border-radius: 999px;
background: background:
conic-gradient( conic-gradient(
rgba(255, 255, 255, 0.9) 0deg, rgba(255, 255, 255, 0.96) 0deg,
rgba(255, 255, 255, 0.9) var(--play-progress, 0deg), rgba(255, 255, 255, 0.96) var(--play-progress, 0deg),
rgba(255, 255, 255, 0.2) var(--play-progress, 0deg), rgba(255, 255, 255, 0.2) var(--play-progress, 0deg),
rgba(255, 255, 255, 0.2) 360deg rgba(255, 255, 255, 0.2) 360deg
); );
mask: radial-gradient(circle at center, transparent 60%, #000 70%); mask: radial-gradient(ellipse at center, transparent 67%, #000 74%);
animation: none; animation: none;
pointer-events: none; pointer-events: none;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
} }
#gallery-play.playing.playing-anim::after { #gallery-play.playing.playing-anim::after {
@ -267,6 +356,186 @@
} }
@keyframes gallery-play-fill { @keyframes gallery-play-fill {
from { --play-progress: 0deg; } from {
to { --play-progress: 360deg; } --play-progress: 0deg;
}
to {
--play-progress: 360deg;
}
}
@media (max-width: 860px) {
#gallery-stage {
inset: 74px 10px 110px;
}
#gallery-modal-content {
max-width: calc(100vw - 20px);
max-height: calc(100vh - 210px);
border-radius: 10px;
}
.gallery-top-bar {
padding: 10px;
gap: 8px;
flex-wrap: wrap;
}
.gallery-actions-left,
.gallery-actions-right {
gap: 8px;
}
.gallery-control {
width: 42px;
height: 42px;
min-width: 42px;
min-height: 42px;
font-size: 1.3rem;
padding: 0;
}
.gallery-nav {
top: auto;
bottom: 62px;
transform: none;
width: 48px;
height: 48px;
min-width: 48px;
min-height: 48px;
font-size: 1.9rem;
}
.gallery-prev {
left: 10px;
}
.gallery-next {
right: 10px;
}
.gallery-nav.nav-hidden {
opacity: 0;
transform: translateY(5px);
}
#gallery-exif {
top: 58px;
left: 10px;
width: calc(100vw - 20px);
max-height: calc(100vh - 224px);
}
.gallery-meta-bar {
left: 10px;
right: 10px;
bottom: 10px;
}
#gallery-filename,
#gallery-position {
font-size: 0.76rem;
padding: 0.42rem 0.6rem;
}
#gallery-play {
font-size: 1.05rem;
}
#gallery-info {
font-size: 1.45rem;
}
#gallery-download,
#gallery-close {
font-size: 1.4rem;
}
}
@media (max-height: 520px) and (orientation: landscape) {
#gallery-stage {
inset: 44px 10px 60px;
}
#gallery-modal-content {
max-width: calc(100vw - 20px);
max-height: calc(100vh - 112px);
border-radius: 8px;
}
.gallery-top-bar {
padding: 8px 10px;
gap: 6px;
flex-wrap: nowrap;
}
.gallery-actions-left,
.gallery-actions-right {
gap: 6px;
}
.gallery-control {
width: 38px;
height: 38px;
min-width: 38px;
min-height: 38px;
font-size: 1.1rem;
}
#gallery-play {
font-size: 0.95rem;
}
#gallery-info {
font-size: 1.22rem;
}
#gallery-download,
#gallery-close {
font-size: 1.18rem;
}
.gallery-nav {
top: 50%;
bottom: auto;
transform: translateY(-50%);
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
font-size: 1.7rem;
}
.gallery-prev {
left: 8px;
}
.gallery-next {
right: 8px;
}
.gallery-nav.nav-hidden {
opacity: 0;
transform: translateY(-50%) scale(0.96);
}
.gallery-meta-bar {
left: 10px;
right: 10px;
bottom: 8px;
gap: 8px;
}
#gallery-filename,
#gallery-position {
padding: 0.32rem 0.52rem;
font-size: 0.7rem;
}
#gallery-exif {
top: 50px;
left: 10px;
width: min(360px, calc(100vw - 20px));
max-height: calc(100vh - 120px);
}
} }

View File

@ -3,6 +3,17 @@ let pinchZoomOccurred = false;
let initialPinchDistance = null; let initialPinchDistance = null;
let initialScale = 1; let initialScale = 1;
let currentScale = 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 exifAbortController = null;
let isGalleryLoading = false; let isGalleryLoading = false;
let autoPlayTimer = null; let autoPlayTimer = null;
@ -179,16 +190,55 @@ function updateFilenameDisplay(relUrl) {
if (!filenameEl) { if (!filenameEl) {
return; return;
} }
const filenameTextEl = filenameEl.querySelector('.filename-text');
if (!relUrl) { if (!relUrl) {
if (filenameTextEl) {
filenameTextEl.textContent = '';
} else {
filenameEl.textContent = ''; filenameEl.textContent = '';
}
filenameEl.dataset.filename = '';
return; return;
} }
const parts = relUrl.split('/'); const parts = relUrl.split('/');
const filename = parts[parts.length - 1] || relUrl; const filename = parts[parts.length - 1] || relUrl;
if (filenameTextEl) {
filenameTextEl.textContent = filename;
} else {
filenameEl.textContent = filename; 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) { function updateDownloadLink(relUrl) {
const downloadBtn = document.getElementById('gallery-download'); const downloadBtn = document.getElementById('gallery-download');
@ -196,15 +246,11 @@ function updateDownloadLink(relUrl) {
return; return;
} }
if (!relUrl) { if (!relUrl) {
downloadBtn.removeAttribute('href');
downloadBtn.removeAttribute('download');
downloadBtn.removeAttribute('data-relurl'); downloadBtn.removeAttribute('data-relurl');
return; return;
} }
// Store relUrl for the click handler (which fetches a short-lived token) // Store relUrl for the click handler (which fetches a short-lived token)
downloadBtn.dataset.relurl = relUrl; downloadBtn.dataset.relurl = relUrl;
downloadBtn.removeAttribute('href');
downloadBtn.removeAttribute('download');
} }
function updateNextButtonState() { function updateNextButtonState() {
@ -214,23 +260,87 @@ function updateNextButtonState() {
} }
const atLastImage = currentGalleryIndex >= currentGalleryImages.length - 1; const atLastImage = currentGalleryIndex >= currentGalleryImages.length - 1;
nextButton.textContent = atLastImage ? 'x' : ''; nextButton.textContent = atLastImage ? '\u00D7' : '\u203A';
nextButton.setAttribute('aria-label', atLastImage ? 'Close gallery' : 'Next image'); nextButton.setAttribute('aria-label', atLastImage ? 'Close gallery' : 'Next image');
nextButton.classList.toggle('is-close', atLastImage);
} }
function showGalleryNav() { function showGalleryNav() {
clearTimeout(navHideTimer); clearTimeout(navHideTimer);
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden')); document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden'));
if (currentScale > 1.01) {
return;
}
navHideTimer = setTimeout(() => { navHideTimer = setTimeout(() => {
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.add('nav-hidden')); document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.add('nav-hidden'));
}, 1000); }, 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) { function openGalleryModal(relUrl) {
const modal = document.getElementById('gallery-modal');
if (!modal) {
return;
}
document.body.style.overflow = 'hidden'; // Disable background scrolling. document.body.style.overflow = 'hidden'; // Disable background scrolling.
currentGalleryIndex = currentGalleryImages.indexOf(relUrl); currentGalleryIndex = currentGalleryImages.indexOf(relUrl);
if (currentGalleryIndex < 0) {
currentGalleryIndex = 0;
}
modal.setAttribute('aria-hidden', 'false');
showGalleryImage(relUrl); showGalleryImage(relUrl);
document.getElementById('gallery-modal').style.display = 'flex'; modal.style.display = 'flex';
showGalleryNav(); showGalleryNav();
} }
@ -241,12 +351,16 @@ function showGalleryImage(relUrl) {
isGalleryLoading = true; isGalleryLoading = true;
clearAutoPlayTimer(); clearAutoPlayTimer();
updateNextButtonState(); updateNextButtonState();
updateGalleryPosition();
updateFilenameProgress();
loadExifData(relUrl); loadExifData(relUrl);
const fullUrl = '/media/' + relUrl; const fullUrl = '/media/' + relUrl;
const modal = document.getElementById('gallery-modal'); const stage = document.getElementById('gallery-stage');
const currentImage = document.getElementById('gallery-modal-content');
const loader = document.getElementById('loader-container'); const loader = document.getElementById('loader-container');
const closeBtn = document.getElementById('gallery-close'); if (!stage || !loader) {
isGalleryLoading = false;
return;
}
updateDownloadLink(relUrl); updateDownloadLink(relUrl);
// Set a timeout for showing the loader after 500ms. // Set a timeout for showing the loader after 500ms.
let loaderTimeout = setTimeout(() => { let loaderTimeout = setTimeout(() => {
@ -262,8 +376,8 @@ function showGalleryImage(relUrl) {
loader.style.display = 'none'; loader.style.display = 'none';
updateFilenameDisplay(relUrl); updateFilenameDisplay(relUrl);
// Select all current images in the gallery modal // Select all current images in the gallery stage
const existingImages = document.querySelectorAll('#gallery-modal img'); const existingImages = stage.querySelectorAll('img');
// Create a new image element for the transition // Create a new image element for the transition
const newImage = document.createElement('img'); const newImage = document.createElement('img');
@ -272,27 +386,23 @@ function showGalleryImage(relUrl) {
newImage.style.top = '50%'; // Center vertically newImage.style.top = '50%'; // Center vertically
newImage.style.left = '50%'; // Center horizontally newImage.style.left = '50%'; // Center horizontally
newImage.style.transform = 'translate(-50%, -50%)'; // Ensure centering newImage.style.transform = 'translate(-50%, -50%)'; // Ensure centering
newImage.style.maxWidth = '95vw'; // Prevent overflow on large screens
newImage.style.maxHeight = '95vh'; // Prevent overflow on tall screens
newImage.style.transition = 'opacity 0.5s ease-in-out'; newImage.style.transition = 'opacity 0.5s ease-in-out';
newImage.style.opacity = 0; // Start fully transparent newImage.style.opacity = 0; // Start fully transparent
newImage.id = 'gallery-modal-content'; newImage.id = 'gallery-modal-content';
newImage.alt = '';
// Add pinch-to-zoom event listeners // Add pinch-to-zoom event listeners
newImage.addEventListener('touchstart', handlePinchStart, { passive: false }); newImage.addEventListener('touchstart', handlePinchStart, { passive: false });
newImage.addEventListener('touchmove', handlePinchMove, { passive: false }); newImage.addEventListener('touchmove', handlePinchMove, { passive: false });
newImage.addEventListener('touchend', handlePinchEnd, { passive: false }); newImage.addEventListener('touchend', handlePinchEnd, { passive: false });
newImage.addEventListener('click', handleGalleryImageClick); newImage.addEventListener('touchcancel', handlePinchEnd, { passive: false });
// Reset pinch variables for the new image // Reset pinch variables for the new image
initialPinchDistance = null; resetImageTransformState(newImage);
initialScale = 1;
currentScale = 1;
pinchZoomOccurred = false;
// Append new image on top of existing ones // Append new image on top of existing ones
modal.appendChild(newImage); stage.appendChild(newImage);
// Fade out all old images and fade in the new one // Fade out all old images and fade in the new one
setTimeout(() => { setTimeout(() => {
@ -347,50 +457,88 @@ function preloadNextImage() {
function closeGalleryModal() { function closeGalleryModal() {
clearTimeout(navHideTimer); clearTimeout(navHideTimer);
document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden')); document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden'));
document.getElementById('gallery-modal').style.display = 'none'; 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; isGalleryLoading = false;
setAutoPlay(false); setAutoPlay(false);
updateDownloadLink(null); updateDownloadLink(null);
// remove all images // remove all images
const existingImages = document.querySelectorAll('#gallery-modal img'); if (stage) {
const existingImages = stage.querySelectorAll('img');
existingImages.forEach(img => { existingImages.forEach(img => {
if (img.parentNode) { if (img.parentNode) {
img.parentNode.removeChild(img); img.parentNode.removeChild(img);
} }
}); });
}
const filenameEl = document.getElementById('gallery-filename'); const filenameEl = document.getElementById('gallery-filename');
if (filenameEl) { if (filenameEl) {
const filenameTextEl = filenameEl.querySelector('.filename-text');
if (filenameTextEl) {
filenameTextEl.textContent = '';
} else {
filenameEl.textContent = ''; filenameEl.textContent = '';
} }
filenameEl.dataset.filename = '';
filenameEl.style.setProperty('--filename-progress', '0%');
}
const positionEl = document.getElementById('gallery-position');
if (positionEl) {
positionEl.textContent = '';
}
resetExifPanel(true); resetExifPanel(true);
resetImageTransformState();
// Restore scrolling. // Restore scrolling.
document.body.style.overflow = ''; document.body.style.overflow = '';
} }
function handleGalleryImageClick(e) { function navigateByScreenHalf(clientX) {
// If a pinch zoom occurred, don't change the image. if (isGalleryLoading) {
if (pinchZoomOccurred) {
pinchZoomOccurred = false; // Reset the flag for the next gesture.
e.stopPropagation();
return; return;
} }
if (e.target.classList.contains('gallery-next') || e.target.classList.contains('gallery-prev')) { const midpoint = window.innerWidth / 2;
return; // Prevent conflict with navigation buttons. if (clientX < midpoint) {
} if (currentGalleryIndex > 0) {
const imgRect = this.getBoundingClientRect();
if (e.clientX < imgRect.left + imgRect.width / 2) {
if (!isGalleryLoading && currentGalleryIndex > 0) {
currentGalleryIndex--; currentGalleryIndex--;
showGalleryImage(currentGalleryImages[currentGalleryIndex]); showGalleryImage(currentGalleryImages[currentGalleryIndex]);
} }
} else { return;
if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) { }
if (currentGalleryIndex < currentGalleryImages.length - 1) {
currentGalleryIndex++; currentGalleryIndex++;
showGalleryImage(currentGalleryImages[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);
} }
@ -400,21 +548,24 @@ function initGallerySwipe() {
let touchStartX = 0; let touchStartX = 0;
let touchEndX = 0; let touchEndX = 0;
const galleryModal = document.getElementById('gallery-modal'); const galleryModal = document.getElementById('gallery-modal');
if (!galleryModal) {
galleryModal.addEventListener('mousemove', showGalleryNav, false); return;
}
galleryModal.addEventListener('touchstart', function(e) { galleryModal.addEventListener('touchstart', function(e) {
showGalleryNav();
// If more than one finger is on screen, ignore swipe tracking. // If more than one finger is on screen, ignore swipe tracking.
if (e.touches.length > 1) { if (e.touches.length > 1) {
return; return;
} }
if (currentScale > 1.01 || isPinching || isPanning) {
return;
}
touchStartX = e.changedTouches[0].screenX; touchStartX = e.changedTouches[0].screenX;
}, false); }, false);
galleryModal.addEventListener('touchend', function(e) { galleryModal.addEventListener('touchend', function(e) {
// If the event was part of a multi-touch (pinch) gesture or a pinch was detected, skip swipe. // If the event was part of a multi-touch (pinch) gesture or a pinch was detected, skip swipe.
if (e.changedTouches.length > 1 || isPinching || pinchZoomOccurred) { if (e.changedTouches.length > 1 || isPinching || isPanning || pinchZoomOccurred || currentScale > 1.01) {
return; return;
} }
touchEndX = e.changedTouches[0].screenX; touchEndX = e.changedTouches[0].screenX;
@ -441,7 +592,7 @@ function initGallerySwipe() {
function clearAutoPlayTimer() { function clearAutoPlayTimer() {
if (autoPlayTimer) { if (autoPlayTimer) {
clearInterval(autoPlayTimer); clearTimeout(autoPlayTimer);
autoPlayTimer = null; autoPlayTimer = null;
} }
} }
@ -450,8 +601,9 @@ function setAutoPlay(active) {
const playButton = document.getElementById('gallery-play'); const playButton = document.getElementById('gallery-play');
isAutoPlay = active; isAutoPlay = active;
if (playButton) { if (playButton) {
playButton.textContent = active ? '\u23F8' : '\u25B6'; playButton.textContent = active ? '\u2225' : '\u25B6';
playButton.setAttribute('aria-pressed', active ? 'true' : 'false'); playButton.setAttribute('aria-pressed', active ? 'true' : 'false');
playButton.setAttribute('aria-label', active ? 'Pause slideshow' : 'Start slideshow');
playButton.classList.toggle('playing', active); playButton.classList.toggle('playing', active);
} }
pendingAutoAdvance = false; pendingAutoAdvance = false;
@ -541,10 +693,28 @@ function handlePinchStart(e) {
if (e.touches.length === 2) { if (e.touches.length === 2) {
e.preventDefault(); e.preventDefault();
isPinching = true; isPinching = true;
isPanning = false;
pinchZoomOccurred = false; // Reset at the start of a pinch gesture. pinchZoomOccurred = false; // Reset at the start of a pinch gesture.
const dx = e.touches[0].pageX - e.touches[1].pageX; const dx = e.touches[0].pageX - e.touches[1].pageX;
const dy = e.touches[0].pageY - e.touches[1].pageY; const dy = e.touches[0].pageY - e.touches[1].pageY;
initialPinchDistance = Math.sqrt(dx * dx + dy * dy); 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();
} }
} }
@ -555,21 +725,97 @@ function handlePinchMove(e) {
const dy = e.touches[0].pageY - e.touches[1].pageY; const dy = e.touches[0].pageY - e.touches[1].pageY;
const currentDistance = Math.sqrt(dx * dx + dy * dy); const currentDistance = Math.sqrt(dx * dx + dy * dy);
let scale = currentDistance / initialPinchDistance; let scale = currentDistance / initialPinchDistance;
scale = Math.max(0.5, Math.min(scale * initialScale, 3)); scale = Math.max(1, Math.min(scale * initialScale, 4));
// If the scale has changed enough, mark that a pinch zoom occurred. // If the scale has changed enough, mark that a pinch zoom occurred.
if (Math.abs(scale - initialScale) > 0.05) { if (Math.abs(scale - currentScale) > 0.01) {
pinchZoomOccurred = true; pinchZoomOccurred = true;
} }
currentScale = scale; currentScale = scale;
e.currentTarget.style.transform = `translate(-50%, -50%) scale(${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) { function handlePinchEnd(e) {
if (e.touches.length < 2) { if (e.touches.length === 1 && currentScale > 1.01) {
initialScale = currentScale; // Transition from pinch to one-finger pan.
initialPinchDistance = null;
isPinching = false; 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);
} }
} }
@ -674,12 +920,8 @@ function initGallery() {
initGallerySwipe(); initGallerySwipe();
initGalleryControls(); initGalleryControls();
// Close modal when clicking outside the image. galleryModal.addEventListener('click', handleGalleryScreenClick);
galleryModal.addEventListener('click', function(e) { document.addEventListener('keydown', handleGalleryKeydown);
if (e.target === galleryModal) {
closeGalleryModal();
}
});
galleryModal.dataset.galleryBound = '1'; galleryModal.dataset.galleryBound = '1';
} }

View File

@ -203,16 +203,30 @@
</div> </div>
<!-- Gallery Modal for Images --> <!-- Gallery Modal for Images -->
<div id="gallery-modal" style="display: none;"> <div id="gallery-modal" style="display: none;" role="dialog" aria-modal="true" aria-hidden="true">
<button id="gallery-close">&#x2715;</button> <div id="gallery-stage">
<button id="gallery-info" aria-expanded="false" aria-controls="gallery-exif">i</button> <img id="gallery-modal-content" src="" alt="">
<a id="gallery-download" aria-label="Download original image" title="Download original">&#8681;</a> </div>
<img id="gallery-modal-content" src="">
<button class="gallery-nav gallery-prev"></button> <div class="gallery-top-bar">
<button class="gallery-nav gallery-next"></button> <div class="gallery-actions-left">
<button id="gallery-play" aria-pressed="false" aria-label="Start slideshow">&#9654;</button> <button id="gallery-info" class="gallery-control" type="button" aria-expanded="false" aria-controls="gallery-exif">i</button>
<button id="gallery-download" class="gallery-control" type="button" aria-label="Download original image" title="Download original image"></button>
</div>
<div class="gallery-actions-right">
<button id="gallery-play" class="gallery-control" type="button" aria-pressed="false" aria-label="Start slideshow"></button>
<button id="gallery-close" class="gallery-control" type="button" aria-label="Close gallery">×</button>
</div>
</div>
<button class="gallery-nav gallery-prev" type="button" aria-label="Previous image"></button>
<button class="gallery-nav gallery-next" type="button" aria-label="Next image"></button>
<div id="gallery-exif" aria-live="polite"></div> <div id="gallery-exif" aria-live="polite"></div>
<div id="gallery-filename" aria-live="polite"></div>
<div class="gallery-meta-bar">
<div id="gallery-filename" aria-live="polite"><span class="filename-text"></span></div>
<div id="gallery-position" aria-live="polite"></div>
</div>
</div> </div>
<div id="loader-container" style="display: none;"> <div id="loader-container" style="display: none;">