From f061dd2f38221e5ae5a43032fa4e806bc477e6e5 Mon Sep 17 00:00:00 2001 From: Arne Baeumler Date: Sat, 13 Jun 2026 08:22:11 +0200 Subject: [PATCH] refactor: modularize browser client --- src/client/app.ts | 1023 ++++++++++++++++++++++++++ src/client/map-view.ts | 17 + src/client/nextcloud-import.ts | 53 ++ src/client/timeline.ts | 145 ++++ src/server/app-assets.ts | 1222 -------------------------------- src/server/client-assets.ts | 30 + src/server/page-template.ts | 6 +- src/server/request-handler.ts | 9 + 8 files changed, 1279 insertions(+), 1226 deletions(-) create mode 100644 src/client/app.ts create mode 100644 src/client/map-view.ts create mode 100644 src/client/nextcloud-import.ts create mode 100644 src/client/timeline.ts create mode 100644 src/server/client-assets.ts diff --git a/src/client/app.ts b/src/client/app.ts new file mode 100644 index 0000000..ec0c9f4 --- /dev/null +++ b/src/client/app.ts @@ -0,0 +1,1023 @@ +// @ts-nocheck +import exifr from "https://cdn.jsdelivr.net/npm/exifr@7.1.3/+esm"; +import { createPhotoMarkerIcon } from "./map-view.js"; +import { parseListing, resolveDavBaseUrl } from "./nextcloud-import.js"; +import { + buildTimeline, + formatTimelineSummary, + getPhotoTimestamp +} from "./timeline.js"; + +const state = { + photos: [], + visiblePhotos: [], + objectUrls: [], + processed: 0, + total: 0, + activePhotoId: null, + timelineSelection: null, + timelineViewport: null, + timeline: { + intervalMs: 15 * 60 * 1000, + bins: [], + start: null, + end: null + } +}; +let activeImportController = null; +const IMPORT_RENDER_INTERVAL_MS = 180; +let scheduledRenderFrame = null; +let scheduledRenderTimer = null; +let scheduledRenderOptions = { fitMap: false }; +let lastRenderAt = 0; +const SHARE_PARAM_NAME = "share"; + +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", { + maxZoom: 20, + subdomains: "abcd", + attribution: "© OpenStreetMap contributors © CARTO" +}); +const darkMapLayer = L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", { + maxZoom: 20, + subdomains: "abcd", + attribution: "© OpenStreetMap contributors © CARTO" +}); +const mapLayers = { + light: lightMapLayer, + dark: darkMapLayer +}; +let activeMapLayer = lightMapLayer; +activeMapLayer.addTo(map); + +const markers = L.layerGroup().addTo(map); +const route = L.polyline([], { + color: "#2d6cdf", + weight: 4, + opacity: 0.85 +}); +const routeColors = { + light: "#2d6cdf", + dark: "#7dd3fc" +}; +const ROUTE_VISIBLE_STORAGE_KEY = "routeVisible"; +let routeVisible = localStorage.getItem(ROUTE_VISIBLE_STORAGE_KEY) !== "false"; + +const overlay = mustGet("overlay"); +const overlayTitle = mustGet("overlay-title"); +const overlayImage = mustGet("overlay-image"); +const overlayLoading = mustGet("overlay-loading"); +const overlayLoadingText = mustGet("overlay-loading-text"); +const closeOverlay = mustGet("close-overlay"); +const routeToggle = mustGet("route-toggle"); +const routeToggleIcon = mustGet("route-toggle-icon"); +const photoList = mustGet("photo-list"); +const emptyState = mustGet("empty-state"); +const photoCount = mustGet("photo-count"); +const progressFill = mustGet("progress-fill"); +const progressText = mustGet("progress-text"); +const progressDetail = mustGet("progress-detail"); +const activitySpinner = mustGet("activity-spinner"); +const timelineSummary = mustGet("timeline-summary"); +const timelineClearButton = mustGet("timeline-clear"); +const timelineChart = mustGet("timeline-chart"); +const timelineBars = mustGet("timeline-bars"); +const timelineSelection = mustGet("timeline-selection"); +const timelineBrush = mustGet("timeline-brush"); +const themeToggle = mustGet("theme-toggle"); +const themeToggleIcon = mustGet("theme-toggle-icon"); +const sidebarCollapseButton = mustGet("sidebar-collapse"); +const sidebarCollapseIcon = mustGet("sidebar-collapse-icon"); +const timelineCollapseButton = mustGet("timeline-collapse"); +const timelineCollapseIcon = mustGet("timeline-collapse-icon"); +const importUrlInput = mustGet("share-url"); +const importStatus = mustGet("share-status"); +const importButton = mustGet("load-share"); +const stopImportButton = mustGet("cancel-share"); +let overlayLoadToken = 0; + +function mustGet(id) { + const element = document.getElementById(id); + if (!element) { + throw new Error("Missing UI element: " + id); + } + return element; +} + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[character]); +} + +function buildRemoteImageUrl(shareUrlValue, entryHref) { + const params = new URLSearchParams({ + share: shareUrlValue.trim(), + url: entryHref + }); + return "/api/nextcloud/blob?" + params.toString(); +} + +async function loadImageElement(blob) { + const objectUrl = URL.createObjectURL(blob); + const image = new Image(); + + try { + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Could not decode image.")); + image.src = objectUrl; + }); + return image; + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +async function createThumbnailObjectUrl(blob) { + const maxDimension = 320; + const quality = 0.82; + let width = 0; + let height = 0; + let drawSource = null; + let closeBitmap = null; + let sourceImage = null; + + if (typeof createImageBitmap === "function") { + const bitmap = await createImageBitmap(blob); + width = bitmap.width; + height = bitmap.height; + drawSource = bitmap; + closeBitmap = () => bitmap.close(); + } else { + sourceImage = await loadImageElement(blob); + width = sourceImage.naturalWidth; + height = sourceImage.naturalHeight; + drawSource = sourceImage; + } + + const scale = Math.min(1, maxDimension / Math.max(width, height)); + const targetWidth = Math.max(1, Math.round(width * scale)); + const targetHeight = Math.max(1, Math.round(height * scale)); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + + if (!context) { + if (closeBitmap) { + closeBitmap(); + } + throw new Error("Canvas 2D context unavailable."); + } + + canvas.width = targetWidth; + canvas.height = targetHeight; + context.drawImage(drawSource, 0, 0, targetWidth, targetHeight); + + const thumbnailBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => { + if (result) { + resolve(result); + return; + } + reject(new Error("Could not create thumbnail.")); + }, + "image/jpeg", + quality + ); + }); + + if (closeBitmap) { + closeBitmap(); + } + + if (sourceImage) { + sourceImage.removeAttribute("src"); + } + + return URL.createObjectURL(thumbnailBlob); +} + +function setTheme(theme, persist = true) { + const resolvedTheme = theme === "light" ? "light" : "dark"; + document.body.dataset.theme = resolvedTheme; + if (persist) { + localStorage.setItem("theme", resolvedTheme); + } + setMapTheme(resolvedTheme); + + if (resolvedTheme === "dark") { + themeToggle.setAttribute("aria-label", "Switch to light mode"); + themeToggleIcon.innerHTML = + '' + + '' + + ""; + } else { + themeToggle.setAttribute("aria-label", "Switch to dark mode"); + themeToggleIcon.innerHTML = + '' + + '' + + '' + + ""; + } +} + +function setMapTheme(theme) { + const resolvedTheme = theme === "dark" ? "dark" : "light"; + const nextMapLayer = mapLayers[resolvedTheme]; + + if (activeMapLayer !== nextMapLayer) { + map.removeLayer(activeMapLayer); + activeMapLayer = nextMapLayer; + activeMapLayer.addTo(map); + } + + route.setStyle({ + color: routeColors[resolvedTheme] + }); +} + +function setRouteVisible(visible) { + routeVisible = Boolean(visible); + localStorage.setItem(ROUTE_VISIBLE_STORAGE_KEY, routeVisible ? "true" : "false"); + + if (routeVisible) { + if (!map.hasLayer(route)) { + route.addTo(map); + } + routeToggle.setAttribute("aria-label", "Hide route line"); + routeToggleIcon.innerHTML = + '' + + '' + + '' + + '' + + ""; + } else { + if (map.hasLayer(route)) { + map.removeLayer(route); + } + routeToggle.setAttribute("aria-label", "Show route line"); + routeToggleIcon.innerHTML = + '' + + '' + + '' + + '' + + '' + + ""; + } +} + +function scheduleMapResize() { + window.clearTimeout(scheduleMapResize.timeoutId); + scheduleMapResize.timeoutId = window.setTimeout(() => { + map.invalidateSize(); + }, 220); +} +scheduleMapResize.timeoutId = 0; + +function setSidebarCollapsed(collapsed) { + const isCollapsed = Boolean(collapsed); + document.querySelector("aside")?.classList.toggle("sidebar-collapsed", isCollapsed); + localStorage.setItem("sidebarCollapsed", isCollapsed ? "true" : "false"); + sidebarCollapseButton.setAttribute("aria-label", isCollapsed ? "Expand left panel" : "Collapse left panel"); + sidebarCollapseIcon.innerHTML = isCollapsed + ? '' + : ''; + scheduleMapResize(); +} + +function setTimelineCollapsed(collapsed) { + const isCollapsed = Boolean(collapsed); + document.querySelector(".timeline")?.classList.toggle("timeline-collapsed", isCollapsed); + localStorage.setItem("timelineCollapsed", isCollapsed ? "true" : "false"); + timelineCollapseButton.setAttribute("aria-label", isCollapsed ? "Expand timeline" : "Collapse timeline"); + timelineCollapseIcon.innerHTML = isCollapsed + ? '' + : ''; + scheduleMapResize(); +} + +function getShareUrlFromLocation() { + const value = new URL(window.location.href).searchParams.get(SHARE_PARAM_NAME); + return value ? value.trim() : ""; +} + +function syncShareUrlInLocation(shareUrlValue) { + const nextUrl = new URL(window.location.href); + const trimmed = shareUrlValue.trim(); + if (trimmed) { + nextUrl.searchParams.set(SHARE_PARAM_NAME, trimmed); + } else { + nextUrl.searchParams.delete(SHARE_PARAM_NAME); + } + + if (nextUrl.toString() !== window.location.href) { + window.history.replaceState({}, "", nextUrl); + } +} + +const themeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); +const storedTheme = localStorage.getItem("theme"); +const initialTheme = storedTheme === "dark" || (storedTheme !== "light" && themeMediaQuery.matches) ? "dark" : "light"; +setTheme(initialTheme, false); +themeToggle.addEventListener("click", () => { + setTheme(document.body.dataset.theme === "dark" ? "light" : "dark"); +}); +if (storedTheme === null) { + themeMediaQuery.addEventListener("change", (event) => { + setTheme(event.matches ? "dark" : "light", false); + }); +} +setRouteVisible(routeVisible); +routeToggle.addEventListener("click", () => { + setRouteVisible(!routeVisible); +}); + +const storedSidebarCollapsed = localStorage.getItem("sidebarCollapsed") === "true"; +setSidebarCollapsed(storedSidebarCollapsed); +sidebarCollapseButton.addEventListener("click", () => { + setSidebarCollapsed(!document.querySelector("aside")?.classList.contains("sidebar-collapsed")); +}); + +setTimelineCollapsed(false); +timelineCollapseButton.addEventListener("click", () => { + setTimelineCollapsed(!document.querySelector(".timeline")?.classList.contains("timeline-collapsed")); +}); + +function formatDate(value) { + if (!value) { + return "no timestamp"; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : date.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }); +} + +function normalizeExifDate(value) { + if (typeof value !== "string") { + return null; + } + const match = value.match(/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!match) { + return null; + } + const [, year, month, day, hour, minute, second] = match; + const date = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function parseDateOrNull(value) { + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function setImportStatus(message, tone = "info") { + importStatus.textContent = message; + importStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)"; +} + +function setProgress(processed, total, detail) { + state.processed = processed; + state.total = total; + progressFill.style.width = total > 0 ? Math.round((processed / total) * 100) + "%" : "0%"; + progressText.textContent = processed + " / " + total; + progressDetail.textContent = detail; +} + +function setImporting(isImporting) { + activitySpinner.classList.toggle("active", isImporting); + importButton.disabled = isImporting; + stopImportButton.disabled = !isImporting; +} + +function clearObjectUrls() { + for (const url of state.objectUrls) { + URL.revokeObjectURL(url); + } + state.objectUrls = []; +} + +function clearGallery() { + cancelScheduledRender(); + closeOverlayView(); + state.photos = []; + state.visiblePhotos = []; + state.activePhotoId = null; + state.timelineSelection = null; + state.timelineViewport = null; + state.timeline = { + intervalMs: 15 * 60 * 1000, + bins: [], + start: null, + end: null + }; + markers.clearLayers(); + route.setLatLngs([]); + photoList.replaceChildren(emptyState); + emptyState.textContent = "No images imported yet."; + photoCount.textContent = "0 photos"; + timelineSummary.textContent = "No photos imported yet."; + timelineClearButton.disabled = true; + timelineBars.replaceChildren(); + timelineSelection.classList.remove("visible"); + timelineSelection.style.left = "0"; + timelineSelection.style.width = "0"; + timelineBrush.classList.remove("visible"); + setProgress(0, 0, "idle"); +} + +function openOverlay(photo) { + const requestToken = ++overlayLoadToken; + overlay.classList.add("open"); + overlay.setAttribute("aria-hidden", "false"); + overlayTitle.textContent = photo.name; + overlayImage.alt = photo.name; + overlay.classList.add("loading"); + overlayLoading.classList.add("active"); + overlayLoading.classList.remove("error"); + overlayLoadingText.textContent = "Loading full-size image..."; + overlayImage.onload = () => { + if (requestToken !== overlayLoadToken) { + return; + } + overlay.classList.remove("loading"); + overlayLoading.classList.remove("active"); + overlayLoading.classList.remove("error"); + }; + overlayImage.onerror = () => { + if (requestToken !== overlayLoadToken) { + return; + } + overlayLoadingText.textContent = "Could not load full-size image."; + overlay.classList.remove("loading"); + overlayLoading.classList.add("error"); + overlayLoading.classList.add("active"); + }; + overlayImage.removeAttribute("src"); + overlayImage.src = photo.fullUrl; +} + +function closeOverlayView() { + overlayLoadToken += 1; + overlay.classList.remove("open"); + overlay.setAttribute("aria-hidden", "true"); + overlay.classList.remove("loading"); + overlayLoading.classList.remove("active"); + overlayLoading.classList.remove("error"); + overlayLoadingText.textContent = "Loading full-size image..."; + overlayImage.removeAttribute("src"); +} + +closeOverlay.addEventListener("click", closeOverlayView); +overlay.addEventListener("click", (event) => { + if (event.target === overlay) { + closeOverlayView(); + } +}); + +function updateRouteView(photos) { + const routePoints = photos + .filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt) + .sort((a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt))) + .map((photo) => [photo.latitude, photo.longitude]); + + route.setLatLngs(routePoints); + + if (routePoints.length === 1) { + map.setView(routePoints[0], 13); + } else if (routePoints.length > 1) { + map.fitBounds(route.getBounds(), { padding: [40, 40] }); + } +} + +function isPhotoInSelection(photo, selection = state.timelineSelection) { + if (!selection) { + return true; + } + + const photoDate = getPhotoTimestamp(photo); + if (!photoDate) { + return false; + } + + return photoDate.getTime() >= selection.start && photoDate.getTime() < selection.end; +} + +function getVisiblePhotos() { + return state.photos.filter((photo) => isPhotoInSelection(photo)); +} + +function syncTimelineSelectionOverlay() { + const selection = state.timelineSelection; + if (!selection || !state.timeline.start || !state.timeline.end) { + timelineSelection.classList.remove("visible"); + timelineSelection.style.left = "0"; + timelineSelection.style.width = "0"; + return; + } + + const total = state.timeline.end.getTime() - state.timeline.start.getTime(); + if (total <= 0) { + timelineSelection.classList.remove("visible"); + return; + } + + const left = clamp((selection.start - state.timeline.start.getTime()) / total, 0, 1); + const right = clamp((selection.end - state.timeline.start.getTime()) / total, 0, 1); + const startPct = Math.min(left, right) * 100; + const widthPct = Math.max(1, (Math.max(left, right) - Math.min(left, right)) * 100); + timelineSelection.classList.add("visible"); + timelineSelection.style.left = startPct + "%"; + timelineSelection.style.width = widthPct + "%"; +} + +function setTimelineSelection(start, end) { + if (start === null || end === null) { + state.timelineSelection = null; + state.timelineViewport = null; + timelineClearButton.disabled = true; + } else { + const normalizedStart = Math.min(start, end); + const normalizedEnd = Math.max(start, end); + state.timelineSelection = { + start: normalizedStart, + end: normalizedEnd + }; + state.timelineViewport = { + start: normalizedStart, + end: normalizedEnd + }; + timelineClearButton.disabled = false; + } + + renderVisiblePhotos({ fitMap: true }); +} + +function clearTimelineSelection() { + setTimelineSelection(null, null); +} + +function renderTimeline() { + const timelinePhotos = state.timelineSelection ? state.visiblePhotos : state.photos; + const timeline = buildTimeline(timelinePhotos, state.timelineViewport); + state.timeline = timeline; + timelineSummary.textContent = formatTimelineSummary(state.photos); + + timelineBars.replaceChildren(); + + if (!timeline.bins.length) { + syncTimelineSelectionOverlay(); + return; + } + + const maxCount = Math.max(...timeline.bins.map((bin) => bin.count), 1); + const labelStep = Math.max(1, Math.ceil(timeline.bins.length / 6)); + + timeline.bins.forEach((bin, index) => { + const bar = document.createElement("button"); + const isSelected = state.timelineSelection + ? bin.end.getTime() > state.timelineSelection.start && bin.start.getTime() < state.timelineSelection.end + : false; + const isActive = state.activePhotoId ? bin.photoIds.includes(state.activePhotoId) : false; + + bar.type = "button"; + bar.className = "timeline-bar" + (isSelected ? " selected" : "") + (isActive ? " active" : ""); + bar.title = bin.label + " - " + bin.count + (bin.count === 1 ? " photo" : " photos"); + bar.style.gridColumn = String(index + 1); + + const fill = document.createElement("div"); + fill.className = "timeline-bar-fill"; + fill.style.height = Math.max(4, Math.round((bin.count / maxCount) * 100)) + "%"; + bar.appendChild(fill); + + const meta = document.createElement("span"); + meta.className = "timeline-bar-meta"; + meta.textContent = index % labelStep === 0 || index === timeline.bins.length - 1 ? bin.label : ""; + bar.appendChild(meta); + + bar.addEventListener("click", () => { + if (timelineClickSuppressed) { + return; + } + setTimelineSelection(bin.start.getTime(), bin.end.getTime()); + }); + + timelineBars.appendChild(bar); + }); + + syncTimelineSelectionOverlay(); +} + +function renderVisiblePhotos(options = {}) { + const visiblePhotos = getVisiblePhotos(); + state.visiblePhotos = visiblePhotos; + markers.clearLayers(); + route.setLatLngs([]); + photoList.replaceChildren(); + + if (!visiblePhotos.length) { + emptyState.textContent = state.photos.length + ? "No photos match the selected range." + : "No images imported yet."; + photoList.appendChild(emptyState); + photoCount.textContent = "0 photos"; + } else { + emptyState.textContent = "No images imported yet."; + photoCount.textContent = + visiblePhotos.length + (visiblePhotos.length === 1 ? " photo shown" : " photos shown"); + + for (const photo of visiblePhotos) { + if (photo.latitude !== null && photo.longitude !== null) { + const marker = L.marker([photo.latitude, photo.longitude], { + icon: createPhotoMarkerIcon(L, photo, state.activePhotoId, escapeHtml) + }).addTo(markers); + + marker.bindPopup( + '
' + + '' + escapeHtml(photo.name) + '' + + '' + escapeHtml(photo.name) + '' + + '' + escapeHtml(formatDate(photo.capturedAt)) + '' + + '
' + ); + + marker.on("mouseover", () => marker.openPopup()); + marker.on("click", () => { + state.activePhotoId = photo.id; + renderVisiblePhotos({ fitMap: false }); + openOverlay(photo); + }); + } + + const item = document.createElement("article"); + item.className = "photo" + (photo.id === state.activePhotoId ? " active" : ""); + const photoTitle = photo.name + " - " + formatDate(photo.capturedAt); + item.title = photoTitle; + item.setAttribute("aria-label", photoTitle); + item.innerHTML = + '' + escapeHtml(photo.name) + ''; + item.addEventListener("click", () => { + state.activePhotoId = photo.id; + renderVisiblePhotos({ fitMap: false }); + openOverlay(photo); + }); + photoList.appendChild(item); + } + } + + updateRouteView(visiblePhotos); + renderTimeline(); + + if (options.fitMap && visiblePhotos.length) { + const bounds = visiblePhotos.filter((photo) => photo.latitude !== null && photo.longitude !== null); + if (bounds.length === 1) { + map.setView([bounds[0].latitude, bounds[0].longitude], 13); + } else if (bounds.length > 1) { + map.fitBounds(bounds.map((photo) => [photo.latitude, photo.longitude]), { padding: [40, 40] }); + } + } +} + +function runScheduledRender() { + scheduledRenderFrame = null; + const options = scheduledRenderOptions; + scheduledRenderOptions = { fitMap: false }; + lastRenderAt = performance.now(); + renderVisiblePhotos(options); +} + +function scheduleVisiblePhotoRender(options = {}) { + scheduledRenderOptions = { + fitMap: Boolean(scheduledRenderOptions.fitMap || options.fitMap) + }; + + if (scheduledRenderFrame !== null || scheduledRenderTimer !== null) { + return; + } + + const elapsed = performance.now() - lastRenderAt; + const delay = Math.max(0, IMPORT_RENDER_INTERVAL_MS - elapsed); + const queueFrame = () => { + scheduledRenderTimer = null; + scheduledRenderFrame = requestAnimationFrame(runScheduledRender); + }; + + if (delay === 0) { + queueFrame(); + } else { + scheduledRenderTimer = setTimeout(queueFrame, delay); + } +} + +function cancelScheduledRender() { + if (scheduledRenderFrame !== null) { + cancelAnimationFrame(scheduledRenderFrame); + scheduledRenderFrame = null; + } + + if (scheduledRenderTimer !== null) { + clearTimeout(scheduledRenderTimer); + scheduledRenderTimer = null; + } + + scheduledRenderOptions = { fitMap: false }; +} + +function flushVisiblePhotoRender(options = {}) { + const renderOptions = { + fitMap: Boolean(scheduledRenderOptions.fitMap || options.fitMap) + }; + cancelScheduledRender(); + lastRenderAt = performance.now(); + renderVisiblePhotos(renderOptions); +} + +let timelinePointerState = null; +let timelineClickSuppressed = false; + +function updateTimelineBrushOverlay(startX, endX) { + if (!state.timeline.start || !state.timeline.end) { + timelineBrush.classList.remove("visible"); + return; + } + + const width = timelineChart.getBoundingClientRect().width; + if (width <= 0) { + timelineBrush.classList.remove("visible"); + return; + } + + const leftX = clamp(Math.min(startX, endX), 0, width); + const rightX = clamp(Math.max(startX, endX), 0, width); + timelineBrush.classList.add("visible"); + timelineBrush.style.left = leftX + "px"; + timelineBrush.style.width = Math.max(1, rightX - leftX) + "px"; +} + +function clearTimelineBrushOverlay() { + timelineBrush.classList.remove("visible"); + timelineBrush.style.left = "0"; + timelineBrush.style.width = "0"; +} + +function timeFromTimelineX(x) { + if (!state.timeline.start || !state.timeline.end) { + return null; + } + + const width = timelineChart.getBoundingClientRect().width; + if (width <= 0) { + return null; + } + + const ratio = clamp(x / width, 0, 1); + const time = state.timeline.start.getTime() + ratio * (state.timeline.end.getTime() - state.timeline.start.getTime()); + return new Date(time); +} + +function selectTimelineBinAtX(x) { + if (!state.timeline.bins.length || !state.timeline.start || !state.timeline.end) { + return; + } + + const width = timelineChart.getBoundingClientRect().width; + if (width <= 0) { + return; + } + + const ratio = clamp(x / width, 0, 1); + const index = clamp(Math.floor(ratio * state.timeline.bins.length), 0, state.timeline.bins.length - 1); + const bin = state.timeline.bins[index]; + setTimelineSelection(bin.start.getTime(), bin.end.getTime()); +} + +timelineChart.addEventListener("pointerdown", (event) => { + if (!state.timeline.bins.length) { + return; + } + + const rect = timelineChart.getBoundingClientRect(); + const startX = event.clientX - rect.left; + timelinePointerState = { + pointerId: event.pointerId, + startX, + currentX: startX, + moved: false + }; + + timelineChart.setPointerCapture(event.pointerId); + updateTimelineBrushOverlay(startX, startX); +}); + +timelineChart.addEventListener("pointermove", (event) => { + if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) { + return; + } + + const rect = timelineChart.getBoundingClientRect(); + const currentX = event.clientX - rect.left; + timelinePointerState.currentX = currentX; + timelinePointerState.moved = timelinePointerState.moved || Math.abs(currentX - timelinePointerState.startX) > 4; + updateTimelineBrushOverlay(timelinePointerState.startX, currentX); +}); + +function finishTimelinePointer(event) { + if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) { + return; + } + + try { + timelineChart.releasePointerCapture(event.pointerId); + } catch { + // Pointer capture may already be released. + } + + if (timelinePointerState.moved) { + const startTime = timeFromTimelineX(timelinePointerState.startX); + const endTime = timeFromTimelineX(timelinePointerState.currentX); + if (startTime && endTime) { + setTimelineSelection(startTime.getTime(), endTime.getTime()); + } + timelineClickSuppressed = true; + setTimeout(() => { + timelineClickSuppressed = false; + }, 0); + } else { + selectTimelineBinAtX(timelinePointerState.currentX); + } + + clearTimelineBrushOverlay(); + timelinePointerState = null; +} + +timelineChart.addEventListener("pointerup", finishTimelinePointer); +timelineChart.addEventListener("pointercancel", finishTimelinePointer); + +timelineClearButton.addEventListener("click", clearTimelineSelection); + +function appendPhoto(photo) { + state.photos.push(photo); + scheduleVisiblePhotoRender({ fitMap: state.photos.length === 1 }); +} + +async function loadImportListing(shareUrlValue, signal) { + const davBaseUrl = resolveDavBaseUrl(shareUrlValue); + const response = await fetch( + "/api/nextcloud/list?share=" + encodeURIComponent(shareUrlValue.trim()), + { + signal + } + ); + + if (!response.ok) { + throw new Error("Could not load the Nextcloud listing: " + response.status); + } + + return parseListing(await response.text(), davBaseUrl); +} + +async function readRemoteImage(entry, shareUrlValue, signal) { + const fullUrl = buildRemoteImageUrl(shareUrlValue, entry.href); + const response = await fetch(fullUrl, { + signal + }); + if (!response.ok) { + throw new Error("Could not load image: " + entry.name); + } + + const blob = await response.blob(); + const [gps, tags] = await Promise.all([exifr.gps(blob), exifr.parse(blob)]); + const capturedAt = + normalizeExifDate(tags?.DateTimeOriginal?.value ?? tags?.DateTimeOriginal) || + parseDateOrNull(entry.lastModified); + const thumbUrl = await createThumbnailObjectUrl(blob); + state.objectUrls.push(thumbUrl); + + return { + id: entry.href, + name: entry.name, + latitude: gps?.latitude ?? null, + longitude: gps?.longitude ?? null, + capturedAt, + thumbUrl, + fullUrl, + source: "nextcloud" + }; +} + +async function importFromShare() { + if (activeImportController) { + return; + } + + syncShareUrlInLocation(importUrlInput.value); + const controller = new AbortController(); + activeImportController = controller; + setImporting(true); + + try { + clearObjectUrls(); + clearGallery(); + setImportStatus("Importing..."); + const listing = await loadImportListing(importUrlInput.value, controller.signal); + + if (!listing.length) { + throw new Error("No images found in the share."); + } + + setProgress(0, listing.length, "checking files"); + + let loaded = 0; + let skipped = 0; + let processed = 0; + + for (const entry of listing) { + if (controller.signal.aborted) { + throw controller.signal.reason ?? new Error("Import canceled"); + } + + try { + const photo = await readRemoteImage(entry, importUrlInput.value, controller.signal); + if (photo.latitude !== null && photo.longitude !== null) { + appendPhoto(photo); + loaded += 1; + } else { + skipped += 1; + } + } catch (error) { + skipped += 1; + console.error(error); + } + + processed += 1; + setProgress(processed, listing.length, "processed " + processed + " / " + listing.length); + setImportStatus("Importing: " + loaded + " shown, " + skipped + " skipped."); + } + + if (!loaded) { + throw new Error("No images with GPS data found."); + } + + setImportStatus( + "Import complete: " + loaded + " images imported" + (skipped ? ", " + skipped + " skipped" : "") + "." + ); + setProgress(listing.length, listing.length, "complete"); + flushVisiblePhotoRender({ fitMap: true }); + } catch (error) { + if (controller.signal.aborted) { + flushVisiblePhotoRender(); + setImportStatus("Import stopped."); + setProgress(state.processed, state.total, "canceled"); + return; + } + + console.error(error); + setImportStatus( + "Import failed: " + (error instanceof Error ? error.message : "unknown error"), + "error" + ); + flushVisiblePhotoRender(); + } finally { + activeImportController = null; + setImporting(false); + } +} + +importButton.addEventListener("click", () => { + void importFromShare(); +}); + +stopImportButton.addEventListener("click", () => { + if (activeImportController) { + activeImportController.abort("Import canceled by user"); + } +}); + +const initialShareUrl = getShareUrlFromLocation(); +if (initialShareUrl) { + importUrlInput.value = initialShareUrl; + window.queueMicrotask(() => { + void importFromShare(); + }); +} + +clearGallery(); +setImportStatus("Ready to import."); diff --git a/src/client/map-view.ts b/src/client/map-view.ts new file mode 100644 index 0000000..6105274 --- /dev/null +++ b/src/client/map-view.ts @@ -0,0 +1,17 @@ +type MarkerPhoto = { + id: string; + name: string; + thumbUrl: string; +}; + +export function createPhotoMarkerIcon(leaflet: any, photo: MarkerPhoto, activePhotoId: string | null, escapeHtml: (value: unknown) => string) { + const activeClass = photo.id === activePhotoId ? " active" : ""; + return leaflet.divIcon({ + className: "photo-map-marker" + activeClass, + html: + '' + escapeHtml(photo.name) + '', + iconSize: [44, 44], + iconAnchor: [22, 22], + popupAnchor: [0, -22] + }); +} diff --git a/src/client/nextcloud-import.ts b/src/client/nextcloud-import.ts new file mode 100644 index 0000000..7f73bb9 --- /dev/null +++ b/src/client/nextcloud-import.ts @@ -0,0 +1,53 @@ +function textContent(node: Element, namespace: string, localName: string): string { + const element = node.getElementsByTagNameNS(namespace, localName)[0]; + return element ? element.textContent?.trim() ?? "" : ""; +} + +export function resolveDavBaseUrl(shareUrlValue: string): string { + const url = new URL(shareUrlValue.trim()); + const publicShare = url.pathname.match(/\/s\/([^/?#]+)/); + if (publicShare) { + return url.origin + "/public.php/dav/files/" + publicShare[1] + "/"; + } + + const davShare = url.pathname.match(/\/public\.php\/dav\/files\/([^/?#]+)\/?/); + if (davShare) { + return url.origin + "/public.php/dav/files/" + davShare[1] + "/"; + } + + throw new Error("Please enter a public Nextcloud share link."); +} + +export function parseListing(xmlText: string, baseUrl: string) { + const documentNode = new DOMParser().parseFromString(xmlText, "application/xml"); + const responses = Array.from(documentNode.getElementsByTagNameNS("DAV:", "response")); + const entries = responses + .map((response) => { + const href = textContent(response, "DAV:", "href"); + const prop = response.getElementsByTagNameNS("DAV:", "prop")[0]; + if (!href || !prop) { + return null; + } + + const contentType = textContent(prop, "DAV:", "getcontenttype"); + const lastModified = textContent(prop, "DAV:", "getlastmodified") || null; + const displayName = textContent(prop, "DAV:", "displayname"); + const isCollection = prop.getElementsByTagNameNS("DAV:", "collection").length > 0; + const absoluteUrl = new URL(href, baseUrl).toString(); + + return { + href: absoluteUrl, + name: displayName || decodeURIComponent(absoluteUrl.split("/").pop() || "image"), + lastModified, + contentType, + isCollection + }; + }) + .filter((entry) => entry !== null); + + return entries.filter((entry) => { + const resolved = entry.href.toLowerCase(); + const imageLike = entry.contentType.startsWith("image/") || /\.(jpe?g|png|gif|webp|heic|heif|tiff?|avif)$/i.test(resolved); + return !entry.isCollection && imageLike; + }); +} diff --git a/src/client/timeline.ts b/src/client/timeline.ts new file mode 100644 index 0000000..029f691 --- /dev/null +++ b/src/client/timeline.ts @@ -0,0 +1,145 @@ +type TimelinePhoto = { + id: string; + capturedAt: string | null; +}; + +type TimelineViewport = { + start: number; + end: number; +} | null; + +type TimelineBin = { + start: Date; + end: Date; + count: number; + photoIds: string[]; + label: string; +}; + +export function getPhotoTimestamp(photo: TimelinePhoto): Date | null { + if (!photo.capturedAt) { + return null; + } + + const date = new Date(photo.capturedAt); + return Number.isNaN(date.getTime()) ? null : date; +} + +function getTimelineIntervalMs(spanMs: number): number { + const minute = 60 * 1000; + const hour = 60 * minute; + + if (spanMs <= 12 * hour) { + return 5 * minute; + } + + if (spanMs <= 2 * 24 * hour) { + return 15 * minute; + } + + if (spanMs <= 7 * 24 * hour) { + return 30 * minute; + } + + if (spanMs <= 21 * 24 * hour) { + return 60 * minute; + } + + if (spanMs <= 60 * 24 * hour) { + return 2 * hour; + } + + return 4 * hour; +} + +function floorToInterval(date: Date, intervalMs: number): Date { + return new Date(Math.floor(date.getTime() / intervalMs) * intervalMs); +} + +function ceilToInterval(date: Date, intervalMs: number): Date { + return new Date(Math.ceil(date.getTime() / intervalMs) * intervalMs); +} + +function formatTimelineLabel(date: Date): string { + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }); +} + +export function formatTimelineSummary(photos: TimelinePhoto[]): string { + if (!photos.length) { + return "No photos imported yet."; + } + + const count = photos.length + (photos.length === 1 ? " photo" : " photos"); + return count + " across a compact timeline."; +} + +export function buildTimeline(photos: TimelinePhoto[], viewport: TimelineViewport = null) { + const datedPhotos = photos + .map((photo) => ({ photo, date: getPhotoTimestamp(photo) })) + .filter((entry): entry is { photo: TimelinePhoto; date: Date } => entry.date !== null) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + + if (!datedPhotos.length) { + return { + intervalMs: 15 * 60 * 1000, + bins: [], + start: null, + end: null + }; + } + + const dataStart = datedPhotos[0]!.date; + const dataEnd = datedPhotos[datedPhotos.length - 1]!.date; + const startDate = viewport ? new Date(viewport.start) : dataStart; + const endDate = viewport ? new Date(viewport.end) : dataEnd; + const spanMs = Math.max(1, endDate.getTime() - startDate.getTime()); + const intervalMs = getTimelineIntervalMs(spanMs); + const domainStart = floorToInterval(startDate, intervalMs); + const domainEnd = ceilToInterval(endDate, intervalMs); + const bins: TimelineBin[] = []; + + for (let cursor = new Date(domainStart); cursor < domainEnd; cursor = new Date(cursor.getTime() + intervalMs)) { + const next = new Date(cursor.getTime() + intervalMs); + bins.push({ + start: new Date(cursor), + end: next, + count: 0, + photoIds: [], + label: formatTimelineLabel(cursor) + }); + } + + const binIndexByStart = new Map(); + bins.forEach((bin, index) => { + binIndexByStart.set(bin.start.getTime(), index); + }); + + for (const entry of datedPhotos) { + if (viewport && (entry.date.getTime() < viewport.start || entry.date.getTime() >= viewport.end)) { + continue; + } + + const bucketStart = floorToInterval(entry.date, intervalMs).getTime(); + const binIndex = binIndexByStart.get(bucketStart); + if (binIndex !== undefined) { + const bin = bins[binIndex]; + if (!bin) { + continue; + } + bin.count += 1; + bin.photoIds.push(entry.photo.id); + } + } + + return { + intervalMs, + bins, + start: domainStart, + end: domainEnd + }; +} diff --git a/src/server/app-assets.ts b/src/server/app-assets.ts index b91757c..5471555 100644 --- a/src/server/app-assets.ts +++ b/src/server/app-assets.ts @@ -940,1225 +940,3 @@ export const APP_STYLES = ` :root { } } `; - -export const CLIENT_SCRIPT = ` import exifr from "https://cdn.jsdelivr.net/npm/exifr@7.1.3/+esm"; - - const state = { - photos: [], - visiblePhotos: [], - objectUrls: [], - processed: 0, - total: 0, - activePhotoId: null, - timelineSelection: null, - timelineViewport: null, - timeline: { - intervalMs: 15 * 60 * 1000, - bins: [], - start: null, - end: null - } - }; - let activeImportController = null; - const IMPORT_RENDER_INTERVAL_MS = 180; - let scheduledRenderFrame = null; - let scheduledRenderTimer = null; - let scheduledRenderOptions = { fitMap: false }; - let lastRenderAt = 0; - const SHARE_PARAM_NAME = "share"; - - 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", { - maxZoom: 20, - subdomains: "abcd", - attribution: "© OpenStreetMap contributors © CARTO" - }); - const darkMapLayer = L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", { - maxZoom: 20, - subdomains: "abcd", - attribution: "© OpenStreetMap contributors © CARTO" - }); - const mapLayers = { - light: lightMapLayer, - dark: darkMapLayer - }; - let activeMapLayer = lightMapLayer; - activeMapLayer.addTo(map); - - const markers = L.layerGroup().addTo(map); - const route = L.polyline([], { - color: "#2d6cdf", - weight: 4, - opacity: 0.85 - }); - const routeColors = { - light: "#2d6cdf", - dark: "#7dd3fc" - }; - const ROUTE_VISIBLE_STORAGE_KEY = "routeVisible"; - let routeVisible = localStorage.getItem(ROUTE_VISIBLE_STORAGE_KEY) !== "false"; - - const overlay = mustGet("overlay"); - const overlayTitle = mustGet("overlay-title"); - const overlayImage = mustGet("overlay-image"); - const overlayLoading = mustGet("overlay-loading"); - const overlayLoadingText = mustGet("overlay-loading-text"); - const closeOverlay = mustGet("close-overlay"); - const routeToggle = mustGet("route-toggle"); - const routeToggleIcon = mustGet("route-toggle-icon"); - const photoList = mustGet("photo-list"); - const emptyState = mustGet("empty-state"); - const photoCount = mustGet("photo-count"); - const progressFill = mustGet("progress-fill"); - const progressText = mustGet("progress-text"); - const progressDetail = mustGet("progress-detail"); - const activitySpinner = mustGet("activity-spinner"); - const timelineSummary = mustGet("timeline-summary"); - const timelineClearButton = mustGet("timeline-clear"); - const timelineChart = mustGet("timeline-chart"); - const timelineBars = mustGet("timeline-bars"); - const timelineSelection = mustGet("timeline-selection"); - const timelineBrush = mustGet("timeline-brush"); - const themeToggle = mustGet("theme-toggle"); - const themeToggleIcon = mustGet("theme-toggle-icon"); - const sidebarCollapseButton = mustGet("sidebar-collapse"); - const sidebarCollapseIcon = mustGet("sidebar-collapse-icon"); - const timelineCollapseButton = mustGet("timeline-collapse"); - const timelineCollapseIcon = mustGet("timeline-collapse-icon"); - const importUrlInput = mustGet("share-url"); - const importStatus = mustGet("share-status"); - const importButton = mustGet("load-share"); - const stopImportButton = mustGet("cancel-share"); - let overlayLoadToken = 0; - - function mustGet(id) { - const element = document.getElementById(id); - if (!element) { - throw new Error("Missing UI element: " + id); - } - return element; - } - - function escapeHtml(value) { - return String(value ?? "").replace(/[&<>"']/g, (character) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - })[character]); - } - - function buildRemoteImageUrl(shareUrlValue, entryHref) { - const params = new URLSearchParams({ - share: shareUrlValue.trim(), - url: entryHref - }); - return "/api/nextcloud/blob?" + params.toString(); - } - - async function loadImageElement(blob) { - const objectUrl = URL.createObjectURL(blob); - const image = new Image(); - - try { - await new Promise((resolve, reject) => { - image.onload = () => resolve(); - image.onerror = () => reject(new Error("Could not decode image.")); - image.src = objectUrl; - }); - return image; - } finally { - URL.revokeObjectURL(objectUrl); - } - } - - async function createThumbnailObjectUrl(blob) { - const maxDimension = 320; - const quality = 0.82; - let width = 0; - let height = 0; - let drawSource = null; - let closeBitmap = null; - let sourceImage = null; - - if (typeof createImageBitmap === "function") { - const bitmap = await createImageBitmap(blob); - width = bitmap.width; - height = bitmap.height; - drawSource = bitmap; - closeBitmap = () => bitmap.close(); - } else { - sourceImage = await loadImageElement(blob); - width = sourceImage.naturalWidth; - height = sourceImage.naturalHeight; - drawSource = sourceImage; - } - - const scale = Math.min(1, maxDimension / Math.max(width, height)); - const targetWidth = Math.max(1, Math.round(width * scale)); - const targetHeight = Math.max(1, Math.round(height * scale)); - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - - if (!context) { - if (closeBitmap) { - closeBitmap(); - } - throw new Error("Canvas 2D context unavailable."); - } - - canvas.width = targetWidth; - canvas.height = targetHeight; - context.drawImage(drawSource, 0, 0, targetWidth, targetHeight); - - const thumbnailBlob = await new Promise((resolve, reject) => { - canvas.toBlob( - (result) => { - if (result) { - resolve(result); - return; - } - reject(new Error("Could not create thumbnail.")); - }, - "image/jpeg", - quality - ); - }); - - if (closeBitmap) { - closeBitmap(); - } - - if (sourceImage) { - sourceImage.removeAttribute("src"); - } - - return URL.createObjectURL(thumbnailBlob); - } - - function setTheme(theme, persist = true) { - const resolvedTheme = theme === "light" ? "light" : "dark"; - document.body.dataset.theme = resolvedTheme; - if (persist) { - localStorage.setItem("theme", resolvedTheme); - } - setMapTheme(resolvedTheme); - - if (resolvedTheme === "dark") { - themeToggle.setAttribute("aria-label", "Switch to light mode"); - themeToggleIcon.innerHTML = - '' + - '' + - ""; - } else { - themeToggle.setAttribute("aria-label", "Switch to dark mode"); - themeToggleIcon.innerHTML = - '' + - '' + - '' + - ""; - } - } - - function setMapTheme(theme) { - const resolvedTheme = theme === "dark" ? "dark" : "light"; - const nextMapLayer = mapLayers[resolvedTheme]; - - if (activeMapLayer !== nextMapLayer) { - map.removeLayer(activeMapLayer); - activeMapLayer = nextMapLayer; - activeMapLayer.addTo(map); - } - - route.setStyle({ - color: routeColors[resolvedTheme] - }); - } - - function setRouteVisible(visible) { - routeVisible = Boolean(visible); - localStorage.setItem(ROUTE_VISIBLE_STORAGE_KEY, routeVisible ? "true" : "false"); - - if (routeVisible) { - if (!map.hasLayer(route)) { - route.addTo(map); - } - routeToggle.setAttribute("aria-label", "Hide route line"); - routeToggleIcon.innerHTML = - '' + - '' + - '' + - '' + - ""; - } else { - if (map.hasLayer(route)) { - map.removeLayer(route); - } - routeToggle.setAttribute("aria-label", "Show route line"); - routeToggleIcon.innerHTML = - '' + - '' + - '' + - '' + - '' + - ""; - } - } - - function scheduleMapResize() { - window.clearTimeout(scheduleMapResize.timeoutId); - scheduleMapResize.timeoutId = window.setTimeout(() => { - map.invalidateSize(); - }, 220); - } - scheduleMapResize.timeoutId = 0; - - function setSidebarCollapsed(collapsed) { - const isCollapsed = Boolean(collapsed); - document.querySelector("aside")?.classList.toggle("sidebar-collapsed", isCollapsed); - localStorage.setItem("sidebarCollapsed", isCollapsed ? "true" : "false"); - sidebarCollapseButton.setAttribute("aria-label", isCollapsed ? "Expand left panel" : "Collapse left panel"); - sidebarCollapseIcon.innerHTML = isCollapsed - ? '' - : ''; - scheduleMapResize(); - } - - function setTimelineCollapsed(collapsed) { - const isCollapsed = Boolean(collapsed); - document.querySelector(".timeline")?.classList.toggle("timeline-collapsed", isCollapsed); - localStorage.setItem("timelineCollapsed", isCollapsed ? "true" : "false"); - timelineCollapseButton.setAttribute("aria-label", isCollapsed ? "Expand timeline" : "Collapse timeline"); - timelineCollapseIcon.innerHTML = isCollapsed - ? '' - : ''; - scheduleMapResize(); - } - - function getShareUrlFromLocation() { - const value = new URL(window.location.href).searchParams.get(SHARE_PARAM_NAME); - return value ? value.trim() : ""; - } - - function syncShareUrlInLocation(shareUrlValue) { - const nextUrl = new URL(window.location.href); - const trimmed = shareUrlValue.trim(); - if (trimmed) { - nextUrl.searchParams.set(SHARE_PARAM_NAME, trimmed); - } else { - nextUrl.searchParams.delete(SHARE_PARAM_NAME); - } - - if (nextUrl.toString() !== window.location.href) { - window.history.replaceState({}, "", nextUrl); - } - } - - const themeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const storedTheme = localStorage.getItem("theme"); - const initialTheme = storedTheme === "dark" || (storedTheme !== "light" && themeMediaQuery.matches) ? "dark" : "light"; - setTheme(initialTheme, false); - themeToggle.addEventListener("click", () => { - setTheme(document.body.dataset.theme === "dark" ? "light" : "dark"); - }); - if (storedTheme === null) { - themeMediaQuery.addEventListener("change", (event) => { - setTheme(event.matches ? "dark" : "light", false); - }); - } - setRouteVisible(routeVisible); - routeToggle.addEventListener("click", () => { - setRouteVisible(!routeVisible); - }); - - const storedSidebarCollapsed = localStorage.getItem("sidebarCollapsed") === "true"; - setSidebarCollapsed(storedSidebarCollapsed); - sidebarCollapseButton.addEventListener("click", () => { - setSidebarCollapsed(!document.querySelector("aside")?.classList.contains("sidebar-collapsed")); - }); - - setTimelineCollapsed(false); - timelineCollapseButton.addEventListener("click", () => { - setTimelineCollapsed(!document.querySelector(".timeline")?.classList.contains("timeline-collapsed")); - }); - - function formatDate(value) { - if (!value) { - return "no timestamp"; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) - ? value - : date.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }); - } - - function normalizeExifDate(value) { - if (typeof value !== "string") { - return null; - } - const match = value.match(/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); - if (!match) { - return null; - } - const [, year, month, day, hour, minute, second] = match; - const date = new Date( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ); - return Number.isNaN(date.getTime()) ? null : date.toISOString(); - } - - function parseDateOrNull(value) { - if (!value) { - return null; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date.toISOString(); - } - - function resolveDavBaseUrl(shareUrlValue) { - const url = new URL(shareUrlValue.trim()); - const publicShare = url.pathname.match(/\\/s\\/([^/?#]+)/); - if (publicShare) { - return url.origin + "/public.php/dav/files/" + publicShare[1] + "/"; - } - - const davShare = url.pathname.match(/\\/public\\.php\\/dav\\/files\\/([^/?#]+)\\/?/); - if (davShare) { - return url.origin + "/public.php/dav/files/" + davShare[1] + "/"; - } - - throw new Error("Please enter a public Nextcloud share link."); - } - - function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); - } - - function getPhotoTimestamp(photo) { - if (!photo.capturedAt) { - return null; - } - - const date = new Date(photo.capturedAt); - return Number.isNaN(date.getTime()) ? null : date; - } - - function startOfHour(date) { - const copy = new Date(date); - copy.setMinutes(0, 0, 0); - return copy; - } - - function getTimelineIntervalMs(spanMs) { - const minute = 60 * 1000; - const hour = 60 * minute; - - if (spanMs <= 12 * hour) { - return 5 * minute; - } - - if (spanMs <= 2 * 24 * hour) { - return 15 * minute; - } - - if (spanMs <= 7 * 24 * hour) { - return 30 * minute; - } - - if (spanMs <= 21 * 24 * hour) { - return 60 * minute; - } - - if (spanMs <= 60 * 24 * hour) { - return 2 * hour; - } - - return 4 * hour; - } - - function floorToInterval(date, intervalMs) { - return new Date(Math.floor(date.getTime() / intervalMs) * intervalMs); - } - - function ceilToInterval(date, intervalMs) { - return new Date(Math.ceil(date.getTime() / intervalMs) * intervalMs); - } - - function formatTimelineLabel(date) { - return date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit" - }); - } - - function formatTimelineSummary(photos) { - if (!photos.length) { - return "No photos imported yet."; - } - - const count = photos.length + (photos.length === 1 ? " photo" : " photos"); - return count + " across a compact timeline."; - } - - function formatSelectionRange(start, end) { - return ( - formatTimelineLabel(start) + - " - " + - formatTimelineLabel(end) - ); - } - - function buildTimeline(photos, viewport = null) { - const datedPhotos = photos - .map((photo) => ({ photo, date: getPhotoTimestamp(photo) })) - .filter((entry) => entry.date !== null) - .sort((a, b) => a.date.getTime() - b.date.getTime()); - - if (!datedPhotos.length) { - return { - intervalMs: 15 * 60 * 1000, - bins: [], - start: null, - end: null - }; - } - - const dataStart = datedPhotos[0].date; - const dataEnd = datedPhotos[datedPhotos.length - 1].date; - const startDate = viewport ? new Date(viewport.start) : dataStart; - const endDate = viewport ? new Date(viewport.end) : dataEnd; - const spanMs = Math.max(1, endDate.getTime() - startDate.getTime()); - const intervalMs = getTimelineIntervalMs(spanMs); - const domainStart = floorToInterval(startDate, intervalMs); - const domainEnd = ceilToInterval(endDate, intervalMs); - const bins = []; - - for (let cursor = new Date(domainStart); cursor < domainEnd; cursor = new Date(cursor.getTime() + intervalMs)) { - const next = new Date(cursor.getTime() + intervalMs); - bins.push({ - start: new Date(cursor), - end: next, - count: 0, - photoIds: [], - label: formatTimelineLabel(cursor) - }); - } - - const binIndexByStart = new Map(); - bins.forEach((bin, index) => { - binIndexByStart.set(bin.start.getTime(), index); - }); - - for (const entry of datedPhotos) { - if (viewport && (entry.date.getTime() < viewport.start || entry.date.getTime() >= viewport.end)) { - continue; - } - - const bucketStart = floorToInterval(entry.date, intervalMs).getTime(); - const binIndex = binIndexByStart.get(bucketStart); - if (binIndex !== undefined) { - const bin = bins[binIndex]; - bin.count += 1; - bin.photoIds.push(entry.photo.id); - } - } - - return { - intervalMs, - bins, - start: domainStart, - end: domainEnd - }; - } - - function setImportStatus(message, tone = "info") { - importStatus.textContent = message; - importStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)"; - } - - function setProgress(processed, total, detail) { - state.processed = processed; - state.total = total; - progressFill.style.width = total > 0 ? Math.round((processed / total) * 100) + "%" : "0%"; - progressText.textContent = processed + " / " + total; - progressDetail.textContent = detail; - } - - function setImporting(isImporting) { - activitySpinner.classList.toggle("active", isImporting); - importButton.disabled = isImporting; - stopImportButton.disabled = !isImporting; - } - - function clearObjectUrls() { - for (const url of state.objectUrls) { - URL.revokeObjectURL(url); - } - state.objectUrls = []; - } - - function clearGallery() { - cancelScheduledRender(); - closeOverlayView(); - state.photos = []; - state.visiblePhotos = []; - state.activePhotoId = null; - state.timelineSelection = null; - state.timelineViewport = null; - state.timeline = { - intervalMs: 15 * 60 * 1000, - bins: [], - start: null, - end: null - }; - markers.clearLayers(); - route.setLatLngs([]); - photoList.replaceChildren(emptyState); - emptyState.textContent = "No images imported yet."; - photoCount.textContent = "0 photos"; - timelineSummary.textContent = "No photos imported yet."; - timelineClearButton.disabled = true; - timelineBars.replaceChildren(); - timelineSelection.classList.remove("visible"); - timelineSelection.style.left = "0"; - timelineSelection.style.width = "0"; - timelineBrush.classList.remove("visible"); - setProgress(0, 0, "idle"); - } - - function openOverlay(photo) { - const requestToken = ++overlayLoadToken; - overlay.classList.add("open"); - overlay.setAttribute("aria-hidden", "false"); - overlayTitle.textContent = photo.name; - overlayImage.alt = photo.name; - overlay.classList.add("loading"); - overlayLoading.classList.add("active"); - overlayLoading.classList.remove("error"); - overlayLoadingText.textContent = "Loading full-size image..."; - overlayImage.onload = () => { - if (requestToken !== overlayLoadToken) { - return; - } - overlay.classList.remove("loading"); - overlayLoading.classList.remove("active"); - overlayLoading.classList.remove("error"); - }; - overlayImage.onerror = () => { - if (requestToken !== overlayLoadToken) { - return; - } - overlayLoadingText.textContent = "Could not load full-size image."; - overlay.classList.remove("loading"); - overlayLoading.classList.add("error"); - overlayLoading.classList.add("active"); - }; - overlayImage.removeAttribute("src"); - overlayImage.src = photo.fullUrl; - } - - function createPhotoMarkerIcon(photo) { - const activeClass = photo.id === state.activePhotoId ? " active" : ""; - return L.divIcon({ - className: "photo-map-marker" + activeClass, - html: - '' + escapeHtml(photo.name) + '', - iconSize: [44, 44], - iconAnchor: [22, 22], - popupAnchor: [0, -22] - }); - } - - function closeOverlayView() { - overlayLoadToken += 1; - overlay.classList.remove("open"); - overlay.setAttribute("aria-hidden", "true"); - overlay.classList.remove("loading"); - overlayLoading.classList.remove("active"); - overlayLoading.classList.remove("error"); - overlayLoadingText.textContent = "Loading full-size image..."; - overlayImage.removeAttribute("src"); - } - - closeOverlay.addEventListener("click", closeOverlayView); - overlay.addEventListener("click", (event) => { - if (event.target === overlay) { - closeOverlayView(); - } - }); - - function updateRouteView(photos) { - const routePoints = photos - .filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt) - .sort((a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt))) - .map((photo) => [photo.latitude, photo.longitude]); - - route.setLatLngs(routePoints); - - if (routePoints.length === 1) { - map.setView(routePoints[0], 13); - } else if (routePoints.length > 1) { - map.fitBounds(route.getBounds(), { padding: [40, 40] }); - } - } - - function isPhotoInSelection(photo, selection = state.timelineSelection) { - if (!selection) { - return true; - } - - const photoDate = getPhotoTimestamp(photo); - if (!photoDate) { - return false; - } - - return photoDate.getTime() >= selection.start && photoDate.getTime() < selection.end; - } - - function getVisiblePhotos() { - return state.photos.filter((photo) => isPhotoInSelection(photo)); - } - - function syncTimelineSelectionOverlay() { - const selection = state.timelineSelection; - if (!selection || !state.timeline.start || !state.timeline.end) { - timelineSelection.classList.remove("visible"); - timelineSelection.style.left = "0"; - timelineSelection.style.width = "0"; - return; - } - - const total = state.timeline.end.getTime() - state.timeline.start.getTime(); - if (total <= 0) { - timelineSelection.classList.remove("visible"); - return; - } - - const left = clamp((selection.start - state.timeline.start.getTime()) / total, 0, 1); - const right = clamp((selection.end - state.timeline.start.getTime()) / total, 0, 1); - const startPct = Math.min(left, right) * 100; - const widthPct = Math.max(1, (Math.max(left, right) - Math.min(left, right)) * 100); - timelineSelection.classList.add("visible"); - timelineSelection.style.left = startPct + "%"; - timelineSelection.style.width = widthPct + "%"; - } - - function setTimelineSelection(start, end) { - if (start === null || end === null) { - state.timelineSelection = null; - state.timelineViewport = null; - timelineClearButton.disabled = true; - } else { - const normalizedStart = Math.min(start, end); - const normalizedEnd = Math.max(start, end); - state.timelineSelection = { - start: normalizedStart, - end: normalizedEnd - }; - state.timelineViewport = { - start: normalizedStart, - end: normalizedEnd - }; - timelineClearButton.disabled = false; - } - - renderVisiblePhotos({ fitMap: true }); - } - - function clearTimelineSelection() { - setTimelineSelection(null, null); - } - - function renderTimeline() { - const timelinePhotos = state.timelineSelection ? state.visiblePhotos : state.photos; - const timeline = buildTimeline(timelinePhotos, state.timelineViewport); - state.timeline = timeline; - timelineSummary.textContent = formatTimelineSummary(state.photos); - - timelineBars.replaceChildren(); - - if (!timeline.bins.length) { - syncTimelineSelectionOverlay(); - return; - } - - const maxCount = Math.max(...timeline.bins.map((bin) => bin.count), 1); - const labelStep = Math.max(1, Math.ceil(timeline.bins.length / 6)); - - timeline.bins.forEach((bin, index) => { - const bar = document.createElement("button"); - const isSelected = state.timelineSelection - ? bin.end.getTime() > state.timelineSelection.start && bin.start.getTime() < state.timelineSelection.end - : false; - const isActive = state.activePhotoId ? bin.photoIds.includes(state.activePhotoId) : false; - - bar.type = "button"; - bar.className = "timeline-bar" + (isSelected ? " selected" : "") + (isActive ? " active" : ""); - bar.title = bin.label + " - " + bin.count + (bin.count === 1 ? " photo" : " photos"); - bar.style.gridColumn = String(index + 1); - - const fill = document.createElement("div"); - fill.className = "timeline-bar-fill"; - fill.style.height = Math.max(4, Math.round((bin.count / maxCount) * 100)) + "%"; - bar.appendChild(fill); - - const meta = document.createElement("span"); - meta.className = "timeline-bar-meta"; - meta.textContent = index % labelStep === 0 || index === timeline.bins.length - 1 ? bin.label : ""; - bar.appendChild(meta); - - bar.addEventListener("click", () => { - if (timelineClickSuppressed) { - return; - } - setTimelineSelection(bin.start.getTime(), bin.end.getTime()); - }); - - timelineBars.appendChild(bar); - }); - - syncTimelineSelectionOverlay(); - } - - function renderVisiblePhotos(options = {}) { - const visiblePhotos = getVisiblePhotos(); - state.visiblePhotos = visiblePhotos; - markers.clearLayers(); - route.setLatLngs([]); - photoList.replaceChildren(); - - if (!visiblePhotos.length) { - emptyState.textContent = state.photos.length - ? "No photos match the selected range." - : "No images imported yet."; - photoList.appendChild(emptyState); - photoCount.textContent = "0 photos"; - } else { - emptyState.textContent = "No images imported yet."; - photoCount.textContent = - visiblePhotos.length + (visiblePhotos.length === 1 ? " photo shown" : " photos shown"); - - for (const photo of visiblePhotos) { - if (photo.latitude !== null && photo.longitude !== null) { - const marker = L.marker([photo.latitude, photo.longitude], { - icon: createPhotoMarkerIcon(photo) - }).addTo(markers); - - marker.bindPopup( - '
' + - '' + escapeHtml(photo.name) + '' + - '' + escapeHtml(photo.name) + '' + - '' + escapeHtml(formatDate(photo.capturedAt)) + '' + - '
' - ); - - marker.on("mouseover", () => marker.openPopup()); - marker.on("click", () => { - state.activePhotoId = photo.id; - renderVisiblePhotos({ fitMap: false }); - openOverlay(photo); - }); - } - - const item = document.createElement("article"); - item.className = "photo" + (photo.id === state.activePhotoId ? " active" : ""); - const photoTitle = photo.name + " - " + formatDate(photo.capturedAt); - item.title = photoTitle; - item.setAttribute("aria-label", photoTitle); - item.innerHTML = - '' + escapeHtml(photo.name) + ''; - item.addEventListener("click", () => { - state.activePhotoId = photo.id; - renderVisiblePhotos({ fitMap: false }); - openOverlay(photo); - }); - photoList.appendChild(item); - } - } - - updateRouteView(visiblePhotos); - renderTimeline(); - - if (options.fitMap && visiblePhotos.length) { - const bounds = visiblePhotos.filter((photo) => photo.latitude !== null && photo.longitude !== null); - if (bounds.length === 1) { - map.setView([bounds[0].latitude, bounds[0].longitude], 13); - } else if (bounds.length > 1) { - map.fitBounds(bounds.map((photo) => [photo.latitude, photo.longitude]), { padding: [40, 40] }); - } - } - } - - function runScheduledRender() { - scheduledRenderFrame = null; - const options = scheduledRenderOptions; - scheduledRenderOptions = { fitMap: false }; - lastRenderAt = performance.now(); - renderVisiblePhotos(options); - } - - function scheduleVisiblePhotoRender(options = {}) { - scheduledRenderOptions = { - fitMap: Boolean(scheduledRenderOptions.fitMap || options.fitMap) - }; - - if (scheduledRenderFrame !== null || scheduledRenderTimer !== null) { - return; - } - - const elapsed = performance.now() - lastRenderAt; - const delay = Math.max(0, IMPORT_RENDER_INTERVAL_MS - elapsed); - const queueFrame = () => { - scheduledRenderTimer = null; - scheduledRenderFrame = requestAnimationFrame(runScheduledRender); - }; - - if (delay === 0) { - queueFrame(); - } else { - scheduledRenderTimer = setTimeout(queueFrame, delay); - } - } - - function cancelScheduledRender() { - if (scheduledRenderFrame !== null) { - cancelAnimationFrame(scheduledRenderFrame); - scheduledRenderFrame = null; - } - - if (scheduledRenderTimer !== null) { - clearTimeout(scheduledRenderTimer); - scheduledRenderTimer = null; - } - - scheduledRenderOptions = { fitMap: false }; - } - - function flushVisiblePhotoRender(options = {}) { - const renderOptions = { - fitMap: Boolean(scheduledRenderOptions.fitMap || options.fitMap) - }; - cancelScheduledRender(); - lastRenderAt = performance.now(); - renderVisiblePhotos(renderOptions); - } - - let timelinePointerState = null; - let timelineClickSuppressed = false; - - function updateTimelineBrushOverlay(startX, endX) { - if (!state.timeline.start || !state.timeline.end) { - timelineBrush.classList.remove("visible"); - return; - } - - const width = timelineChart.getBoundingClientRect().width; - if (width <= 0) { - timelineBrush.classList.remove("visible"); - return; - } - - const leftX = clamp(Math.min(startX, endX), 0, width); - const rightX = clamp(Math.max(startX, endX), 0, width); - timelineBrush.classList.add("visible"); - timelineBrush.style.left = leftX + "px"; - timelineBrush.style.width = Math.max(1, rightX - leftX) + "px"; - } - - function clearTimelineBrushOverlay() { - timelineBrush.classList.remove("visible"); - timelineBrush.style.left = "0"; - timelineBrush.style.width = "0"; - } - - function timeFromTimelineX(x) { - if (!state.timeline.start || !state.timeline.end) { - return null; - } - - const width = timelineChart.getBoundingClientRect().width; - if (width <= 0) { - return null; - } - - const ratio = clamp(x / width, 0, 1); - const time = state.timeline.start.getTime() + ratio * (state.timeline.end.getTime() - state.timeline.start.getTime()); - return new Date(time); - } - - function selectTimelineBinAtX(x) { - if (!state.timeline.bins.length || !state.timeline.start || !state.timeline.end) { - return; - } - - const width = timelineChart.getBoundingClientRect().width; - if (width <= 0) { - return; - } - - const ratio = clamp(x / width, 0, 1); - const index = clamp(Math.floor(ratio * state.timeline.bins.length), 0, state.timeline.bins.length - 1); - const bin = state.timeline.bins[index]; - setTimelineSelection(bin.start.getTime(), bin.end.getTime()); - } - - timelineChart.addEventListener("pointerdown", (event) => { - if (!state.timeline.bins.length) { - return; - } - - const rect = timelineChart.getBoundingClientRect(); - const startX = event.clientX - rect.left; - timelinePointerState = { - pointerId: event.pointerId, - startX, - currentX: startX, - moved: false - }; - - timelineChart.setPointerCapture(event.pointerId); - updateTimelineBrushOverlay(startX, startX); - }); - - timelineChart.addEventListener("pointermove", (event) => { - if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) { - return; - } - - const rect = timelineChart.getBoundingClientRect(); - const currentX = event.clientX - rect.left; - timelinePointerState.currentX = currentX; - timelinePointerState.moved = timelinePointerState.moved || Math.abs(currentX - timelinePointerState.startX) > 4; - updateTimelineBrushOverlay(timelinePointerState.startX, currentX); - }); - - function finishTimelinePointer(event) { - if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) { - return; - } - - try { - timelineChart.releasePointerCapture(event.pointerId); - } catch { - // Pointer capture may already be released. - } - - if (timelinePointerState.moved) { - const startTime = timeFromTimelineX(timelinePointerState.startX); - const endTime = timeFromTimelineX(timelinePointerState.currentX); - if (startTime && endTime) { - setTimelineSelection(startTime.getTime(), endTime.getTime()); - } - timelineClickSuppressed = true; - setTimeout(() => { - timelineClickSuppressed = false; - }, 0); - } else { - selectTimelineBinAtX(timelinePointerState.currentX); - } - - clearTimelineBrushOverlay(); - timelinePointerState = null; - } - - timelineChart.addEventListener("pointerup", finishTimelinePointer); - timelineChart.addEventListener("pointercancel", finishTimelinePointer); - - timelineClearButton.addEventListener("click", clearTimelineSelection); - - function appendPhoto(photo) { - state.photos.push(photo); - scheduleVisiblePhotoRender({ fitMap: state.photos.length === 1 }); - } - - function textContent(node, namespace, localName) { - const element = node.getElementsByTagNameNS(namespace, localName)[0]; - return element ? element.textContent?.trim() ?? "" : ""; - } - - function parseListing(xmlText, baseUrl) { - const documentNode = new DOMParser().parseFromString(xmlText, "application/xml"); - const responses = Array.from(documentNode.getElementsByTagNameNS("DAV:", "response")); - const entries = responses - .map((response) => { - const href = textContent(response, "DAV:", "href"); - const prop = response.getElementsByTagNameNS("DAV:", "prop")[0]; - if (!href || !prop) { - return null; - } - - const contentType = textContent(prop, "DAV:", "getcontenttype"); - const lastModified = textContent(prop, "DAV:", "getlastmodified") || null; - const displayName = textContent(prop, "DAV:", "displayname"); - const isCollection = prop.getElementsByTagNameNS("DAV:", "collection").length > 0; - const absoluteUrl = new URL(href, baseUrl).toString(); - - return { - href: absoluteUrl, - name: displayName || decodeURIComponent(absoluteUrl.split("/").pop() || "image"), - lastModified, - contentType, - isCollection - }; - }) - .filter((entry) => entry !== null); - - return entries.filter((entry) => { - const resolved = entry.href.toLowerCase(); - const imageLike = entry.contentType.startsWith("image/") || /\.(jpe?g|png|gif|webp|heic|heif|tiff?|avif)$/i.test(resolved); - return !entry.isCollection && imageLike; - }); - } - - async function loadImportListing(shareUrlValue, signal) { - const davBaseUrl = resolveDavBaseUrl(shareUrlValue); - const response = await fetch( - "/api/nextcloud/list?share=" + encodeURIComponent(shareUrlValue.trim()), - { - signal - } - ); - - if (!response.ok) { - throw new Error("Could not load the Nextcloud listing: " + response.status); - } - - return parseListing(await response.text(), davBaseUrl); - } - - async function readRemoteImage(entry, shareUrlValue, signal) { - const fullUrl = buildRemoteImageUrl(shareUrlValue, entry.href); - const response = await fetch(fullUrl, { - signal - }); - if (!response.ok) { - throw new Error("Could not load image: " + entry.name); - } - - const blob = await response.blob(); - const [gps, tags] = await Promise.all([exifr.gps(blob), exifr.parse(blob)]); - const capturedAt = - normalizeExifDate(tags?.DateTimeOriginal?.value ?? tags?.DateTimeOriginal) || - parseDateOrNull(entry.lastModified); - const thumbUrl = await createThumbnailObjectUrl(blob); - state.objectUrls.push(thumbUrl); - - return { - id: entry.href, - name: entry.name, - latitude: gps?.latitude ?? null, - longitude: gps?.longitude ?? null, - capturedAt, - thumbUrl, - fullUrl, - source: "nextcloud" - }; - } - - async function importFromShare() { - if (activeImportController) { - return; - } - - syncShareUrlInLocation(importUrlInput.value); - const controller = new AbortController(); - activeImportController = controller; - setImporting(true); - - try { - clearObjectUrls(); - clearGallery(); - setImportStatus("Importing..."); - const listing = await loadImportListing(importUrlInput.value, controller.signal); - - if (!listing.length) { - throw new Error("No images found in the share."); - } - - setProgress(0, listing.length, "checking files"); - - let loaded = 0; - let skipped = 0; - let processed = 0; - - for (const entry of listing) { - if (controller.signal.aborted) { - throw controller.signal.reason ?? new Error("Import canceled"); - } - - try { - const photo = await readRemoteImage(entry, importUrlInput.value, controller.signal); - if (photo.latitude !== null && photo.longitude !== null) { - appendPhoto(photo); - loaded += 1; - } else { - skipped += 1; - } - } catch (error) { - skipped += 1; - console.error(error); - } - - processed += 1; - setProgress(processed, listing.length, "processed " + processed + " / " + listing.length); - setImportStatus("Importing: " + loaded + " shown, " + skipped + " skipped."); - } - - if (!loaded) { - throw new Error("No images with GPS data found."); - } - - setImportStatus( - "Import complete: " + loaded + " images imported" + (skipped ? ", " + skipped + " skipped" : "") + "." - ); - setProgress(listing.length, listing.length, "complete"); - flushVisiblePhotoRender({ fitMap: true }); - } catch (error) { - if (controller.signal.aborted) { - flushVisiblePhotoRender(); - setImportStatus("Import stopped."); - setProgress(state.processed, state.total, "canceled"); - return; - } - - console.error(error); - setImportStatus( - "Import failed: " + (error instanceof Error ? error.message : "unknown error"), - "error" - ); - flushVisiblePhotoRender(); - } finally { - activeImportController = null; - setImporting(false); - } - } - - importButton.addEventListener("click", () => { - void importFromShare(); - }); - - stopImportButton.addEventListener("click", () => { - if (activeImportController) { - activeImportController.abort("Import canceled by user"); - } - }); - - const initialShareUrl = getShareUrlFromLocation(); - if (initialShareUrl) { - importUrlInput.value = initialShareUrl; - window.queueMicrotask(() => { - void importFromShare(); - }); - } - - clearGallery(); - setImportStatus("Ready to import."); -`; diff --git a/src/server/client-assets.ts b/src/server/client-assets.ts new file mode 100644 index 0000000..0575067 --- /dev/null +++ b/src/server/client-assets.ts @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; +import type { ServerResponse } from "node:http"; + +const CLIENT_ASSET_PREFIX = "/client/"; + +export function isClientAssetPath(pathname: string): boolean { + return pathname.startsWith(CLIENT_ASSET_PREFIX); +} + +export async function handleClientAssetRequest(url: URL, res: ServerResponse): Promise { + const assetName = url.pathname.slice(CLIENT_ASSET_PREFIX.length); + + if (!/^[a-z0-9-]+\.js$/i.test(assetName)) { + res.statusCode = 404; + res.end("Not found"); + return; + } + + try { + const assetUrl = new URL(`../client/${assetName}`, import.meta.url); + const source = await readFile(assetUrl, "utf8"); + res.statusCode = 200; + res.setHeader("content-type", "text/javascript; charset=utf-8"); + res.setHeader("cache-control", "no-cache"); + res.end(source); + } catch { + res.statusCode = 404; + res.end("Not found"); + } +} diff --git a/src/server/page-template.ts b/src/server/page-template.ts index 5a91ccc..c950e25 100644 --- a/src/server/page-template.ts +++ b/src/server/page-template.ts @@ -1,4 +1,4 @@ -import { APP_STYLES, CLIENT_SCRIPT } from "./app-assets.js"; +import { APP_STYLES } from "./app-assets.js"; export function htmlPage(): string { return ` @@ -154,9 +154,7 @@ ${APP_STYLES} - + `; } diff --git a/src/server/request-handler.ts b/src/server/request-handler.ts index 236ff4d..a14023f 100644 --- a/src/server/request-handler.ts +++ b/src/server/request-handler.ts @@ -1,3 +1,7 @@ +import { + handleClientAssetRequest, + isClientAssetPath +} from "./client-assets.js"; import { htmlPage } from "./page-template.js"; import { handleNextcloudBlobRequest, @@ -16,6 +20,11 @@ export function createRequestHandler() { return; } + if (isClientAssetPath(url.pathname)) { + await handleClientAssetRequest(url, res); + return; + } + if (url.pathname === "/api/nextcloud/list") { await handleNextcloudListRequest(url, res); return;