feat(offline): cache map tiles on-device + photo mirror fallback
Map tiles: new lib/tileCache.ts (Dexie SkateSpotsTilesDB, bounded to ~3000 tiles) and lib/cachedTileLayer.ts, a Leaflet TileLayer that reads through it (cached -> fetch+cache -> plain URL fallback). Wired into both maps (SpotMap, RouteDialog). Chosen over a service worker so it works in the Capacitor WebView too, where a SW never sees tile requests. Photos: useUploadFile drops from three Blossom hosts to two and records ['fallback', '<mirror>/<sha256>']. BlossomUploader signs once and Promise.any PUTs to every server in parallel, so the old three-host list was triple- uploading every photo (the 'tries them in order' comment was wrong). Read side: lib/imageFallback.ts derives same-hash mirrors and ImageWithFallback retries them before showing the placeholder - one choke point for ~20 photo call sites, so a dead host no longer blanks a spot.
This commit is contained in:
parent
08bc3a8b41
commit
d9ad9e9b29
8 changed files with 305 additions and 21 deletions
23
AGENTS.md
23
AGENTS.md
|
|
@ -536,8 +536,27 @@ session with **no other memory of this project**. So:
|
||||||
reuses the existing **`useRelayStatus(url)`** (`lib/relayProbe`, a real
|
reuses the existing **`useRelayStatus(url)`** (`lib/relayProbe`, a real
|
||||||
Nostr handshake) plus `useOnlineStatus` — NOT `navigator.onLine` alone,
|
Nostr handshake) plus `useOnlineStatus` — NOT `navigator.onLine` alone,
|
||||||
which is true on a captive-portal Wi-Fi.
|
which is true on a captive-portal Wi-Fi.
|
||||||
- Not done yet: offline **map tiles** (needs a service worker on web / an
|
- **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie
|
||||||
IndexedDB tile layer for native) and the app shell offline — the next slice.
|
`SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)
|
||||||
|
+ `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it),
|
||||||
|
used by **both** maps (`SpotMap`, `RouteDialog`). Deliberately NOT a service
|
||||||
|
worker: a SW only helps the web build, while the Android app runs inside
|
||||||
|
Capacitor's WebView where a SW never sees tile requests. Works on both.
|
||||||
|
Still open: offline **app shell** (SW) — unnecessary for native, deferred for
|
||||||
|
web.
|
||||||
|
- **Photo redundancy (2026-09-28):** `useUploadFile` now uses **two** Blossom
|
||||||
|
hosts (`blossom.primal.net`, `blossom.band`) instead of three. ⚠️
|
||||||
|
`BlossomUploader.upload` signs ONE auth event then does
|
||||||
|
`Promise.any(servers.map(fetch))` — it **PUTs to every server in parallel**, so
|
||||||
|
the old 3-host list was silently **triple-uploading every photo** (the code
|
||||||
|
comment claiming "tries them in order" was wrong). Two hosts = less client
|
||||||
|
data, still a single signature (no extra Amber prompt). The returned tags gain
|
||||||
|
`['fallback', '<mirror-host>/<sha256>']`, and **`lib/imageFallback.ts` +
|
||||||
|
`ImageWithFallback` retry the same hash on the other known Blossom host on
|
||||||
|
error** — one choke point covers every photo/logo call site (~20), so a dead
|
||||||
|
host costs a moment, not a blank spot. Blossom is content-addressed, so the
|
||||||
|
mirror URL is derived from the sha256 in the path (handles `/<sha>` and
|
||||||
|
`/<sha>.<ext>`).
|
||||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { imageCandidates } from '@/lib/imageFallback';
|
||||||
|
|
||||||
interface ImageWithFallbackProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface ImageWithFallbackProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
/**
|
/**
|
||||||
|
|
@ -14,21 +15,35 @@ interface ImageWithFallbackProps extends React.ImgHTMLAttributes<HTMLImageElemen
|
||||||
/**
|
/**
|
||||||
* Drop-in replacement for <img> that degrades gracefully when the source is
|
* Drop-in replacement for <img> that degrades gracefully when the source is
|
||||||
* missing or 404s. Content images across the app (spots, events, marketplace
|
* missing or 404s. Content images across the app (spots, events, marketplace
|
||||||
* listings, crew logos, shop photos, tours, stash gear) come from
|
* listings, crew logos, shop photos, tours, stash gear) come from user-supplied
|
||||||
* user-supplied URLs on arbitrary Blossom/host servers, which routinely go
|
* URLs on arbitrary Blossom/host servers, which routinely go away — without this
|
||||||
* away — without this they render a broken-image glyph. The fallback fills the
|
* they render a broken-image glyph. The fallback fills the same layout box
|
||||||
* same layout box (className is applied to the wrapper) so nothing shifts.
|
* (className is applied to the wrapper) so nothing shifts.
|
||||||
|
*
|
||||||
|
* Photo redundancy: before giving up, it retries the SAME file on the other known
|
||||||
|
* Blossom hosts (see lib/imageFallback — Blossom is content-addressed, so the
|
||||||
|
* mirror URL is derived from the sha256 in the path). A dead host therefore
|
||||||
|
* usually costs a moment, not a missing spot photo.
|
||||||
*/
|
*/
|
||||||
export function ImageWithFallback({
|
export function ImageWithFallback({
|
||||||
src,
|
src,
|
||||||
alt = '',
|
alt = '',
|
||||||
className,
|
className,
|
||||||
fallback,
|
fallback,
|
||||||
|
onError,
|
||||||
...props
|
...props
|
||||||
}: ImageWithFallbackProps) {
|
}: ImageWithFallbackProps) {
|
||||||
|
const candidates = useMemo(() => imageCandidates(src), [src]);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
if (!src || failed) {
|
// Reset when the src changes — list rows recycle these components.
|
||||||
|
useEffect(() => {
|
||||||
|
setIndex(0);
|
||||||
|
setFailed(false);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
if (!src || failed || candidates.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -45,10 +60,17 @@ export function ImageWithFallback({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={candidates[Math.min(index, candidates.length - 1)]}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className={className}
|
className={className}
|
||||||
onError={() => setFailed(true)}
|
onError={(event) => {
|
||||||
|
if (index + 1 < candidates.length) {
|
||||||
|
setIndex(index + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFailed(true);
|
||||||
|
onError?.(event);
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from '@/lib/geolocation';
|
} from '@/lib/geolocation';
|
||||||
import { getLastLocation } from '@/lib/lastLocation';
|
import { getLastLocation } from '@/lib/lastLocation';
|
||||||
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
|
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
|
||||||
|
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||||
import { calculateDistance } from '@/lib/sortOptions';
|
import { calculateDistance } from '@/lib/sortOptions';
|
||||||
import {
|
import {
|
||||||
fetchRoute,
|
fetchRoute,
|
||||||
|
|
@ -336,7 +337,7 @@ export function RouteDialog({
|
||||||
minZoom: 2,
|
minZoom: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '',
|
attribution: '',
|
||||||
subdomains: 'abc',
|
subdomains: 'abc',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { SpotWeatherData } from '@/hooks/useSpotWeather';
|
||||||
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
||||||
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
|
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
|
||||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||||
|
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping.
|
* SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping.
|
||||||
|
|
@ -896,7 +897,8 @@ export function SpotMap({
|
||||||
// Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS
|
// Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS
|
||||||
// filter (see the `.skatespots-dark-tiles` rule in index.css) so it matches
|
// filter (see the `.skatespots-dark-tiles` rule in index.css) so it matches
|
||||||
// the app's dark theme without depending on a keyed provider like CARTO.
|
// the app's dark theme without depending on a keyed provider like CARTO.
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
// Wrapped in an on-device tile cache so the basemap still draws at the park.
|
||||||
|
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '',
|
attribution: '',
|
||||||
subdomains: 'abc',
|
subdomains: 'abc',
|
||||||
minZoom: 2,
|
minZoom: 2,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,23 @@ import { BlossomUploader } from '@nostrify/nostrify/uploaders';
|
||||||
|
|
||||||
import { useCurrentUser } from "./useCurrentUser";
|
import { useCurrentUser } from "./useCurrentUser";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two Blossom hosts. `BlossomUploader` signs ONE auth event and PUTs to every
|
||||||
|
* server in parallel, resolving with the first success — so the same bytes
|
||||||
|
* normally land on both from a single signature (no double Amber prompt). We then
|
||||||
|
* record a mirror URL on the returned tags so photo redundancy is explicit in the
|
||||||
|
* event; the read side also derives it from the sha256 (lib/imageFallback).
|
||||||
|
*/
|
||||||
|
const UPLOAD_SERVERS = ['https://blossom.primal.net/', 'https://blossom.band/'];
|
||||||
|
|
||||||
|
function hostOf(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useUploadFile() {
|
export function useUploadFile() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
|
|
@ -13,18 +30,22 @@ export function useUploadFile() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploader = new BlossomUploader({
|
const uploader = new BlossomUploader({
|
||||||
// Multiple servers so a single slow/down Blossom host doesn't hang the
|
servers: UPLOAD_SERVERS,
|
||||||
// upload (crew logo uploads were stalling when primal.net was slow).
|
|
||||||
// The uploader tries them in order and falls back on failure.
|
|
||||||
servers: [
|
|
||||||
'https://blossom.primal.net/',
|
|
||||||
'https://blossom.band/',
|
|
||||||
'https://cdn.satellite.earth/',
|
|
||||||
],
|
|
||||||
signer: user.signer,
|
signer: user.signer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tags = await uploader.upload(file);
|
const tags = await uploader.upload(file);
|
||||||
|
|
||||||
|
// Remember the mirror copy of the same blob. Blossom is content-addressed,
|
||||||
|
// so `<other-host>/<sha256>` is the same file.
|
||||||
|
const primaryUrl = tags[0]?.[1];
|
||||||
|
const sha = tags.find(([name]) => name === 'x')?.[1];
|
||||||
|
if (primaryUrl && sha) {
|
||||||
|
const primaryHost = hostOf(primaryUrl);
|
||||||
|
const mirror = UPLOAD_SERVERS.find((s) => hostOf(s) !== primaryHost);
|
||||||
|
if (mirror) tags.push(['fallback', `${mirror.replace(/\/+$/, '')}/${sha}`]);
|
||||||
|
}
|
||||||
|
|
||||||
return tags;
|
return tags;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
62
src/lib/cachedTileLayer.ts
Normal file
62
src/lib/cachedTileLayer.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
import { getTile, putTile } from '@/lib/tileCache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Leaflet tile layer that reads through an on-device IndexedDB cache.
|
||||||
|
*
|
||||||
|
* Behaviour per tile:
|
||||||
|
* 1. cached blob → draw it immediately (works fully offline);
|
||||||
|
* 2. else fetch → cache the bytes → draw them;
|
||||||
|
* 3. offline and uncached → fall back to the plain tile URL, so Leaflet shows
|
||||||
|
* its normal missing-tile state instead of hanging forever.
|
||||||
|
*
|
||||||
|
* Works on web and in the Capacitor WebView alike, unlike a service worker.
|
||||||
|
*/
|
||||||
|
export class CachedTileLayer extends L.TileLayer {
|
||||||
|
protected createTile(coords: L.Coords, done: L.DoneCallback): HTMLElement {
|
||||||
|
const tile = document.createElement('img');
|
||||||
|
tile.alt = '';
|
||||||
|
tile.setAttribute('role', 'presentation');
|
||||||
|
|
||||||
|
const key = `${coords.z}/${coords.x}/${coords.y}`;
|
||||||
|
const url = this.getTileUrl(coords);
|
||||||
|
|
||||||
|
const useSrc = (src: string, revoke: boolean) => {
|
||||||
|
tile.onload = () => {
|
||||||
|
if (revoke) URL.revokeObjectURL(src);
|
||||||
|
done(undefined, tile);
|
||||||
|
};
|
||||||
|
tile.onerror = (e) => done(e as unknown as Error, tile);
|
||||||
|
tile.src = src;
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const cached = await getTile(key);
|
||||||
|
if (cached) {
|
||||||
|
useSrc(URL.createObjectURL(cached), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { mode: 'cors' });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
if (blob.size > 0) void putTile(key, blob);
|
||||||
|
useSrc(URL.createObjectURL(blob), true);
|
||||||
|
} catch {
|
||||||
|
// No cache and no network: let the browser try the URL directly.
|
||||||
|
useSrc(url, false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCachedTileLayer(
|
||||||
|
urlTemplate: string,
|
||||||
|
options?: L.TileLayerOptions,
|
||||||
|
): CachedTileLayer {
|
||||||
|
return new CachedTileLayer(urlTemplate, options);
|
||||||
|
}
|
||||||
51
src/lib/imageFallback.ts
Normal file
51
src/lib/imageFallback.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Photo redundancy helpers.
|
||||||
|
*
|
||||||
|
* SkateSpots uploads images to Blossom, which is CONTENT-ADDRESSED: a blob's URL
|
||||||
|
* is `<host>/<sha256>` (optionally with a file extension). That means a mirror of
|
||||||
|
* the same file on another known host is derivable from the URL alone — no extra
|
||||||
|
* bookkeeping needed — so a dead photo host never has to leave a blank spot.
|
||||||
|
*
|
||||||
|
* We only derive mirrors for Blossom-style hash paths; anything else is left
|
||||||
|
* untouched (an external image URL has no meaningful alternative location).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hosts the app uploads to, and therefore the hosts we can mirror across. */
|
||||||
|
export const BLOSSOM_MIRROR_HOSTS = ['blossom.primal.net', 'blossom.band'];
|
||||||
|
|
||||||
|
/** `<64-hex>` or `<64-hex>.<ext>` */
|
||||||
|
const HASH_RE = /^([0-9a-f]{64})(\.[a-z0-9]+)?$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered candidate URLs for an image: the original first, then any explicitly
|
||||||
|
* supplied fallbacks, then same-hash mirrors on the other known Blossom hosts.
|
||||||
|
*/
|
||||||
|
export function imageCandidates(
|
||||||
|
src: string | undefined | null,
|
||||||
|
extra: string[] = [],
|
||||||
|
): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const add = (url?: string | null) => {
|
||||||
|
if (typeof url === 'string' && url && !out.includes(url)) out.push(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
add(src);
|
||||||
|
for (const url of extra) add(url);
|
||||||
|
if (!src) return out;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(src);
|
||||||
|
const last = parsed.pathname.split('/').filter(Boolean).pop() ?? '';
|
||||||
|
const match = HASH_RE.exec(last);
|
||||||
|
if (match) {
|
||||||
|
for (const host of BLOSSOM_MIRROR_HOSTS) {
|
||||||
|
if (host === parsed.hostname) continue;
|
||||||
|
add(`${parsed.protocol}//${host}/${match[1]}${match[2] ?? ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Relative or malformed URL — nothing to mirror.
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
106
src/lib/tileCache.ts
Normal file
106
src/lib/tileCache.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import Dexie, { type Table } from 'dexie';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-device cache of map tiles, so the basemap survives a dead network.
|
||||||
|
*
|
||||||
|
* Why not a service worker? A SW only helps the web build; the Android app runs
|
||||||
|
* the bundle inside Capacitor's WebView, where a SW never sees tile requests. An
|
||||||
|
* IndexedDB-backed Leaflet layer caches tiles identically on web and native, and
|
||||||
|
* keeps the failure contained to the map (no "stale app shell" class of bug).
|
||||||
|
*
|
||||||
|
* Growth is bounded: tiles are pruned oldest-used-first once the count exceeds
|
||||||
|
* MAX_TILES, and oversized responses are skipped.
|
||||||
|
*/
|
||||||
|
interface CachedTile {
|
||||||
|
/** `${z}/${x}/${y}` */
|
||||||
|
key: string;
|
||||||
|
blob: Blob;
|
||||||
|
cachedAt: number;
|
||||||
|
lastUsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skip caching anything bigger than this (a 256px PNG is normally 5–30 KB). */
|
||||||
|
export const MAX_TILE_BYTES = 500 * 1024;
|
||||||
|
|
||||||
|
/** Roughly a large city's worth of tiles; pruning keeps us under this. */
|
||||||
|
const MAX_TILES = 3000;
|
||||||
|
|
||||||
|
class TileDB extends Dexie {
|
||||||
|
tiles!: Table<CachedTile, string>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('SkateSpotsTilesDB');
|
||||||
|
this.version(1).stores({ tiles: 'key, lastUsed, cachedAt' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: TileDB | null = null;
|
||||||
|
|
||||||
|
function getDB(): TileDB {
|
||||||
|
if (!db) db = new TileDB();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTile(key: string): Promise<Blob | null> {
|
||||||
|
try {
|
||||||
|
const row = await getDB().tiles.get(key);
|
||||||
|
if (!row) return null;
|
||||||
|
// Touch lastUsed so pruning keeps tiles you actually look at.
|
||||||
|
void getDB().tiles.update(key, { lastUsed: Date.now() });
|
||||||
|
return row.blob;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putTile(key: string, blob: Blob): Promise<void> {
|
||||||
|
if (blob.size === 0 || blob.size > MAX_TILE_BYTES) return;
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
await getDB().tiles.put({ key, blob, cachedAt: now, lastUsed: now });
|
||||||
|
void prune();
|
||||||
|
} catch {
|
||||||
|
// Quota / private mode — caching is best-effort, never fatal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pruning = false;
|
||||||
|
|
||||||
|
async function prune(): Promise<void> {
|
||||||
|
if (pruning) return;
|
||||||
|
pruning = true;
|
||||||
|
try {
|
||||||
|
const count = await getDB().tiles.count();
|
||||||
|
if (count <= MAX_TILES) return;
|
||||||
|
const oldest = await getDB()
|
||||||
|
.tiles.orderBy('lastUsed')
|
||||||
|
.limit(count - MAX_TILES)
|
||||||
|
.primaryKeys();
|
||||||
|
await getDB().tiles.bulkDelete(oldest);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
pruning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTileCacheStats(): Promise<{ tiles: number; bytes: number }> {
|
||||||
|
try {
|
||||||
|
let bytes = 0;
|
||||||
|
const tiles = await getDB().tiles.count();
|
||||||
|
await getDB().tiles.each((t) => {
|
||||||
|
bytes += t.blob?.size ?? 0;
|
||||||
|
});
|
||||||
|
return { tiles, bytes };
|
||||||
|
} catch {
|
||||||
|
return { tiles: 0, bytes: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearTiles(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await getDB().tiles.clear();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue