feat: load nextcloud shares in browser

This commit is contained in:
2026-06-07 17:12:05 +02:00
parent 4ef8ae37b0
commit 3ec8b28cf9

View File

@@ -1,38 +1,64 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
const demoPhotos = [ 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: "1", id: "demo-1",
name: "berlin-brandenburg-gate.jpg", name: "berlin-brandenburg-gate.jpg",
lat: 52.516275, latitude: 52.516275,
lon: 13.377704, longitude: 13.377704,
capturedAt: "2026-06-07T08:20:00Z", capturedAt: "2026-06-07T08:20:00.000Z",
thumb: thumbUrl:
"https://images.unsplash.com/photo-1467269204594-9661b134dd2b?auto=format&fit=crop&w=200&q=60" "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: "2", id: "demo-2",
name: "museum-island.jpg", name: "museum-island.jpg",
lat: 52.5169, latitude: 52.5169,
lon: 13.4015, longitude: 13.4015,
capturedAt: "2026-06-07T08:42:00Z", capturedAt: "2026-06-07T08:42:00.000Z",
thumb: thumbUrl:
"https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=200&q=60" "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: "3", id: "demo-3",
name: "alexanderplatz.jpg", name: "alexanderplatz.jpg",
lat: 52.521918, latitude: 52.521918,
lon: 13.413215, longitude: 13.413215,
capturedAt: "2026-06-07T09:05:00Z", capturedAt: "2026-06-07T09:05:00.000Z",
thumb: thumbUrl:
"https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=200&q=60" "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"
} }
] as const; ];
function htmlPage(): string { function htmlPage(): string {
const points = JSON.stringify(demoPhotos);
return `<!doctype html> return `<!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
@@ -50,10 +76,11 @@ function htmlPage(): string {
color-scheme: light; color-scheme: light;
--bg: #f3efe6; --bg: #f3efe6;
--panel: rgba(255, 255, 255, 0.82); --panel: rgba(255, 255, 255, 0.82);
--panel-border: rgba(33, 37, 41, 0.12); --border: rgba(15, 23, 42, 0.12);
--text: #1f2937; --text: #16202a;
--muted: #5b6472; --muted: #5b6472;
--accent: #2d6cdf; --accent: #2d6cdf;
--accent-strong: #1f56c2;
--shadow: 0 18px 50px rgba(23, 31, 45, 0.14); --shadow: 0 18px 50px rgba(23, 31, 45, 0.14);
} }
@@ -63,8 +90,13 @@ function htmlPage(): string {
html, html,
body { body {
height: 100%;
margin: 0; margin: 0;
height: 100%;
}
body {
display: grid;
grid-template-rows: auto 1fr;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text); color: var(--text);
background: background:
@@ -73,34 +105,34 @@ function htmlPage(): string {
var(--bg); var(--bg);
} }
body {
display: grid;
grid-template-rows: auto 1fr;
}
header { header {
padding: 20px 24px 12px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 16px;
align-items: end; align-items: end;
gap: 16px;
padding: 20px 24px 12px;
}
h1,
h2,
p {
margin: 0;
} }
.brand h1 { .brand h1 {
margin: 0; font-size: clamp(1.6rem, 2.4vw, 2.2rem);
font-size: clamp(1.5rem, 2.4vw, 2.2rem);
} }
.brand p, .brand p,
.meta { .meta,
margin: 6px 0 0; .muted {
color: var(--muted); color: var(--muted);
} }
.layout { .layout {
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: 340px 1fr; grid-template-columns: 360px 1fr;
gap: 16px; gap: 16px;
padding: 0 16px 16px; padding: 0 16px 16px;
} }
@@ -110,44 +142,92 @@ function htmlPage(): string {
.overlay-card { .overlay-card {
background: var(--panel); background: var(--panel);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
border: 1px solid var(--panel-border); border: 1px solid var(--border);
border-radius: 20px; border-radius: 20px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
aside { aside {
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0; min-height: 0;
}
.dropzone {
border: 1.5px dashed rgba(45, 108, 223, 0.35);
border-radius: 16px;
padding: 18px; padding: 18px;
background: rgba(255, 255, 255, 0.7); display: grid;
gap: 16px;
} }
.dropzone strong { .card {
display: block; padding: 16px;
margin-bottom: 6px; border-radius: 16px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.72);
} }
.dropzone small, .card h2 {
.section small { font-size: 1rem;
margin-bottom: 8px;
}
.card p,
.card small {
color: var(--muted); color: var(--muted);
} }
.section { label {
display: grid; 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; 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 { .list {
display: grid; display: grid;
gap: 10px; gap: 10px;
max-height: min(44vh, 380px);
overflow: auto;
padding-right: 2px;
} }
.photo { .photo {
@@ -158,6 +238,7 @@ function htmlPage(): string {
padding: 10px; padding: 10px;
border-radius: 14px; border-radius: 14px;
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(15, 23, 42, 0.06);
} }
.photo img { .photo img {
@@ -189,15 +270,16 @@ function htmlPage(): string {
min-height: 560px; min-height: 560px;
} }
.status { .map-label {
position: absolute; position: absolute;
left: 16px;
top: 16px;
z-index: 500; z-index: 500;
top: 16px;
left: 16px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(0, 0, 0, 0.08); border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
font-size: 0.88rem; font-size: 0.88rem;
} }
@@ -206,7 +288,7 @@ function htmlPage(): string {
inset: 0; inset: 0;
display: none; display: none;
place-items: center; place-items: center;
background: rgba(8, 12, 18, 0.78); background: rgba(8, 12, 18, 0.82);
z-index: 1000; z-index: 1000;
padding: 24px; padding: 24px;
} }
@@ -216,41 +298,29 @@ function htmlPage(): string {
} }
.overlay-card { .overlay-card {
max-width: min(1100px, 100%); width: min(1120px, 100%);
width: 100%;
overflow: hidden; overflow: hidden;
} }
.overlay-card header { .overlay-card header {
padding: 16px 18px; padding: 16px 18px;
border-bottom: 1px solid var(--panel-border); border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
} }
.overlay-card img { .overlay-card img {
display: block;
width: 100%; width: 100%;
max-height: 78vh; max-height: 78vh;
object-fit: contain; object-fit: contain;
display: block;
background: #111; background: #111;
} }
.close { .close {
appearance: none;
border: 0;
background: #111827; background: #111827;
color: white; color: white;
border-radius: 999px;
padding: 8px 12px;
cursor: pointer;
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: rgba(255, 255, 255, 0.96);
}
.leaflet-popup-content {
margin: 10px 12px;
} }
.thumb { .thumb {
@@ -268,11 +338,19 @@ function htmlPage(): string {
.thumb button { .thumb button {
border: 0; border: 0;
border-radius: 10px;
padding: 8px 10px;
background: var(--accent); background: var(--accent);
color: white; color: white;
padding: 8px 10px; }
border-radius: 10px;
cursor: pointer; .leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: rgba(255, 255, 255, 0.97);
}
.leaflet-popup-content {
margin: 10px 12px;
} }
@media (max-width: 920px) { @media (max-width: 920px) {
@@ -290,27 +368,42 @@ function htmlPage(): string {
<header> <header>
<div class="brand"> <div class="brand">
<h1>mapy-mg</h1> <h1>mapy-mg</h1>
<p>Fotos hochladen, Positionen sichtbar machen und Wege grob nachzeichnen.</p> <p>Fotos laden, EXIF lokal im Browser auslesen und auf OpenStreetMap anzeigen.</p>
</div> </div>
<div class="meta">OpenStreetMap + Leaflet · Prototyp</div> <div class="meta">Client-only Import · kein Bildspeicher auf dem Server</div>
</header> </header>
<div class="layout"> <div class="layout">
<aside> <aside>
<div class="dropzone"> <section class="card">
<strong>Fotos hochladen</strong> <h2>Nextcloud Share</h2>
<small>Der Upload ist im ersten Schritt nur als Oberfläche vorbereitet. Später lesen wir EXIF und Positionen daraus aus.</small> <p>Public share-Link einfügen. Die App lädt die Bilder direkt im Browser und extrahiert die GPS-Daten lokal.</p>
</div> <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>
<div class="section"> <section class="card">
<strong>Beispieldaten</strong> <h2>Fotos</h2>
<small>${demoPhotos.length} Marker zur Vorschau</small> <small id="photo-count" class="muted">0 Bilder</small>
<div class="list" id="photo-list"></div> <div id="photo-list" class="list"></div>
</div> </section>
</aside> </aside>
<main> <main>
<div class="status">Marker per Hover -> Thumbnail, Klick -> Vollbild</div> <div class="map-label">Hover: Thumbnail · Klick: Vollbild · Route: zeitlich sortiert</div>
<div id="map"></div> <div id="map"></div>
</main> </main>
</div> </div>
@@ -326,87 +419,352 @@ function htmlPage(): string {
</div> </div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script> <script type="module">
const photos = ${points}; import exifr from "https://cdn.jsdelivr.net/npm/exifr@7.1.3/+esm";
const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
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", { L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19, maxZoom: 19,
attribution: '&copy; OpenStreetMap-Mitwirkende' attribution: "&copy; OpenStreetMap-Mitwirkende"
}).addTo(map); }).addTo(map);
const overlay = document.getElementById("overlay"); const markers = L.layerGroup().addTo(map);
const overlayImage = document.getElementById("overlay-image"); const route = L.polyline([], {
const overlayTitle = document.getElementById("overlay-title"); color: "#2d6cdf",
const closeOverlay = document.getElementById("close-overlay"); weight: 4,
const photoList = document.getElementById("photo-list"); opacity: 0.85
}).addTo(map);
if (!overlay || !overlayImage || !overlayTitle || !closeOverlay || !photoList) { const overlay = mustGet("overlay");
throw new Error("UI elements missing"); 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) { function openOverlay(photo) {
overlay.classList.add("open"); overlay.classList.add("open");
overlay.setAttribute("aria-hidden", "false"); overlay.setAttribute("aria-hidden", "false");
overlayTitle.textContent = photo.name; overlayTitle.textContent = photo.name;
overlayImage.src = photo.thumb; overlayImage.src = photo.fullUrl;
overlayImage.alt = photo.name; overlayImage.alt = photo.name;
} }
function close() { function closeOverlayView() {
overlay.classList.remove("open"); overlay.classList.remove("open");
overlay.setAttribute("aria-hidden", "true"); overlay.setAttribute("aria-hidden", "true");
} }
closeOverlay.addEventListener("click", close); closeOverlay.addEventListener("click", closeOverlayView);
overlay.addEventListener("click", (event) => { overlay.addEventListener("click", (event) => {
if (event.target === overlay) { if (event.target === overlay) {
close(); closeOverlayView();
} }
}); });
const bounds = []; function renderPhotos(photos) {
state.photos = photos;
markers.clearLayers();
route.setLatLngs([]);
photoList.replaceChildren();
photoCount.textContent = photos.length + (photos.length === 1 ? " Bild" : " Bilder");
photos.forEach((photo) => { const bounds = [];
const marker = L.marker([photo.lat, photo.lon]).addTo(map);
bounds.push([photo.lat, photo.lon]);
marker.bindPopup( for (const photo of photos) {
'<div class="thumb">' + if (photo.latitude !== null && photo.longitude !== null) {
'<img src="' + photo.thumb + '" alt="' + photo.name + '" />' + const marker = L.marker([photo.latitude, photo.longitude]).addTo(markers);
'<strong>' + photo.name + '</strong>' + bounds.push([photo.latitude, photo.longitude]);
'<small>' + new Date(photo.capturedAt).toLocaleString("de-DE") + '</small>' +
'<button type="button" data-open="' + photo.id + '">Vollbild</button>' +
'</div>'
);
marker.on("mouseover", () => marker.openPopup()); marker.bindPopup(
marker.on("click", () => openOverlay(photo)); '<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) => { document.addEventListener("click", (event) => {
const target = event.target; const target = event.target;
if (target instanceof HTMLElement && target.dataset.open) { if (target instanceof HTMLElement && target.dataset.open) {
const photo = photos.find((entry) => entry.id === target.dataset.open); const photo = state.photos.find((item) => item.id === target.dataset.open);
if (photo) openOverlay(photo); if (photo) {
openOverlay(photo);
}
} }
}); });
photos.forEach((photo) => { loadShare.addEventListener("click", () => {
const item = document.createElement("article"); void importFromNextcloud();
item.className = "photo";
item.innerHTML =
'<img src="' + photo.thumb + '" alt="' + photo.name + '">' +
'<div><strong>' + photo.name + '</strong><span>' +
new Date(photo.capturedAt).toLocaleString("de-DE") +
'</span></div>';
item.addEventListener("click", () => openOverlay(photo));
photoList.appendChild(item);
}); });
if (bounds.length) { useDemo.addEventListener("click", showDemo);
map.fitBounds(bounds, { padding: [40, 40] });
} showDemo();
</script> </script>
</body> </body>
</html>`; </html>`;