SkateSpots/src/lib/imageFallback.ts
SkateSpots d9ad9e9b29 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.
2026-09-28 13:01:27 +07:00

51 lines
1.7 KiB
TypeScript

/**
* 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;
}