From 06e8291896e262f44bfb95d336b5eb8899e39852 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 14 Oct 2026 20:51:07 +0200 Subject: [PATCH 1/3] build(image): rebuild the apt-get upgrade layer when APT_REFRESH changes A build cache kept the upgrade layer for as long as nothing above it changed, so a publish shipped curl and librsvg a release behind fixes already in the Debian archive (the CVE gate refused it). The release build passes the day; empty, the default for a self-hosted build, leaves the cache alone. --- Dockerfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b04e23be..31542cf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,8 +55,14 @@ RUN adduser --disabled-password --gecos "" appuser \ # token, else only storyboards are offered). yt-dlp auto-detects deno on PATH. # `apt-get upgrade`: the base image is a snapshot, and the Debian security fixes released since it # was cut are only in the archive — the first Trivy scan found one (pcre2) fixed upstream but not in it. +# APT_REFRESH: the upgrade only runs when this layer is rebuilt, and a build cache keeps it for as long +# as nothing above it changes — a publish shipped an image whose curl and librsvg were a release behind +# fixes already in the archive. The release build passes the day here, so the layer is rebuilt at +# least daily; empty (the default, a self-hosted build) leaves the cache alone. ARG DENO_VERSION=v2.9.1 -RUN apt-get update \ +ARG APT_REFRESH= +RUN echo "apt refresh: ${APT_REFRESH:-cached}" \ + && apt-get update \ && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \ && curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \ From 383d394e32c0382e53099aa4dd669e0d63cfc565 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 14 Oct 2026 20:51:08 +0200 Subject: [PATCH 2/3] fix(ui): a picture that fails to load shows the card's own frame, then retries A bare sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses a bare . --- CLAUDE.md | 2 +- frontend/eslint.config.js | 7 ++ .../ChannelPage.busyFocus.dom.test.tsx | 20 ++- frontend/src/components/ChannelPage.tsx | 74 +++-------- frontend/src/components/FilePreview.tsx | 15 ++- frontend/src/components/Home.tsx | 5 +- frontend/src/components/IssueComposer.tsx | 4 +- frontend/src/components/LibraryPlayer.tsx | 3 +- frontend/src/components/Playlists.tsx | 7 +- frontend/src/components/PlaylistsRail.tsx | 9 +- frontend/src/components/PlexBrowse.tsx | 24 ++-- frontend/src/components/PlexInfo.tsx | 9 +- frontend/src/components/PlexLibraryPicker.tsx | 3 +- frontend/src/components/PlexPlaylistView.tsx | 12 +- frontend/src/components/PlexSearchBox.tsx | 3 +- frontend/src/components/PosterTile.tsx | 3 +- frontend/src/components/UpNextCard.tsx | 3 +- frontend/src/components/VideoCard.tsx | 3 +- frontend/src/components/Welcome.tsx | 26 ++-- frontend/src/components/downloadRow.tsx | 94 +++++++------- frontend/src/components/playlistColumns.tsx | 3 +- frontend/src/components/plexCards.tsx | 25 ++-- frontend/src/components/plexDetailUi.tsx | 6 +- frontend/src/components/plexViews.tsx | 18 +-- frontend/src/components/ui/Avatar.tsx | 66 +++------- .../src/components/ui/PageMenu.dom.test.tsx | 1 + frontend/src/components/ui/README.md | 2 +- .../components/ui/RemoteImage.dom.test.tsx | 116 ++++++++++++++++++ frontend/src/components/ui/RemoteImage.tsx | 91 ++++++++++++++ .../src/lib/controlRules.contract.test.ts | 12 ++ frontend/src/lib/gates.snapshot.json | 4 + 31 files changed, 435 insertions(+), 235 deletions(-) create mode 100644 frontend/src/components/ui/RemoteImage.dom.test.tsx create mode 100644 frontend/src/components/ui/RemoteImage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 347e1f3a..7d7a807b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory Not repeated here, because a lint rule refuses the wrong one and its message names the right one: buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native controls (`ui/Input`, `Checkbox`, `Slider`, …), glass surfaces (`SURFACE.*`), a native `: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.", }, + // A picture that failed to load left the browser's broken-image mark on a card: a bare + // sends a Referer Google's image CDNs refuse, and has no retry and no fallback. + { + selector: "JSXOpeningElement[name.name='img']", + message: + "No bare : use ui/RemoteImage — no Referer, a retry past the browser's negative cache, and a fallback instead of the broken-image mark.", + }, // Keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT // scale with the Settings --font-scale slider while its rem neighbours do; use // `text-2xs`/`text-3xs` or a `text-[Nrem]` arbitrary instead. Matches both plain strings and diff --git a/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx b/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx index 7fc5a128..b0ee918e 100644 --- a/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx +++ b/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx @@ -99,10 +99,10 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => { }); // The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the -// next waits, a visible shows the browser's broken-image glyph (seen live). It stays -// invisible until a source has actually loaded. +// next waits, a visible showed the browser's broken-image glyph (seen live). While a retry +// waits there is no at all — the bar's own ground — and the retry asks past the cache. describe("ChannelPage — the banner", () => { - it("is invisible until it loads, and again while a failed attempt is retried", async () => { + it("leaves the bar empty while a failed attempt waits, then asks again past the cache", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); onTestFinished(() => { vi.useRealTimers(); @@ -134,16 +134,14 @@ describe("ChannelPage — the banner", () => { , ); const banner = () => - container.querySelector('img[src^="https://yt3.example/banner"]')!; + container.querySelector('img[src^="https://yt3.example/banner"]'); await waitFor(() => expect(banner()).toBeTruthy()); - expect(banner().classList.contains("opacity-0")).toBe(true); - fireEvent.error(banner()); + expect(banner()!.getAttribute("referrerpolicy")).toBe("no-referrer"); + fireEvent.error(banner()!); + expect(banner()).toBeNull(); await act(async () => { - await vi.advanceTimersByTimeAsync(600); + await vi.advanceTimersByTimeAsync(1000); }); - expect(banner().getAttribute("src")).toContain("?r=1"); - expect(banner().classList.contains("opacity-0")).toBe(true); - fireEvent.load(banner()); - expect(banner().classList.contains("opacity-0")).toBe(false); + expect(banner()!.getAttribute("src")).toContain("?r=1"); }); }); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index fa0279ca..9a61acca 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -47,6 +47,7 @@ import { IconButton } from "./ui/IconButton"; import { IconLink } from "./ui/LinkButton"; import { PageHeading } from "./ui/Headings"; import { useTags } from "../lib/useTags"; +import RemoteImage from "./ui/RemoteImage"; // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it @@ -94,17 +95,6 @@ export default function ChannelPage({ // is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row. const foldable = useRef(null); const folded = useScrollFold(foldable); - // The banner comes from Google's image CDN, which refuses any request carrying a Referer from our - // origin — every time, not only in a burst (measured 2026-09-26) — so the sends none - // (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss, - // so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall - // back to the blank bar if it keeps failing (a genuinely dead URL — rare). - const MAX_BANNER_RETRIES = 3; - const [bannerAttempt, setBannerAttempt] = useState(0); - // The banner source that has actually loaded. Until then the is invisible: an attempt that - // fails shows the browser's broken-image glyph for as long as the retry waits (seen live). - const [bannerLoaded, setBannerLoaded] = useState(null); - // A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and // the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the // scheduler tops up any tail on its ~10-minute ticks — then stop polling once the back-catalogue is @@ -113,13 +103,6 @@ export default function ChannelPage({ intervalMs: channelDetailPollMs, }); const ch = detail.data; - // A fresh channel/banner gets the retries back, in the render that changes it: as an effect the - // new banner was committed with the previous one's exhausted attempt count still in place. - const [bannerFor, setBannerFor] = useState(ch?.banner_url); - if (bannerFor !== ch?.banner_url) { - setBannerFor(ch?.banner_url); - setBannerAttempt(0); - } // The poll above is also the ONLY signal that the running backfill has written videos, so it // drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on // the PATCH response, before the background job has stored anything). @@ -348,9 +331,7 @@ export default function ChannelPage({ // The channel page owns no scroller of its own (PageShell provides the one app scroller). Its // whole chrome is fixed — wrapped in it portals into the shell's fixed band, so the // tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile. - const bannerSrc = ch?.banner_url - ? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}` - : null; + const bannerSrc = ch?.banner_url ? `${ch.banner_url}=w1707` : null; return ( <> @@ -359,44 +340,21 @@ export default function ChannelPage({
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
- {bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? ( - // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a - // low default size, so request a crisp wide version (=w1707) and object-cover the - // desktop "safe area" — the centre 2560×423 (~6:1) band. -
- per attempt; the ?r= cache-buster on a retry bypasses the - // browser's negative cache (googleusercontent accepts an extra query param). - key={bannerAttempt} - src={bannerSrc} - referrerPolicy="no-referrer" - alt="" - className={`w-full h-full object-cover object-center ${ - bannerLoaded === bannerSrc ? "" : "opacity-0" - }`} - onLoad={() => setBannerLoaded(bannerSrc)} - onError={() => { - if (bannerAttempt <= MAX_BANNER_RETRIES) { - window.setTimeout( - () => setBannerAttempt((a) => a + 1), - 500 * (bannerAttempt + 1), - ); - } - }} - /> -
- ) : ( - // No banner (or a dead one): a neutral bar the SAME height as a real banner, so the - // overlapping avatar + the Back button have the same room and don't collide (a short bar - // let the -mt-8 avatar ride up into the Back button). -
+ - )} +
diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx index a37f6a3f..92c44434 100644 --- a/frontend/src/components/FilePreview.tsx +++ b/frontend/src/components/FilePreview.tsx @@ -23,6 +23,7 @@ import Modal from "./ui/Modal"; import { MoreButton } from "./ui/MoreButton"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useContextMenu } from "./ui/useContextMenu"; +import RemoteImage from "./ui/RemoteImage"; // The Files viewer (DESIGN.md "Files preview"): the library's files that are not media, one at a time, // in the shared dialog shell. What a file shows is the server's call (`asset.preview`, @@ -187,19 +188,17 @@ function Stage({ title: string; }) { const { t } = useTranslation(); - const [failed, setFailed] = useState(false); if (kind === "image") - return failed ? ( - - {t("common.loadError")} - - ) : ( + return (
- {title} setFailed(true)} className="max-h-full max-w-full rounded-md object-contain" + retries={0} + fallback={{t("common.loadError")}} />
); diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 3f86a066..44a53f2b 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -72,6 +72,7 @@ import { mediaPictureBox, mediaTitleClass, } from "./ui/mediaCardStyles"; +import RemoteImage from "./ui/RemoteImage"; // The Home surface — "Mit nézzek most?". // @@ -450,7 +451,7 @@ function ShelfTile({ className={MEDIA_PICTURE} > {card.thumb ? ( - + ) : null} {/* The Play only when there is something to play: the click plays the channel's newest unwatched video, the playlist's first. */} @@ -574,7 +575,7 @@ function Hero({ aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`} > {card.thumb ? ( - + ) : null} {card.duration_seconds != null && ( // It steps aside while the hover row covers its corner, as a card's length does. diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx index 64052c2c..b10c3ec2 100644 --- a/frontend/src/components/IssueComposer.tsx +++ b/frontend/src/components/IssueComposer.tsx @@ -29,6 +29,7 @@ import SegmentedToggle from "./ui/SegmentedToggle"; import { Textarea, Input } from "./ui/Input"; import { Checkbox } from "./ui/NativeControls"; import { SURFACE } from "./ui/surfaces"; +import RemoteImage from "./ui/RemoteImage"; // The issue composer: one dialog — the draft on the left (title, description, the // steps drafted from the session, the environment), what goes with it on the right (the two logs, @@ -680,10 +681,11 @@ function Attachments({ key={shot.id} className="flex items-center gap-3 rounded-lg border border-border px-3 py-2" > - {t("recorder.composer.screenshot",} />
{t("recorder.composer.screenshot", { n: i + 1 })} diff --git a/frontend/src/components/LibraryPlayer.tsx b/frontend/src/components/LibraryPlayer.tsx index 031df4af..e22319ea 100644 --- a/frontend/src/components/LibraryPlayer.tsx +++ b/frontend/src/components/LibraryPlayer.tsx @@ -10,6 +10,7 @@ import { useMediaLogRef } from "../lib/mediaLog"; import { useMediaSession } from "../lib/mediaSession"; import { useMediaKeys } from "../lib/mediaKeys"; import { keptPosition, useResume } from "../lib/resume"; +import RemoteImage from "./ui/RemoteImage"; // In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin // YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain @@ -94,7 +95,7 @@ export default function LibraryPlayer({ {isAudio ? (
{poster ? ( -
{items[0]?.thumbnail_url && ( - + )}
diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 6013690f..ec8079a7 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -29,6 +29,7 @@ import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { MoreButton } from "./ui/MoreButton"; +import RemoteImage from "./ui/RemoteImage"; type PlSort = { key: "custom" | "name" | "count" | "duration" | "recent"; @@ -298,7 +299,11 @@ export default function PlaylistsRail({
{pl.cover_thumbnail && ( - + )}
{pl.cover_thumbnail && ( - import("./PlexPlayer")); @@ -672,18 +673,17 @@ export default function PlexBrowse() { title={t("plex.info.viewPerson", { name: p.name })} className="group/person flex min-w-0 items-center gap-2.5 rounded-lg" > - {p.photo ? ( - - ) : ( -
- -
- )} + + +
+ } + />
{p.name} diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 3256ea2f..934b7efd 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -35,6 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu"; import { usePlexCardActions } from "./usePlexCardActions"; import { toggleWatchState } from "../lib/plexWatch"; import { vocab } from "../lib/vocab"; +import RemoteImage from "./ui/RemoteImage"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -403,10 +404,16 @@ export default function PlexInfo({ >
{/* Poster */} - + } />
diff --git a/frontend/src/components/PlexLibraryPicker.tsx b/frontend/src/components/PlexLibraryPicker.tsx index 3e042133..3cf06368 100644 --- a/frontend/src/components/PlexLibraryPicker.tsx +++ b/frontend/src/components/PlexLibraryPicker.tsx @@ -6,6 +6,7 @@ import { api, type PlexCard } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; import { LoadingState } from "./ui/QueryState"; import { cardImage } from "../lib/plexImage"; +import RemoteImage from "./ui/RemoteImage"; // A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and // the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and @@ -49,7 +50,7 @@ export default function PlexLibraryPicker({
    {results.map((c) => (
  • - - +
); } @@ -201,7 +207,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
- {block.items.slice(0, 3).map((ep, i) => ( - - 0 && [0, 1].map((i) => posters[i] ? ( -
{next && ( - ; open: boolean }; @@ -252,7 +253,7 @@ function Thumb({ className={MEDIA_PICTURE} > {video.thumbnail_url ? ( - void; }) { - const [failed, setFailed] = useState(false); - if (failed) { - return ( -
-
- {label} -
-
- ); - } return ( onOpen?.(src, label)} aria-label={label} className={`group relative ${SURFACE.card} overflow-hidden block cursor-zoom-in ${className ?? ""}`} > - {label} setFailed(true)} className="w-full h-full object-cover object-top" + fallback={} /> @@ -254,6 +245,15 @@ function Preview({ ); } +// A screenshot that would not load: its caption on the card's own ground, not the broken-image mark. +function ShotLabel({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + // Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport // (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape. function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) { @@ -320,7 +320,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose className="flex max-h-full max-w-full flex-col items-center gap-3" onClick={(e) => e.stopPropagation()} > - {label}(null); - if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null); - // The remote picture is the last resort, and it fails too (a dead CDN link): remembered the same - // way, so the row falls to the glyph below instead of the browser's broken-image mark. - const [failedRemote, setFailedRemote] = useState(null); - if (failedRemote !== null && failedRemote !== remote) setFailedRemote(null); - // An image file's own picture failed (gone since, or not an image after all): the glyph instead. - const [failedPicture, setFailedPicture] = useState(null); - const candidate = failedPrimary && failedPrimary === primary ? remote : primary; - const src = candidate && candidate === failedRemote ? null : candidate; // A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/ // archive, not playable media. An image is its own picture (its sandboxed preview); the rest show - // a neutral file glyph instead of an (always-empty) thumbnail. + // a neutral file glyph instead of an (always-empty) thumbnail. A preview that fails is an answer + // (gone since, or not an image after all), not a hiccup: the glyph at once, no retries. if (job.asset?.media_kind === "file") { - const picture = imageThumbOf(job); + const glyph =
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the same text height and the row of circles sits on one baseline regardless of name length. */} diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index 532bacea..e4cc3da8 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -16,6 +16,7 @@ import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { Chip } from "./ui/Chip"; +import RemoteImage from "./ui/RemoteImage"; // Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, // so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two @@ -263,10 +264,13 @@ export function DetailHero({ className={`group/poster relative ${posterSizeClass} shrink-0 self-start`} title={posterTitle} > - + } /> {showPlayOverlay && (
diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index e03d7092..fd9b4269 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -62,6 +62,7 @@ import { SURFACE } from "./ui/surfaces"; import { PageHeading } from "./ui/Headings"; import { vocab } from "../lib/vocab"; import { formatDate, formatDecimal } from "../lib/format"; +import RemoteImage from "./ui/RemoteImage"; // The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, @@ -292,13 +293,16 @@ export function PlexPersonView({
- {d.photo ? ( - - ) : ( -
- -
- )} + + +
+ } + />
{d.name} diff --git a/frontend/src/components/ui/Avatar.tsx b/frontend/src/components/ui/Avatar.tsx index 712283b8..43366d8e 100644 --- a/frontend/src/components/ui/Avatar.tsx +++ b/frontend/src/components/ui/Avatar.tsx @@ -1,15 +1,9 @@ -import { useState } from "react"; +import RemoteImage from "./RemoteImage"; -// Avatar image with a graceful fallback. -// -// Uses referrerPolicy="no-referrer" because Google's avatar CDNs -// (yt3.ggpht.com / lh3.googleusercontent.com) refuse referrer-bearing requests -// from our origin — yt3 refuses even a single load, not only a burst (measured -// 2026-09-26) — and the browser shows its broken-image icon. Dropping the -// referrer makes those CDNs serve the images. The channel banner, from the same -// CDN, does the same (ChannelPage). -// If a load still fails we render a neutral placeholder (optionally an initial) -// instead of the broken-image icon. +// Avatar image with a graceful fallback: a neutral disc (optionally the name's initial) while there is +// no picture. The loading itself — no Referer for Google's avatar CDNs, the retry past the browser's +// negative cache, the failure remembered by URL so a recycled feed slot never paints one channel's +// initial over the next one's picture — is ui/RemoteImage's. export default function Avatar({ src, alt = "", @@ -21,43 +15,21 @@ export default function Avatar({ fallback?: string; // a name/title; its first letter is shown when there's no image className?: string; }) { - // The failure is remembered BY URL, not as a boolean that an effect resets when `src` changes. - // That distinction is visible: this component is recycled across items in the virtualized feed, so - // one avatar's 429 used to paint the fallback initial over the NEXT item's perfectly good image - // for a frame — React committed the render with the new `src` and the stale `failed`, and only the - // post-paint effect cleared it. Derived state cannot be stale, so there is nothing to reset. - // - // Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429 - // TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src` - // swaps — the player's channel avatar stepping through a queue — would - // show the initial for a throttled channel forever, never re-requesting even after the CDN - // relented. Written in render because it is convergent (the write falsifies its own condition) and - // because an effect would leave one committed frame in which the memory is stale, which is the - // frame this whole component is about. - const [failedSrc, setFailedSrc] = useState(null); - if (failedSrc !== null && failedSrc !== src) setFailedSrc(null); - const failed = !!src && failedSrc === src; - - if (src && !failed) { - return ( - {alt} setFailedSrc(src)} - className={className} - /> - ); - } - const initial = fallback?.trim()?.[0]?.toUpperCase() ?? ""; return ( -
- {initial} -
+ + {initial} +
+ } + /> ); } diff --git a/frontend/src/components/ui/PageMenu.dom.test.tsx b/frontend/src/components/ui/PageMenu.dom.test.tsx index 6140b4ca..b96375df 100644 --- a/frontend/src/components/ui/PageMenu.dom.test.tsx +++ b/frontend/src/components/ui/PageMenu.dom.test.tsx @@ -71,6 +71,7 @@ function Page({ withMenu = true }: { withMenu?: boolean }) { >
+ {/* eslint-disable-next-line no-restricted-syntax -- the native element whose browser menu is kept */}
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 86f00cc6..b92cf515 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -39,7 +39,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | | actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `