| 1 | "use client"; |
| 2 | |
| 3 | import { useState, type ImgHTMLAttributes } from "react"; |
| 4 | |
| 5 | interface ImageWithFallbackProps extends Omit< |
| 6 | ImgHTMLAttributes<HTMLImageElement>, |
| 7 | "src" | "onError" |
| 8 | > { |
| 9 | src: string; |
| 10 | fallbackSrc?: string; |
| 11 | fallbackElement?: React.ReactNode; |
| 12 | } |
| 13 | |
| 14 | /** |
| 15 | * An image component that tries the primary src first, then falls back to fallbackSrc if it fails. |
| 16 | * If both fail (or no fallbackSrc is provided), it renders the fallbackElement. |
| 17 | */ |
| 18 | export function ImageWithFallback({ |
| 19 | src, |
| 20 | fallbackSrc, |
| 21 | fallbackElement, |
| 22 | alt, |
| 23 | ...props |
| 24 | }: ImageWithFallbackProps) { |
| 25 | const [currentSrc, setCurrentSrc] = useState(src); |
| 26 | const [hasError, setHasError] = useState(false); |
| 27 | const [triedFallback, setTriedFallback] = useState(false); |
| 28 | |
| 29 | const handleError = () => { |
| 30 | if (!triedFallback && fallbackSrc) { |
| 31 | setCurrentSrc(fallbackSrc); |
| 32 | setTriedFallback(true); |
| 33 | } else { |
| 34 | setHasError(true); |
| 35 | } |
| 36 | }; |
| 37 | |
| 38 | if (hasError) { |
| 39 | return fallbackElement ?? null; |
| 40 | } |
| 41 | |
| 42 | return ( |
| 43 | /* biome-ignore lint/performance/noImgElement: Intentionally using img for external URLs with fallback mechanism */ |
| 44 | <img src={currentSrc} alt={alt} onError={handleError} {...props} /> |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * Generates the alternative extension URL for an image. |
| 50 | * If the image ends with .png, returns .jpg version and vice versa. |
| 51 | * Handles URLs with query parameters. |
| 52 | */ |
| 53 | export function getAlternateImageUrl(imageUrl: string): string { |
| 54 | // Remove query parameters for extension check, but preserve them |
| 55 | const parts = imageUrl.split("?"); |
| 56 | const baseUrl = parts[0]; |
| 57 | const queryParams = parts[1]; |
| 58 | |
| 59 | if (!baseUrl) { |
| 60 | return ""; |
| 61 | } |
| 62 | |
| 63 | if (baseUrl.endsWith(".png")) { |
| 64 | const newBase = `${baseUrl.slice(0, -4)}.jpg`; |
| 65 | return queryParams ? `${newBase}?${queryParams}` : newBase; |
| 66 | } |
| 67 | |
| 68 | if (baseUrl.endsWith(".jpg") || baseUrl.endsWith(".jpeg")) { |
| 69 | const extension = baseUrl.endsWith(".jpeg") ? ".jpeg" : ".jpg"; |
| 70 | const newBase = `${baseUrl.slice(0, -extension.length)}.png`; |
| 71 | return queryParams ? `${newBase}?${queryParams}` : newBase; |
| 72 | } |
| 73 | |
| 74 | // If no known extension, return empty string |
| 75 | return ""; |
| 76 | } |
| 77 |