add offline support
This commit is contained in:
+27
-2
@@ -1,8 +1,33 @@
|
||||
(function (ns, $) {
|
||||
'use strict';
|
||||
|
||||
function request(path) {
|
||||
return $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false });
|
||||
async function offlineResponse(path) {
|
||||
if (!ns.OfflineStore) return null;
|
||||
if (path === '/health') {
|
||||
const available = await ns.OfflineStore.state() || (await ns.OfflineStore.routes()).length;
|
||||
return available ? { ok: true, offline: true } : null;
|
||||
}
|
||||
if (path.startsWith('/routes?')) {
|
||||
if (!await ns.OfflineStore.state() && !(await ns.OfflineStore.routes()).length) return null;
|
||||
return { routes: (await ns.OfflineStore.routes()).map(item => item.route), offline: true };
|
||||
}
|
||||
const response = await ns.OfflineStore.match(ns.Config.apiBase + path);
|
||||
return response ? response.json() : null;
|
||||
}
|
||||
|
||||
async function request(path) {
|
||||
if (globalThis.navigator?.onLine === false) {
|
||||
const response = await offlineResponse(path);
|
||||
if (response) return response;
|
||||
}
|
||||
try {
|
||||
return await $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false, timeout: 10000 });
|
||||
} catch (error) {
|
||||
if (error.status > 0) throw error;
|
||||
const response = await offlineResponse(path);
|
||||
if (response) return response;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function routeQuery(position) {
|
||||
|
||||
@@ -375,6 +375,7 @@
|
||||
}
|
||||
|
||||
function renderRouteView(route) {
|
||||
ns.OfflineCache?.renderRoute?.(route)?.catch?.(() => {});
|
||||
$('#route-title').text(route.name);
|
||||
$('#route-description').text(route.description || '');
|
||||
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
||||
|
||||
Vendored
+3
-1
@@ -144,8 +144,9 @@
|
||||
}
|
||||
|
||||
function openApplication(event) {
|
||||
if (!ready || !isActivation(event)) return;
|
||||
if (!ready || globalThis.Wegwichtel.OfflineCache?.busy || !isActivation(event)) return;
|
||||
if (event.target !== enter && event.target.closest('button, input, label, a, dialog')) return;
|
||||
if (event.target.closest('.offline-help, .storage-usage')) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation?.();
|
||||
@@ -192,6 +193,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
await globalThis.Wegwichtel.OfflineCache?.initialize();
|
||||
setStatusText(stepLabels.storage, true);
|
||||
await cleanupOldProgress();
|
||||
mark('storage', 'done');
|
||||
|
||||
@@ -1384,7 +1384,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function guarded(action) {
|
||||
try {
|
||||
await action();
|
||||
await ns.StorageUsage?.refresh();
|
||||
} catch (error) {
|
||||
ns.StorageUsage?.reportError(error);
|
||||
setStatus(error.message || 'Die Aktion konnte nicht ausgeführt werden.', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +467,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
} catch {
|
||||
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
|
||||
}
|
||||
ns.StorageUsage?.reportError(error);
|
||||
const name = error?.name === 'QuotaExceededError'
|
||||
? 'Der lokale Speicher für Streckendaten ist voll.'
|
||||
: 'Die lokale Strecke konnte nicht gespeichert werden.';
|
||||
|
||||
@@ -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));
|
||||
@@ -0,0 +1,151 @@
|
||||
globalThis.Wegwichtel ??= {};
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const CONTROL = 'wegwichtel-offline-control-v1';
|
||||
const PREFIX = 'wegwichtel-offline-data-';
|
||||
const STATE_URL = '/__wegwichtel_offline_state__';
|
||||
const ROUTES_URL = '/__wegwichtel_offline_routes__';
|
||||
const DIRECTORY = 'wegwichtel-offline-v1';
|
||||
|
||||
function key(value) {
|
||||
const url = new URL(value, globalThis.location.origin);
|
||||
if (url.origin !== globalThis.location.origin) throw new TypeError('Nur Ressourcen dieser App sind zulässig.');
|
||||
url.searchParams.delete('v');
|
||||
if (url.pathname.startsWith('/api/')) url.searchParams.delete('_');
|
||||
url.hash = '';
|
||||
return url.href;
|
||||
}
|
||||
|
||||
async function state() {
|
||||
const cache = await caches.open(CONTROL);
|
||||
const response = await cache.match(STATE_URL);
|
||||
return response ? response.json() : null;
|
||||
}
|
||||
|
||||
async function routes() {
|
||||
const response = await (await caches.open(CONTROL)).match(ROUTES_URL);
|
||||
return response ? response.json() : [];
|
||||
}
|
||||
|
||||
async function writeRoutes(saved) {
|
||||
await (await caches.open(CONTROL)).put(ROUTES_URL, new Response(JSON.stringify(saved), { headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
|
||||
async function folder(id, create = false) {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const directory = await root.getDirectoryHandle(DIRECTORY, { create });
|
||||
return directory.getDirectoryHandle(id, { create });
|
||||
}
|
||||
|
||||
async function writeFile(directory, name, contents) {
|
||||
const handle = await directory.getFileHandle(name, { create: true });
|
||||
const stream = await handle.createWritable();
|
||||
try {
|
||||
await stream.write(contents);
|
||||
await stream.close();
|
||||
} catch (error) {
|
||||
await stream.abort().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const id = `${PREFIX}${crypto.randomUUID()}`;
|
||||
let directory = null;
|
||||
if (typeof navigator.storage?.getDirectory === 'function') {
|
||||
try { directory = await folder(id, true); } catch { /* Use CacheStorage when OPFS is unavailable. */ }
|
||||
}
|
||||
return { id, mode: directory ? 'filesystem' : 'cache', directory, resources: {}, count: 0 };
|
||||
}
|
||||
|
||||
async function put(stage, url, response) {
|
||||
if (!response.ok || response.type === 'opaque') throw new Error(`Download fehlgeschlagen: ${url}`);
|
||||
const normalized = key(url);
|
||||
if (stage.mode === 'cache') {
|
||||
const cache = await caches.open(stage.id);
|
||||
await cache.put(normalized, response.clone());
|
||||
} else {
|
||||
const filename = `${stage.count}.bin`;
|
||||
const headers = [...response.headers].filter(([name]) => !['content-encoding', 'content-length', 'transfer-encoding'].includes(name));
|
||||
await writeFile(stage.directory, filename, await response.blob());
|
||||
stage.resources[normalized] = { filename, headers, status: response.status, statusText: response.statusText };
|
||||
}
|
||||
stage.count += 1;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!id?.startsWith(PREFIX)) return;
|
||||
await caches.delete(id);
|
||||
if (typeof navigator.storage?.getDirectory === 'function') {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const directory = await root.getDirectoryHandle(DIRECTORY);
|
||||
await directory.removeEntry(id, { recursive: true });
|
||||
} catch { /* Missing directory or disabled OPFS needs no cleanup. */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(stage, route = null) {
|
||||
if (stage.mode === 'filesystem') {
|
||||
await writeFile(stage.directory, 'index.json', JSON.stringify(stage.resources));
|
||||
}
|
||||
const savedRoutes = route ? await routes() : [];
|
||||
const previous = route ? savedRoutes.find(item => String(item.route.id) === String(route.id)) : await state();
|
||||
const current = { id: stage.id, mode: stage.mode, count: stage.count, savedAt: new Date().toISOString(), appOnly: !route };
|
||||
if (route) {
|
||||
const summary = { ...route };
|
||||
delete summary.pois;
|
||||
delete summary.points;
|
||||
current.route = summary;
|
||||
await writeRoutes([...savedRoutes.filter(item => String(item.route.id) !== String(route.id)), current]);
|
||||
} else {
|
||||
const cache = await caches.open(CONTROL);
|
||||
await cache.put(STATE_URL, new Response(JSON.stringify(current), { headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
if (previous?.id !== current.id) await remove(previous?.id).catch(() => {});
|
||||
return current;
|
||||
}
|
||||
|
||||
async function matchSnapshot(current, normalized) {
|
||||
if (!current) return null;
|
||||
if (current.mode === 'cache') return (await caches.open(current.id)).match(normalized);
|
||||
try {
|
||||
const directory = await folder(current.id);
|
||||
const index = JSON.parse(await (await (await directory.getFileHandle('index.json')).getFile()).text());
|
||||
const entry = index[normalized];
|
||||
if (!entry) return null;
|
||||
const file = await (await directory.getFileHandle(entry.filename)).getFile();
|
||||
return new Response(file, { status: entry.status, statusText: entry.statusText, headers: entry.headers });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function match(url) {
|
||||
const normalized = key(url);
|
||||
const snapshots = [await state(), ...await routes()];
|
||||
for (const snapshot of snapshots) {
|
||||
const response = await matchSnapshot(snapshot, normalized);
|
||||
if (response) return response;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function clearRoute(routeId) {
|
||||
const saved = await routes();
|
||||
const previous = saved.find(item => String(item.route.id) === String(routeId));
|
||||
await writeRoutes(saved.filter(item => String(item.route.id) !== String(routeId)));
|
||||
await remove(previous?.id);
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
// Disable serving first; remove only our offline resources, never route drafts/media.
|
||||
const cache = await caches.open(CONTROL);
|
||||
const previous = await state();
|
||||
await cache.delete(STATE_URL);
|
||||
await remove(previous?.id);
|
||||
}
|
||||
|
||||
ns.OfflineStore = Object.freeze({ key, state, routes, create, put, commit, match, remove, clear, clearRoute });
|
||||
}(globalThis.Wegwichtel));
|
||||
@@ -0,0 +1,85 @@
|
||||
globalThis.Wegwichtel ??= {};
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
const LOCAL_REFERENCE = 5 * 1024 * 1024;
|
||||
let initialized = false;
|
||||
let lastError = '';
|
||||
let refreshTask = null;
|
||||
|
||||
function format(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} MiB`;
|
||||
}
|
||||
|
||||
function localBytes() {
|
||||
const storage = globalThis.localStorage;
|
||||
if (!storage) return null;
|
||||
let bytes = 0;
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key != null) bytes += 2 * (key.length + (storage.getItem(key) || '').length);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function show(selector, usage, quota, text) {
|
||||
for (const panel of document.querySelectorAll('[data-storage-usage]')) {
|
||||
const meter = panel.querySelector(`[data-storage-${selector}-meter]`);
|
||||
const label = panel.querySelector(`[data-storage-${selector}-text]`);
|
||||
if (meter) {
|
||||
meter.hidden = !quota;
|
||||
if (quota) { meter.max = quota; meter.value = Math.min(usage, quota); }
|
||||
}
|
||||
if (label) label.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (refreshTask) return refreshTask;
|
||||
refreshTask = (async () => {
|
||||
let estimate = null;
|
||||
try { estimate = await navigator.storage?.estimate?.(); } catch { /* Display unknown rather than a guessed quota. */ }
|
||||
const usage = estimate?.usage;
|
||||
const quota = estimate?.quota;
|
||||
const known = Number.isFinite(usage) && Number.isFinite(quota) && quota > 0;
|
||||
show('site', usage, known ? quota : null, known
|
||||
? `Website-Speicher: ${format(usage)} von ${format(quota)} belegt (${Math.round(usage / quota * 100)} %). Etwa ${format(Math.max(0, quota - usage))} verfügbar.${usage / quota >= .9 ? ' Speicher fast voll.' : ''}`
|
||||
: 'Der Browser meldet keine Speicherkapazität.');
|
||||
let local = null;
|
||||
try { local = localBytes(); } catch { /* Storage can be blocked by browser settings. */ }
|
||||
show('local', local, local == null ? null : LOCAL_REFERENCE, local == null
|
||||
? 'Local Storage ist nicht verfügbar.'
|
||||
: `Local Storage: ungefähr ${format(local)} belegt. Balken bezogen auf einen Richtwert von 5 MiB; die tatsächliche Grenze hängt vom Browser ab.${local >= LOCAL_REFERENCE * .9 ? ' Richtwert fast erreicht: Große Bilder können das Speichern verhindern.' : ''}`);
|
||||
for (const panel of document.querySelectorAll('[data-storage-usage]')) {
|
||||
const warning = panel.querySelector('[data-storage-warning]');
|
||||
if (warning) { warning.hidden = !lastError; warning.textContent = lastError; }
|
||||
}
|
||||
})().finally(() => { refreshTask = null; });
|
||||
return refreshTask;
|
||||
}
|
||||
|
||||
function reportError(error) {
|
||||
let current = error;
|
||||
while (current) {
|
||||
if (current.name === 'QuotaExceededError') {
|
||||
lastError = 'Speicher voll: Entferne nicht mehr benötigte Offline-Strecken oder lokale Entwürfe. Eine Browser-Quote oder die separate Local-Storage-Grenze kann erreicht sein.';
|
||||
break;
|
||||
}
|
||||
current = current.cause;
|
||||
}
|
||||
void refresh();
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
document.addEventListener('wegwichtel:local-routes-changed', () => { lastError = ''; void refresh(); });
|
||||
document.addEventListener('wegwichtel:storage-error', event => reportError(event.detail));
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') void refresh(); });
|
||||
globalThis.addEventListener?.('storage', () => { void refresh(); });
|
||||
globalThis.addEventListener?.('focus', () => { void refresh(); });
|
||||
for (const button of document.querySelectorAll('[data-storage-refresh]')) button.addEventListener('click', () => { void refresh(); });
|
||||
void refresh();
|
||||
}
|
||||
|
||||
ns.StorageUsage = Object.freeze({ initialize, refresh, reportError });
|
||||
}(globalThis.Wegwichtel));
|
||||
Reference in New Issue
Block a user