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.
79 lines
2.5 KiB
TypeScript
79 lines
2.5 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { imageCandidates } from '@/lib/imageFallback';
|
|
|
|
interface ImageWithFallbackProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|
/**
|
|
* What to show when the image fails to load (or has no src). Defaults to a
|
|
* subtle skate emoji on a muted background that fills the same box as the
|
|
* image would have, so broken user-uploaded URLs never surface the browser's
|
|
* ugly broken-image icon.
|
|
*/
|
|
fallback?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Drop-in replacement for <img> that degrades gracefully when the source is
|
|
* missing or 404s. Content images across the app (spots, events, marketplace
|
|
* listings, crew logos, shop photos, tours, stash gear) come from user-supplied
|
|
* URLs on arbitrary Blossom/host servers, which routinely go away — without this
|
|
* they render a broken-image glyph. The fallback fills the same layout box
|
|
* (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({
|
|
src,
|
|
alt = '',
|
|
className,
|
|
fallback,
|
|
onError,
|
|
...props
|
|
}: ImageWithFallbackProps) {
|
|
const candidates = useMemo(() => imageCandidates(src), [src]);
|
|
const [index, setIndex] = useState(0);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
// Reset when the src changes — list rows recycle these components.
|
|
useEffect(() => {
|
|
setIndex(0);
|
|
setFailed(false);
|
|
}, [src]);
|
|
|
|
if (!src || failed || candidates.length === 0) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-center bg-muted/40 text-muted-foreground',
|
|
className,
|
|
)}
|
|
aria-label={typeof alt === 'string' && alt ? alt : 'Image unavailable'}
|
|
role="img"
|
|
>
|
|
{fallback ?? <span className="text-2xl opacity-40 select-none">🛹</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<img
|
|
src={candidates[Math.min(index, candidates.length - 1)]}
|
|
alt={alt}
|
|
className={className}
|
|
onError={(event) => {
|
|
if (index + 1 < candidates.length) {
|
|
setIndex(index + 1);
|
|
return;
|
|
}
|
|
setFailed(true);
|
|
onError?.(event);
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default ImageWithFallback;
|