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(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 =
+ '
';
+ 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:
+ '
',
+ 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:
- '
',
- 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(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 =
- '
';
- 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}
-
+