feat: add progressive nextcloud loading
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user