diff --git a/client/src/components/Collections/CollectionCard.tsx b/client/src/components/Collections/CollectionCard.tsx deleted file mode 100644 index bb16588b9..000000000 --- a/client/src/components/Collections/CollectionCard.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Collection, CollectionResource } from "../../types"; -import { Link } from "react-router-dom"; -import { getLibGlyphAltText, getLibGlyphURL } from "../util/LibraryOptions"; -import { isBook as checkIsBook } from "../../utils/typeHelpers"; -import { getCollectionHref } from "../util/CollectionHelpers"; -import { Card, Heading, Text, Stack } from "@libretexts/davis-react"; -import "../commons/Commons.css"; - -interface CollectionCardProps { - item: Collection | CollectionResource; - to?: string; -} - -/** Shown when a collection has no cover set or its cover fails to load. */ -const COVER_PLACEHOLDER = "/mini.logo.png"; - -const CollectionCard: React.FC = ({ item, to }) => { - const getResourceData = () => { - if ("resourceData" in item) { - return item.resourceData; - } - return item; - }; - - const resourceData = getResourceData(); - - // A CollectionResource whose parent record was deleted, made private, or - // filtered out by the server arrives with no resourceData. Dereferencing it - // below threw "Cannot read properties of undefined (reading 'thumbnail')" - // and took the whole collections grid down with it. - if (!resourceData) { - console.warn("CollectionCard: item has no resource data, skipping.", item); - return null; - } - - const isBook = checkIsBook(resourceData); - const thumbnail = - (isBook ? resourceData.thumbnail : resourceData.coverPhoto) || - COVER_PLACEHOLDER; - - // A collection with a stale cover URL would otherwise paint the browser's - // broken-image glyph. The guard stops a missing placeholder from looping. - function handleImageError(event: React.SyntheticEvent) { - if (event.currentTarget.src.endsWith(COVER_PLACEHOLDER)) return; - event.currentTarget.src = COVER_PLACEHOLDER; - } - - return ( - grid cell so rows stay equal-height. - className="relative h-full hover:border-secondary hover:border-2" - > -
- - {/* Negative margins cancel headerContent padding so the image stays - full-bleed (matches the Card.Header image prop layout). The image - is rendered here rather than through that prop because the prop - hardcodes object-cover. */} -
- {isBook ? ( - - ) : ( - // Collection covers are campus logos and wordmarks, so they are - // contained and centered rather than cropped to fill the band. -
- -
- )} -
-
- {isBook && ( -
- {getLibGlyphAltText(resourceData.library)} -
- )} -
- - - - - {resourceData.title} - - - {isBook && ( - <> - - {resourceData.author} - - - {resourceData.affiliation} - - - )} - - -
- ); -}; - -export default CollectionCard; diff --git a/client/src/components/commons/CommonsCatalog/CatalogCard/BookCardContent.tsx b/client/src/components/commons/CommonsCatalog/CatalogCard/BookCardContent.tsx index f7a5a3ebd..81aebfa00 100644 --- a/client/src/components/commons/CommonsCatalog/CatalogCard/BookCardContent.tsx +++ b/client/src/components/commons/CommonsCatalog/CatalogCard/BookCardContent.tsx @@ -4,6 +4,7 @@ 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"; +import { COVER_PLACEHOLDER, handleCoverError } from "./cardImageFallback"; import { IconFileDescription, IconSchoolFilled, IconUsersGroup } from "@tabler/icons-react"; interface BookCardContentProps { @@ -27,10 +28,11 @@ const BookCardContent: React.FC = ({ {/* Negative margins cancel headerContent padding so the image stays full-bleed (matches Card.Header image prop layout) */}
diff --git a/client/src/components/commons/CommonsCatalog/CatalogCard/CollectionCardContent.tsx b/client/src/components/commons/CommonsCatalog/CatalogCard/CollectionCardContent.tsx new file mode 100644 index 000000000..8990b1d92 --- /dev/null +++ b/client/src/components/commons/CommonsCatalog/CatalogCard/CollectionCardContent.tsx @@ -0,0 +1,61 @@ +import { Link } from "react-router-dom"; +import { Collection } from "../../../../types"; +import { getCollectionHref } from "../../../util/CollectionHelpers"; +import { Card, Heading, Stack } from "@libretexts/davis-react"; +import { COVER_PLACEHOLDER, handleCoverError } from "./cardImageFallback"; + +interface CollectionCardContentProps { + collection: Collection; + /** + * Overrides the default target. A collection nested inside another links + * relative to the current pathname rather than to /collections/. + */ + linkTo?: string; + headingLevel?: 2 | 3; +} + +const CollectionCardContent: React.FC<CollectionCardContentProps> = ({ + collection, + linkTo, + headingLevel = 2, +}) => ( + <> + <div className="relative"> + <Card.Header> + {/* Negative margins cancel headerContent padding so the image stays + full-bleed (matches the Card.Header image prop layout). The image + is rendered here rather than through that prop because the prop + hardcodes object-cover. */} + <div className="-mx-6 -my-4"> + {/* Collection covers are campus logos and wordmarks, so they are + contained and centered rather than cropped to fill the band. */} + <div className="aspect-3/2 w-full flex items-center justify-center bg-white p-6"> + <img + src={collection.coverPhoto || COVER_PLACEHOLDER} + alt="" // Covers are purely decorative + className="max-h-full max-w-full object-contain block" + onError={handleCoverError} + /> + </div> + </div> + </Card.Header> + </div> + <Card.Body> + <Stack direction="vertical" gap="xs" className="py-1!"> + <Heading + level={headingLevel} + className="line-clamp-3 !text-[1.125rem] !leading-7 !mb-1" + > + <Link + to={linkTo || getCollectionHref(collection)} + className="commons-card-title-link" + > + {collection.title} + </Link> + </Heading> + </Stack> + </Card.Body> + </> +); + +export default CollectionCardContent; diff --git a/client/src/components/commons/CommonsCatalog/CatalogCard/cardImageFallback.ts b/client/src/components/commons/CommonsCatalog/CatalogCard/cardImageFallback.ts new file mode 100644 index 000000000..e719efbbc --- /dev/null +++ b/client/src/components/commons/CommonsCatalog/CatalogCard/cardImageFallback.ts @@ -0,0 +1,21 @@ +/** + * Shared cover/thumbnail fallback for every catalog card. + * + * Library thumbnails and collection covers are remote URLs recorded at sync + * time, so a record can outlive its image. Without a fallback the card paints + * the browser's broken-image glyph. + */ + +/** Shown when a record has no cover set or its cover fails to load. */ +export const COVER_PLACEHOLDER = "/mini.logo.png"; + +/** + * Swaps a failed cover for the placeholder. The guard stops a missing + * placeholder from looping the error handler forever. + */ +export function handleCoverError( + event: React.SyntheticEvent<HTMLImageElement> +) { + if (event.currentTarget.src.endsWith(COVER_PLACEHOLDER)) return; + event.currentTarget.src = COVER_PLACEHOLDER; +} diff --git a/client/src/components/commons/CommonsCatalog/CatalogCard/index.tsx b/client/src/components/commons/CommonsCatalog/CatalogCard/index.tsx index bb0a78408..827f598ae 100644 --- a/client/src/components/commons/CommonsCatalog/CatalogCard/index.tsx +++ b/client/src/components/commons/CommonsCatalog/CatalogCard/index.tsx @@ -2,6 +2,7 @@ import { Card } from "@libretexts/davis-react"; import { Book, Author, + Collection, ConductorSearchResponseFile, Project, } from "../../../../types"; @@ -11,16 +12,24 @@ import { isConductorSearchResponseFile, isProject, } from "../../../../utils/typeHelpers"; +import { checkIsCollection } from "../../../util/TypeHelpers"; import BookCardContent from "./BookCardContent"; +import CollectionCardContent from "./CollectionCardContent"; import FileCardContent from "./FileCardContent"; import "../../Commons.css"; import ProjectCardContent from "./ProjectCardContent"; import AuthorCardContent from "./AuthorCardContent"; interface CatalogCardProps { - item: Book | ConductorSearchResponseFile | Project | Author; + item: Book | ConductorSearchResponseFile | Project | Author | Collection; onDetailClick?: () => void; headingLevel?: 2 | 3; + /** + * Overrides the link target. Only the Collection branch honors it: a nested + * collection links relative to the pathname it was reached from, which the + * record alone cannot express. Every other item type has a canonical route. + */ + linkTo?: string; } // `relative` anchors the title's stretched-link overlay to the card, so the @@ -34,7 +43,22 @@ const CatalogCard: React.FC<CatalogCardProps> = ({ item, onDetailClick, headingLevel = 2, + linkTo, }) => { + // Checked first: a Collection carries a title like a Book does, so it is + // classified on its own discriminant before the looser guards below run. + if (checkIsCollection(item)) { + return ( + <Card variant="elevated" className={CARD_CLASSNAME}> + <CollectionCardContent + collection={item} + linkTo={linkTo} + headingLevel={headingLevel} + /> + </Card> + ); + } + if (isAuthor(item)) { return ( <Card variant="elevated" className={CARD_CLASSNAME}> diff --git a/client/src/screens/commons/Collection/index.tsx b/client/src/screens/commons/Collection/index.tsx index bb9ff8aad..89cb34658 100644 --- a/client/src/screens/commons/Collection/index.tsx +++ b/client/src/screens/commons/Collection/index.tsx @@ -32,7 +32,8 @@ import { import { useTypedSelector } from "../../../state/hooks"; import api from "../../../api"; import { Collection, CollectionResource } from "../../../types"; -import CollectionCard from "../../../components/Collections/CollectionCard"; +import CatalogCard from "../../../components/commons/CommonsCatalog/CatalogCard"; +import PlaceholderCard from "../../../components/commons/CommonsCatalog/PlaceholderCard"; import CollectionTable from "../../../components/Collections/CollectionTable"; import useDebounce from "../../../hooks/useDebounce"; import { useJumpToBottom } from "../../../hooks/useJumpToBottom"; @@ -42,8 +43,12 @@ import { } from "../../../components/util/CollectionHelpers"; import DOMPurify from "dompurify"; import { marked } from "marked"; +import { ErrorBoundary } from "react-error-boundary"; -const limit = 12; +// The Collection endpoint's lookup is far cheaper than the Book catalog's, +// and most orgs hold only a handful of collections, so pull effectively +// everything on the first page instead of paging 12 at a time. +const limit = 500; const BASE_PATH = "/collections"; const getIDFromPath = (path?: string): string => { @@ -109,21 +114,48 @@ const VisualMode: React.FC<{ return ( // Davis Grid rendered as a real <ul>; role="list" restores the list // semantics Chromium drops from a display:grid container (SC 1.3.1). + // Breakpoints match /catalog's grid: CatalogCard's type scale and + // line clamps are tuned for that cell width, so a wider column count + // here would make the same card look different between the two pages. <Grid as="ul" role="list" gap="lg" - className="grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 list-none m-0 p-0" + className="grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 list-none m-0 p-0" > {/* Items arrive already flattened across pages, so the same record appearing on two pages still gets distinct, render-stable keys. These were previously crypto.randomUUID(), which remounted every card on every render. */} - {keyCollectionItems(items).map(({ item, key }) => ( - <li key={key} className="h-full"> - <CollectionCard item={item} to={getToLink(item, pathname)} /> - </li> - ))} + {keyCollectionItems(items).map(({ item, key }) => { + // The root listing returns Collections directly; a collection's + // contents arrive wrapped as CollectionResources. CatalogCard + // classifies the record itself, so unwrap before handing it over. + const record = "resourceData" in item ? item.resourceData : item; + + // A CollectionResource whose parent record was deleted, made private, + // or filtered out by the server arrives with no resourceData. + if (!record) { + console.warn("Collection item has no resource data, skipping.", item); + return null; + } + + return ( + <li key={key} className="h-full"> + {/* Per-card boundary: a single malformed record degrades to a + placeholder tile instead of unmounting the whole grid via the + app-level ErrorBoundary in Platform.tsx. */} + <ErrorBoundary + fallback={<PlaceholderCard />} + onError={(error) => + console.error("CatalogCard failed to render item:", error, item) + } + > + <CatalogCard item={record} linkTo={getToLink(item, pathname)} /> + </ErrorBoundary> + </li> + ); + })} </Grid> ); }; @@ -201,6 +233,7 @@ const CommonsCollection: React.FC<{}> = () => { isSuccess: resourcesLoaded, hasNextPage, fetchNextPage, + isFetchingNextPage, } = useInfiniteQuery<{ data: Collection[] | CollectionResource[]; total_items: number; @@ -241,6 +274,12 @@ const CommonsCollection: React.FC<{}> = () => { const hasMore = hasNextPage || false; const isLoading = resourcesLoading; + // Only the first fetch may replace the list with a spinner. Unmounting the + // rendered pages on "Load More" collapses the document height and the + // browser clamps the scroll position back to the top, which is what + // /catalog avoids by keeping its results mounted while the next page loads. + const isInitialLoad = resourcesLoading && !isFetchingNextPage && !resources; + // Flattened once here so both view modes render the same list and the key // helper can disambiguate a record that appears on more than one page. const items = useMemo( @@ -467,7 +506,7 @@ const CommonsCollection: React.FC<{}> = () => { </div> <div className="min-h-[800px] px-6 pb-6" aria-busy={collectionLoading || resourcesLoading}> - {collectionLoading || resourcesLoading ? ( + {collectionLoading || isInitialLoad ? ( <div className="flex justify-center items-center p-16"> <Spinner /> </div>