diff --git a/src/App.tsx b/src/App.tsx index 42799df..dc69c41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { CategoryPage } from "./pages/Category"; import { OfficialRecommendationsPage } from "./pages/OfficialRecommendations"; import { SearchPage } from "./pages/Search"; import { PostRedirect } from "./pages/PostRedirect"; +import { ScrollToTop } from "./components/ScrollToTop"; import { AboutPage } from "./pages/About"; import Favorites from "./pages/Favorites"; import { adminUiPrefix } from "./adminPaths"; @@ -29,6 +30,7 @@ export default function App() { + }> } /> diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx new file mode 100644 index 0000000..af39833 --- /dev/null +++ b/src/components/ScrollToTop.tsx @@ -0,0 +1,22 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +/** + * Resets the window to the top on every route change. React Router does not + * restore scroll on client navigation, so without this a short new page would + * clamp to wherever the previous (taller) page was scrolled — e.g. landing at + * the bottom of a category page after clicking a card far down the home grid. + * + * Skips navigations that carry a hash (`#post-`, `#categories`, …) so + * anchor / deep-link targets keep their own scroll handling. + */ +export function ScrollToTop() { + const { pathname, hash } = useLocation(); + + useEffect(() => { + if (hash) return; + window.scrollTo({ top: 0, left: 0 }); + }, [pathname, hash]); + + return null; +} diff --git a/src/components/messageStream/BubbleImage.tsx b/src/components/messageStream/BubbleImage.tsx new file mode 100644 index 0000000..027136e --- /dev/null +++ b/src/components/messageStream/BubbleImage.tsx @@ -0,0 +1,46 @@ +import { ImageOff } from "lucide-react"; +import { useEffect, useState } from "react"; + +type BubbleImageProps = { + src: 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. + */ +export function BubbleImage({ src, className, loading }: BubbleImageProps) { + const [failed, setFailed] = useState(false); + + // Reset when the source changes so a reused element re-attempts the new src. + useEffect(() => { + setFailed(false); + }, [src]); + + if (!src || failed) { + return ( +
+ +
+ ); + } + + return ( + setFailed(true)} + /> + ); +} diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index c186571..40a22b5 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -5,6 +5,7 @@ import { createPortal } from "react-dom"; import { useI18n } from "../../../i18n"; import type { Attachment, Post } from "../../../types/post"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; +import { BubbleImage } from "../BubbleImage"; import { useLightbox } from "../overlays/ImageLightbox"; import { autolink } from "../utils/autolink"; import { downloadAttachment } from "../utils/downloadFile"; @@ -126,9 +127,8 @@ function ImageListDialog({ className="flex min-w-0 flex-1 items-center gap-3 text-left" >
-
@@ -181,11 +181,10 @@ export function AlbumBubble({ post }: { post: Post }) { else openLightbox(images, i, text, post.id); }} className="block h-full w-full" - aria-label={isLastSlot ? "Open image list" : att.filename} + aria-label={isLastSlot ? "Open image list" : "View image"} > - {att.filename} diff --git a/src/components/messageStream/bubbles/ImageBubble.tsx b/src/components/messageStream/bubbles/ImageBubble.tsx index 5c7c391..f8c1e1e 100644 --- a/src/components/messageStream/bubbles/ImageBubble.tsx +++ b/src/components/messageStream/bubbles/ImageBubble.tsx @@ -1,5 +1,6 @@ import type { Post } from "../../../types/post"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; +import { BubbleImage } from "../BubbleImage"; import { useLightbox } from "../overlays/ImageLightbox"; export function ImageBubble({ post }: { post: Post }) { @@ -12,11 +13,10 @@ export function ImageBubble({ post }: { post: Post }) { type="button" onClick={() => openLightbox([att], 0, undefined, post.id)} className="block h-full w-full" - aria-label={att.filename} + aria-label="View image" > - {att.filename} diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index 5c51dcb..a003fd7 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -2,6 +2,7 @@ import { useI18n } from "../../../i18n"; import type { Post } from "../../../types/post"; import { useLightbox } from "../overlays/ImageLightbox"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; +import { BubbleImage } from "../BubbleImage"; import { autolink } from "../utils/autolink"; import { postDisplayText } from "../utils/postText"; @@ -18,11 +19,10 @@ export function ImageWithTextBubble({ post }: { post: Post }) { type="button" onClick={() => openLightbox([att], 0, text, post.id)} className="block h-full w-full" - aria-label={att.filename} + aria-label="View image" > - {att.filename}