From c99920205675715713a54738f415ce2322dfc9ae Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Thu, 7 May 2026 15:36:57 -0700 Subject: [PATCH] feat(Books): auto-detect animated thumbnails and support pausing at presentation time --- .../CatalogCard/BookCardContent.tsx | 18 ++- client/src/components/util/PausableImage.tsx | 121 ++++++++++++++++++ client/src/screens/commons/Book/index.tsx | 7 +- client/src/types/Book.ts | 1 + server/api/books.ts | 48 ++++++- server/models/book.ts | 6 + 6 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 client/src/components/util/PausableImage.tsx diff --git a/client/src/components/commons/CommonsCatalog/CatalogCard/BookCardContent.tsx b/client/src/components/commons/CommonsCatalog/CatalogCard/BookCardContent.tsx index ffa2f764..6170c917 100644 --- a/client/src/components/commons/CommonsCatalog/CatalogCard/BookCardContent.tsx +++ b/client/src/components/commons/CommonsCatalog/CatalogCard/BookCardContent.tsx @@ -3,6 +3,7 @@ import { Book } from "../../../../types"; import { truncateString } from "../../../util/HelperFunctions"; import { getLibGlyphAltText, getLibGlyphURL } from "../../../util/LibraryOptions"; import { Heading, Card, Text, Stack } from "@libretexts/davis-react"; +import PausableImage from "../../../util/PausableImage"; interface BookCardContentProps { book: Book; @@ -25,12 +26,17 @@ const BookCardContent: React.FC = ({ book }) => { return ( <>
- + + {/* Negative margins cancel headerContent padding so the image stays full-bleed (matches Card.Header image prop layout) */} +
+ +
+
{ + /** Whether this image is animated (e.g. GIF). When true, renders a + * canvas-based freeze frame and a pause/play toggle button. */ + isAnimated?: boolean; +} + +/** + * Drop-in replacement for that adds a pause/play toggle for animated + * images. Pass `isAnimated` to enable the pause UI — animation detection + * is handled upstream (e.g. server-side Content-Type check during sync). + * + * Accessibility: + * - Auto-pauses when prefers-reduced-motion: reduce is active (SC 2.2.2) + * - Pause button meets 24x24px minimum target size (SC 2.5.8) + * - Decorative images (alt="") get aria-hidden on the canvas + */ +const PausableImage: React.FC = ({ + src, + alt, + className, + style, + onLoad, + isAnimated = false, + ...props +}) => { + const canvasRef = useRef(null); + const imgRef = useRef(null); + const [paused, setPaused] = useState( + () => window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); + const [frameCaptured, setFrameCaptured] = useState(false); + + const captureFrame = useCallback(() => { + const img = imgRef.current; + const canvas = canvasRef.current; + if (!img || !canvas) return; + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext("2d")?.drawImage(img, 0, 0); + setFrameCaptured(true); + }, []); + + // Auto-pause when prefers-reduced-motion is active + useEffect(() => { + if (!isAnimated) return; + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + if (mq.matches) setPaused(true); + const handler = (e: MediaQueryListEvent) => { + if (e.matches) setPaused(true); + }; + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [isAnimated]); + + if (!isAnimated) { + return ( + {alt} + ); + } + + const showPauseUI = frameCaptured; + const isDecorative = alt === ""; + + return ( +
+ {alt} { + captureFrame(); + onLoad?.(e); + }} + {...props} + /> + + {showPauseUI && ( + + )} +
+ ); +}; + +export default PausableImage; diff --git a/client/src/screens/commons/Book/index.tsx b/client/src/screens/commons/Book/index.tsx index 9fb3e98c..2a4250af 100644 --- a/client/src/screens/commons/Book/index.tsx +++ b/client/src/screens/commons/Book/index.tsx @@ -82,6 +82,7 @@ import api from "../../../api"; import BookPeerReviewsModal from "../../../components/peerreview/BookPeerReviewsModal"; import { format, parseISO } from "date-fns"; import { getLanguageName } from "../../../utils/languageCodes"; +import PausableImage from "../../../components/util/PausableImage"; type CustomPieChartData = { value: number; title: string; @@ -883,11 +884,11 @@ const CommonsBook = () => { {/* Left Column — Book Meta */} - {book.title} diff --git a/client/src/types/Book.ts b/client/src/types/Book.ts index fd5229fd..f435cdc6 100644 --- a/client/src/types/Book.ts +++ b/client/src/types/Book.ts @@ -14,6 +14,7 @@ export type Book = { program: string; license: string; thumbnail: string; + thumbnailIsAnimated?: boolean; summary: string; rating: number; links: BookLinks; diff --git a/server/api/books.ts b/server/api/books.ts index dd5c60e9..ba340bc5 100644 --- a/server/api/books.ts +++ b/server/api/books.ts @@ -357,7 +357,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => { // Execute requests Promise.all(allRequests) - .then((booksRes) => { + .then(async (booksRes) => { // Extract books from responses booksRes.forEach((axiosRes) => { allBooks = allBooks.concat(axiosRes.data.items); @@ -456,11 +456,14 @@ const syncWithLibraries = async (_req: Request, res: Response) => { }); } }); + let booksQuery = Book.aggregate([ { $project: { _id: 0, bookID: 1, + thumbnail: 1, + thumbnailIsAnimated: 1, }, }, ]); @@ -485,7 +488,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => { ]); return Promise.all([booksQuery, projsQuery]); }) - .then((queryResults) => { + .then(async (queryResults) => { if (queryResults.length === 2) { if (Array.isArray(queryResults[0])) { existingBooks = queryResults[0] @@ -497,6 +500,46 @@ const syncWithLibraries = async (_req: Request, res: Response) => { } if (Array.isArray(queryResults[1])) existingProjects = queryResults[1]; } + + // Detect animated thumbnails (GIFs) via HEAD requests in parallel. + // Server-side requests avoid CORS restrictions that block client-side detection. + const existingBooksMap = new Map( + (queryResults[0] ?? []).map((b: any) => [b.bookID, b]) + ); + await Promise.all( + processedBooks.map(async (book) => { + if (!book.thumbnail) return; + + // Short-circuit: URL clearly identifies a GIF + if (/\.gif(\?|$)/i.test(book.thumbnail)) { + (book as any).thumbnailIsAnimated = true; + return; + } + + // Skip HEAD request if the thumbnail URL hasn't changed and we + // already have a detection result from a previous sync cycle + const existing = existingBooksMap.get(book.bookID); + if ( + existing && + existing.thumbnail === book.thumbnail && + existing.thumbnailIsAnimated !== undefined + ) { + (book as any).thumbnailIsAnimated = existing.thumbnailIsAnimated; + return; + } + + try { + const headRes = await axios.head(book.thumbnail, { timeout: 5000 }); + const contentType = headRes.headers["content-type"]; + if (typeof contentType === "string" && contentType.includes("image/gif")) { + (book as any).thumbnailIsAnimated = true; + } + } catch { + // Request failed — leave thumbnailIsAnimated unset + } + }) + ); + processedBooks.forEach((book) => { /* check if project needs to be created */ let [bookLib, bookCoverID] = getLibraryAndPageFromBookID(book.bookID); @@ -541,6 +584,7 @@ const syncWithLibraries = async (_req: Request, res: Response) => { program: book.program, license: book.license, thumbnail: book.thumbnail, + thumbnailIsAnimated: !!(book as any).thumbnailIsAnimated, summary: book.summary, links: book.links, lastUpdated: book.lastUpdated, diff --git a/server/models/book.ts b/server/models/book.ts index 16b2db6d..eeb5231d 100644 --- a/server/models/book.ts +++ b/server/models/book.ts @@ -26,6 +26,7 @@ export interface BookInterface extends Document { lastUpdated?: string; libraryTags?: string[]; readerResources: ReaderResource[]; + thumbnailIsAnimated?: boolean; trafficAnalyticsConfigured?: boolean; randomIndex?: number; } @@ -89,6 +90,11 @@ const BookSchema = new Schema( * URL of the Book's thumbnail. */ thumbnail: String, + /** + * Whether the Book's thumbnail is an animated image (e.g. GIF). + * Determined during library sync via Content-Type header check. + */ + thumbnailIsAnimated: Boolean, /** * The Book's overview/description/summary. */