From 62b1745c0a111166814f2e5ad08b386c3c7d6d3e Mon Sep 17 00:00:00 2001 From: Arne Baeumler Date: Sat, 13 Jun 2026 08:16:23 +0200 Subject: [PATCH] refactor: split server page assets --- src/server/app-assets.ts | 2164 ++++++++++++++++++++++++++++++ src/server/page-template.ts | 162 +++ src/server/request-handler.ts | 2321 +-------------------------------- 3 files changed, 2327 insertions(+), 2320 deletions(-) create mode 100644 src/server/app-assets.ts create mode 100644 src/server/page-template.ts diff --git a/src/server/app-assets.ts b/src/server/app-assets.ts new file mode 100644 index 0000000..b91757c --- /dev/null +++ b/src/server/app-assets.ts @@ -0,0 +1,2164 @@ +export const APP_STYLES = ` :root { + --radius-xl: 0; + --radius-lg: 0; + --radius-md: 0; + --shadow: 0 12px 30px rgba(2, 6, 23, 0.14); + --shadow-soft: 0 8px 18px rgba(2, 6, 23, 0.1); + --chart-grid: rgba(255, 255, 255, 0.035); + --chart-band: rgba(100, 116, 139, 0.16); + } + + body[data-theme="dark"] { + color-scheme: dark; + --bg: #0b1120; + --bg-accent-a: rgba(59, 130, 246, 0.12); + --bg-accent-b: rgba(30, 41, 59, 0.14); + --panel: rgba(15, 23, 42, 0.9); + --panel-float: rgba(15, 23, 42, 0.72); + --panel-strong: rgba(30, 41, 59, 0.96); + --surface: rgba(15, 23, 42, 0.72); + --surface-float: rgba(15, 23, 42, 0.54); + --surface-strong: rgba(30, 41, 59, 0.95); + --border: rgba(148, 163, 184, 0.16); + --border-strong: rgba(148, 163, 184, 0.26); + --text: #e2e8f0; + --muted: #94a3b8; + --accent: #94a3b8; + --accent-strong: #cbd5e1; + --input-bg: rgba(15, 23, 42, 0.92); + --button-secondary-bg: rgba(148, 163, 184, 0.12); + --button-secondary-hover: rgba(148, 163, 184, 0.18); + --danger-bg: rgba(239, 68, 68, 0.12); + --danger-hover: rgba(239, 68, 68, 0.18); + --danger-text: #fca5a5; + --status-tint: rgba(148, 163, 184, 0.16); + --progress-track: rgba(148, 163, 184, 0.16); + --photo-bg: rgba(15, 23, 42, 0.7); + --photo-active-bg: rgba(51, 65, 85, 0.76); + --photo-active-border: rgba(148, 163, 184, 0.34); + --empty-border: rgba(148, 163, 184, 0.22); + --overlay-bg: rgba(2, 6, 23, 0.9); + --card-border: rgba(148, 163, 184, 0.16); + --timeline-surface: rgba(15, 23, 42, 0.88); + --timeline-surface-soft: rgba(15, 23, 42, 0.76); + --timeline-surface-strong: rgba(30, 41, 59, 0.96); + --timeline-border: rgba(148, 163, 184, 0.18); + --timeline-border-strong: rgba(191, 219, 254, 0.28); + --timeline-text: #e2e8f0; + --timeline-text-muted: #94a3b8; + --timeline-bar-border: rgba(148, 163, 184, 0.48); + --timeline-bar-fill: rgba(148, 163, 184, 0.84); + --timeline-bar-fill-hover: rgba(125, 211, 252, 0.88); + --timeline-bar-fill-selected: rgba(186, 230, 253, 0.92); + --timeline-bar-fill-selected-active: rgba(226, 232, 240, 0.98); + --timeline-axis-color: rgba(148, 163, 184, 0.5); + --timeline-axis-label: #d1d5db; + --timeline-meta: rgba(209, 213, 219, 0.72); + --timeline-selection-border: rgba(148, 163, 184, 0.34); + --timeline-selection-bg: rgba(148, 163, 184, 0.08); + --timeline-brush-border: rgba(148, 163, 184, 0.56); + --timeline-brush-bg: rgba(148, 163, 184, 0.14); + --chart-grid: rgba(255, 255, 255, 0.05); + --accent-shadow: rgba(148, 163, 184, 0.18); + --spinner-track: rgba(125, 211, 252, 0.26); + --route-color: #7dd3fc; + --marker-border: rgba(15, 23, 42, 0.94); + --marker-border-active: rgba(191, 219, 254, 0.96); + --marker-shadow: 0 8px 18px rgba(2, 6, 23, 0.34); + --marker-active-shadow: 0 0 0 3px rgba(125, 211, 252, 0.18), 0 10px 22px rgba(2, 6, 23, 0.38); + } + + body[data-theme="light"] { + color-scheme: light; + --bg: #eef2f6; + --bg-accent-a: rgba(100, 116, 139, 0.08); + --bg-accent-b: rgba(148, 163, 184, 0.06); + --panel: rgba(255, 255, 255, 0.9); + --panel-float: rgba(255, 255, 255, 0.68); + --panel-strong: rgba(255, 255, 255, 0.98); + --surface: rgba(255, 255, 255, 0.84); + --surface-float: rgba(255, 255, 255, 0.56); + --surface-strong: rgba(241, 245, 249, 0.98); + --border: rgba(15, 23, 42, 0.1); + --border-strong: rgba(15, 23, 42, 0.16); + --text: #111827; + --muted: #5b6472; + --accent: #64748b; + --accent-strong: #475569; + --input-bg: white; + --button-secondary-bg: rgba(15, 23, 42, 0.05); + --button-secondary-hover: rgba(15, 23, 42, 0.08); + --danger-bg: rgba(120, 53, 15, 0.08); + --danger-hover: rgba(120, 53, 15, 0.14); + --danger-text: #b91c1c; + --status-tint: rgba(100, 116, 139, 0.12); + --progress-track: rgba(15, 23, 42, 0.08); + --photo-bg: rgba(255, 255, 255, 0.84); + --photo-active-bg: rgba(100, 116, 139, 0.12); + --photo-active-border: rgba(100, 116, 139, 0.32); + --empty-border: rgba(15, 23, 42, 0.16); + --overlay-bg: rgba(8, 12, 18, 0.78); + --card-border: rgba(15, 23, 42, 0.11); + --timeline-surface: rgba(255, 255, 255, 0.84); + --timeline-surface-soft: rgba(255, 255, 255, 0.94); + --timeline-surface-strong: rgba(241, 245, 249, 0.98); + --timeline-border: rgba(148, 163, 184, 0.22); + --timeline-border-strong: rgba(100, 116, 139, 0.32); + --timeline-text: #303030; + --timeline-text-muted: rgba(48, 48, 48, 0.72); + --timeline-bar-border: rgba(75, 85, 99, 0.82); + --timeline-bar-fill: rgba(100, 116, 139, 0.82); + --timeline-bar-fill-hover: rgba(75, 85, 99, 0.92); + --timeline-bar-fill-selected: rgba(55, 65, 81, 0.92); + --timeline-bar-fill-selected-active: rgba(15, 23, 42, 0.96); + --timeline-axis-color: rgba(107, 114, 128, 0.52); + --timeline-axis-label: #303030; + --timeline-meta: rgba(48, 48, 48, 0.72); + --timeline-selection-border: rgba(107, 114, 128, 0.3); + --timeline-selection-bg: rgba(107, 114, 128, 0.08); + --timeline-brush-border: rgba(107, 114, 128, 0.52); + --timeline-brush-bg: rgba(107, 114, 128, 0.12); + --chart-grid: rgba(15, 23, 42, 0.06); + --accent-shadow: rgba(100, 116, 139, 0.16); + --spinner-track: rgba(96, 165, 250, 0.22); + --route-color: #2d6cdf; + --marker-border: rgba(255, 255, 255, 0.92); + --marker-border-active: rgba(100, 116, 139, 0.95); + --marker-shadow: 0 8px 18px rgba(15, 23, 42, 0.22); + --marker-active-shadow: 0 0 0 3px rgba(100, 116, 139, 0.18), 0 10px 22px rgba(15, 23, 42, 0.28); + } + + * { + box-sizing: border-box; + } + + html, + body { + margin: 0; + min-height: 100%; + } + + body { + position: relative; + height: 100dvh; + overflow: hidden; + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, var(--bg-accent-a), transparent 28%), + radial-gradient(circle at bottom right, var(--bg-accent-b), transparent 26%), + var(--bg); + } + + h1, + h2, + p { + margin: 0; + } + + aside, + main, + .overlay-card { + background: var(--panel); + backdrop-filter: blur(14px); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + } + + .muted { + color: var(--muted); + } + + aside { + position: absolute; + top: 16px; + left: 16px; + bottom: 128px; + width: min(280px, calc(100vw - 32px)); + min-height: 0; + min-width: 0; + padding: 8px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 8px; + overflow: hidden; + z-index: 700; + backdrop-filter: blur(16px); + background: var(--timeline-surface-soft); + border-color: var(--timeline-border); + transition: width 180ms ease, transform 180ms ease, opacity 180ms ease, padding 180ms ease; + } + + aside.sidebar-collapsed { + width: 44px; + padding: 6px; + } + + aside.sidebar-collapsed .panel-stack { + padding: 0; + background: transparent; + border-color: transparent; + backdrop-filter: none; + } + + aside.sidebar-collapsed .panel-stack > :not(.panel-toolbar), + aside.sidebar-collapsed .photo-card { + display: none; + } + + aside.sidebar-collapsed .panel-toolbar { + justify-content: center; + } + + aside.sidebar-collapsed .theme-toggle { + display: none; + } + + .card { + min-width: 0; + padding: 10px; + border-radius: var(--radius-lg); + border: 1px solid var(--timeline-border); + background: var(--timeline-surface-soft); + color: var(--timeline-text); + backdrop-filter: blur(16px); + } + + .photo-card { + min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + overflow: hidden; + } + + .card h2 { + font-size: 0.92rem; + margin-bottom: 6px; + } + + .card p, + .card small { + color: var(--timeline-text-muted); + } + + label { + display: grid; + gap: 6px; + margin-top: 10px; + font-size: 0.84rem; + } + + input { + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + padding: 9px 11px; + font: inherit; + color: var(--text); + background: var(--input-bg); + } + + .button-row { + display: flex; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; + } + + .panel-stack { + display: grid; + gap: 8px; + } + + .panel-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + } + + .theme-toggle { + width: 40px; + height: 40px; + padding: 0; + justify-content: center; + } + + .top-right-controls { + position: fixed; + top: 16px; + right: 16px; + z-index: 900; + display: grid; + gap: 8px; + } + + .route-toggle { + width: 40px; + height: 40px; + padding: 0; + justify-content: center; + } + + .theme-toggle, + .route-toggle { + background: var(--timeline-surface-soft); + color: var(--timeline-text); + border: 1px solid var(--timeline-border); + box-shadow: var(--shadow-soft); + } + + .theme-toggle:hover, + .route-toggle:hover { + background: var(--timeline-surface-strong); + } + + .theme-toggle .button-icon, + .route-toggle .button-icon { + width: 18px; + height: 18px; + } + + .panel-collapse { + width: 32px; + min-width: 32px; + height: 32px; + padding: 0; + justify-content: center; + flex: 0 0 auto; + } + + button { + border: 0; + border-radius: var(--radius-md); + padding: 8px 11px; + font: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + } + + .button-icon { + width: 1em; + height: 1em; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + } + + .button-icon svg { + display: block; + width: 100%; + height: 100%; + } + + .primary { + background: var(--accent); + color: white; + box-shadow: 0 6px 14px var(--accent-shadow); + } + + .primary:hover { + background: var(--accent-strong); + } + + .secondary { + background: var(--button-secondary-bg); + color: var(--text); + } + + .secondary:hover { + background: var(--button-secondary-hover); + } + + .danger { + background: var(--danger-bg); + color: var(--danger-text); + } + + .danger:hover { + background: var(--danger-hover); + } + + .status { + margin-top: 6px; + font-size: 0.8rem; + color: var(--muted); + overflow-wrap: anywhere; + } + + .status-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + min-width: 0; + } + + .status-row .status { + margin-top: 0; + } + + .spinner { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--spinner-track); + border-top-color: var(--accent); + opacity: 0; + transform: scale(0.8); + transition: opacity 120ms ease, transform 120ms ease; + } + + .spinner.active { + opacity: 1; + transform: scale(1); + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + .progress { + display: grid; + gap: 6px; + margin-top: 8px; + } + + .progress-bar { + height: 8px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--progress-track); + } + + .progress-fill { + width: 0%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), #67b2ff); + transition: width 180ms ease; + } + + .progress-meta { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 0.76rem; + color: var(--muted); + min-width: 0; + } + + .progress-meta span { + min-width: 0; + overflow-wrap: anywhere; + } + + .list { + --photo-thumb-size: 64px; + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 4px; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + padding-right: 2px; + } + + .photo { + display: block; + position: relative; + flex: 0 0 var(--photo-thumb-size); + width: var(--photo-thumb-size); + height: var(--photo-thumb-size); + padding: 0; + border-radius: var(--radius-md); + background: var(--timeline-surface-soft); + border: 1px solid var(--timeline-border); + color: var(--timeline-text); + min-width: 0; + overflow: hidden; + } + + .photo img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center center; + border-radius: 0; + display: block; + } + + .photo::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(15, 23, 42, 0.34), transparent 42%); + opacity: 0; + transition: opacity 140ms ease; + } + + .photo:hover::after, + .photo.active::after { + opacity: 1; + } + + .photo.active { + border-color: var(--timeline-border-strong); + background: var(--timeline-surface-strong); + } + + .empty-state { + padding: 10px; + border-radius: var(--radius-md); + border: 1px dashed var(--timeline-border); + color: var(--timeline-text-muted); + background: var(--timeline-surface); + text-align: center; + } + + main { + position: absolute; + inset: 0; + min-height: 0; + min-width: 0; + overflow: hidden; + } + + .map-panel { + position: absolute; + inset: 0; + min-height: 0; + overflow: hidden; + border-radius: 0; + border: 0; + background: transparent; + backdrop-filter: none; + box-shadow: none; + } + + #map { + width: 100%; + height: 100%; + } + + .overlay { + position: fixed; + inset: 0; + display: none; + place-items: center; + background: var(--overlay-bg); + z-index: 1000; + padding: 24px; + } + + .overlay.open { + display: grid; + } + + .overlay-card { + display: inline-grid; + width: fit-content; + max-width: 100%; + overflow: hidden; + border-radius: 0; + background: var(--timeline-surface-soft); + border-color: var(--timeline-border); + color: var(--timeline-text); + } + + .overlay-media { + position: relative; + width: fit-content; + max-width: 100%; + min-height: 48vh; + background: var(--timeline-surface-strong); + } + + .overlay-card header { + padding: 16px 18px; + border-bottom: 1px solid var(--timeline-border); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--timeline-surface-strong); + color: var(--timeline-text); + } + + .overlay-card img { + display: block; + width: auto; + max-width: min(1120px, calc(100vw - 48px)); + max-height: 78vh; + object-fit: contain; + background: var(--timeline-surface-strong); + transition: opacity 120ms ease; + } + + .overlay-card.loading img { + opacity: 0; + } + + .overlay-loading { + position: absolute; + inset: 0; + display: none; + place-items: center; + align-content: center; + gap: 10px; + padding: 24px; + min-width: 240px; + text-align: center; + color: var(--timeline-text-muted); + } + + .overlay-loading.active { + display: grid; + } + + .overlay-loading .spinner { + opacity: 1; + transform: scale(1); + animation: spin 0.8s linear infinite; + } + + .overlay-loading.error .spinner { + animation: none; + opacity: 0.45; + } + + .overlay-loading strong { + color: var(--timeline-text); + font-size: 0.92rem; + font-weight: 600; + } + + .close { + background: var(--timeline-surface); + color: var(--timeline-text); + } + + .thumb { + width: min(210px, 100%); + box-sizing: border-box; + padding: 8px; + display: grid; + gap: 8px; + background: var(--timeline-surface-soft); + border: 1px solid var(--timeline-border); + color: var(--timeline-text); + } + + .thumb small { + color: var(--timeline-text-muted); + } + + .thumb img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + border-radius: 0; + } + + .leaflet-popup-content-wrapper, + .leaflet-popup-tip { + background: var(--timeline-surface-strong); + color: var(--timeline-text); + } + + .leaflet-popup-content-wrapper { + border-radius: 6px; + overflow: hidden; + } + + .leaflet-popup-content { + margin: 14px 12px 12px; + color: var(--timeline-text); + } + + .leaflet-popup-content small { + color: var(--timeline-text-muted); + } + + .photo-map-marker { + width: 44px; + height: 44px; + border-radius: 50%; + overflow: hidden; + border: 2px solid var(--marker-border); + box-shadow: var(--marker-shadow); + background: var(--timeline-surface-strong); + } + + .photo-map-marker img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + .photo-map-marker.active { + border-color: var(--marker-border-active); + box-shadow: var(--marker-active-shadow); + } + + .timeline { + position: absolute; + left: 8px; + right: 8px; + bottom: 6px; + padding: 4px; + display: grid; + gap: 4px; + z-index: 650; + backdrop-filter: none; + background: transparent; + opacity: 1; + border-color: var(--timeline-border); + transition: height 180ms ease, transform 180ms ease, opacity 180ms ease, padding 180ms ease; + } + + .timeline.timeline-collapsed { + height: 40px; + padding: 4px; + overflow: hidden; + } + + .timeline.timeline-collapsed .timeline-summary, + .timeline.timeline-collapsed .timeline-chart, + .timeline.timeline-collapsed .timeline-body { + display: none; + } + + .timeline-header h2 { + margin: 0 0 4px; + font-size: 0.84rem; + } + + .timeline-summary { + color: var(--timeline-text-muted); + font-size: 0.72rem; + } + + .timeline-body { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px; + align-items: stretch; + } + + .timeline-chart { + position: relative; + min-height: 64px; + padding: 4px 6px 4px; + border-radius: 0; + border: 0; + background: var(--timeline-surface); + overflow: hidden; + touch-action: none; + user-select: none; + box-shadow: none; + } + + .timeline-selection, + .timeline-brush { + position: absolute; + top: 4px; + bottom: 18px; + left: 0; + width: 0; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, background 120ms ease, border-color 120ms ease; + } + + .timeline-selection { + background: var(--timeline-selection-bg); + border: 1px solid var(--timeline-selection-border); + z-index: 0; + } + + .timeline-selection.visible { + opacity: 1; + } + + .timeline-brush { + background: var(--timeline-brush-bg); + border: 1px solid var(--timeline-brush-border); + z-index: 2; + } + + .timeline-brush.visible { + opacity: 1; + } + + .timeline-chart::before { + content: none; + } + + .timeline-bars { + height: 36px; + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(8px, 1fr); + align-items: end; + gap: 0; + position: relative; + z-index: 1; + } + + .timeline-bar { + position: relative; + height: 100%; + display: grid; + align-items: end; + cursor: pointer; + border-radius: 0; + background: transparent; + padding: 0; + border: 0; + } + + .timeline-bar-fill { + width: 100%; + border-radius: inherit; + min-height: 3px; + background: var(--timeline-bar-fill); + border: 1px solid var(--timeline-bar-border); + transition: transform 140ms ease, background 140ms ease, opacity 140ms ease, box-shadow 140ms ease; + transform-origin: bottom; + box-shadow: none; + box-sizing: border-box; + } + + .timeline-bar:hover .timeline-bar-fill, + .timeline-bar.active .timeline-bar-fill { + background: var(--timeline-bar-fill-hover); + } + + .timeline-bar.selected .timeline-bar-fill { + background: var(--timeline-bar-fill-selected); + } + + .timeline-bar.active .timeline-bar-fill { + transform: translateY(-1px); + } + + .timeline-bar.selected.active .timeline-bar-fill { + background: var(--timeline-bar-fill-selected-active); + } + + .timeline-bar-meta { + position: absolute; + left: 50%; + bottom: -12px; + transform: translateX(-50%); + white-space: nowrap; + font-size: 0.62rem; + color: var(--timeline-axis-label); + font-variant-numeric: tabular-nums; + pointer-events: none; + } + + .timeline-actions { + display: flex; + align-items: flex-start; + gap: 4px; + justify-content: flex-end; + padding-top: 0; + } + + .timeline-actions .panel-collapse { + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; + justify-content: center; + background: var(--timeline-surface-soft); + color: var(--timeline-text); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); + border: 1px solid var(--timeline-border); + } + + @media (max-width: 920px) { + body { + height: auto; + min-height: 100dvh; + overflow: auto; + } + + aside { + position: static; + width: auto; + margin: 12px; + bottom: auto; + right: auto; + top: auto; + overflow: visible; + } + + .theme-toggle { + top: 12px; + right: 12px; + } + + .top-right-controls { + top: 12px; + right: 12px; + } + + .list { + max-height: min(52vh, 420px); + --photo-thumb-size: 64px; + gap: 4px; + } + + main { + position: static; + inset: auto; + min-height: 60vh; + } + + #map { + min-height: 60vh; + } + + .timeline { + position: static; + left: auto; + right: auto; + bottom: auto; + margin: 0 12px 12px; + } + } +`; + +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/page-template.ts b/src/server/page-template.ts new file mode 100644 index 0000000..5a91ccc --- /dev/null +++ b/src/server/page-template.ts @@ -0,0 +1,162 @@ +import { APP_STYLES, CLIENT_SCRIPT } from "./app-assets.js"; + +export function htmlPage(): string { + return ` + + + + + mapix + + + + +
+ + +
+
+
+
+
+
+ + + +
+
No photos imported yet.
+
+
+
+
+
+
+
+
+ + +
+
+ + + + + + +`; +} diff --git a/src/server/request-handler.ts b/src/server/request-handler.ts index 8af3010..bdf9936 100644 --- a/src/server/request-handler.ts +++ b/src/server/request-handler.ts @@ -1,3 +1,4 @@ +import { htmlPage } from "./page-template.js"; import type { IncomingMessage, ServerResponse } from "node:http"; type Photo = { @@ -72,2326 +73,6 @@ async function proxyUpstream(url: string, init?: RequestInit) { }; } -function htmlPage(): string { - return ` - - - - - mapix - - - - -
- - -
-
-
-
-
-
- - - -
-
No photos imported yet.
-
-
-
-
-
-
-
-
- - -
-
- - - - - - -`; -} - export function createRequestHandler() { return async function handleRequest(req: IncomingMessage, res: ServerResponse) { const url = new URL(req.url ?? "/", "http://localhost");