feat: add import spinner and cancel action

This commit is contained in:
2026-06-07 19:35:09 +02:00
parent 36f65040cc
commit 8218583ffa

View File

@@ -244,12 +244,51 @@ function htmlPage(): string {
color: var(--text); color: var(--text);
} }
.danger {
background: rgba(157, 23, 77, 0.12);
color: #9d174d;
}
.danger:hover {
background: rgba(157, 23, 77, 0.18);
}
.status { .status {
margin-top: 10px; margin-top: 10px;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted); color: var(--muted);
} }
.status-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(45, 108, 223, 0.22);
border-top-color: var(--accent);
opacity: 0;
transform: scale(0.8);
transition: opacity 120ms ease, transform 120ms ease;
}
.spinner.active {
opacity: 1;
transform: scale(1);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.progress { .progress {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -446,9 +485,13 @@ function htmlPage(): string {
</label> </label>
<div class="button-row"> <div class="button-row">
<button class="primary" id="load-share" type="button">Share laden</button> <button class="primary" id="load-share" type="button">Share laden</button>
<button class="danger" id="cancel-share" type="button" disabled>Abbrechen</button>
<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-row">
<span class="spinner" id="activity-spinner" aria-hidden="true"></span>
<div class="status" id="share-status">Demo-Daten aktiv.</div>
</div>
<div class="progress" aria-label="Ladefortschritt"> <div class="progress" aria-label="Ladefortschritt">
<div class="progress-bar" aria-hidden="true"> <div class="progress-bar" aria-hidden="true">
<div class="progress-fill" id="progress-fill"></div> <div class="progress-fill" id="progress-fill"></div>
@@ -495,6 +538,7 @@ function htmlPage(): string {
processed: 0, processed: 0,
total: 0 total: 0
}; };
let activeImportController = null;
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);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
@@ -518,9 +562,11 @@ function htmlPage(): string {
const progressFill = mustGet("progress-fill"); const progressFill = mustGet("progress-fill");
const progressText = mustGet("progress-text"); const progressText = mustGet("progress-text");
const progressDetail = mustGet("progress-detail"); const progressDetail = mustGet("progress-detail");
const activitySpinner = mustGet("activity-spinner");
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");
const cancelShare = mustGet("cancel-share");
const useDemo = mustGet("use-demo"); const useDemo = mustGet("use-demo");
function mustGet(id) { function mustGet(id) {
@@ -580,6 +626,13 @@ function htmlPage(): string {
progressDetail.textContent = detail; progressDetail.textContent = detail;
} }
function setImporting(isImporting) {
activitySpinner.classList.toggle("active", isImporting);
loadShare.disabled = isImporting;
useDemo.disabled = isImporting;
cancelShare.disabled = !isImporting;
}
function clearObjectUrls() { function clearObjectUrls() {
for (const url of state.objectUrls) { for (const url of state.objectUrls) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
@@ -733,9 +786,12 @@ function htmlPage(): string {
}); });
} }
async function loadShareListing(davUrl) { async function loadShareListing(davUrl, signal) {
const response = await fetch( const response = await fetch(
"/api/nextcloud/list?share=" + encodeURIComponent(shareUrl.value.trim()) "/api/nextcloud/list?share=" + encodeURIComponent(shareUrl.value.trim()),
{
signal
}
); );
if (!response.ok) { if (!response.ok) {
@@ -745,8 +801,10 @@ function htmlPage(): string {
return parseListing(await response.text(), davUrl); return parseListing(await response.text(), davUrl);
} }
async function readRemotePhoto(entry) { async function readRemotePhoto(entry, signal) {
const response = await fetch("/api/nextcloud/blob?url=" + encodeURIComponent(entry.href)); const response = await fetch("/api/nextcloud/blob?url=" + encodeURIComponent(entry.href), {
signal
});
if (!response.ok) { if (!response.ok) {
throw new Error("Bild konnte nicht geladen werden: " + entry.name); throw new Error("Bild konnte nicht geladen werden: " + entry.name);
} }
@@ -772,12 +830,20 @@ function htmlPage(): string {
} }
async function importFromNextcloud() { async function importFromNextcloud() {
if (activeImportController) {
return;
}
const controller = new AbortController();
activeImportController = controller;
setImporting(true);
try { try {
clearObjectUrls(); clearObjectUrls();
clearGallery(); 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, controller.signal);
if (!listing.length) { if (!listing.length) {
throw new Error("Im Share wurden keine Bilder gefunden."); throw new Error("Im Share wurden keine Bilder gefunden.");
@@ -790,8 +856,12 @@ function htmlPage(): string {
let processed = 0; let processed = 0;
for (const entry of listing) { for (const entry of listing) {
if (controller.signal.aborted) {
throw new DOMException("Import abgebrochen", "AbortError");
}
try { try {
const photo = await readRemotePhoto(entry); const photo = await readRemotePhoto(entry, controller.signal);
if (photo.latitude !== null && photo.longitude !== null) { if (photo.latitude !== null && photo.longitude !== null) {
appendPhoto(photo); appendPhoto(photo);
loaded += 1; loaded += 1;
@@ -831,11 +901,20 @@ function htmlPage(): string {
); );
setProgress(listing.length, listing.length, "fertig"); setProgress(listing.length, listing.length, "fertig");
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
updateStatus("Import abgebrochen.");
setProgress(state.processed, state.total, "abgebrochen");
return;
}
console.error(error); console.error(error);
updateStatus( updateStatus(
"Import fehlgeschlagen: " + (error instanceof Error ? error.message : "Unbekannter Fehler"), "Import fehlgeschlagen: " + (error instanceof Error ? error.message : "Unbekannter Fehler"),
"error" "error"
); );
} finally {
activeImportController = null;
setImporting(false);
} }
} }
@@ -859,6 +938,12 @@ function htmlPage(): string {
void importFromNextcloud(); void importFromNextcloud();
}); });
cancelShare.addEventListener("click", () => {
if (activeImportController) {
activeImportController.abort();
}
});
useDemo.addEventListener("click", showDemo); useDemo.addEventListener("click", showDemo);
showDemo(); showDemo();