Files
mapix/src/server/request-handler.ts

1824 lines
54 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
type Photo = {
id: string;
name: string;
latitude: number | null;
longitude: number | null;
capturedAt: string | null;
thumbUrl: string;
fullUrl: string;
source: "demo" | "nextcloud";
};
type ShareListingEntry = {
href: string;
name: string;
lastModified: string | null;
contentType: string;
isCollection: boolean;
};
function resolveDavUrlFromShareUrl(shareUrl: string): string {
const url = new URL(shareUrl);
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.");
}
async function proxyUpstream(url: string, init?: RequestInit) {
const upstream = await fetch(url, init);
const body = await upstream.arrayBuffer();
return {
body: Buffer.from(body),
contentType: upstream.headers.get("content-type") ?? "application/octet-stream",
status: upstream.status
};
}
function htmlPage(): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mapy-mg</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<style>
:root {
color-scheme: light;
--bg: #f3efe6;
--panel: rgba(255, 255, 255, 0.82);
--border: rgba(15, 23, 42, 0.12);
--text: #16202a;
--muted: #5b6472;
--accent: #2d6cdf;
--accent-strong: #1f56c2;
--shadow: 0 18px 50px rgba(23, 31, 45, 0.14);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
display: grid;
grid-template-rows: auto 1fr;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(45, 108, 223, 0.16), transparent 30%),
radial-gradient(circle at bottom right, rgba(220, 167, 47, 0.18), transparent 28%),
var(--bg);
}
header {
display: flex;
justify-content: space-between;
align-items: end;
gap: 16px;
padding: 20px 24px 12px;
}
h1,
h2,
p {
margin: 0;
}
.brand h1 {
font-size: clamp(1.6rem, 2.4vw, 2.2rem);
}
.brand p,
.meta,
.muted {
color: var(--muted);
}
.layout {
min-height: 0;
display: grid;
grid-template-columns: 360px 1fr;
gap: 16px;
padding: 0 16px 16px;
}
aside,
main,
.overlay-card {
background: var(--panel);
backdrop-filter: blur(14px);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: var(--shadow);
}
aside {
min-height: 0;
padding: 18px;
display: grid;
gap: 16px;
}
.card {
padding: 16px;
border-radius: 16px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.72);
}
.card h2 {
font-size: 1rem;
margin-bottom: 8px;
}
.card p,
.card small {
color: var(--muted);
}
label {
display: grid;
gap: 8px;
margin-top: 12px;
font-size: 0.92rem;
}
input {
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.18);
border-radius: 12px;
padding: 12px 14px;
font: inherit;
background: white;
}
.button-row {
display: flex;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
button {
border: 0;
border-radius: 999px;
padding: 10px 14px;
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;
}
.primary:hover {
background: var(--accent-strong);
}
.secondary {
background: rgba(15, 23, 42, 0.08);
color: var(--text);
}
.danger {
background: rgba(157, 23, 77, 0.12);
color: #9d174d;
}
.danger:hover {
background: rgba(157, 23, 77, 0.18);
}
.status {
margin-top: 10px;
font-size: 0.9rem;
color: var(--muted);
}
.status-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(45, 108, 223, 0.22);
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: 8px;
margin-top: 12px;
}
.progress-bar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(15, 23, 42, 0.1);
}
.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.82rem;
color: var(--muted);
}
.list {
display: grid;
gap: 10px;
max-height: min(44vh, 380px);
overflow: auto;
padding-right: 2px;
}
.photo {
display: grid;
grid-template-columns: 52px 1fr;
gap: 12px;
align-items: center;
padding: 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(15, 23, 42, 0.06);
}
.photo img {
width: 52px;
height: 52px;
object-fit: cover;
border-radius: 10px;
}
.photo strong {
display: block;
font-size: 0.95rem;
}
.photo span {
color: var(--muted);
font-size: 0.82rem;
}
.photo.active {
border-color: rgba(45, 108, 223, 0.42);
background: rgba(45, 108, 223, 0.12);
}
.empty-state {
padding: 14px;
border-radius: 14px;
border: 1px dashed rgba(15, 23, 42, 0.16);
color: var(--muted);
background: rgba(255, 255, 255, 0.45);
text-align: center;
}
main {
position: relative;
min-height: 0;
overflow: hidden;
display: grid;
grid-template-rows: minmax(460px, 1fr) auto;
gap: 16px;
}
.map-panel {
position: relative;
min-height: 0;
overflow: hidden;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--panel);
backdrop-filter: blur(14px);
box-shadow: var(--shadow);
}
#map {
width: 100%;
height: 100%;
min-height: 460px;
}
.map-label {
position: absolute;
z-index: 500;
top: 16px;
left: 16px;
padding: 10px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
font-size: 0.88rem;
}
.overlay {
position: fixed;
inset: 0;
display: none;
place-items: center;
background: rgba(8, 12, 18, 0.82);
z-index: 1000;
padding: 24px;
}
.overlay.open {
display: grid;
}
.overlay-card {
width: min(1120px, 100%);
overflow: hidden;
}
.overlay-card header {
padding: 16px 18px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.overlay-card img {
display: block;
width: 100%;
max-height: 78vh;
object-fit: contain;
background: #111;
}
.close {
background: #111827;
color: white;
}
.thumb {
width: 180px;
display: grid;
gap: 8px;
}
.thumb img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 12px;
}
.thumb button {
border: 0;
border-radius: 10px;
padding: 8px 10px;
background: var(--accent);
color: white;
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: rgba(255, 255, 255, 0.97);
}
.leaflet-popup-content {
margin: 10px 12px;
}
.timeline {
padding: 16px;
display: grid;
gap: 12px;
}
.timeline-header,
.timeline-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.timeline-header h2 {
margin: 0 0 4px;
font-size: 1rem;
}
.timeline-summary {
color: var(--muted);
font-size: 0.88rem;
}
.timeline-chart {
position: relative;
min-height: 170px;
padding: 14px 12px 10px;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.18);
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(15, 23, 42, 0.88)),
radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 42%);
overflow: hidden;
touch-action: none;
user-select: none;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
inset 0 -1px 0 rgba(255, 255, 255, 0.03);
}
.timeline-chart::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(to top, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 100% 33.333%, 10% 100%;
opacity: 0.35;
pointer-events: none;
}
.timeline-bars {
height: 124px;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(10px, 1fr);
align-items: end;
gap: 2px;
position: relative;
z-index: 1;
}
.timeline-bar {
position: relative;
height: 100%;
display: grid;
align-items: end;
cursor: pointer;
border-radius: 6px 6px 3px 3px;
}
.timeline-bar-fill {
width: 100%;
border-radius: inherit;
min-height: 4px;
background: linear-gradient(180deg, rgba(96, 165, 250, 0.98), rgba(59, 130, 246, 0.36));
transition: transform 140ms ease, background 140ms ease, opacity 140ms ease, box-shadow 140ms ease;
transform-origin: bottom;
box-shadow: 0 0 0 1px rgba(30, 64, 175, 0.28);
}
.timeline-bar:hover .timeline-bar-fill,
.timeline-bar.active .timeline-bar-fill {
background: linear-gradient(180deg, #38bdf8, #0ea5e9);
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.38), 0 0 18px rgba(14, 165, 233, 0.18);
}
.timeline-bar.selected .timeline-bar-fill {
background: linear-gradient(180deg, #22c55e, #86efac);
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.35), 0 0 18px rgba(34, 197, 94, 0.18);
}
.timeline-bar.active .timeline-bar-fill {
transform: translateY(-1px);
}
.timeline-bar.selected.active .timeline-bar-fill {
background: linear-gradient(180deg, #34d399, #10b981);
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.38), 0 0 22px rgba(16, 185, 129, 0.24);
}
.timeline-bar-meta {
position: absolute;
left: 50%;
bottom: -18px;
transform: translateX(-50%);
white-space: nowrap;
font-size: 0.72rem;
color: rgba(226, 232, 240, 0.82);
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.timeline-axis {
position: relative;
height: 20px;
margin-top: -2px;
color: rgba(226, 232, 240, 0.72);
}
.timeline-axis-label {
position: absolute;
top: 0;
transform: translateX(-50%);
font-size: 0.72rem;
color: rgba(226, 232, 240, 0.78);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.timeline-selection {
position: absolute;
top: 14px;
bottom: 34px;
border-radius: 10px;
border: 1px solid rgba(56, 189, 248, 0.55);
background:
linear-gradient(180deg, rgba(56, 189, 248, 0.18), rgba(14, 165, 233, 0.08)),
rgba(14, 165, 233, 0.12);
pointer-events: none;
opacity: 0;
transition: opacity 120ms ease;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 0 22px rgba(14, 165, 233, 0.12);
}
.timeline-selection.visible {
opacity: 1;
}
.timeline-brush {
position: absolute;
top: 14px;
bottom: 34px;
border-radius: 10px;
border: 1px dashed rgba(56, 189, 248, 0.9);
background: rgba(56, 189, 248, 0.16);
pointer-events: none;
opacity: 0;
box-shadow: 0 0 20px rgba(56, 189, 248, 0.12);
}
.timeline-brush.visible {
opacity: 1;
}
.timeline-footer {
font-size: 0.84rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.timeline-footer span:last-child {
color: rgba(51, 65, 85, 0.92);
}
@media (max-width: 920px) {
.layout {
grid-template-columns: 1fr;
}
main {
grid-template-rows: minmax(400px, 1fr) auto;
}
#map {
min-height: 460px;
}
.timeline-header,
.timeline-footer {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<header>
<div class="brand">
<h1>mapy-mg</h1>
<p>Load photos, read EXIF locally in the browser, and show them on OpenStreetMap.</p>
</div>
<div class="meta">Client-side import · no image storage on the server</div>
</header>
<div class="layout">
<aside>
<section class="card">
<h2>Nextcloud Share</h2>
<p>Paste a public share link. The app loads the images in the browser and extracts GPS data locally.</p>
<label>
Share URL
<input
id="share-url"
placeholder="https://cloud.example.com/index.php/s/..."
spellcheck="false"
autocomplete="off"
/>
</label>
<div class="button-row">
<button class="primary" id="load-share" type="button">
<span class="button-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2v7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<path d="M5 6.5 8 9.5 11 6.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 12.5h10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</span>
<span>Load share</span>
</button>
<button class="danger" id="cancel-share" type="button" disabled>
<span class="button-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</span>
<span>Cancel</span>
</button>
</div>
<div class="status-row">
<span class="spinner" id="activity-spinner" aria-hidden="true"></span>
<div class="status" id="share-status">Ready to load a share.</div>
</div>
<div class="progress" aria-label="Loading progress">
<div class="progress-bar" aria-hidden="true">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-meta">
<span id="progress-text">0 / 0</span>
<span id="progress-detail">ready</span>
</div>
</div>
<small>Public Nextcloud shares are read via WebDAV.</small>
</section>
<section class="card">
<h2>Photos</h2>
<small id="photo-count" class="muted">0 photos</small>
<div id="photo-list" class="list">
<div class="empty-state" id="empty-state">No images loaded yet.</div>
</div>
</section>
</aside>
<main>
<section class="map-panel">
<div class="map-label">Hover: thumbnail · click: fullscreen · route: time sorted</div>
<div id="map"></div>
</section>
<section class="card timeline" aria-label="Photo timeline">
<div class="timeline-header">
<div>
<h2>Timeline</h2>
<div class="timeline-summary" id="timeline-summary">No photos loaded yet.</div>
</div>
<button class="secondary" id="timeline-clear" type="button" disabled>Clear range</button>
</div>
<div class="timeline-chart" id="timeline-chart">
<div class="timeline-bars" id="timeline-bars"></div>
<div class="timeline-selection" id="timeline-selection"></div>
<div class="timeline-brush" id="timeline-brush"></div>
</div>
<div class="timeline-axis" id="timeline-axis"></div>
<div class="timeline-footer">
<span id="timeline-range">All photos</span>
<span id="timeline-unit">Scale: day</span>
</div>
</section>
</main>
</div>
<div class="overlay" id="overlay" aria-hidden="true">
<div class="overlay-card">
<header>
<strong id="overlay-title">Photo</strong>
<button class="close" id="close-overlay" type="button">
<span class="button-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</span>
<span>Close</span>
</button>
</header>
<img id="overlay-image" alt="" />
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script type="module">
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: {
unit: "day",
bins: [],
start: null,
end: null
}
};
let activeImportController = null;
const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "&copy; OpenStreetMap-Mitwirkende"
}).addTo(map);
const markers = L.layerGroup().addTo(map);
const route = L.polyline([], {
color: "#2d6cdf",
weight: 4,
opacity: 0.85
}).addTo(map);
const overlay = mustGet("overlay");
const overlayTitle = mustGet("overlay-title");
const overlayImage = mustGet("overlay-image");
const closeOverlay = mustGet("close-overlay");
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 timelineClear = mustGet("timeline-clear");
const timelineChart = mustGet("timeline-chart");
const timelineBars = mustGet("timeline-bars");
const timelineSelection = mustGet("timeline-selection");
const timelineBrush = mustGet("timeline-brush");
const timelineAxis = mustGet("timeline-axis");
const timelineRange = mustGet("timeline-range");
const timelineUnit = mustGet("timeline-unit");
const shareUrl = mustGet("share-url");
const shareStatus = mustGet("share-status");
const loadShare = mustGet("load-share");
const cancelShare = mustGet("cancel-share");
function mustGet(id) {
const element = document.getElementById(id);
if (!element) {
throw new Error("Missing UI element: " + id);
}
return element;
}
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 startOfDay(date) {
const copy = new Date(date);
copy.setHours(0, 0, 0, 0);
return copy;
}
function startOfWeek(date) {
const copy = startOfDay(date);
const day = copy.getDay();
const offset = (day + 6) % 7;
copy.setDate(copy.getDate() - offset);
return copy;
}
function startOfMonth(date) {
const copy = startOfDay(date);
copy.setDate(1);
return copy;
}
function addUnit(date, unit) {
const copy = new Date(date);
if (unit === "hour") {
copy.setHours(copy.getHours() + 1);
return copy;
}
if (unit === "day") {
copy.setDate(copy.getDate() + 1);
return copy;
}
if (unit === "week") {
copy.setDate(copy.getDate() + 7);
return copy;
}
copy.setMonth(copy.getMonth() + 1);
return copy;
}
function getUnitStart(date, unit) {
if (unit === "hour") {
return startOfHour(date);
}
if (unit === "day") {
return startOfDay(date);
}
if (unit === "week") {
return startOfWeek(date);
}
return startOfMonth(date);
}
function chooseTimelineUnit(spanMs) {
const hour = 60 * 60 * 1000;
const day = 24 * hour;
const week = 7 * day;
if (spanMs <= 48 * hour) {
return "hour";
}
if (spanMs <= 21 * day) {
return "day";
}
if (spanMs <= 16 * week) {
return "week";
}
return "month";
}
function formatTimelineLabel(date, unit) {
if (unit === "hour") {
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
});
}
if (unit === "day") {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric"
});
}
if (unit === "week") {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric"
});
}
return date.toLocaleDateString("en-US", {
month: "short",
year: "numeric"
});
}
function formatTimelineSummary(photos, unit) {
if (!photos.length) {
return "No photos loaded yet.";
}
const count = photos.length + (photos.length === 1 ? " photo" : " photos");
const unitLabel = {
hour: "hourly",
day: "daily",
week: "weekly",
month: "monthly"
}[unit] || unit;
return count + " across a " + unitLabel + " timeline.";
}
function formatSelectionRange(start, end) {
return (
formatTimelineLabel(start, state.timeline.unit) +
" - " +
formatTimelineLabel(end, state.timeline.unit)
);
}
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 {
unit: "day",
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 unit = chooseTimelineUnit(spanMs);
const domainStart = getUnitStart(startDate, unit);
const domainEnd = addUnit(getUnitStart(endDate, unit), unit);
const bins = [];
for (let cursor = new Date(domainStart); cursor < domainEnd; cursor = addUnit(cursor, unit)) {
const next = addUnit(cursor, unit);
bins.push({
start: new Date(cursor),
end: next,
count: 0,
photoIds: [],
label: formatTimelineLabel(cursor, unit)
});
}
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 = getUnitStart(entry.date, unit).getTime();
const binIndex = binIndexByStart.get(bucketStart);
if (binIndex !== undefined) {
const bin = bins[binIndex];
bin.count += 1;
bin.photoIds.push(entry.photo.id);
}
}
return {
unit,
bins,
start: domainStart,
end: domainEnd
};
}
function updateStatus(message, tone = "info") {
shareStatus.textContent = message;
shareStatus.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);
loadShare.disabled = isImporting;
cancelShare.disabled = !isImporting;
}
function clearObjectUrls() {
for (const url of state.objectUrls) {
URL.revokeObjectURL(url);
}
state.objectUrls = [];
}
function clearGallery() {
state.photos = [];
state.visiblePhotos = [];
state.activePhotoId = null;
state.timelineSelection = null;
state.timelineViewport = null;
state.timeline = {
unit: "day",
bins: [],
start: null,
end: null
};
markers.clearLayers();
route.setLatLngs([]);
photoList.replaceChildren(emptyState);
emptyState.textContent = "No images loaded yet.";
photoCount.textContent = "0 photos";
timelineSummary.textContent = "No photos loaded yet.";
timelineRange.textContent = "All photos";
timelineUnit.textContent = "Scale: day";
timelineClear.disabled = true;
timelineBars.replaceChildren();
timelineAxis.replaceChildren();
timelineSelection.classList.remove("visible");
timelineSelection.style.left = "0";
timelineSelection.style.width = "0";
timelineBrush.classList.remove("visible");
setProgress(0, 0, "ready");
}
function openOverlay(photo) {
overlay.classList.add("open");
overlay.setAttribute("aria-hidden", "false");
overlayTitle.textContent = photo.name;
overlayImage.src = photo.fullUrl;
overlayImage.alt = photo.name;
}
function closeOverlayView() {
overlay.classList.remove("open");
overlay.setAttribute("aria-hidden", "true");
}
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;
timelineClear.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
};
timelineClear.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, timeline.unit);
timelineUnit.textContent = "Scale: " + timeline.unit + (state.timelineSelection ? " · zoomed" : "");
timelineBars.replaceChildren();
timelineAxis.replaceChildren();
if (!timeline.bins.length) {
timelineRange.textContent = "All photos";
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);
});
const selection = state.timelineSelection;
timelineRange.textContent = selection
? formatSelectionRange(new Date(selection.start), new Date(selection.end))
: "All photos";
const axisLabels = [];
timeline.bins.forEach((bin, index) => {
if (index % labelStep !== 0 && index !== timeline.bins.length - 1) {
return;
}
const label = document.createElement("span");
label.className = "timeline-axis-label";
const position = timeline.bins.length === 1 ? 0 : index / (timeline.bins.length - 1);
label.style.left = position * 100 + "%";
label.textContent = bin.label;
axisLabels.push(label);
});
axisLabels.forEach((label) => timelineAxis.appendChild(label));
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 loaded yet.";
photoList.appendChild(emptyState);
photoCount.textContent = "0 photos";
} else {
emptyState.textContent = "No images loaded 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]).addTo(markers);
marker.bindPopup(
'<div class="thumb">' +
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
'<strong>' + photo.name + '</strong>' +
'<small>' + formatDate(photo.capturedAt) + '</small>' +
'<button type="button" data-open="' + photo.id + '">Open</button>' +
'</div>'
);
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" : "");
item.innerHTML =
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
'<div><strong>' + photo.name + '</strong><span>' + formatDate(photo.capturedAt) + '</span></div>';
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] });
}
}
}
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);
timelineClear.addEventListener("click", clearTimelineSelection);
function appendPhoto(photo) {
state.photos.push(photo);
renderVisiblePhotos({ 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 loadShareListing(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 readRemotePhoto(entry, signal) {
const response = await fetch("/api/nextcloud/blob?url=" + encodeURIComponent(entry.href), {
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 objectUrl = URL.createObjectURL(blob);
state.objectUrls.push(objectUrl);
return {
id: entry.href,
name: entry.name,
latitude: gps?.latitude ?? null,
longitude: gps?.longitude ?? null,
capturedAt,
thumbUrl: objectUrl,
fullUrl: objectUrl,
source: "nextcloud"
};
}
async function importFromNextcloud() {
if (activeImportController) {
return;
}
const controller = new AbortController();
activeImportController = controller;
setImporting(true);
try {
clearObjectUrls();
clearGallery();
updateStatus("Loading share...");
const listing = await loadShareListing(shareUrl.value, controller.signal);
if (!listing.length) {
throw new Error("No images were 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 readRemotePhoto(entry, 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,
"loaded " + loaded + ", skipped " + skipped
);
updateStatus(
"Import running: " +
loaded +
" images shown, " +
skipped +
" skipped."
);
}
if (!loaded) {
throw new Error("No images with GPS data were found.");
}
updateStatus(
"Nextcloud import complete: " +
loaded +
" images loaded" +
(skipped ? ", " + skipped + " skipped" : "") +
"."
);
setProgress(listing.length, listing.length, "done");
} catch (error) {
if (controller.signal.aborted) {
updateStatus("Import canceled.");
setProgress(state.processed, state.total, "canceled");
return;
}
console.error(error);
updateStatus(
"Import failed: " + (error instanceof Error ? error.message : "unknown error"),
"error"
);
} finally {
activeImportController = null;
setImporting(false);
}
}
document.addEventListener("click", (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.dataset.open) {
const photo = state.photos.find((item) => item.id === target.dataset.open);
if (photo) {
openOverlay(photo);
}
}
});
loadShare.addEventListener("click", () => {
void importFromNextcloud();
});
cancelShare.addEventListener("click", () => {
if (activeImportController) {
activeImportController.abort("Import canceled by user");
}
});
clearGallery();
updateStatus("Ready to load a share.");
</script>
</body>
</html>`;
}
export function createRequestHandler() {
return async function handleRequest(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === "/health") {
res.statusCode = 200;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify({ status: "ok" }));
return;
}
if (url.pathname === "/api/nextcloud/list") {
const share = url.searchParams.get("share");
if (!share) {
res.statusCode = 400;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify({ error: "missing `share` parameter" }));
return;
}
try {
const davUrl = resolveDavUrlFromShareUrl(share);
const xml =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<d:propfind xmlns:d="DAV:">' +
"<d:prop>" +
"<d:displayname/>" +
"<d:getlastmodified/>" +
"<d:getcontenttype/>" +
"<d:getcontentlength/>" +
"<d:resourcetype/>" +
"</d:prop>" +
"</d:propfind>";
const upstream = await proxyUpstream(davUrl, {
method: "PROPFIND",
headers: {
Depth: "1",
"Content-Type": "application/xml; charset=utf-8",
Accept: "application/xml, text/xml"
},
body: xml
});
res.statusCode = upstream.status;
res.setHeader("content-type", upstream.contentType);
res.end(upstream.body);
return;
} catch (error) {
res.statusCode = 502;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : "upstream request failed"
})
);
return;
}
}
if (url.pathname === "/api/nextcloud/blob") {
const target = url.searchParams.get("url");
if (!target) {
res.statusCode = 400;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify({ error: "missing `url` parameter" }));
return;
}
try {
const upstream = await proxyUpstream(target);
res.statusCode = upstream.status;
res.setHeader("content-type", upstream.contentType);
res.end(upstream.body);
return;
} catch (error) {
res.statusCode = 502;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : "upstream request failed"
})
);
return;
}
}
res.statusCode = 200;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(htmlPage());
};
}