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

789 lines
22 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;
};
const demoPhotos: Photo[] = [
{
id: "demo-1",
name: "berlin-brandenburg-gate.jpg",
latitude: 52.516275,
longitude: 13.377704,
capturedAt: "2026-06-07T08:20:00.000Z",
thumbUrl:
"https://images.unsplash.com/photo-1467269204594-9661b134dd2b?auto=format&fit=crop&w=240&q=60",
fullUrl:
"https://images.unsplash.com/photo-1467269204594-9661b134dd2b?auto=format&fit=crop&w=1600&q=80",
source: "demo"
},
{
id: "demo-2",
name: "museum-island.jpg",
latitude: 52.5169,
longitude: 13.4015,
capturedAt: "2026-06-07T08:42:00.000Z",
thumbUrl:
"https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=240&q=60",
fullUrl:
"https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=1600&q=80",
source: "demo"
},
{
id: "demo-3",
name: "alexanderplatz.jpg",
latitude: 52.521918,
longitude: 13.413215,
capturedAt: "2026-06-07T09:05:00.000Z",
thumbUrl:
"https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=240&q=60",
fullUrl:
"https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=1600&q=80",
source: "demo"
}
];
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;
}
.primary {
background: var(--accent);
color: white;
}
.primary:hover {
background: var(--accent-strong);
}
.secondary {
background: rgba(15, 23, 42, 0.08);
color: var(--text);
}
.status {
margin-top: 10px;
font-size: 0.9rem;
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;
}
main {
position: relative;
min-height: 0;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
min-height: 560px;
}
.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;
}
@media (max-width: 920px) {
.layout {
grid-template-columns: 1fr;
}
#map {
min-height: 460px;
}
}
</style>
</head>
<body>
<header>
<div class="brand">
<h1>mapy-mg</h1>
<p>Fotos laden, EXIF lokal im Browser auslesen und auf OpenStreetMap anzeigen.</p>
</div>
<div class="meta">Client-only Import · kein Bildspeicher auf dem Server</div>
</header>
<div class="layout">
<aside>
<section class="card">
<h2>Nextcloud Share</h2>
<p>Public share-Link einfügen. Die App lädt die Bilder direkt im Browser und extrahiert die GPS-Daten lokal.</p>
<label>
Share-Link
<input
id="share-url"
value="https://cloud.br0tkasten.de/index.php/s/cjYjeSYZwgLJNBT"
spellcheck="false"
autocomplete="off"
/>
</label>
<div class="button-row">
<button class="primary" id="load-share" type="button">Share laden</button>
<button class="secondary" id="use-demo" type="button">Demo anzeigen</button>
</div>
<div class="status" id="share-status">Demo-Daten aktiv.</div>
<small>Hinweis: Öffentliche Nextcloud-Shares werden per WebDAV gelesen.</small>
</section>
<section class="card">
<h2>Fotos</h2>
<small id="photo-count" class="muted">0 Bilder</small>
<div id="photo-list" class="list"></div>
</section>
</aside>
<main>
<div class="map-label">Hover: Thumbnail · Klick: Vollbild · Route: zeitlich sortiert</div>
<div id="map"></div>
</main>
</div>
<div class="overlay" id="overlay" aria-hidden="true">
<div class="overlay-card">
<header>
<strong id="overlay-title">Foto</strong>
<button class="close" id="close-overlay" type="button">Schließen</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 DEMO_PHOTOS = ${JSON.stringify(demoPhotos)};
const state = {
photos: DEMO_PHOTOS,
objectUrls: []
};
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 photoCount = mustGet("photo-count");
const shareUrl = mustGet("share-url");
const shareStatus = mustGet("share-status");
const loadShare = mustGet("load-share");
const useDemo = mustGet("use-demo");
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 "kein Zeitstempel";
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("de-DE");
}
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 updateStatus(message, tone = "info") {
shareStatus.textContent = message;
shareStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)";
}
function clearObjectUrls() {
for (const url of state.objectUrls) {
URL.revokeObjectURL(url);
}
state.objectUrls = [];
}
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 renderPhotos(photos) {
state.photos = photos;
markers.clearLayers();
route.setLatLngs([]);
photoList.replaceChildren();
photoCount.textContent = photos.length + (photos.length === 1 ? " Bild" : " Bilder");
const bounds = [];
for (const photo of photos) {
if (photo.latitude !== null && photo.longitude !== null) {
const marker = L.marker([photo.latitude, photo.longitude]).addTo(markers);
bounds.push([photo.latitude, photo.longitude]);
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 + '">Vollbild</button>' +
'</div>'
);
marker.on("mouseover", () => marker.openPopup());
marker.on("click", () => openOverlay(photo));
}
const item = document.createElement("article");
item.className = "photo";
item.innerHTML =
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
'<div><strong>' + photo.name + '</strong><span>' + formatDate(photo.capturedAt) + '</span></div>';
item.addEventListener("click", () => openOverlay(photo));
photoList.appendChild(item);
}
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]);
if (routePoints.length > 1) {
route.setLatLngs(routePoints);
}
if (bounds.length > 0) {
map.fitBounds(bounds, { padding: [40, 40] });
}
}
function parseShareInput(value) {
const trimmed = value.trim();
const url = new URL(trimmed);
const publicShare = url.pathname.match(/\/s\/([^/?#]+)/);
if (publicShare) {
return {
origin: url.origin,
davUrl: url.origin + "/public.php/dav/files/" + publicShare[1] + "/"
};
}
const davShare = url.pathname.match(/\/public\.php\/dav\/files\/([^/?#]+)\/?/);
if (davShare) {
return {
origin: url.origin,
davUrl: url.origin + "/public.php/dav/files/" + davShare[1] + "/"
};
}
throw new Error("Bitte einen öffentlichen Nextcloud-Share-Link einfügen.");
}
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() || "Bild"),
lastModified,
contentType,
isCollection
};
})
.filter((entry): entry is ShareListingEntry => 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(davUrl) {
const response = await fetch(davUrl, {
method: "PROPFIND",
mode: "cors",
headers: {
Depth: "1",
"Content-Type": "application/xml; charset=utf-8",
Accept: "application/xml, text/xml"
},
body:
'<?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>"
});
if (!response.ok) {
throw new Error("WebDAV hat mit Status " + response.status + " geantwortet.");
}
return parseListing(await response.text(), davUrl);
}
async function readRemotePhoto(entry) {
const response = await fetch(entry.href, { mode: "cors" });
if (!response.ok) {
throw new Error("Bild konnte nicht geladen werden: " + 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() {
try {
updateStatus("Share wird geladen...");
const { davUrl } = parseShareInput(shareUrl.value);
const listing = await loadShareListing(davUrl);
if (!listing.length) {
throw new Error("Im Share wurden keine Bilder gefunden.");
}
const loaded = [];
const skipped = [];
for (const entry of listing) {
try {
const photo = await readRemotePhoto(entry);
if (photo.latitude !== null && photo.longitude !== null) {
loaded.push(photo);
} else {
skipped.push(entry.name + " (kein GPS)");
}
} catch (error) {
skipped.push(entry.name);
console.error(error);
}
}
if (!loaded.length) {
throw new Error("Keine Bilder mit GPS-Daten gefunden.");
}
renderPhotos(loaded);
updateStatus(
"Nextcloud-Import fertig: " +
loaded.length +
" Bilder geladen" +
(skipped.length ? ", " + skipped.length + " übersprungen" : "") +
"."
);
} catch (error) {
console.error(error);
updateStatus(
"Import fehlgeschlagen: " + (error instanceof Error ? error.message : "Unbekannter Fehler"),
"error"
);
}
}
function showDemo() {
clearObjectUrls();
renderPhotos(DEMO_PHOTOS);
updateStatus("Demo-Daten aktiv.");
}
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();
});
useDemo.addEventListener("click", showDemo);
showDemo();
</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;
}
res.statusCode = 200;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(htmlPage());
};
}