feat: add progressive nextcloud loading

This commit is contained in:
2026-06-07 17:44:59 +02:00
parent 9fe4752c2e
commit 36f65040cc

View File

@@ -250,6 +250,35 @@ function htmlPage(): string {
color: var(--muted); color: var(--muted);
} }
.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 { .list {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -420,6 +449,15 @@ function htmlPage(): string {
<button class="secondary" id="use-demo" type="button">Demo anzeigen</button> <button class="secondary" id="use-demo" type="button">Demo anzeigen</button>
</div> </div>
<div class="status" id="share-status">Demo-Daten aktiv.</div> <div class="status" id="share-status">Demo-Daten aktiv.</div>
<div class="progress" aria-label="Ladefortschritt">
<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">bereit</span>
</div>
</div>
<small>Hinweis: Öffentliche Nextcloud-Shares werden per WebDAV gelesen.</small> <small>Hinweis: Öffentliche Nextcloud-Shares werden per WebDAV gelesen.</small>
</section> </section>
@@ -453,7 +491,9 @@ function htmlPage(): string {
const DEMO_PHOTOS = ${JSON.stringify(demoPhotos)}; const DEMO_PHOTOS = ${JSON.stringify(demoPhotos)};
const state = { const state = {
photos: DEMO_PHOTOS, photos: DEMO_PHOTOS,
objectUrls: [] objectUrls: [],
processed: 0,
total: 0
}; };
const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13); const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
@@ -475,6 +515,9 @@ function htmlPage(): string {
const closeOverlay = mustGet("close-overlay"); const closeOverlay = mustGet("close-overlay");
const photoList = mustGet("photo-list"); const photoList = mustGet("photo-list");
const photoCount = mustGet("photo-count"); const photoCount = mustGet("photo-count");
const progressFill = mustGet("progress-fill");
const progressText = mustGet("progress-text");
const progressDetail = mustGet("progress-detail");
const shareUrl = mustGet("share-url"); const shareUrl = mustGet("share-url");
const shareStatus = mustGet("share-status"); const shareStatus = mustGet("share-status");
const loadShare = mustGet("load-share"); const loadShare = mustGet("load-share");
@@ -529,6 +572,14 @@ function htmlPage(): string {
shareStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)"; 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 clearObjectUrls() { function clearObjectUrls() {
for (const url of state.objectUrls) { for (const url of state.objectUrls) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
@@ -536,6 +587,15 @@ function htmlPage(): string {
state.objectUrls = []; state.objectUrls = [];
} }
function clearGallery() {
state.photos = [];
markers.clearLayers();
route.setLatLngs([]);
photoList.replaceChildren();
photoCount.textContent = "0 Bilder";
setProgress(0, 0, "bereit");
}
function openOverlay(photo) { function openOverlay(photo) {
overlay.classList.add("open"); overlay.classList.add("open");
overlay.setAttribute("aria-hidden", "false"); overlay.setAttribute("aria-hidden", "false");
@@ -556,53 +616,59 @@ function htmlPage(): string {
} }
}); });
function renderPhotos(photos) { function updateRouteView() {
state.photos = photos; const routePoints = state.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) .filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt)
.sort((a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt))) .sort((a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt)))
.map((photo) => [photo.latitude, photo.longitude]); .map((photo) => [photo.latitude, photo.longitude]);
if (routePoints.length > 1) { route.setLatLngs(routePoints);
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 appendPhoto(photo) {
state.photos.push(photo);
photoCount.textContent = state.photos.length + (state.photos.length === 1 ? " Bild" : " Bilder");
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 + '">Vollbild</button>' +
'</div>'
);
marker.on("mouseover", () => marker.openPopup());
marker.on("click", () => openOverlay(photo));
} }
if (bounds.length > 0) { const item = document.createElement("article");
map.fitBounds(bounds, { padding: [40, 40] }); 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);
updateRouteView();
}
function renderPhotos(photos) {
clearGallery();
let processed = 0;
for (const photo of photos) {
appendPhoto(photo);
processed += 1;
setProgress(processed, photos.length, "Demo geladen");
} }
} }
@@ -707,6 +773,8 @@ function htmlPage(): string {
async function importFromNextcloud() { async function importFromNextcloud() {
try { try {
clearObjectUrls();
clearGallery();
updateStatus("Share wird geladen..."); updateStatus("Share wird geladen...");
const { davUrl } = parseShareInput(shareUrl.value); const { davUrl } = parseShareInput(shareUrl.value);
const listing = await loadShareListing(davUrl); const listing = await loadShareListing(davUrl);
@@ -715,35 +783,53 @@ function htmlPage(): string {
throw new Error("Im Share wurden keine Bilder gefunden."); throw new Error("Im Share wurden keine Bilder gefunden.");
} }
const loaded = []; setProgress(0, listing.length, "Dateien werden geprüft");
const skipped = [];
let loaded = 0;
let skipped = 0;
let processed = 0;
for (const entry of listing) { for (const entry of listing) {
try { try {
const photo = await readRemotePhoto(entry); const photo = await readRemotePhoto(entry);
if (photo.latitude !== null && photo.longitude !== null) { if (photo.latitude !== null && photo.longitude !== null) {
loaded.push(photo); appendPhoto(photo);
loaded += 1;
} else { } else {
skipped.push(entry.name + " (kein GPS)"); skipped += 1;
} }
} catch (error) { } catch (error) {
skipped.push(entry.name); skipped += 1;
console.error(error); console.error(error);
} }
processed += 1;
setProgress(
processed,
listing.length,
"geladen " + loaded + ", übersprungen " + skipped
);
updateStatus(
"Import läuft: " +
loaded +
" Bilder angezeigt, " +
skipped +
" übersprungen."
);
} }
if (!loaded.length) { if (!loaded) {
throw new Error("Keine Bilder mit GPS-Daten gefunden."); throw new Error("Keine Bilder mit GPS-Daten gefunden.");
} }
renderPhotos(loaded);
updateStatus( updateStatus(
"Nextcloud-Import fertig: " + "Nextcloud-Import fertig: " +
loaded.length + loaded +
" Bilder geladen" + " Bilder geladen" +
(skipped.length ? ", " + skipped.length + " übersprungen" : "") + (skipped ? ", " + skipped + " übersprungen" : "") +
"." "."
); );
setProgress(listing.length, listing.length, "fertig");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
updateStatus( updateStatus(