feat: add import spinner and cancel action
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user