diff --git a/src/assets/overlay-arrow-small-left.svg b/src/assets/overlay-arrow-small-left.svg new file mode 100644 index 0000000..33aaec2 --- /dev/null +++ b/src/assets/overlay-arrow-small-left.svg @@ -0,0 +1,5 @@ +Error +An error occurred while processing your request.

+Reference #199.85461402.1781428260.4adffb46 +

https://errors.edgesuite.net/199.85461402.1781428260.4adffb46

+ diff --git a/src/assets/overlay-arrow-small-right.svg b/src/assets/overlay-arrow-small-right.svg new file mode 100644 index 0000000..835f671 --- /dev/null +++ b/src/assets/overlay-arrow-small-right.svg @@ -0,0 +1,5 @@ +Error +An error occurred while processing your request.

+Reference #199.84461402.1781428258.69d99949 +

https://errors.edgesuite.net/199.84461402.1781428258.69d99949

+ diff --git a/src/client/app.ts b/src/client/app.ts index 44fbd18..68182dd 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -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 = '' + 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(() => { diff --git a/src/server/app-assets.ts b/src/server/app-assets.ts index 380c6a3..617470e 100644 --- a/src/server/app-assets.ts +++ b/src/server/app-assets.ts @@ -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; diff --git a/src/server/page-template.ts b/src/server/page-template.ts index cd67c29..5b9d870 100644 --- a/src/server/page-template.ts +++ b/src/server/page-template.ts @@ -140,21 +140,35 @@ ${APP_STYLES}