add offline support
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
globalThis.Wegwichtel ??= {};
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const toggle = document.getElementById('offline-cache-toggle');
|
||||
const status = document.getElementById('offline-cache-status');
|
||||
const refresh = document.getElementById('offline-cache-refresh');
|
||||
const enter = document.getElementById('bootstrap-enter');
|
||||
let controller = null;
|
||||
let task = null;
|
||||
let initialized = null;
|
||||
let selectedRoute = null;
|
||||
let routeTask = null;
|
||||
|
||||
const supported = () => Boolean(globalThis.isSecureContext && navigator.serviceWorker && globalThis.caches);
|
||||
|
||||
function announce(message) {
|
||||
if (status) status.textContent = message;
|
||||
}
|
||||
|
||||
async function updateControls() {
|
||||
const saved = supported() ? await ns.OfflineStore.state() : null;
|
||||
if (toggle) {
|
||||
toggle.checked = Boolean(saved);
|
||||
toggle.setAttribute('aria-checked', String(Boolean(saved)));
|
||||
}
|
||||
if (refresh) {
|
||||
refresh.hidden = !saved;
|
||||
refresh.disabled = Boolean(task);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
async function worker() {
|
||||
const existing = await navigator.serviceWorker.getRegistration?.('/');
|
||||
if (!existing?.active || new URL(existing.active.scriptURL).pathname !== '/offline-worker.js') {
|
||||
await navigator.serviceWorker.register('/offline-worker.js', { scope: '/', updateViaCache: 'none' });
|
||||
} else if (navigator.onLine !== false) {
|
||||
// Check imported storage code for updates without delaying an offline restart.
|
||||
void existing.update?.().catch(() => {});
|
||||
}
|
||||
await navigator.serviceWorker.ready;
|
||||
if (navigator.serviceWorker.controller) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
navigator.serviceWorker.removeEventListener('controllerchange', changed);
|
||||
reject(new Error('Offline-Speicher konnte nicht aktiviert werden. Bitte erneut versuchen.'));
|
||||
}, 10000);
|
||||
function changed() {
|
||||
clearTimeout(timeout);
|
||||
navigator.serviceWorker.removeEventListener('controllerchange', changed);
|
||||
resolve();
|
||||
}
|
||||
navigator.serviceWorker.addEventListener('controllerchange', changed);
|
||||
if (navigator.serviceWorker.controller) changed();
|
||||
});
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (task) return task;
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
task = (async () => {
|
||||
let stage = null;
|
||||
try {
|
||||
await worker();
|
||||
announce('Offline-Dateien werden vorbereitet …');
|
||||
// Persistent storage is best-effort; a denial must not block downloads.
|
||||
await navigator.storage?.persist?.().catch(() => false);
|
||||
const options = { cache: 'no-store', headers: { 'X-Wegwichtel-Offline-Download': 'true' }, signal };
|
||||
const manifestResponse = await fetch('/api/offline-manifest', options);
|
||||
if (!manifestResponse.ok) throw new Error('Die Liste der Offline-Dateien konnte nicht geladen werden.');
|
||||
const manifest = await manifestResponse.json();
|
||||
if (!Array.isArray(manifest.resources) || !manifest.resources.length) throw new TypeError('Ungültige Offline-Dateiliste.');
|
||||
stage = await ns.OfflineStore.create();
|
||||
const urls = [...new Set(manifest.resources.map(url => ns.OfflineStore.key(url)))];
|
||||
for (const [index, url] of urls.entries()) {
|
||||
signal.throwIfAborted();
|
||||
announce(`Offline-Dateien speichern: ${index + 1} von ${urls.length}`);
|
||||
const response = await fetch(url, options);
|
||||
await ns.OfflineStore.put(stage, url, response);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const saved = await ns.OfflineStore.commit(stage);
|
||||
announce(`${saved.count} Dateien sind offline verfügbar.`);
|
||||
return saved;
|
||||
} catch (error) {
|
||||
if (stage) await ns.OfflineStore.remove(stage.id);
|
||||
ns.StorageUsage?.reportError(error);
|
||||
const saved = await ns.OfflineStore.state();
|
||||
if (error.name === 'AbortError') announce('Download abgebrochen.');
|
||||
else announce(`${error.message} ${saved ? 'Der bisherige Offline-Stand bleibt erhalten.' : 'Die App ist noch nicht vollständig offline verfügbar.'}`);
|
||||
throw error;
|
||||
} finally {
|
||||
task = null;
|
||||
controller = null;
|
||||
if (enter) enter.disabled = false;
|
||||
await updateControls();
|
||||
await ns.StorageUsage?.refresh();
|
||||
}
|
||||
})();
|
||||
if (enter) enter.disabled = true;
|
||||
if (refresh) refresh.disabled = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
controller?.abort();
|
||||
if (task) await task.catch(() => {});
|
||||
await ns.OfflineStore.clear();
|
||||
await updateControls();
|
||||
announce('App-Dateien wurden entfernt. Gespeicherte Strecken bleiben erhalten.');
|
||||
await ns.StorageUsage?.refresh();
|
||||
}
|
||||
|
||||
async function renderRoute(route) {
|
||||
selectedRoute = route;
|
||||
const section = document.getElementById('route-offline-controls');
|
||||
if (!section) return;
|
||||
section.hidden = route?.source === 'local' || !route;
|
||||
if (section.hidden) return;
|
||||
const saved = supported() ? (await ns.OfflineStore.routes()).find(item => String(item.route.id) === String(route.id)) : null;
|
||||
if (selectedRoute !== route) return;
|
||||
const save = document.getElementById('route-offline-save');
|
||||
const remove = document.getElementById('route-offline-remove');
|
||||
const message = document.getElementById('route-offline-status');
|
||||
if (save) {
|
||||
save.disabled = !supported() || Boolean(routeTask);
|
||||
save.textContent = saved ? 'Offline-Strecke aktualisieren' : 'Strecke offline speichern';
|
||||
}
|
||||
if (remove) { remove.hidden = !saved; remove.disabled = Boolean(routeTask); }
|
||||
if (message && !routeTask) message.textContent = saved
|
||||
? `${saved.count} Dateien dieser Strecke sind auf dem Gerät gespeichert.`
|
||||
: 'Speichert nur diese Strecke mit GPX, Bildern und Audio für die App.';
|
||||
const gpx = document.getElementById('route-gpx-download');
|
||||
if (gpx) {
|
||||
gpx.hidden = !route.gpxUrl;
|
||||
if (route.gpxUrl) {
|
||||
gpx.href = route.gpxUrl;
|
||||
gpx.download = `strecke-${route.id}.gpx`;
|
||||
} else gpx.removeAttribute('href');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRoute(route) {
|
||||
if (routeTask) throw new Error('Eine Strecke wird bereits gespeichert.');
|
||||
if (!supported()) throw new Error('Offline-Speicherung benötigt HTTPS und Browser-Unterstützung.');
|
||||
if (!route || route.source === 'local') throw new Error('Bitte eine veröffentlichte Strecke auswählen.');
|
||||
routeTask = (async () => {
|
||||
let stage = null;
|
||||
const message = document.getElementById('route-offline-status');
|
||||
try {
|
||||
await worker();
|
||||
await navigator.storage?.persist?.().catch(() => false);
|
||||
const url = `/api/routes/${encodeURIComponent(route.id)}`;
|
||||
const options = { cache: 'no-store', headers: { 'X-Wegwichtel-Offline-Download': 'true' } };
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) throw new Error('Die Strecke konnte nicht geladen werden.');
|
||||
const fresh = await response.clone().json();
|
||||
if (String(fresh.id) !== String(route.id) || !fresh.activatedAt) throw new Error('Nur veröffentlichte Strecken können offline gespeichert werden.');
|
||||
if (!fresh.gpxUrl) throw new Error('Für diese Strecke ist keine GPX-Datei vorhanden.');
|
||||
const resources = [fresh.gpxUrl];
|
||||
for (const poi of fresh.pois || []) {
|
||||
if (poi.audioUrl) resources.push(poi.audioUrl);
|
||||
for (const picture of poi.images || []) resources.push(picture.url);
|
||||
}
|
||||
const urls = [...new Set(resources.map(value => ns.OfflineStore.key(value)))];
|
||||
stage = await ns.OfflineStore.create();
|
||||
await ns.OfflineStore.put(stage, url, response);
|
||||
for (const [index, resource] of urls.entries()) {
|
||||
if (message && selectedRoute === route) message.textContent = `Strecke speichern: ${index + 1} von ${urls.length}`;
|
||||
await ns.OfflineStore.put(stage, resource, await fetch(resource, options));
|
||||
await ns.StorageUsage?.refresh();
|
||||
}
|
||||
return await ns.OfflineStore.commit(stage, fresh);
|
||||
} catch (error) {
|
||||
if (stage) await ns.OfflineStore.remove(stage.id);
|
||||
ns.StorageUsage?.reportError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
routeTask = null;
|
||||
await renderRoute(selectedRoute);
|
||||
await ns.StorageUsage?.refresh();
|
||||
}
|
||||
})();
|
||||
const pending = routeTask;
|
||||
await renderRoute(selectedRoute);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function removeRoute(route) {
|
||||
if (routeTask) throw new Error('Bitte den laufenden Download abwarten.');
|
||||
await ns.OfflineStore.clearRoute(route.id);
|
||||
await renderRoute(selectedRoute);
|
||||
await ns.StorageUsage?.refresh();
|
||||
}
|
||||
|
||||
function bindRouteControls() {
|
||||
const run = action => {
|
||||
const route = selectedRoute;
|
||||
if (!route) return;
|
||||
void action(route).catch(error => {
|
||||
if (selectedRoute !== route) return;
|
||||
const message = document.getElementById('route-offline-status');
|
||||
if (message) message.textContent = `${error.message} Ein bisheriger Offline-Stand bleibt erhalten.`;
|
||||
});
|
||||
};
|
||||
document.getElementById('route-offline-save')?.addEventListener('click', () => run(saveRoute));
|
||||
document.getElementById('route-offline-remove')?.addEventListener('click', () => run(removeRoute));
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
bindRouteControls();
|
||||
ns.StorageUsage?.initialize();
|
||||
const helpButton = document.getElementById('offline-cache-help-button');
|
||||
const help = document.getElementById('offline-cache-help');
|
||||
if (helpButton && help) {
|
||||
helpButton.addEventListener('click', () => {
|
||||
help.hidden = !help.hidden;
|
||||
helpButton.setAttribute('aria-expanded', String(!help.hidden));
|
||||
});
|
||||
}
|
||||
if (!supported()) {
|
||||
if (toggle) toggle.disabled = true;
|
||||
announce('Offline-Speicherung ist in diesem Browser oder ohne HTTPS nicht verfügbar.');
|
||||
return;
|
||||
}
|
||||
// Remove old v1 snapshots that also included all routes automatically.
|
||||
const old = await ns.OfflineStore.state();
|
||||
if (old && !old.appOnly) await ns.OfflineStore.clear();
|
||||
const saved = await updateControls();
|
||||
if ((await ns.OfflineStore.routes()).length) await worker();
|
||||
if (saved) {
|
||||
await worker();
|
||||
announce(`${saved.count} Dateien sind offline verfügbar.`);
|
||||
} else {
|
||||
announce('Speichert nur die App-Dateien auf diesem Gerät. Strecken kannst du einzeln speichern.');
|
||||
}
|
||||
toggle?.addEventListener('change', () => {
|
||||
toggle.setAttribute('aria-checked', String(toggle.checked));
|
||||
void (toggle.checked ? download() : disable()).catch(() => {});
|
||||
});
|
||||
refresh?.addEventListener('click', () => { void download().catch(() => {}); });
|
||||
}
|
||||
|
||||
ns.OfflineCache = Object.freeze({
|
||||
initialize() {
|
||||
initialized ??= setup().catch(error => { announce(error.message); });
|
||||
return initialized;
|
||||
},
|
||||
download,
|
||||
disable,
|
||||
renderRoute,
|
||||
saveRoute,
|
||||
removeRoute,
|
||||
get busy() { return Boolean(task); }
|
||||
});
|
||||
}(globalThis.Wegwichtel));
|
||||
Reference in New Issue
Block a user