Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,12 +26,17 @@ const BookCardContent: React.FC<BookCardContentProps> = ({ book }) => {
return (
<>
<div className="relative">
<Card.Header
image={{
src: book.thumbnail,
alt: "", // The thumbnails are purely decorative, so leave alt text as empty string to be ignored by screen readers
}}
/>
<Card.Header>
{/* Negative margins cancel headerContent padding so the image stays full-bleed (matches Card.Header image prop layout) */}
<div className="-mx-6 -my-4">
<PausableImage
src={book.thumbnail}
alt="" // Thumbnails are purely decorative
className="w-full h-48 object-cover block"
isAnimated={book.thumbnailIsAnimated}
/>
</div>
</Card.Header>
<div className="library-glyph-header">
<img
src={getLibGlyphURL(book.library)}
Expand Down
121 changes: 121 additions & 0 deletions client/src/components/util/PausableImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
useRef,
useState,
useEffect,
useCallback,
ImgHTMLAttributes,
} from "react";

interface PausableImageProps extends ImgHTMLAttributes<HTMLImageElement> {
/** 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 <img> 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<PausableImageProps> = ({
src,
alt,
className,
style,
onLoad,
isAnimated = false,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement>(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 (
<img src={src} alt={alt} className={className} style={style} {...props} />
);
}

const showPauseUI = frameCaptured;
const isDecorative = alt === "";

return (
<div style={{ position: "relative" }}>
<img
ref={imgRef}
src={src}
alt={alt}
className={className}
style={{
...style,
...(showPauseUI && paused ? { display: "none" } : {}),
}}
onLoad={(e) => {
captureFrame();
onLoad?.(e);
}}
{...props}
/>
<canvas
ref={canvasRef}
className={className}
style={{
...style,
...(!(showPauseUI && paused) ? { display: "none" } : {}),
}}
{...(isDecorative
? { "aria-hidden": true as const }
: { role: "img", "aria-label": alt })}
/>
{showPauseUI && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setPaused((p) => !p);
}}
aria-label={paused ? "Play animation" : "Pause animation"}
className="absolute bottom-2 right-2 flex items-center justify-center
min-w-6 min-h-6 w-8 h-8 rounded-full
bg-black/60 text-white
hover:bg-black/80
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700"
>
{paused ? "\u25B6" : "\u23F8"}
</button>
)}
</div>
);
};

export default PausableImage;
7 changes: 4 additions & 3 deletions client/src/screens/commons/Book/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -883,11 +884,11 @@ const CommonsBook = () => {
{/* Left Column — Book Meta */}
<Card padding="sm">
<Stack direction="vertical" gap="md">
<img
<PausableImage
src={book.thumbnail}
aria-hidden={true}
alt=""
alt="" // Thumbnails are purely decorative
className="w-full rounded-md"
isAnimated={book.thumbnailIsAnimated}
/>
<Heading level={1} className="text-center">
{book.title}
Expand Down
1 change: 1 addition & 0 deletions client/src/types/Book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type Book = {
program: string;
license: string;
thumbnail: string;
thumbnailIsAnimated?: boolean;
summary: string;
rating: number;
links: BookLinks;
Expand Down
48 changes: 46 additions & 2 deletions server/api/books.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -456,11 +456,14 @@ const syncWithLibraries = async (_req: Request, res: Response) => {
});
}
});

let booksQuery = Book.aggregate([
{
$project: {
_id: 0,
bookID: 1,
thumbnail: 1,
thumbnailIsAnimated: 1,
},
},
]);
Expand All @@ -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]
Expand All @@ -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")) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably want to also match against image/apng, image/avif, and image/webp to be safe.

(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);
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions server/models/book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface BookInterface extends Document {
lastUpdated?: string;
libraryTags?: string[];
readerResources: ReaderResource[];
thumbnailIsAnimated?: boolean;
trafficAnalyticsConfigured?: boolean;
randomIndex?: number;
}
Expand Down Expand Up @@ -89,6 +90,11 @@ const BookSchema = new Schema<BookInterface>(
* 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.
*/
Expand Down
Loading