789 lines
22 KiB
TypeScript
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: "© 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());
|
|
};
|
|
}
|