diff --git a/frontend/README.md b/frontend/README.md index 70ddafb3f3..1d4ac7f129 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -62,8 +62,12 @@ objectives on the server, not dataset names, and it does not search simulated-conversation JSON. Filter by that seed type to find those examples. Matching ignores ASCII case only. Examples stay in the groups the server returns, including multimodal members. Expanding an example loads its stored -records for inspection only: nothing is executed, imported into chat, or played -as media. Filters, page size, and the open example stay in the address bar. +records for inspection only: nothing is executed or imported into chat. +**Show image/audio/video preview** loads supported media on demand, without +autoplay. Reopening waits for a fresh display URL, with Show disabled while the +request is pending. If refreshing fails, the preview stays closed and shows a +message so you can try again. Hiding a preview stops playback. +Filters, page size, and the open example stay in the address bar. **Back to datasets** returns to the catalog search, facets, and sort you came from. A direct link without that return state goes to `/datasets`. On narrow screens, scroll the example table horizontally to read its columns. diff --git a/frontend/e2e/datasets.seeded.spec.ts b/frontend/e2e/datasets.seeded.spec.ts index 3d9c012059..4cf23081e7 100644 --- a/frontend/e2e/datasets.seeded.spec.ts +++ b/frontend/e2e/datasets.seeded.spec.ts @@ -13,6 +13,7 @@ interface SeedExampleSummary { interface SeedMember { data_type: string; value: string; + value_url?: string | null; } interface SeedExampleDetail extends SeedExampleSummary { @@ -82,6 +83,16 @@ test.describe("dataset examples against the real backend @seeded", () => { if (!textMember) { throw new Error("Expected the stored example to include a text member"); } + for (const member of detail.members) { + if (member.data_type === "text") { + expect(member.value_url ?? null).toBeNull(); + } + } + + const mediaRequests: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/media") mediaRequests.push(request.url()); + }); await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(SELECTION_KEY)}`); await expect(page.getByRole("heading", { level: 1, name: "jailbreak_templates" })).toBeVisible(); @@ -96,7 +107,9 @@ test.describe("dataset examples against the real backend @seeded", () => { const region = page.getByRole("region", { name: "Example detail" }); const snippet = textMember.value.replace(/\s+/g, " ").trim().slice(0, 80); await expect(region.getByText(snippet, { exact: false })).toBeVisible(); + await expect(page.getByRole("table", { name: "Examples" }).locator("img, audio, video")).toHaveCount(0); await expect(region.locator("img, audio, video")).toHaveCount(0); + expect(mediaRequests).toEqual([]); await expect(page).toHaveURL(new RegExp(`[?&]example=${first.example_id}`)); await page.reload(); diff --git a/frontend/e2e/datasets.spec.ts b/frontend/e2e/datasets.spec.ts index bbedb1b957..a739ff6494 100644 --- a/frontend/e2e/datasets.spec.ts +++ b/frontend/e2e/datasets.spec.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; + import { expect, test, type Page } from "./_fixtures"; import { mockVersion } from "./_compatibility"; import { READY_RUNTIME } from "./_runtime"; @@ -247,7 +249,10 @@ function seedPage(items: unknown[], nextCursor: string | null, requestCursor: st }; } -async function mockDatasetExamples(page: Page): Promise { +async function mockDatasetExamples( + page: Page, + detail: typeof EXAMPLE_DETAIL = EXAMPLE_DETAIL, +): Promise { const seedRequests: string[] = []; await page.route("**/api/**", async (route) => { const requestUrl = new URL(route.request().url()); @@ -291,7 +296,7 @@ async function mockDatasetExamples(page: Page): Promise { throw new Error("Seed detail was requested without this dataset's selection key"); } seedRequests.push(`${requestUrl.pathname}${requestUrl.search}`); - await route.fulfill(body(EXAMPLE_DETAIL)); + await route.fulfill(body(detail)); return; } if (apiPath === "/runtime") { @@ -373,11 +378,12 @@ test.describe("dataset example table", () => { await expect(page.getByText("short preview")).toBeVisible(); await expect(page).not.toHaveURL(/cursor=/); + await expect(table.locator("img, audio, video")).toHaveCount(0); await page.getByRole("button", { name: "Show alpha" }).click(); const detail = page.getByRole("region", { name: "Example detail" }); await expect(detail.getByText("prompt body kept")).toBeVisible(); await expect(detail.getByText("photo.png")).toBeVisible(); - await expect(detail.getByText("Media is not played in this view.")).toBeVisible(); + await expect(detail.getByText("File is missing.")).toBeVisible(); await expect(detail.locator("img, audio, video")).toHaveCount(0); await expect(page).toHaveURL(/example=example-1/); expect(seedRequests.some((request) => { @@ -534,3 +540,421 @@ test.describe("dataset example touch targets", () => { expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); }); + +const MEDIA_URLS = { + image: "/api/media?path=%2Fdbdata%2Fseed-prompt-entries%2Fpic.png", + audio: "/api/media?path=%2Fdbdata%2Fseed-prompt-entries%2Ftalk.wav", + video: "/api/media?path=%2Fdbdata%2Fseed-prompt-entries%2Fclip.mp4", + broken: "/api/media?path=%2Fdbdata%2Fseed-prompt-entries%2Fbroken.mp4", + svg: "/api/media?path=%2Fdbdata%2Fseed-prompt-entries%2Fmark.svg", +}; + +function mediaMember(id: string, dataType: string, value: string, valueUrl: string) { + return { + id, + seed_type: "prompt", + value, + value_url: valueUrl, + value_sha256: "def", + data_type: dataType, + name: null, + dataset_name: "harmbench", + origin: "local", + harm_categories: [], + description: null, + authors: null, + groups: null, + source: null, + date_added: "2026-01-01T00:00:00Z", + added_by: "tester", + metadata: null, + prompt_group_id: "group-1", + sequence: 1, + role: "user", + parameters: null, + conditions: null, + response_json_schema: null, + }; +} + +test.describe("dataset media previews", () => { + test("loads image, audio, and video only from the open example and never autoplays", async ({ page }) => { + const mediaRequests: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/media") mediaRequests.push(request.url()); + }); + await mockDatasetExamples(page, { + ...EXAMPLE_DETAIL, + modalities: ["text", "image_path", "audio_path", "video_path"], + members: [ + EXAMPLE_DETAIL.members[0], + mediaMember("image-1", "image_path", "/dbdata/seed-prompt-entries/pic.png", MEDIA_URLS.image), + mediaMember("audio-1", "audio_path", "/dbdata/seed-prompt-entries/talk.wav", MEDIA_URLS.audio), + mediaMember("video-1", "video_path", "/dbdata/seed-prompt-entries/clip.mp4", MEDIA_URLS.video), + ], + }); + await page.route("**/api/media**", async (route) => { + const path = new URL(route.request().url()).searchParams.get("path") ?? ""; + if (path.endsWith(".png")) { + await route.fulfill({ + status: 200, + contentType: "image/png", + body: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), + }); + return; + } + await route.fulfill({ status: 200, contentType: "application/octet-stream", body: "media" }); + }); + + await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(DATASET.selection_key)}&example=example-1`); + const table = page.getByRole("table", { name: "Examples" }); + const detail = page.getByRole("region", { name: "Example detail" }); + await expect(detail.getByText("prompt body kept")).toBeVisible(); + await expect(detail.getByText("/dbdata/seed-prompt-entries/pic.png")).toBeVisible(); + await expect(table.locator("img, audio, video")).toHaveCount(0); + await expect(detail.locator("img, audio, video")).toHaveCount(0); + expect(mediaRequests).toEqual([]); + + await detail.getByRole("button", { name: "Show image preview" }).click(); + const image = detail.getByRole("img", { name: "image seed" }); + await expect(image).toHaveAttribute("src", MEDIA_URLS.image); + await expect(image).toHaveAttribute("loading", "lazy"); + await expect.poll(() => mediaRequests.length).toBe(1); + + await detail.getByRole("button", { name: "Show audio preview" }).click(); + const audio = detail.locator("audio"); + await expect(audio).toHaveAttribute("src", MEDIA_URLS.audio); + await expect(audio).toHaveAttribute("preload", "none"); + await expect(audio).toHaveJSProperty("autoplay", false); + await expect(audio).toHaveJSProperty("paused", true); + + await detail.getByRole("button", { name: "Show video preview" }).click(); + const video = detail.locator("video"); + await expect(video).toHaveAttribute("src", MEDIA_URLS.video); + await expect(video).toHaveAttribute("preload", "none"); + await expect(video).toHaveJSProperty("autoplay", false); + await expect(video).toHaveJSProperty("paused", true); + expect(mediaRequests.filter((url) => url.includes("talk.wav") || url.includes("clip.mp4"))).toEqual([]); + + await detail.getByRole("button", { name: "Hide video preview" }).click(); + await expect(detail.locator("video")).toHaveCount(0); + await expect(table).toBeVisible(); + await expect(table.getByRole("row")).toHaveCount(3); + }); + + test("shows broken and unsupported media without breaking the example table", async ({ page }) => { + await mockDatasetExamples(page, { + ...EXAMPLE_DETAIL, + members: [ + EXAMPLE_DETAIL.members[0], + mediaMember("video-1", "video_path", "/dbdata/seed-prompt-entries/broken.mp4", MEDIA_URLS.broken), + mediaMember("mark-1", "image_path", "/dbdata/seed-prompt-entries/mark.svg", MEDIA_URLS.svg), + ], + }); + await page.route("**/api/media**", async (route) => { + await route.fulfill({ status: 404, contentType: "text/plain", body: "missing" }); + }); + + await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(DATASET.selection_key)}&example=example-1`); + const table = page.getByRole("table", { name: "Examples" }); + const detail = page.getByRole("region", { name: "Example detail" }); + await expect(table).toBeVisible(); + await expect(detail.getByText("This format cannot be previewed.")).toBeVisible(); + await expect(detail.getByRole("link", { name: "Download" })).toHaveAttribute("href", MEDIA_URLS.svg); + await expect(detail.locator("img, audio, video")).toHaveCount(0); + + await detail.getByRole("button", { name: "Show video preview" }).click(); + const video = detail.locator("video"); + await expect(video).toHaveJSProperty("paused", true); + await video.evaluate((element: HTMLVideoElement) => element.play().catch(() => undefined)); + await expect(detail.getByText(/Video failed to load/)).toBeVisible(); + await expect(detail.getByRole("button", { name: "Retry video" })).toBeVisible(); + await expect(table.getByRole("row")).toHaveCount(3); + await expect(detail.getByText("prompt body kept")).toBeVisible(); + }); + + test("opens a cross-origin download in a new tab and stays on the example", async ({ page }) => { + const signed = "https://account.blob.core.windows.net/container/clip.mp4?sig=expired"; + await mockDatasetExamples(page, { + ...EXAMPLE_DETAIL, + members: [ + EXAMPLE_DETAIL.members[0], + mediaMember("video-1", "video_path", "https://account.blob.core.windows.net/container/clip.mp4", signed), + ], + }); + await page.context().route("https://account.blob.core.windows.net/**", async (route) => { + await route.fulfill({ status: 403, contentType: "text/plain", body: "expired" }); + }); + + await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(DATASET.selection_key)}&example=example-1`); + const detail = page.getByRole("region", { name: "Example detail" }); + const stayed = page.url(); + await detail.getByRole("button", { name: "Show video preview" }).click(); + const video = detail.locator("video"); + await video.evaluate((element: HTMLVideoElement) => element.play().catch(() => undefined)); + await expect(detail.getByText(/Video failed to load/)).toBeVisible(); + + const popupPromise = page.waitForEvent("popup"); + await detail.getByRole("link", { name: "Open in new tab" }).click(); + const popup = await popupPromise; + await expect.poll(() => popup.url()).toContain("blob.core.windows.net"); + await expect(page).toHaveURL(stayed); + await expect(detail.getByText("prompt body kept")).toBeVisible(); + await expect(page.getByRole("table", { name: "Examples" })).toBeVisible(); + await popup.close(); + }); + + test("retries an expired signature with a newly signed url", async ({ page }) => { + const stored = "https://account.blob.core.windows.net/container/pic.png"; + const expired = `${stored}?sig=expired`; + const fresh = `${stored}?sig=fresh`; + let signature: string = expired; + await mockDatasetExamples(page, { + ...EXAMPLE_DETAIL, + members: [ + EXAMPLE_DETAIL.members[0], + mediaMember("image-1", "image_path", stored, expired), + ], + }); + await page.route("**/api/datasets/seeds/example-1**", async (route) => { + const detail = { + ...EXAMPLE_DETAIL, + members: [ + EXAMPLE_DETAIL.members[0], + mediaMember("image-1", "image_path", stored, signature), + ], + }; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(detail) }); + }); + await page.context().route("https://account.blob.core.windows.net/**", async (route) => { + const expiredRequest = route.request().url().includes("sig=expired"); + await route.fulfill(expiredRequest + ? { status: 403, contentType: "text/plain", body: "expired" } + : { + status: 200, + contentType: "image/png", + body: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), + }); + }); + + const mediaRequests: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).hostname === "account.blob.core.windows.net") mediaRequests.push(request.url()); + }); + await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(DATASET.selection_key)}&example=example-1`); + const detail = page.getByRole("region", { name: "Example detail" }); + await expect(detail.getByTestId("seed-value-image-1")).toHaveText(stored); + await detail.getByRole("button", { name: "Show image preview" }).click(); + await expect(detail.getByText(/Image failed to load/)).toBeVisible(); + expect(mediaRequests).toEqual([expired]); + + signature = fresh; + await detail.getByRole("button", { name: "Retry image" }).click(); + const image = detail.locator("img"); + await expect.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth)).toBeGreaterThan(0); + await expect(image).toHaveAttribute("src", fresh); + expect(mediaRequests).toContain(fresh); + await expect(detail.getByText(/Image failed to load/)).toHaveCount(0); + await expect(detail.getByTestId("seed-value-image-1")).toHaveText(stored); + await expect(page.getByRole("region", { name: "Example detail" })).toBeVisible(); + }); + + test("keeps media hidden while a repeated Show waits for its refreshed url", async ({ page }) => { + const stored = "https://account.blob.core.windows.net/container/pic.png"; + const opened = `${stored}?sig=opened`; + const fresh = `${stored}?sig=fresh`; + const portrait = readFileSync(new URL("./fixtures/portrait-600x1200.png", import.meta.url)); + let reopening = false; + let refreshRequests = 0; + let finishRefresh: () => void = () => {}; + const refreshed = new Promise((resolve) => { finishRefresh = resolve; }); + const detailFor = (signature: string) => ({ + ...EXAMPLE_DETAIL, + members: [mediaMember("image-1", "image_path", stored, signature)], + }); + await mockDatasetExamples(page, detailFor(opened)); + await page.route("**/api/datasets/seeds/example-1**", async (route) => { + const signature = reopening ? fresh : opened; + if (reopening) { + refreshRequests += 1; + await refreshed; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(detailFor(signature)), + }); + }); + await page.context().route("https://account.blob.core.windows.net/**", async (route) => { + if (reopening && route.request().url() === opened) { + await route.fulfill({ status: 403, contentType: "text/plain", body: "expired" }); + return; + } + await route.fulfill({ status: 200, contentType: "image/png", body: portrait }); + }); + const mediaRequests: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).hostname === "account.blob.core.windows.net") mediaRequests.push(request.url()); + }); + + await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(DATASET.selection_key)}&example=example-1`); + const detail = page.getByRole("region", { name: "Example detail" }); + await detail.getByRole("button", { name: "Show image preview" }).click(); + const image = detail.locator("img"); + await expect.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth)).toBe(600); + await detail.getByRole("button", { name: "Hide image preview" }).click(); + + reopening = true; + const show = detail.getByRole("button", { name: "Show image preview" }); + const refreshStarted = page.waitForRequest((request) => request.url().includes("/api/datasets/seeds/example-1")); + try { + await show.dblclick(); + await refreshStarted; + await expect(show).toBeDisabled(); + await expect(show).toHaveAttribute("aria-busy", "true"); + expect(refreshRequests).toBe(1); + await expect(image).toHaveCount(0); + expect(mediaRequests).toEqual([opened]); + } finally { + finishRefresh(); + } + + await expect(image).toHaveAttribute("src", fresh); + await expect.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth)).toBe(600); + expect(mediaRequests).toEqual([opened, fresh]); + await expect(detail.getByText(/Image failed to load/)).toHaveCount(0); + await expect(detail.getByTestId("seed-value-image-1")).toHaveText(stored); + }); + + test("ignores a media refresh that finishes after the example is closed", async ({ page }) => { + const stored = "https://account.blob.core.windows.net/container/pic.png"; + const opened = `${stored}?sig=opened`; + const reopened = `${stored}?sig=newly-opened`; + const olderRefresh = `${stored}?sig=older-refresh`; + let mode: "opened" | "hold" | "reopened" = "opened"; + let releaseHeld: () => void = () => {}; + const held = new Promise((resolve) => { + releaseHeld = resolve; + }); + const detailFor = (signature: string) => ({ + ...EXAMPLE_DETAIL, + members: [ + EXAMPLE_DETAIL.members[0], + mediaMember("image-1", "image_path", stored, signature), + ], + }); + + await mockDatasetExamples(page, detailFor(opened)); + await page.route("**/api/datasets/seeds/example-1**", async (route) => { + if (mode === "hold") { + await held; + try { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(detailFor(olderRefresh)), + }); + } catch { + // The example was closed, so the browser aborted this refresh. + } + return; + } + const signature = mode === "reopened" ? reopened : opened; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(detailFor(signature)), + }); + }); + await page.context().route("https://account.blob.core.windows.net/**", async (route) => { + if (route.request().url().includes("sig=newly-opened")) { + await route.fulfill({ + status: 200, + contentType: "image/png", + body: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), + }); + return; + } + await route.fulfill({ status: 403, contentType: "text/plain", body: "expired" }); + }); + + await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(DATASET.selection_key)}&example=example-1`); + const detail = page.getByRole("region", { name: "Example detail" }); + await detail.getByRole("button", { name: "Show image preview" }).click(); + await expect(detail.getByText(/Image failed to load/)).toBeVisible(); + + mode = "hold"; + const refreshStarted = page.waitForRequest((request) => request.url().includes("/api/datasets/seeds/example-1")); + await detail.getByRole("button", { name: "Retry image" }).click(); + await refreshStarted; + + mode = "reopened"; + await page.getByRole("button", { name: "Hide alpha" }).click(); + await expect(detail).toHaveCount(0); + await page.getByRole("button", { name: "Show alpha" }).click(); + await expect(detail.getByText("prompt body kept")).toBeVisible(); + await detail.getByRole("button", { name: "Show image preview" }).click(); + const image = detail.locator("img"); + await expect(image).toHaveAttribute("src", reopened); + + releaseHeld(); + await page.waitForLoadState("networkidle"); + await expect(image).toHaveAttribute("src", reopened); + await expect(page.getByText("Couldn't refresh the link.")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Retry example" })).toHaveCount(0); + }); + + test("keeps a portrait image above its stored value at desktop and mobile widths", async ({ page }) => { + const portrait = readFileSync(new URL("./fixtures/portrait-600x1200.png", import.meta.url)); + await mockDatasetExamples(page, { + ...EXAMPLE_DETAIL, + members: [ + EXAMPLE_DETAIL.members[0], + mediaMember("image-1", "image_path", "/dbdata/seed-prompt-entries/portrait.png", "/api/media?path=%2Fdbdata%2Fseed-prompt-entries%2Fportrait.png"), + ], + }); + await page.route("**/api/media**", async (route) => { + await route.fulfill({ status: 200, contentType: "image/png", body: portrait }); + }); + + await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(DATASET.selection_key)}&example=example-1`); + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 900 }); + const detail = page.getByRole("region", { name: "Example detail" }); + await detail.getByRole("button", { name: "Show image preview" }).click(); + const image = detail.getByRole("img", { name: "image seed" }); + const value = detail.getByTestId("seed-value-image-1"); + await image.scrollIntoViewIfNeeded(); + await expect.poll(() => image.evaluate((element: HTMLImageElement) => ( + element.naturalWidth === 600 + && element.naturalHeight === 1200 + && element.getBoundingClientRect().height > 100 + ))).toBe(true); + const layout = await image.evaluate((element) => { + const stored = element.ownerDocument.querySelector("[data-testid='seed-value-image-1']"); + if (!stored) throw new Error("Stored value is missing"); + const imageBox = element.getBoundingClientRect(); + const valueBox = stored.getBoundingClientRect(); + return { + imageHeight: imageBox.height, + imageBottom: imageBox.bottom, + valueTop: valueBox.top, + }; + }); + expect(layout.imageHeight).toBeGreaterThan(100); + expect(layout.imageHeight).toBeLessThanOrEqual(404); + expect(layout.valueTop).toBeGreaterThanOrEqual(layout.imageBottom - 1); + await expect(value).toBeVisible(); + await detail.getByRole("button", { name: "Hide image preview" }).click(); + } + }); +}); diff --git a/frontend/e2e/fixtures/portrait-600x1200.png b/frontend/e2e/fixtures/portrait-600x1200.png new file mode 100644 index 0000000000..c491be7a1d Binary files /dev/null and b/frontend/e2e/fixtures/portrait-600x1200.png differ diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index ea364ca777..bc55541482 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Text, Avatar, @@ -28,6 +28,7 @@ import { OpenRegular, } from '@fluentui/react-icons' import MarkdownContent from '@/components/Markdown/MarkdownContent' +import MediaPreview from '@/components/Media/MediaPreview' import type { DisplayScore, Message, MessageAttachment } from '@/types' @@ -49,51 +50,6 @@ interface MessageListProps { globalMarkdown?: boolean } -/** Image that shows a spinner while loading. */ -function ImageWithSpinner({ src, alt, className, hiddenClassName, containerClassName, spinnerClassName }: { - src: string - alt: string - className: string - hiddenClassName: string - containerClassName: string - spinnerClassName: string -}) { - const [loaded, setLoaded] = useState(false) - const [error, setError] = useState(false) - const onLoad = useCallback(() => setLoaded(true), []) - const onError = useCallback(() => { setError(true); setLoaded(true) }, []) - - return ( -
- {!loaded && } - {error - ? Image failed to load - : {alt} - } -
- ) -} - -function MediaWithFallback({ type, src, className }: { type: 'video' | 'audio'; src: string; className?: string }) { - const [error, setError] = useState(false) - const handleError = useCallback(() => setError(true), []) - - if (error) { - return {type === 'video' ? 'Video' : 'Audio'} failed to load - } - - if (type === 'video') { - return