feat: load nextcloud shares in browser
This commit is contained in:
@@ -1,38 +1,64 @@
|
||||
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",
|
||||
lat: 52.516275,
|
||||
lon: 13.377704,
|
||||
capturedAt: "2026-06-07T08:20:00Z",
|
||||
thumb:
|
||||
"https://images.unsplash.com/photo-1467269204594-9661b134dd2b?auto=format&fit=crop&w=200&q=60"
|
||||
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: "2",
|
||||
id: "demo-2",
|
||||
name: "museum-island.jpg",
|
||||
lat: 52.5169,
|
||||
lon: 13.4015,
|
||||
capturedAt: "2026-06-07T08:42:00Z",
|
||||
thumb:
|
||||
"https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=200&q=60"
|
||||
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: "3",
|
||||
id: "demo-3",
|
||||
name: "alexanderplatz.jpg",
|
||||
lat: 52.521918,
|
||||
lon: 13.413215,
|
||||
capturedAt: "2026-06-07T09:05:00Z",
|
||||
thumb:
|
||||
"https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=200&q=60"
|
||||
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"
|
||||
}
|
||||
] as const;
|
||||
];
|
||||
|
||||
function htmlPage(): string {
|
||||
const points = JSON.stringify(demoPhotos);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
@@ -50,10 +76,11 @@ function htmlPage(): string {
|
||||
color-scheme: light;
|
||||
--bg: #f3efe6;
|
||||
--panel: rgba(255, 255, 255, 0.82);
|
||||
--panel-border: rgba(33, 37, 41, 0.12);
|
||||
--text: #1f2937;
|
||||
--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);
|
||||
}
|
||||
|
||||
@@ -63,8 +90,13 @@ function htmlPage(): string {
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
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:
|
||||
@@ -73,34 +105,34 @@ function htmlPage(): string {
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 20px 24px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 12px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 2.4vw, 2.2rem);
|
||||
font-size: clamp(1.6rem, 2.4vw, 2.2rem);
|
||||
}
|
||||
|
||||
.brand p,
|
||||
.meta {
|
||||
margin: 6px 0 0;
|
||||
.meta,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.layout {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 340px 1fr;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 16px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
@@ -110,44 +142,92 @@ function htmlPage(): string {
|
||||
.overlay-card {
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid var(--panel-border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
aside {
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 1.5px dashed rgba(45, 108, 223, 0.35);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dropzone strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
.card {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.dropzone small,
|
||||
.section small {
|
||||
.card h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card p,
|
||||
.card small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section {
|
||||
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 {
|
||||
@@ -158,6 +238,7 @@ function htmlPage(): string {
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.photo img {
|
||||
@@ -189,15 +270,16 @@ function htmlPage(): string {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.status {
|
||||
.map-label {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 16px;
|
||||
z-index: 500;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -206,7 +288,7 @@ function htmlPage(): string {
|
||||
inset: 0;
|
||||
display: none;
|
||||
place-items: center;
|
||||
background: rgba(8, 12, 18, 0.78);
|
||||
background: rgba(8, 12, 18, 0.82);
|
||||
z-index: 1000;
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -216,41 +298,29 @@ function htmlPage(): string {
|
||||
}
|
||||
|
||||
.overlay-card {
|
||||
max-width: min(1100px, 100%);
|
||||
width: 100%;
|
||||
width: min(1120px, 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay-card header {
|
||||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 78vh;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.close {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: #111827;
|
||||
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 {
|
||||
@@ -268,11 +338,19 @@ function htmlPage(): string {
|
||||
|
||||
.thumb button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--accent);
|
||||
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) {
|
||||
@@ -290,27 +368,42 @@ function htmlPage(): string {
|
||||
<header>
|
||||
<div class="brand">
|
||||
<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 class="meta">OpenStreetMap + Leaflet · Prototyp</div>
|
||||
<div class="meta">Client-only Import · kein Bildspeicher auf dem Server</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div class="dropzone">
|
||||
<strong>Fotos hochladen</strong>
|
||||
<small>Der Upload ist im ersten Schritt nur als Oberfläche vorbereitet. Später lesen wir EXIF und Positionen daraus aus.</small>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="section">
|
||||
<strong>Beispieldaten</strong>
|
||||
<small>${demoPhotos.length} Marker zur Vorschau</small>
|
||||
<div class="list" id="photo-list"></div>
|
||||
</div>
|
||||
<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="status">Marker per Hover -> Thumbnail, Klick -> Vollbild</div>
|
||||
<div class="map-label">Hover: Thumbnail · Klick: Vollbild · Route: zeitlich sortiert</div>
|
||||
<div id="map"></div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -326,87 +419,352 @@ function htmlPage(): string {
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script>
|
||||
const photos = ${points};
|
||||
const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
|
||||
<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'
|
||||
attribution: "© OpenStreetMap-Mitwirkende"
|
||||
}).addTo(map);
|
||||
|
||||
const overlay = document.getElementById("overlay");
|
||||
const overlayImage = document.getElementById("overlay-image");
|
||||
const overlayTitle = document.getElementById("overlay-title");
|
||||
const closeOverlay = document.getElementById("close-overlay");
|
||||
const photoList = document.getElementById("photo-list");
|
||||
const markers = L.layerGroup().addTo(map);
|
||||
const route = L.polyline([], {
|
||||
color: "#2d6cdf",
|
||||
weight: 4,
|
||||
opacity: 0.85
|
||||
}).addTo(map);
|
||||
|
||||
if (!overlay || !overlayImage || !overlayTitle || !closeOverlay || !photoList) {
|
||||
throw new Error("UI elements missing");
|
||||
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.thumb;
|
||||
overlayImage.src = photo.fullUrl;
|
||||
overlayImage.alt = photo.name;
|
||||
}
|
||||
|
||||
function close() {
|
||||
function closeOverlayView() {
|
||||
overlay.classList.remove("open");
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
closeOverlay.addEventListener("click", close);
|
||||
closeOverlay.addEventListener("click", closeOverlayView);
|
||||
overlay.addEventListener("click", (event) => {
|
||||
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 marker = L.marker([photo.lat, photo.lon]).addTo(map);
|
||||
bounds.push([photo.lat, photo.lon]);
|
||||
const bounds = [];
|
||||
|
||||
marker.bindPopup(
|
||||
'<div class="thumb">' +
|
||||
'<img src="' + photo.thumb + '" alt="' + photo.name + '" />' +
|
||||
'<strong>' + photo.name + '</strong>' +
|
||||
'<small>' + new Date(photo.capturedAt).toLocaleString("de-DE") + '</small>' +
|
||||
'<button type="button" data-open="' + photo.id + '">Vollbild</button>' +
|
||||
'</div>'
|
||||
);
|
||||
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.on("mouseover", () => marker.openPopup());
|
||||
marker.on("click", () => openOverlay(photo));
|
||||
});
|
||||
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 = photos.find((entry) => entry.id === target.dataset.open);
|
||||
if (photo) openOverlay(photo);
|
||||
const photo = state.photos.find((item) => item.id === target.dataset.open);
|
||||
if (photo) {
|
||||
openOverlay(photo);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
photos.forEach((photo) => {
|
||||
const item = document.createElement("article");
|
||||
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);
|
||||
loadShare.addEventListener("click", () => {
|
||||
void importFromNextcloud();
|
||||
});
|
||||
|
||||
if (bounds.length) {
|
||||
map.fitBounds(bounds, { padding: [40, 40] });
|
||||
}
|
||||
useDemo.addEventListener("click", showDemo);
|
||||
|
||||
showDemo();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
Reference in New Issue
Block a user