diff --git a/src/components/messageStream/BubbleImage.tsx b/src/components/messageStream/BubbleImage.tsx index 027136e..799be0a 100644 --- a/src/components/messageStream/BubbleImage.tsx +++ b/src/components/messageStream/BubbleImage.tsx @@ -1,27 +1,49 @@ import { ImageOff } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; type BubbleImageProps = { src: string | undefined; + /** + * Optional absolute URL(s) to try if `src` fails to load. Useful when `src` + * is an optimized-but-fragile thumbnail (e.g. a root-relative thumbnailUrl) + * and we want to fall back to the reliable full-size asset before giving up. + */ + fallbackSrc?: string | (string | undefined)[]; className?: string; loading?: "lazy" | "eager"; }; /** * Thumbnail for message bubbles. Renders with an empty alt (decorative) - * and, if the asset fails to load, falls back to a neutral placeholder instead - * of the browser's broken-image box — which would otherwise expose the raw - * file name via alt text. + * and, if every candidate source fails to load, falls back to a neutral + * placeholder instead of the browser's broken-image box — which would otherwise + * expose the raw file name via alt text. */ -export function BubbleImage({ src, className, loading }: BubbleImageProps) { - const [failed, setFailed] = useState(false); +export function BubbleImage({ + src, + fallbackSrc, + className, + loading, +}: BubbleImageProps) { + // Ordered, de-duplicated list of sources to attempt in turn. + const candidates = useMemo(() => { + const extra = Array.isArray(fallbackSrc) ? fallbackSrc : [fallbackSrc]; + return [src, ...extra].filter( + (value, index, all): value is string => + !!value && all.indexOf(value) === index, + ); + }, [src, fallbackSrc]); - // Reset when the source changes so a reused element re-attempts the new src. + const [attempt, setAttempt] = useState(0); + + // Reset when the candidate set changes so a reused element re-attempts. useEffect(() => { - setFailed(false); - }, [src]); + setAttempt(0); + }, [candidates]); - if (!src || failed) { + const current = candidates[attempt]; + + if (!current) { return (
setFailed(true)} + onError={() => setAttempt((i) => i + 1)} /> ); } diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index 7b91ccf..2347969 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -50,7 +50,8 @@ export function AlbumBubble({ post }: { post: Post }) { } > diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index 62241e7..639f786 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -169,6 +169,7 @@ function Filmstrip({ > diff --git a/vite.config.ts b/vite.config.ts index 685ae82..0f556f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,17 @@ import react from "@vitejs/plugin-react"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); const apiProxyTarget = env.DEV_API_PROXY_TARGET || "http://127.0.0.1:8080"; + // Uploaded assets (thumbnails etc.) are served from the site root, NOT under + // the API's /apnew prefix. Proxy /uploads to the origin root so relative + // thumbnailUrls like "/uploads/thumb52-….jpg" resolve to a real image in dev + // instead of falling through to the SPA's index.html. + const uploadsProxyTarget = (() => { + try { + return new URL(apiProxyTarget).origin; + } catch { + return apiProxyTarget; + } + })(); return { plugins: [react()], @@ -26,7 +37,7 @@ export default defineConfig(({ mode }) => { rewrite: (path) => path.replace(/^\/apnew/, ""), }, "/api": { target: apiProxyTarget, changeOrigin: true }, - "/uploads": { target: apiProxyTarget, changeOrigin: true }, + "/uploads": { target: uploadsProxyTarget, changeOrigin: true }, }, }, };