Skip to content
Merged
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
118 changes: 0 additions & 118 deletions client/src/components/Collections/CollectionCard.tsx

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,10 +28,11 @@ const BookCardContent: React.FC<BookCardContentProps> = ({
{/* 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}
src={book.thumbnail || COVER_PLACEHOLDER}
alt="" // Thumbnails are purely decorative
className="w-full aspect-3/2 object-cover block"
isAnimated={book.thumbnailIsAnimated}
onError={handleCoverError}
/>
</div>
</Card.Header>
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<title>.
*/
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;
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Card } from "@libretexts/davis-react";
import {
Book,
Author,
Collection,
ConductorSearchResponseFile,
Project,
} from "../../../../types";
Expand All @@ -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
Expand All @@ -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}>
Expand Down
57 changes: 48 additions & 9 deletions client/src/screens/commons/Collection/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 => {
Expand Down Expand Up @@ -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>
);
};
Expand Down Expand Up @@ -201,6 +233,7 @@ const CommonsCollection: React.FC<{}> = () => {
isSuccess: resourcesLoaded,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuery<{
data: Collection[] | CollectionResource[];
total_items: number;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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>
Expand Down
Loading