feat: add fullscreen photo navigation

This commit is contained in:
2026-06-14 11:16:38 +02:00
parent e32f2d8926
commit 8c84472921
5 changed files with 237 additions and 25 deletions

View File

@@ -0,0 +1,5 @@
<HTML><HEAD><TITLE>Error</TITLE></HEAD><BODY>
An error occurred while processing your request.<p>
Reference&#32;&#35;199&#46;85461402&#46;1781428260&#46;4adffb46
<P>https&#58;&#47;&#47;errors&#46;edgesuite&#46;net&#47;199&#46;85461402&#46;1781428260&#46;4adffb46</P>
</BODY></HTML>

View File

@@ -0,0 +1,5 @@
<HTML><HEAD><TITLE>Error</TITLE></HEAD><BODY>
An error occurred while processing your request.<p>
Reference&#32;&#35;199&#46;84461402&#46;1781428258&#46;69d99949
<P>https&#58;&#47;&#47;errors&#46;edgesuite&#46;net&#47;199&#46;84461402&#46;1781428258&#46;69d99949</P>
</BODY></HTML>

View File

@@ -32,7 +32,9 @@ let scheduledRenderTimer = null;
let scheduledRenderOptions = { fitMap: false };
let lastRenderAt = 0;
const SHARE_PARAM_NAME = "share";
let overlayMapView = null;
const PHOTO_PARAM_NAME = "photo";
const OVERLAY_ZOOM_LEVEL = 16;
let pendingOverlayPhotoName = "";
const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
const lightMapLayer = L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
@@ -70,6 +72,9 @@ const overlayTitle = mustGet("overlay-title");
const overlayImage = mustGet("overlay-image");
const overlayLoading = mustGet("overlay-loading");
const overlayLoadingText = mustGet("overlay-loading-text");
const overlayMedia = mustGet("overlay-media");
const overlayPrev = mustGet("overlay-prev");
const overlayNext = mustGet("overlay-next");
const closeOverlay = mustGet("close-overlay");
const routeToggle = mustGet("route-toggle");
const routeToggleIcon = mustGet("route-toggle-icon");
@@ -249,25 +254,104 @@ function setMapTheme(theme) {
});
}
function captureMapView() {
const center = map.getCenter();
return {
center: [center.lat, center.lng],
zoom: map.getZoom()
};
function getPhotoNameFromLocation() {
const value = new URL(window.location.href).searchParams.get(PHOTO_PARAM_NAME);
return value ? value.trim() : "";
}
function rememberOverlayMapView() {
overlayMapView = captureMapView();
function syncPhotoNameInLocation(photoName) {
const nextUrl = new URL(window.location.href);
const trimmed = photoName.trim();
if (trimmed) {
nextUrl.searchParams.set(PHOTO_PARAM_NAME, trimmed);
} else {
nextUrl.searchParams.delete(PHOTO_PARAM_NAME);
}
if (nextUrl.toString() !== window.location.href) {
window.history.replaceState({}, "", nextUrl);
}
}
function restoreOverlayMapView() {
if (!overlayMapView) {
function getOverlayPhotos() {
if (!state.visiblePhotos.length) {
return state.photos;
}
if (state.activePhotoId && state.visiblePhotos.some((photo) => photo.id === state.activePhotoId)) {
return state.visiblePhotos;
}
return state.photos.length ? state.photos : state.visiblePhotos;
}
function findPhotoByName(photoName, photos = state.photos) {
return photos.find((photo) => photo.name === photoName) ?? null;
}
function zoomMapToPhoto(photo) {
if (photo.latitude === null || photo.longitude === null) {
return;
}
map.setView(overlayMapView.center, overlayMapView.zoom, { animate: false });
overlayMapView = null;
const targetZoom = Math.min(18, Math.max(map.getZoom(), OVERLAY_ZOOM_LEVEL));
map.setView([photo.latitude, photo.longitude], targetZoom, { animate: false });
}
function updateOverlayNavigationState() {
const photos = getOverlayPhotos();
const hasNavigation = photos.length > 1;
overlayPrev.disabled = !hasNavigation;
overlayNext.disabled = !hasNavigation;
overlayPrev.setAttribute("aria-hidden", hasNavigation ? "false" : "true");
overlayNext.setAttribute("aria-hidden", hasNavigation ? "false" : "true");
}
function openPhotoOverlay(photo, options = {}) {
const photoName = photo.name ?? "";
pendingOverlayPhotoName = "";
state.activePhotoId = photo.id;
renderVisiblePhotos({ fitMap: false, preserveMapView: true });
updateOverlayNavigationState();
if (options.syncLocation !== false) {
syncPhotoNameInLocation(photoName);
}
if (options.zoomMap !== false) {
zoomMapToPhoto(photo);
}
openOverlay(photo);
}
function navigateOverlayPhoto(direction) {
const photos = getOverlayPhotos();
if (photos.length < 2 || !state.activePhotoId) {
return;
}
const index = photos.findIndex((photo) => photo.id === state.activePhotoId);
if (index < 0) {
return;
}
const nextIndex = (index + direction + photos.length) % photos.length;
openPhotoOverlay(photos[nextIndex]);
}
function tryOpenPendingPhoto() {
if (!pendingOverlayPhotoName || overlay.classList.contains("open")) {
return;
}
const targetPhoto = findPhotoByName(pendingOverlayPhotoName);
if (!targetPhoto) {
return;
}
openPhotoOverlay(targetPhoto);
}
function setRouteVisible(visible) {
@@ -516,7 +600,8 @@ function closeOverlayView() {
overlayLoading.classList.remove("error");
overlayLoadingText.textContent = "Loading full-size image...";
overlayImage.removeAttribute("src");
restoreOverlayMapView();
syncPhotoNameInLocation("");
overlaySwipeState = null;
}
closeOverlay.addEventListener("click", closeOverlayView);
@@ -526,6 +611,75 @@ overlay.addEventListener("click", (event) => {
}
});
overlayPrev.addEventListener("click", () => {
navigateOverlayPhoto(-1);
});
overlayNext.addEventListener("click", () => {
navigateOverlayPhoto(1);
});
let overlaySwipeState = null;
overlayMedia.addEventListener("pointerdown", (event) => {
if (!overlay.classList.contains("open") || event.button !== 0 || event.pointerType === "mouse") {
return;
}
overlaySwipeState = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
moved: false
};
overlayMedia.setPointerCapture(event.pointerId);
});
overlayMedia.addEventListener("pointermove", (event) => {
if (!overlaySwipeState || overlaySwipeState.pointerId !== event.pointerId) {
return;
}
const deltaX = event.clientX - overlaySwipeState.startX;
const deltaY = event.clientY - overlaySwipeState.startY;
overlaySwipeState.moved = overlaySwipeState.moved || Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10;
});
function finishOverlaySwipe(event) {
if (!overlaySwipeState || overlaySwipeState.pointerId !== event.pointerId) {
return;
}
try {
overlayMedia.releasePointerCapture(event.pointerId);
} catch {
// Pointer capture may already be released.
}
const deltaX = event.clientX - overlaySwipeState.startX;
const deltaY = event.clientY - overlaySwipeState.startY;
const isHorizontalSwipe = Math.abs(deltaX) > 60 && Math.abs(deltaX) > Math.abs(deltaY) * 1.2;
if (isHorizontalSwipe) {
navigateOverlayPhoto(deltaX < 0 ? 1 : -1);
}
overlaySwipeState = null;
}
overlayMedia.addEventListener("pointerup", finishOverlaySwipe);
overlayMedia.addEventListener("pointercancel", () => {
if (overlaySwipeState) {
try {
overlayMedia.releasePointerCapture(overlaySwipeState.pointerId);
} catch {
// Pointer capture may already be released.
}
}
overlaySwipeState = null;
});
function updateRouteView(photos, options = {}) {
const routePoints = photos
.filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt)
@@ -697,10 +851,7 @@ function renderVisiblePhotos(options = {}) {
marker.on("mouseover", () => marker.openPopup());
marker.on("click", () => {
state.activePhotoId = photo.id;
rememberOverlayMapView();
renderVisiblePhotos({ fitMap: false, preserveMapView: true });
openOverlay(photo);
openPhotoOverlay(photo);
});
}
@@ -712,10 +863,7 @@ function renderVisiblePhotos(options = {}) {
item.innerHTML =
'<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />';
item.addEventListener("click", () => {
state.activePhotoId = photo.id;
rememberOverlayMapView();
renderVisiblePhotos({ fitMap: false, preserveMapView: true });
openOverlay(photo);
openPhotoOverlay(photo);
});
photoList.appendChild(item);
}
@@ -723,6 +871,7 @@ function renderVisiblePhotos(options = {}) {
updateRouteView(visiblePhotos, options);
renderTimeline();
updateOverlayNavigationState();
if (options.fitMap && visiblePhotos.length) {
const bounds = visiblePhotos.filter((photo) => photo.latitude !== null && photo.longitude !== null);
@@ -916,6 +1065,7 @@ timelineClearButton.addEventListener("click", clearTimelineSelection);
function appendPhoto(photo) {
state.photos.push(photo);
scheduleVisiblePhotoRender({ fitMap: state.photos.length === 1 });
tryOpenPendingPhoto();
}
async function loadImportListing(shareUrlValue, signal) {
@@ -1076,6 +1226,7 @@ stopImportButton.addEventListener("click", () => {
});
const initialShareUrl = getShareUrlFromLocation();
pendingOverlayPhotoName = getPhotoNameFromLocation();
if (initialShareUrl) {
importUrlInput.value = initialShareUrl;
window.queueMicrotask(() => {

View File

@@ -621,6 +621,8 @@ export const APP_STYLES = ` :root {
border: 0;
box-shadow: none;
color: var(--timeline-text);
touch-action: none;
user-select: none;
}
.overlay-media {
@@ -728,6 +730,41 @@ export const APP_STYLES = ` :root {
border-color: var(--timeline-border-strong);
}
.overlay-nav {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 1001;
width: 44px;
height: 44px;
padding: 0;
justify-content: center;
background: var(--timeline-surface-soft);
color: var(--timeline-text);
border: 1px solid var(--timeline-border);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(14px);
}
.overlay-nav:hover {
background: var(--timeline-surface-strong);
border-color: var(--timeline-border-strong);
}
.overlay-prev {
left: 16px;
}
.overlay-next {
right: 16px;
}
.overlay-nav[disabled] {
opacity: 0.45;
cursor: default;
pointer-events: none;
}
.thumb {
width: min(210px, 100%);
box-sizing: border-box;

View File

@@ -140,21 +140,35 @@ ${APP_STYLES}
</section>
<div class="overlay" id="overlay" aria-hidden="true">
<button class="overlay-nav overlay-prev" id="overlay-prev" type="button" aria-label="Previous photo">
<span class="button-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5 4 5.5 8l4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</button>
<button class="close overlay-close" id="close-overlay" type="button" aria-label="Close photo">
x
</button>
<div class="overlay-card">
<div class="overlay-media">
<div class="overlay-card" id="overlay-card">
<div class="overlay-media" id="overlay-media">
<div class="overlay-loading" id="overlay-loading" aria-live="polite" aria-atomic="true">
<span class="spinner active" aria-hidden="true"></span>
<strong id="overlay-loading-text">Loading full-size image...</strong>
</div>
<img id="overlay-image" alt="" />
</div>
<footer class="overlay-caption">
<footer class="overlay-caption" id="overlay-caption">
<strong id="overlay-title">Photo</strong>
</footer>
</div>
<button class="overlay-nav overlay-next" id="overlay-next" type="button" aria-label="Next photo">
<span class="button-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 4 10.5 8l-4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</button>
</div>
<script src="/vendor/leaflet/leaflet.js"></script>