("img")
+
+ const matching = imageEls.filter((im) =>
+ expected.src === DEFAULT_RESOURCE_IMG
+ ? im.src === DEFAULT_RESOURCE_IMG
+ : im.src ===
+ embedlyCroppedImage(expected.src, {
+ width: 116,
+ height: 104,
+ key: "fake-embedly-key",
+ }),
+ )
+ expect(matching.length).toBe(1)
+ expect(matching[0]).toHaveAttribute("alt", expected.alt)
+ })
+})
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx
new file mode 100644
index 0000000000..16f5c9a843
--- /dev/null
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx
@@ -0,0 +1,316 @@
+import React from "react"
+import styled from "@emotion/styled"
+import Skeleton from "@mui/material/Skeleton"
+import { RiMenuAddLine, RiBookmarkLine, RiAwardFill } from "@remixicon/react"
+import { LearningResource, ResourceTypeEnum, PlatformEnum } from "api"
+import {
+ findBestRun,
+ formatDate,
+ getReadableResourceType,
+ embedlyCroppedImage,
+ DEFAULT_RESOURCE_IMG,
+ pluralize,
+} from "ol-utilities"
+import { ListCard } from "../Card/ListCard"
+import { ActionButton } from "../Button/Button"
+import { theme } from "../ThemeProvider/ThemeProvider"
+import { useMuiBreakpointAtLeast } from "../../hooks/useBreakpoint"
+
+const IMAGE_SIZES = {
+ mobile: { width: 116, height: 104 },
+ desktop: { width: 236, height: 122 },
+}
+
+const Certificate = styled.div`
+ border-radius: 4px;
+ background-color: ${theme.custom.colors.lightGray1};
+ padding: 4px;
+ color: ${theme.custom.colors.silverGrayDark};
+ gap: 4px;
+ margin: 0 8px 0 auto;
+
+ ${{ ...theme.typography.subtitle3 }}
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
+
+ ${theme.breakpoints.down("md")} {
+ ${{ ...theme.typography.body4 }}
+ background: none;
+ color: ${theme.custom.colors.darkGray2};
+ gap: 2px;
+
+ svg {
+ width: 12px;
+ height: 12px;
+ fill: ${theme.custom.colors.silverGrayDark};
+ }
+
+ margin: 0 12px 0 auto;
+ }
+
+ display: flex;
+ align-items: center;
+`
+
+const Price = styled.div`
+ ${{ ...theme.typography.subtitle2 }}
+ color: ${theme.custom.colors.darkGray2};
+ ${theme.breakpoints.down("md")} {
+ ${{ ...theme.typography.subtitle3 }}
+ color: ${theme.custom.colors.mitRed};
+ }
+`
+
+const BorderSeparator = styled.div`
+ div {
+ display: inline;
+ }
+
+ div + div {
+ margin-left: 8px;
+ padding-left: 8px;
+ border-left: 1px solid ${theme.custom.colors.lightGray2};
+ }
+`
+
+const StyledActionButton = styled(ActionButton)<{ edge: string }>`
+ ${({ edge }) =>
+ edge === "none"
+ ? `
+ width: 16px;
+ height: 16px;`
+ : ""}
+`
+
+type ResourceIdCallback = (resourceId: number) => void
+
+const getEmbedlyUrl = (url: string, isMobile: boolean) => {
+ return embedlyCroppedImage(url, {
+ key: APP_SETTINGS.embedlyKey || process.env.EMBEDLY_KEY!,
+ ...IMAGE_SIZES[isMobile ? "mobile" : "desktop"],
+ })
+}
+
+const getPrice = (resource: LearningResource) => {
+ if (!resource) {
+ return null
+ }
+ const price = resource.prices?.[0]
+ if (resource.platform?.code === PlatformEnum.Ocw || price === 0) {
+ return "Free"
+ }
+ return price ? `$${price}` : null
+}
+
+const Info = ({ resource }: { resource: LearningResource }) => {
+ const price = getPrice(resource)
+ return (
+ <>
+ {getReadableResourceType(resource.resource_type)}
+ {resource.certification && (
+
+
+ Certificate
+
+ )}
+ {price && {price}}
+ >
+ )
+}
+
+const Count = ({ resource }: { resource: LearningResource }) => {
+ if (resource.resource_type !== ResourceTypeEnum.LearningPath) {
+ return null
+ }
+ const count = resource.learning_path.item_count
+ return (
+
+ {count} {pluralize("item", count)}
+
+ )
+}
+
+const isOcw = (resource: LearningResource) =>
+ resource.resource_type === ResourceTypeEnum.Course &&
+ resource.platform?.code === PlatformEnum.Ocw
+
+const getStartDate = (resource: LearningResource) => {
+ let startDate = resource.next_start_date
+
+ if (!startDate) {
+ const bestRun = findBestRun(resource.runs ?? [])
+
+ if (isOcw(resource) && bestRun?.semester && bestRun?.year) {
+ return `${bestRun?.semester} ${bestRun?.year}`
+ }
+ startDate = bestRun?.start_date
+ }
+
+ if (!startDate) return null
+
+ return formatDate(startDate, "MMMM DD, YYYY")
+}
+
+const StartDate: React.FC<{ resource: LearningResource }> = ({ resource }) => {
+ const startDate = getStartDate(resource)
+
+ if (!startDate) return null
+
+ const label = isOcw(resource) ? "As taught in:" : "Starts:"
+
+ return (
+
+ {label} {startDate}
+
+ )
+}
+
+const Format = ({ resource }: { resource: LearningResource }) => {
+ const format = resource.learning_format?.[0]?.name
+ if (!format) return null
+ return (
+
+ Format: {format}
+
+ )
+}
+
+const Loading = styled.div<{ mobile?: boolean }>`
+ display: flex;
+ padding: 24px;
+ justify-content: space-between;
+
+ > div {
+ width: calc(100% - 236px);
+ }
+
+ > span {
+ flex-grow: 0;
+ margin-left: auto;
+ }
+ ${({ mobile }) =>
+ mobile
+ ? `
+ padding: 0px;
+ > div {
+ padding: 12px;
+ }`
+ : ""}
+`
+
+const LoadingView = ({ isMobile }: { isMobile: boolean }) => {
+ const { width, height } = IMAGE_SIZES[isMobile ? "mobile" : "desktop"]
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+interface LearningResourceListCardProps {
+ isLoading?: boolean
+ resource?: LearningResource | null
+ className?: string
+ href?: string
+ onAddToLearningPathClick?: ResourceIdCallback | null
+ onAddToUserListClick?: ResourceIdCallback | null
+ editMenu?: React.ReactNode | null
+}
+
+const LearningResourceListCard: React.FC = ({
+ isLoading,
+ resource,
+ className,
+ href,
+ onAddToLearningPathClick,
+ onAddToUserListClick,
+ editMenu,
+}) => {
+ const isMobile = !useMuiBreakpointAtLeast("md")
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ )
+ }
+ if (!resource) {
+ return null
+ }
+ return (
+
+
+
+
+
+ {resource.title}
+
+ {onAddToLearningPathClick && (
+ onAddToLearningPathClick(resource.id)}
+ >
+
+
+ )}
+ {onAddToUserListClick && (
+ onAddToUserListClick(resource.id)}
+ >
+
+
+ )}
+ {editMenu}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export { LearningResourceListCard }
diff --git a/frontends/ol-components/src/components/TruncateText/TruncateText.tsx b/frontends/ol-components/src/components/TruncateText/TruncateText.tsx
index 6a1b9f269b..4f7cd33a25 100644
--- a/frontends/ol-components/src/components/TruncateText/TruncateText.tsx
+++ b/frontends/ol-components/src/components/TruncateText/TruncateText.tsx
@@ -15,6 +15,12 @@ const truncateText = (lines?: number | "none") =>
overflow: "hidden",
textOverflow: "ellipsis",
WebkitLineClamp: lines,
+ [`@supports (-webkit-line-clamp: ${lines})`]: {
+ whiteSpace: "initial",
+ display: "-webkit-box",
+ WebkitLineClamp: `${lines}`, // cast to any to avoid typechecking error in lines,
+ WebkitBoxOrient: "vertical",
+ },
})
/**
diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts
index fa09c2d095..251dfde5b3 100644
--- a/frontends/ol-components/src/index.ts
+++ b/frontends/ol-components/src/index.ts
@@ -162,6 +162,7 @@ export * from "./components/Chips/ChipLink"
export * from "./components/EmbedlyCard/EmbedlyCard"
export * from "./components/FormDialog/FormDialog"
export * from "./components/LearningResourceCard/LearningResourceCard"
+export * from "./components/LearningResourceCard/LearningResourceListCard"
export * from "./components/LearningResourceExpanded/LearningResourceExpanded"
export * from "./components/LoadingSpinner/LoadingSpinner"
export * from "./components/Logo/Logo"