From 68bc37985fe8851eb64e48ebce241a7bc1dee150 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Fri, 14 Feb 2025 20:25:33 +0100 Subject: [PATCH 01/13] Remove columned chatbot. Refactor --- .../AiRecommendationBotSlideDown.tsx | 219 +++++ .../LearningResourceDrawer.test.tsx | 2 +- .../LearningResourceDrawer.tsx | 2 +- .../LearningResourceExpanded.tsx | 920 ------------------ .../AiChatSyllabus.test.tsx | 0 .../AiChatSyllabus.tsx | 0 .../CallToActionSection.tsx | 433 +++++++++ .../DifferingRunsTable.test.tsx | 0 .../DifferingRunsTable.tsx | 0 .../InfoSection.test.tsx | 0 .../InfoSection.tsx | 0 .../LearningResourceExpanded.test.tsx | 9 +- .../LearningResourceExpanded.tsx | 262 +++++ .../ResourceDescription.tsx | 101 ++ .../LearningResourceExpanded/TitleSection.tsx | 98 ++ .../VideoFrame.tsx | 0 .../testUtils.ts | 0 17 files changed, 1118 insertions(+), 928 deletions(-) create mode 100644 frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotSlideDown.tsx delete mode 100644 frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx rename frontends/main/src/page-components/{LearningResourceDrawer => LearningResourceExpanded}/AiChatSyllabus.test.tsx (100%) rename frontends/main/src/page-components/{LearningResourceDrawer => LearningResourceExpanded}/AiChatSyllabus.tsx (100%) create mode 100644 frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx rename frontends/main/src/page-components/{LearningResourceDrawer => LearningResourceExpanded}/DifferingRunsTable.test.tsx (100%) rename frontends/main/src/page-components/{LearningResourceDrawer => LearningResourceExpanded}/DifferingRunsTable.tsx (100%) rename frontends/main/src/page-components/{LearningResourceDrawer => LearningResourceExpanded}/InfoSection.test.tsx (100%) rename frontends/main/src/page-components/{LearningResourceDrawer => LearningResourceExpanded}/InfoSection.tsx (100%) rename frontends/main/src/page-components/{LearningResourceDrawer => LearningResourceExpanded}/LearningResourceExpanded.test.tsx (97%) create mode 100644 frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx create mode 100644 frontends/main/src/page-components/LearningResourceExpanded/ResourceDescription.tsx create mode 100644 frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx rename frontends/main/src/page-components/{LearningResourceDrawer => LearningResourceExpanded}/VideoFrame.tsx (100%) rename frontends/main/src/page-components/{LearningResourceDrawer => LearningResourceExpanded}/testUtils.ts (100%) diff --git a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotSlideDown.tsx b/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotSlideDown.tsx new file mode 100644 index 0000000000..08ab5d3e9a --- /dev/null +++ b/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotSlideDown.tsx @@ -0,0 +1,219 @@ +import React, { useState, useRef, useEffect } from "react" +import { Typography, styled, AdornmentButton } from "ol-components" +import { Button, Input } from "@mitodl/smoot-design" +import { RiSparkling2Line, RiSendPlaneFill } from "@remixicon/react" +import type { AiChatMessage } from "@mitodl/smoot-design/ai" +import AiRecommendationBot, { STARTERS } from "./AiRecommendationBot" +import Image from "next/image" +import timLogo from "@/public/images/icons/tim.svg" + +const StyledButton = styled(Button)(({ theme }) => ({ + display: "flex", + flexDirection: "row", + gap: "8px", + minWidth: "auto", + padding: "4px 0", + color: theme.custom.colors.darkGray2, + border: "none", + background: "none", + svg: { + fill: theme.custom.colors.lightRed, + width: "20px", + height: "20px", + }, + "&&": { + ":hover": { + background: "none", + color: theme.custom.colors.mitRed, + p: { + color: theme.custom.colors.mitRed, + }, + }, + }, +})) + +const EntryScreen = styled.div({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "16px", + padding: "104px 32px", +}) + +const TimLogoBox = styled.div(({ theme }) => ({ + position: "relative", + padding: "16px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "8px", + svg: { + fill: theme.custom.colors.red, + position: "absolute", + top: "-10px", + left: "-10px", + }, +})) + +const TimLogo = styled(Image)({ + display: "block", +}) + +const StyledInput = styled(Input)(({ theme }) => ({ + backgroundColor: theme.custom.colors.lightGray1, + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + margin: "24px 0", + width: "700px", + [theme.breakpoints.down("md")]: { + width: "100%", + }, + "button:disabled": { + backgroundColor: "inherit", + }, +})) + +const SendIcon = styled(RiSendPlaneFill)(({ theme }) => ({ + fill: theme.custom.colors.red, + "button:disabled &": { + fill: theme.custom.colors.silverGray, + }, +})) + +const Starters = styled.div(({ theme }) => ({ + display: "flex", + gap: "16px", + maxWidth: "836px", + marginTop: "12px", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + }, +})) + +const Starter = styled.button(({ theme }) => ({ + flex: 1, + display: "flex", + alignItems: "center", + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + padding: "12px 16px", + color: theme.custom.colors.darkGray2, + backgroundColor: "transparent", + textAlign: "left", + [theme.breakpoints.down("md")]: { + textAlign: "center", + padding: "12px 36px", + }, + ":hover": { + cursor: "pointer", + borderColor: "transparent", + color: theme.custom.colors.white, + backgroundColor: theme.custom.colors.darkGray1, + }, +})) + +const AiRecommendationBotSlideDown = () => { + const [initialPrompt, setInitialPrompt] = useState("") + const [open, setOpen] = useState(false) + const [showEntryScreen, setShowEntryScreen] = useState(true) + const aiChatRef = useRef<{ + append: (message: Omit) => void + }>(null) + + useEffect(() => { + if (!initialPrompt || showEntryScreen) return + const timer = setTimeout(() => { + aiChatRef.current?.append({ + content: initialPrompt, + role: "user", + }) + setInitialPrompt("") + }, 0) + + return () => { + clearTimeout(timer) + setInitialPrompt("") + } + }, [initialPrompt, showEntryScreen]) + + const onPromptChange = (e: React.ChangeEvent) => { + setInitialPrompt(e.target.value) + } + + const onPromptKeyDown: React.KeyboardEventHandler = (e) => { + if (e.key !== "Enter") return + setShowEntryScreen(false) + } + + const onStarterClick = (content: string) => { + setInitialPrompt(content) + setShowEntryScreen(false) + } + + if (!open) + return ( + <> + setOpen(true)} + > + + + AskTIM + + + + ) + + return ( + <> + {showEntryScreen ? ( + + + + + + Welcome! I am TIM the Beaver. + Need assistance getting started? + setShowEntryScreen(false)} + disabled={!initialPrompt} + > + + + } + responsive + /> + Let me know how I can help. + + {STARTERS.map(({ content }, index) => ( + onStarterClick(content)} + tabIndex={index} + onKeyDown={(e) => { + if (e.key === "Enter") { + onStarterClick(content) + } + }} + > + {content} + + ))} + + + ) : ( + + )} + + ) +} + +export default AiRecommendationBotSlideDown diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx index 312b3ff0cd..dc1e10e5fb 100644 --- a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx +++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx @@ -8,7 +8,7 @@ import { } from "@/test-utils" import LearningResourceDrawer from "./LearningResourceDrawer" import { urls, factories, setMockResponse } from "api/test-utils" -import { LearningResourceExpanded } from "./LearningResourceExpanded" +import { LearningResourceExpanded } from "../LearningResourceExpanded/LearningResourceExpanded" import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls" import { LearningResource, ResourceTypeEnum } from "api" import { makeUserSettings } from "@/test-utils/factories" diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx index df49e6069f..975ffb8d25 100644 --- a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx +++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx @@ -1,6 +1,6 @@ import React, { Suspense, useEffect, useId, useMemo } from "react" import { RoutedDrawer, imgConfigs } from "ol-components" -import { LearningResourceExpanded } from "./LearningResourceExpanded" +import { LearningResourceExpanded } from "../LearningResourceExpanded/LearningResourceExpanded" import type { LearningResourceCardProps, RoutedDrawerProps, diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx deleted file mode 100644 index aa4474e3b1..0000000000 --- a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx +++ /dev/null @@ -1,920 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react" -import styled from "@emotion/styled" -import { - Skeleton, - theme, - PlatformLogo, - PLATFORM_LOGOS, - Link, - Input, - Typography, -} from "ol-components" -import type { ImageConfig, LearningResourceCardProps } from "ol-components" -import { default as NextImage } from "next/image" -import { - ActionButton, - Button, - ButtonLink, - ButtonProps, -} from "@mitodl/smoot-design" -import type { LearningResource } from "api" -import { ResourceTypeEnum, PlatformEnum } from "api" -import { - DEFAULT_RESOURCE_IMG, - getReadableResourceType, - useToggle, -} from "ol-utilities" -import { - RiBookmarkFill, - RiBookmarkLine, - RiCloseLargeLine, - RiExternalLinkLine, - RiFacebookFill, - RiLink, - RiLinkedinFill, - RiMenuAddLine, - RiShareLine, - RiTwitterXLine, - RiSparkling2Line, -} from "@remixicon/react" -import classNames from "classnames" -import InfoSection from "./InfoSection" -import type { User } from "api/hooks/user" -import VideoFrame from "./VideoFrame" -import { FeatureFlags } from "@/common/feature_flags" -import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react" -import AiChatSyllabus from "./AiChatSyllabus" -import { PostHogEvents } from "@/common/constants" - -const DRAWER_WIDTH = "900px" -const showChatClass = "show-chat" -const showChatSelector = `.${showChatClass} &` - -const Outer = styled.div({ - display: "flex", - flexDirection: "column", - flexGrow: 1, - width: "100%", - overflowX: "hidden", -}) - -const TitleContainer = styled.div({ - display: "flex", - position: "sticky", - justifyContent: "space-between", - top: "0", - padding: "24px 28px", - gap: "16px", - zIndex: 1, - backgroundColor: theme.custom.colors.white, - [theme.breakpoints.down("md")]: { - padding: "24px 16px", - }, -}) - -const CHAT_WIDTH = "400px" -const CHAT_RIGHT = "0px" - -const Container = styled.div(({ chatOpen }: { chatOpen: boolean }) => - chatOpen - ? { - paddingRight: `calc(${CHAT_WIDTH} + ${CHAT_RIGHT})`, - [theme.breakpoints.down("md")]: { - paddingRight: 0, - flexGrow: 1, - }, - } - : { - paddingRight: 0, - }, -) - -const TopContainer = styled.div({ - display: "flex", - flexDirection: "column", - padding: "0 28px 24px", - [theme.breakpoints.down("md")]: { - width: "auto", - padding: "0 16px 24px", - }, - [showChatSelector]: { - padding: "0 16px 24px 28px", - [theme.breakpoints.between("sm", "md")]: { - padding: "0 0 16px 24px", - }, - }, -}) - -const BottomContainer = styled.div({ - display: "flex", - flexDirection: "column", - gap: "32px", - borderTop: `1px solid ${theme.custom.colors.lightGray2}`, - background: theme.custom.colors.lightGray1, - "> div": { - width: "100%", - }, - padding: "32px 28px", - [theme.breakpoints.down("md")]: { - padding: "16px 0 16px 16px", - }, - [showChatSelector]: { - [theme.breakpoints.up("md")]: { - padding: "32px 16px 32px 28px", - }, - }, -}) - -const MainCol = styled.div(({ chatOpen }: { chatOpen: boolean }) => ({ - /** - * Note: - * Without a width specified, the carousels will overflow up to 100vw - */ - maxWidth: DRAWER_WIDTH, - flex: 1, - [theme.breakpoints.down("md")]: { - maxWidth: "100%", - display: chatOpen ? "none" : "inherit", - }, -})) - -/** - * Chat offset from top of drawer. - * 48px + 3rem = height of 1-line title plus padding. - * If title is two lines, the chat will overflow into title. - */ -const CHAT_TOP = "calc(48px + 3rem)" - -const ChatCol = styled.div({ - zIndex: 2, - position: "fixed", - top: CHAT_TOP, - right: CHAT_RIGHT, - height: `calc(100vh - ${CHAT_TOP})`, - flex: 1, - boxSizing: "border-box", - padding: "0 16px 16px 16px", - maxWidth: CHAT_WIDTH, - [theme.breakpoints.down("md")]: { - maxWidth: "100%", - position: "static", - }, - [theme.breakpoints.down("sm")]: { - height: "100%", - }, - ".MitAiChat--title": { - paddingTop: "0px", - }, -}) - -const ContentContainer = styled.div({ - display: "flex", - gap: "32px", - [theme.breakpoints.down("md")]: { - flexDirection: "column-reverse", - gap: "16px", - }, - [theme.breakpoints.up("md")]: { - [showChatSelector]: { - flexDirection: "column-reverse", - gap: "16px", - }, - }, -}) - -const ContentLeft = styled.div({ - display: "flex", - flexDirection: "column", - flexGrow: 1, - alignItems: "flex-start", - gap: "24px", - maxWidth: "100%", -}) - -const ContentRight = styled.div({ - display: "flex", - flexDirection: "column", - gap: "24px", -}) - -const ImageContainer = styled.div({ - width: "100%", -}) - -const Image = styled(NextImage)<{ aspect: number }>` - position: relative !important; - border-radius: 8px; - width: 100%; - aspect-ratio: ${({ aspect }) => aspect}; - object-fit: cover; -` - -const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({ - borderRadius: "8px", - paddingBottom: `${100 / aspect.aspect}%`, -})) - -const CallToAction = styled.div({ - display: "flex", - width: "350px", - padding: "16px", - flexDirection: "column", - gap: "10px", - borderRadius: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - boxShadow: "0px 2px 10px 0px rgba(37, 38, 43, 0.10)", - [theme.breakpoints.down("md")]: { - width: "100%", - padding: "0", - border: "none", - boxShadow: "none", - }, - [showChatSelector]: { - [theme.breakpoints.up("sm")]: { - width: "auto", - maxWidth: "424px", - }, - [theme.breakpoints.down("md")]: { - width: "100%", - padding: "0", - border: "none", - boxShadow: "none", - }, - }, -}) - -const ActionsContainer = styled.div({ - display: "flex", - flexDirection: "column", - gap: "16px", - width: "100%", -}) - -const PlatformContainer = styled.div({ - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: "16px", - alignSelf: "stretch", -}) - -const StyledLink = styled(ButtonLink)({ - textAlign: "center", - width: "100%", - [theme.breakpoints.down("sm")]: { - marginTop: "10px", - marginBottom: "10px", - }, -}) - -const Platform = styled.div({ - display: "flex", - justifyContent: "flex-end", - alignItems: "center", - gap: "16px", -}) - -const DescriptionContainer = styled.div({ - display: "flex", - flexDirection: "column", - gap: "4px", - width: "100%", -}) - -const Description = styled.p({ - ...theme.typography.body2, - color: theme.custom.colors.black, - margin: 0, - wordBreak: "break-word", - "> *": { - ":first-child": { - marginTop: 0, - }, - ":last-child": { - marginBottom: 0, - }, - ":empty": { - display: "none", - }, - }, -}) - -const DescriptionCollapsed = styled(Description)({ - display: "-webkit-box", - overflow: "hidden", - maxHeight: `calc(${theme.typography.body2.lineHeight} * 5)`, - "@supports (-webkit-line-clamp: 5)": { - maxHeight: "unset", - WebkitLineClamp: 5, - WebkitBoxOrient: "vertical", - }, -}) - -const DescriptionExpanded = styled(Description)({ - display: "block", -}) - -const StyledPlatformLogo = styled(PlatformLogo)({ - height: "26px", - maxWidth: "180px", -}) - -const OnPlatform = styled.span({ - ...theme.typography.body2, - color: theme.custom.colors.black, -}) - -const ButtonContainer = styled.div({ - display: "flex", - width: "100%", - gap: "8px", - flexGrow: 1, - justifyContent: "center", -}) - -const SelectableButton = styled(Button)<{ selected?: boolean }>((props) => [ - { - flex: 1, - whiteSpace: "nowrap", - }, - props.selected - ? { - backgroundColor: theme.custom.colors.red, - border: `1px solid ${theme.custom.colors.red}`, - color: theme.custom.colors.white, - "&:hover:not(:disabled)": { - backgroundColor: theme.custom.colors.red, - border: `1px solid ${theme.custom.colors.red}`, - color: theme.custom.colors.white, - }, - } - : {}, -]) - -const ShareContainer = styled.div({ - display: "flex", - flexDirection: "column", - alignItems: "center", - alignSelf: "stretch", - padding: "16px 0 8px 0", - gap: "12px", -}) - -const ShareLabel = styled(Typography)({ - ...theme.typography.body3, - color: theme.custom.colors.darkGray1, -}) - -const ShareButtonContainer = styled.div({ - display: "flex", - justifyContent: "center", - alignItems: "center", - alignSelf: "stretch", - gap: "16px", - a: { - height: "18px", - }, -}) - -const ShareLink = styled(Link)({ - color: theme.custom.colors.silverGrayDark, -}) - -const RedLinkIcon = styled(RiLink)({ - color: theme.custom.colors.red, -}) - -const CopyLinkButton = styled(Button)({ - flexGrow: 0, - flexBasis: "112px", -}) - -const TopCarouselContainer = styled.div({ - display: "flex", - flexDirection: "column", - paddingTop: "24px", -}) - -type LearningResourceExpandedProps = { - resourceId: number - titleId?: string - resource?: LearningResource - user?: User - shareUrl?: string - imgConfig: ImageConfig - topCarousels?: React.ReactNode[] - bottomCarousels?: React.ReactNode[] - inLearningPath?: boolean - inUserList?: boolean - onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] - onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] - closeDrawer?: () => void -} - -const CloseButton = styled(ActionButton)(({ theme }) => ({ - "&&&": { - flexShrink: 0, - backgroundColor: theme.custom.colors.lightGray2, - color: theme.custom.colors.black, - ["&:hover"]: { - backgroundColor: theme.custom.colors.red, - color: theme.custom.colors.white, - }, - }, -})) - -const CloseIcon = styled(RiCloseLargeLine)` - &&& { - width: 18px; - height: 18px; - } -` - -const TitleSection: React.FC<{ - titleId?: string - resource?: LearningResource - closeDrawer: () => void -}> = ({ resource, closeDrawer, titleId }) => { - const closeButton = ( - closeDrawer()} - aria-label="Close" - > - - - ) - - const type = resource ? ( - getReadableResourceType(resource.resource_type) - ) : ( - - ) - const title = resource ? ( - resource.title - ) : ( - - ) - - return ( - - - - {type} - - {title} - - {closeButton} - - ) -} - -const ImageSection: React.FC<{ - resource?: LearningResource - config: ImageConfig -}> = ({ resource, config }) => { - const aspect = config.width / config.height - if (resource?.resource_type === "video" && resource?.url) { - return ( - - ) - } else if (resource) { - return ( - - {resource?.image?.alt - - ) - } else { - return ( - - ) - } -} - -const getCallToActionText = (resource: LearningResource): string => { - const accessCourseMaterials = "Access Course Materials" - const watchOnYouTube = "Watch on YouTube" - const listenToPodcast = "Listen to Podcast" - const learnMore = "Learn More" - const callsToAction = { - [ResourceTypeEnum.Course]: learnMore, - [ResourceTypeEnum.Program]: learnMore, - [ResourceTypeEnum.LearningPath]: learnMore, - [ResourceTypeEnum.Video]: watchOnYouTube, - [ResourceTypeEnum.VideoPlaylist]: watchOnYouTube, - [ResourceTypeEnum.Podcast]: listenToPodcast, - [ResourceTypeEnum.PodcastEpisode]: listenToPodcast, - } - if ( - resource?.resource_type === ResourceTypeEnum.Video || - resource?.resource_type === ResourceTypeEnum.VideoPlaylist - ) { - // Video resources should always show "Watch on YouTube" as the CTA - return watchOnYouTube - } else { - if (resource?.platform?.code === PlatformEnum.Ocw) { - // Non-video OCW resources should show "Access Course Materials" as the CTA - return accessCourseMaterials - } else { - // Return the default CTA for the resource type - return callsToAction[resource?.resource_type] || learnMore - } - } -} - -const CallToActionButton: React.FC = ( - props, -) => { - return ( - - ) -} - -const CallToActionSection = ({ - imgConfig, - resource, - hide, - user, - shareUrl, - inUserList, - inLearningPath, - onAddToLearningPathClick, - onAddToUserListClick, -}: { - imgConfig: ImageConfig - resource?: LearningResource - hide?: boolean - user?: User - shareUrl?: string - inUserList?: boolean - inLearningPath?: boolean - onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] - onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] -}) => { - const posthog = usePostHog() - const [shareExpanded, setShareExpanded] = useState(false) - const [copyText, setCopyText] = useState("Copy Link") - if (hide) { - return null - } - - if (!resource) { - return ( - - - - - ) - } - const { platform } = resource! - const offeredBy = resource?.offered_by - const platformCode = - (offeredBy?.code as PlatformEnum) === PlatformEnum.Xpro - ? (offeredBy?.code as PlatformEnum) - : (platform?.code as PlatformEnum) - const platformImage = PLATFORM_LOGOS[platformCode]?.image - const cta = getCallToActionText(resource) - const addToLearningPathLabel = "Add to list" - const bookmarkLabel = "Bookmark" - const shareLabel = "Share" - const socialIconSize = 18 - const facebookShareBaseUrl = "https://www.facebook.com/sharer/sharer.php" - const twitterShareBaseUrl = "https://x.com/share" - const linkedInShareBaseUrl = "https://www.linkedin.com/sharing/share-offsite" - - return ( - - - - } - href={resource.url || ""} - onClick={() => { - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { - posthog.capture(PostHogEvents.CallToActionClicked, { resource }) - } - }} - data-ph-action="click-cta" - data-ph-offered-by={offeredBy?.code} - data-ph-resource-type={resource.resource_type} - data-ph-resource-id={resource.id} - > - {cta} - - {platformImage ? ( - - - on - - - - ) : null} - - {user?.is_learning_path_editor && ( - } - aria-label={addToLearningPathLabel} - onClick={(event) => - onAddToLearningPathClick - ? onAddToLearningPathClick(event, resource.id) - : null - } - > - {addToLearningPathLabel} - - )} - : } - aria-label={bookmarkLabel} - onClick={ - onAddToUserListClick - ? (event) => onAddToUserListClick?.(event, resource.id) - : undefined - } - > - {bookmarkLabel} - - } - aria-label={shareLabel} - onClick={() => setShareExpanded(!shareExpanded)} - > - {shareLabel} - - - {shareExpanded && shareUrl && ( - - Share a link to this Resource - { - const input = event.currentTarget.querySelector("input") - if (!input) return - input.select() - }} - /> - - - - - - - - - - - } - aria-label={copyText} - onClick={() => { - navigator.clipboard.writeText(shareUrl) - setCopyText("Copied!") - }} - > - {copyText} - - - - )} - - - ) -} - -const ResourceDescription = ({ resource }: { resource?: LearningResource }) => { - const firstRender = useRef(true) - const clampedOnFirstRender = useRef(false) - const [isClamped, setClamped] = useState(false) - const [isExpanded, setExpanded] = useState(false) - const descriptionRendered = useCallback((node: HTMLDivElement) => { - if (node !== null) { - const clamped = node.scrollHeight > node.clientHeight - setClamped(clamped) - if (firstRender.current) { - firstRender.current = false - clampedOnFirstRender.current = clamped - return - } - } - }, []) - const DescriptionText = isExpanded - ? DescriptionExpanded - : DescriptionCollapsed - if (!resource) { - return ( - <> - - - - - - - - ) - } - return ( - - - {(isClamped || clampedOnFirstRender.current) && ( - setExpanded(!isExpanded)} - > - {isExpanded ? "Show less" : "Show more"} - - )} - - ) -} - -const StyledAskButton = styled(Button)(({ theme }) => ({ - display: "flex", - flexDirection: "row", - gap: "2px", - minWidth: "auto", - paddingLeft: "16px", - paddingRight: "24px", - color: theme.custom.colors.darkGray2, - borderColor: theme.custom.colors.lightGray2, - svg: { - fill: theme.custom.colors.red, - }, - "&&": { - ":hover": { - borderColor: "transparent", - color: theme.custom.colors.white, - backgroundColor: theme.custom.colors.darkGray2, - p: { - color: theme.custom.colors.white, - }, - svg: { - fill: theme.custom.colors.white, - }, - }, - }, -})) - -const LearningResourceExpanded: React.FC = ({ - resourceId, - resource, - imgConfig, - user, - shareUrl, - topCarousels, - bottomCarousels, - inUserList, - inLearningPath, - titleId, - onAddToLearningPathClick, - onAddToUserListClick, - closeDrawer, -}) => { - const chatEnabled = - useFeatureFlagEnabled(FeatureFlags.LrDrawerChatbot) && - resource?.resource_type === ResourceTypeEnum.Course - - const [chatExpanded, setChatExpanded] = useToggle(false) - const showChat = chatEnabled && chatExpanded - - const outerContainerRef = useRef(null) - useEffect(() => { - if (outerContainerRef.current && outerContainerRef.current.scrollTo) { - outerContainerRef.current.scrollTo(0, 0) - } - }, [resourceId]) - - useEffect(() => { - if (chatExpanded && resource && !chatEnabled) { - setChatExpanded.off() - } - }, [chatExpanded, resource, chatEnabled, setChatExpanded]) - - const chatOpen = !!(resource && showChat) - - return ( - - {})} - /> - - - - - - - - - - - {chatEnabled && !chatExpanded ? ( - } - > - - Need help? AskTIM - - - ) : null} - - - {topCarousels && ( - - {topCarousels?.map((carousel, index) => ( -
{carousel}
- ))} -
- )} -
- - {bottomCarousels?.map((carousel, index) => ( -
{carousel}
- ))} -
-
- - {chatOpen ? ( - - ) : null} - -
-
- ) -} - -export { LearningResourceExpanded, getCallToActionText } -export type { LearningResourceExpandedProps } diff --git a/frontends/main/src/page-components/LearningResourceDrawer/AiChatSyllabus.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.test.tsx similarity index 100% rename from frontends/main/src/page-components/LearningResourceDrawer/AiChatSyllabus.test.tsx rename to frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.test.tsx diff --git a/frontends/main/src/page-components/LearningResourceDrawer/AiChatSyllabus.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx similarity index 100% rename from frontends/main/src/page-components/LearningResourceDrawer/AiChatSyllabus.tsx rename to frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx new file mode 100644 index 0000000000..ca5958acb4 --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx @@ -0,0 +1,433 @@ +import React, { useState } from "react" +import styled from "@emotion/styled" +import { default as NextImage } from "next/image" +import { usePostHog } from "posthog-js/react" +import { + Skeleton, + theme, + PlatformLogo, + PLATFORM_LOGOS, + Link, + Input, + Typography, +} from "ol-components" +import type { ImageConfig, LearningResourceCardProps } from "ol-components" +import { DEFAULT_RESOURCE_IMG } from "ol-utilities" +import { ResourceTypeEnum, PlatformEnum } from "api" +import { Button, ButtonLink, ButtonProps } from "@mitodl/smoot-design" +import type { LearningResource } from "api" +import { + RiBookmarkFill, + RiBookmarkLine, + RiExternalLinkLine, + RiFacebookFill, + RiLink, + RiLinkedinFill, + RiMenuAddLine, + RiShareLine, + RiTwitterXLine, +} from "@remixicon/react" +import type { User } from "api/hooks/user" +import { PostHogEvents } from "@/common/constants" +import VideoFrame from "./VideoFrame" + +const showChatClass = "show-chat" +const showChatSelector = `.${showChatClass} &` + +const CallToAction = styled.div({ + display: "flex", + width: "350px", + padding: "16px", + flexDirection: "column", + gap: "10px", + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + boxShadow: "0px 2px 10px 0px rgba(37, 38, 43, 0.10)", + [theme.breakpoints.down("md")]: { + width: "100%", + padding: "0", + border: "none", + boxShadow: "none", + }, + [showChatSelector]: { + [theme.breakpoints.up("sm")]: { + width: "auto", + maxWidth: "424px", + }, + [theme.breakpoints.down("md")]: { + width: "100%", + padding: "0", + border: "none", + boxShadow: "none", + }, + }, +}) + +const ActionsContainer = styled.div({ + display: "flex", + flexDirection: "column", + gap: "16px", + width: "100%", +}) + +const PlatformContainer = styled.div({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "16px", + alignSelf: "stretch", +}) + +const StyledLink = styled(ButtonLink)({ + textAlign: "center", + width: "100%", + [theme.breakpoints.down("sm")]: { + marginTop: "10px", + marginBottom: "10px", + }, +}) + +const Platform = styled.div({ + display: "flex", + justifyContent: "flex-end", + alignItems: "center", + gap: "16px", +}) + +const StyledPlatformLogo = styled(PlatformLogo)({ + height: "26px", + maxWidth: "180px", +}) + +const OnPlatform = styled.span({ + ...theme.typography.body2, + color: theme.custom.colors.black, +}) + +const ButtonContainer = styled.div({ + display: "flex", + width: "100%", + gap: "8px", + flexGrow: 1, + justifyContent: "center", +}) + +const SelectableButton = styled(Button)<{ selected?: boolean }>((props) => [ + { + flex: 1, + whiteSpace: "nowrap", + }, + props.selected + ? { + backgroundColor: theme.custom.colors.red, + border: `1px solid ${theme.custom.colors.red}`, + color: theme.custom.colors.white, + "&:hover:not(:disabled)": { + backgroundColor: theme.custom.colors.red, + border: `1px solid ${theme.custom.colors.red}`, + color: theme.custom.colors.white, + }, + } + : {}, +]) + +const ShareContainer = styled.div({ + display: "flex", + flexDirection: "column", + alignItems: "center", + alignSelf: "stretch", + padding: "16px 0 8px 0", + gap: "12px", +}) + +const ShareLabel = styled(Typography)({ + ...theme.typography.body3, + color: theme.custom.colors.darkGray1, +}) + +const ShareButtonContainer = styled.div({ + display: "flex", + justifyContent: "center", + alignItems: "center", + alignSelf: "stretch", + gap: "16px", + a: { + height: "18px", + }, +}) + +const ShareLink = styled(Link)({ + color: theme.custom.colors.silverGrayDark, +}) + +const RedLinkIcon = styled(RiLink)({ + color: theme.custom.colors.red, +}) + +const CopyLinkButton = styled(Button)({ + flexGrow: 0, + flexBasis: "112px", +}) + +const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({ + borderRadius: "8px", + paddingBottom: `${100 / aspect.aspect}%`, +})) + +const ImageContainer = styled.div({ + width: "100%", +}) + +const Image = styled(NextImage)<{ aspect: number }>` + position: relative !important; + border-radius: 8px; + width: 100%; + aspect-ratio: ${({ aspect }) => aspect}; + object-fit: cover; +` + +const ImageSection: React.FC<{ + resource?: LearningResource + config: ImageConfig +}> = ({ resource, config }) => { + const aspect = config.width / config.height + if (resource?.resource_type === "video" && resource?.url) { + return ( + + ) + } else if (resource) { + return ( + + {resource?.image?.alt + + ) + } else { + return ( + + ) + } +} + +const CallToActionButton: React.FC = ( + props, +) => { + return ( + + ) +} + +const getCallToActionText = (resource: LearningResource): string => { + const accessCourseMaterials = "Access Course Materials" + const watchOnYouTube = "Watch on YouTube" + const listenToPodcast = "Listen to Podcast" + const learnMore = "Learn More" + const callsToAction = { + [ResourceTypeEnum.Course]: learnMore, + [ResourceTypeEnum.Program]: learnMore, + [ResourceTypeEnum.LearningPath]: learnMore, + [ResourceTypeEnum.Video]: watchOnYouTube, + [ResourceTypeEnum.VideoPlaylist]: watchOnYouTube, + [ResourceTypeEnum.Podcast]: listenToPodcast, + [ResourceTypeEnum.PodcastEpisode]: listenToPodcast, + } + if ( + resource?.resource_type === ResourceTypeEnum.Video || + resource?.resource_type === ResourceTypeEnum.VideoPlaylist + ) { + // Video resources should always show "Watch on YouTube" as the CTA + return watchOnYouTube + } else { + if (resource?.platform?.code === PlatformEnum.Ocw) { + // Non-video OCW resources should show "Access Course Materials" as the CTA + return accessCourseMaterials + } else { + // Return the default CTA for the resource type + return callsToAction[resource?.resource_type] || learnMore + } + } +} + +const CallToActionSection = ({ + imgConfig, + resource, + hide, + user, + shareUrl, + inUserList, + inLearningPath, + onAddToLearningPathClick, + onAddToUserListClick, +}: { + imgConfig: ImageConfig + resource?: LearningResource + hide?: boolean + user?: User + shareUrl?: string + inUserList?: boolean + inLearningPath?: boolean + onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] + onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] +}) => { + const posthog = usePostHog() + const [shareExpanded, setShareExpanded] = useState(false) + const [copyText, setCopyText] = useState("Copy Link") + if (hide) { + return null + } + + if (!resource) { + return ( + + + + + ) + } + const { platform } = resource! + const offeredBy = resource?.offered_by + const platformCode = + (offeredBy?.code as PlatformEnum) === PlatformEnum.Xpro + ? (offeredBy?.code as PlatformEnum) + : (platform?.code as PlatformEnum) + const platformImage = PLATFORM_LOGOS[platformCode]?.image + const cta = getCallToActionText(resource) + const addToLearningPathLabel = "Add to list" + const bookmarkLabel = "Bookmark" + const shareLabel = "Share" + const socialIconSize = 18 + const facebookShareBaseUrl = "https://www.facebook.com/sharer/sharer.php" + const twitterShareBaseUrl = "https://x.com/share" + const linkedInShareBaseUrl = "https://www.linkedin.com/sharing/share-offsite" + + return ( + + + + } + href={resource.url || ""} + onClick={() => { + if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + posthog.capture(PostHogEvents.CallToActionClicked, { resource }) + } + }} + data-ph-action="click-cta" + data-ph-offered-by={offeredBy?.code} + data-ph-resource-type={resource.resource_type} + data-ph-resource-id={resource.id} + > + {cta} + + {platformImage ? ( + + + on + + + + ) : null} + + {user?.is_learning_path_editor && ( + } + aria-label={addToLearningPathLabel} + onClick={(event) => + onAddToLearningPathClick + ? onAddToLearningPathClick(event, resource.id) + : null + } + > + {addToLearningPathLabel} + + )} + : } + aria-label={bookmarkLabel} + onClick={ + onAddToUserListClick + ? (event) => onAddToUserListClick?.(event, resource.id) + : undefined + } + > + {bookmarkLabel} + + } + aria-label={shareLabel} + onClick={() => setShareExpanded(!shareExpanded)} + > + {shareLabel} + + + {shareExpanded && shareUrl && ( + + Share a link to this Resource + { + const input = event.currentTarget.querySelector("input") + if (!input) return + input.select() + }} + /> + + + + + + + + + + + } + aria-label={copyText} + onClick={() => { + navigator.clipboard.writeText(shareUrl) + setCopyText("Copied!") + }} + > + {copyText} + + + + )} + + + ) +} + +export default CallToActionSection +export { getCallToActionText } diff --git a/frontends/main/src/page-components/LearningResourceDrawer/DifferingRunsTable.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/DifferingRunsTable.test.tsx similarity index 100% rename from frontends/main/src/page-components/LearningResourceDrawer/DifferingRunsTable.test.tsx rename to frontends/main/src/page-components/LearningResourceExpanded/DifferingRunsTable.test.tsx diff --git a/frontends/main/src/page-components/LearningResourceDrawer/DifferingRunsTable.tsx b/frontends/main/src/page-components/LearningResourceExpanded/DifferingRunsTable.tsx similarity index 100% rename from frontends/main/src/page-components/LearningResourceDrawer/DifferingRunsTable.tsx rename to frontends/main/src/page-components/LearningResourceExpanded/DifferingRunsTable.tsx diff --git a/frontends/main/src/page-components/LearningResourceDrawer/InfoSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx similarity index 100% rename from frontends/main/src/page-components/LearningResourceDrawer/InfoSection.test.tsx rename to frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx diff --git a/frontends/main/src/page-components/LearningResourceDrawer/InfoSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx similarity index 100% rename from frontends/main/src/page-components/LearningResourceDrawer/InfoSection.tsx rename to frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx similarity index 97% rename from frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.test.tsx rename to frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx index 6cbef8f1b2..eb017ab22d 100644 --- a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx @@ -1,11 +1,8 @@ import React from "react" import { screen, waitFor, within } from "@testing-library/react" - -import { - getCallToActionText, - LearningResourceExpanded, -} from "./LearningResourceExpanded" -import type { LearningResourceExpandedProps } from "./LearningResourceExpanded" +import { LearningResourceExpanded } from "../LearningResourceExpanded/LearningResourceExpanded" +import { getCallToActionText } from "./CallToActionSection" +import type { LearningResourceExpandedProps } from "../LearningResourceExpanded/LearningResourceExpanded" import { ResourceTypeEnum } from "api" import { factories, setMockResponse, urls } from "api/test-utils" import invariant from "tiny-invariant" diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx new file mode 100644 index 0000000000..9d1ae9d456 --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -0,0 +1,262 @@ +import React, { useEffect, useRef } from "react" +import styled from "@emotion/styled" +import { theme } from "ol-components" +import type { ImageConfig, LearningResourceCardProps } from "ol-components" +import type { LearningResource } from "api" +import { useToggle } from "ol-utilities" +import InfoSection from "./InfoSection" +import type { User } from "api/hooks/user" +import TitleSection from "./TitleSection" +import CallToActionSection from "./CallToActionSection" +import ResourceDescription from "./ResourceDescription" + +const DRAWER_WIDTH = "900px" + +const Outer = styled.div({ + display: "flex", + flexDirection: "column", + flexGrow: 1, + width: "100%", + overflowX: "hidden", + minWidth: DRAWER_WIDTH, + [theme.breakpoints.down("md")]: { + minWidth: "100%", + }, +}) + +// const CHAT_WIDTH = "400px" +// const CHAT_RIGHT = "0px" + +const Layers = styled.div({ + paddingRight: 0, + position: "relative", +}) + +const Layer = styled.div({ + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", +}) + +// const ChatLayer = styled(Layer)({ +// zIndex: 2, +// }) + +const TopContainer = styled.div({ + display: "flex", + flexDirection: "column", + padding: "0 28px 24px", + [theme.breakpoints.down("md")]: { + width: "auto", + padding: "0 16px 24px", + }, + // [showChatSelector]: { + // padding: "0 16px 24px 28px", + // [theme.breakpoints.between("sm", "md")]: { + // padding: "0 0 16px 24px", + // }, + // }, +}) + +const BottomContainer = styled.div({ + display: "flex", + flexDirection: "column", + gap: "32px", + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, + background: theme.custom.colors.lightGray1, + "> div": { + width: "100%", + }, + padding: "32px 28px", + [theme.breakpoints.down("md")]: { + padding: "16px 0 16px 16px", + }, + // [showChatSelector]: { + // [theme.breakpoints.up("md")]: { + // padding: "32px 16px 32px 28px", + // }, + // }, +}) + +// const MainCol = styled.div({ +// /** +// * Note: +// * Without a width specified, the carousels will overflow up to 100vw +// */ +// maxWidth: DRAWER_WIDTH, +// flex: 1, +// [theme.breakpoints.down("md")]: { +// maxWidth: "100%", +// }, +// }) + +/** + * Chat offset from top of drawer. + * 48px + 3rem = height of 1-line title plus padding. + * If title is two lines, the chat will overflow into title. + */ +// const CHAT_TOP = "calc(48px + 3rem)" + +// const ChatCol = styled.div({ +// zIndex: 2, +// position: "fixed", +// top: CHAT_TOP, +// right: CHAT_RIGHT, +// height: `calc(100vh - ${CHAT_TOP})`, +// flex: 1, +// boxSizing: "border-box", +// padding: "0 16px 16px 16px", +// maxWidth: CHAT_WIDTH, +// [theme.breakpoints.down("md")]: { +// maxWidth: "100%", +// position: "static", +// }, +// [theme.breakpoints.down("sm")]: { +// height: "100%", +// }, +// ".MitAiChat--title": { +// paddingTop: "0px", +// }, +// }) + +const ContentContainer = styled.div({ + display: "flex", + gap: "32px", + [theme.breakpoints.down("md")]: { + flexDirection: "column-reverse", + gap: "16px", + }, + // [theme.breakpoints.up("md")]: { + // [showChatSelector]: { + // flexDirection: "column-reverse", + // gap: "16px", + // }, + // }, +}) + +const ContentLeft = styled.div({ + display: "flex", + flexDirection: "column", + flexGrow: 1, + alignItems: "flex-start", + gap: "24px", + maxWidth: "100%", +}) + +const ContentRight = styled.div({ + display: "flex", + flexDirection: "column", + gap: "24px", +}) + +const TopCarouselContainer = styled.div({ + display: "flex", + flexDirection: "column", + paddingTop: "24px", +}) + +type LearningResourceExpandedProps = { + resourceId: number + titleId?: string + resource?: LearningResource + user?: User + shareUrl?: string + imgConfig: ImageConfig + topCarousels?: React.ReactNode[] + bottomCarousels?: React.ReactNode[] + inLearningPath?: boolean + inUserList?: boolean + onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] + onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] + closeDrawer?: () => void +} + +const LearningResourceExpanded: React.FC = ({ + resourceId, + resource, + imgConfig, + user, + shareUrl, + topCarousels, + bottomCarousels, + inUserList, + inLearningPath, + titleId, + onAddToLearningPathClick, + onAddToUserListClick, + closeDrawer, +}) => { + const chatEnabled = true // + // useFeatureFlagEnabled(FeatureFlags.LrDrawerChatbot) && + // resource?.resource_type === ResourceTypeEnum.Course + + const [chatExpanded, setChatExpanded] = useToggle(false) + // const showChat = chatEnabled && chatExpanded + + const outerContainerRef = useRef(null) + useEffect(() => { + if (outerContainerRef.current && outerContainerRef.current.scrollTo) { + outerContainerRef.current.scrollTo(0, 0) + } + }, [resourceId]) + + useEffect(() => { + if (chatExpanded && resource && !chatEnabled) { + setChatExpanded.off() + } + }, [chatExpanded, resource, chatEnabled, setChatExpanded]) + + // const chatOpen = !!(resource && showChat) + + return ( + + {})} + /> + + + + + + + + + + + + + + {topCarousels && ( + + {topCarousels?.map((carousel, index) => ( +
{carousel}
+ ))} +
+ )} +
+ + {bottomCarousels?.map((carousel, index) => ( +
{carousel}
+ ))} +
+
+
+
+ ) +} + +export { LearningResourceExpanded } +export type { LearningResourceExpandedProps } diff --git a/frontends/main/src/page-components/LearningResourceExpanded/ResourceDescription.tsx b/frontends/main/src/page-components/LearningResourceExpanded/ResourceDescription.tsx new file mode 100644 index 0000000000..f32b5db7a0 --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceExpanded/ResourceDescription.tsx @@ -0,0 +1,101 @@ +import React, { useCallback, useRef, useState } from "react" +import styled from "@emotion/styled" +import { Skeleton, theme, Link } from "ol-components" +import type { LearningResource } from "api" +const DescriptionContainer = styled.div({ + display: "flex", + flexDirection: "column", + gap: "4px", + width: "100%", +}) + +const Description = styled.p({ + ...theme.typography.body2, + color: theme.custom.colors.black, + margin: 0, + wordBreak: "break-word", + "> *": { + ":first-child": { + marginTop: 0, + }, + ":last-child": { + marginBottom: 0, + }, + ":empty": { + display: "none", + }, + }, +}) + +const DescriptionCollapsed = styled(Description)({ + display: "-webkit-box", + overflow: "hidden", + maxHeight: `calc(${theme.typography.body2.lineHeight} * 5)`, + "@supports (-webkit-line-clamp: 5)": { + maxHeight: "unset", + WebkitLineClamp: 5, + WebkitBoxOrient: "vertical", + }, +}) + +const DescriptionExpanded = styled(Description)({ + display: "block", +}) + +const ResourceDescription = ({ resource }: { resource?: LearningResource }) => { + const firstRender = useRef(true) + const clampedOnFirstRender = useRef(false) + const [isClamped, setClamped] = useState(false) + const [isExpanded, setExpanded] = useState(false) + const descriptionRendered = useCallback((node: HTMLDivElement) => { + if (node !== null) { + const clamped = node.scrollHeight > node.clientHeight + setClamped(clamped) + if (firstRender.current) { + firstRender.current = false + clampedOnFirstRender.current = clamped + return + } + } + }, []) + const DescriptionText = isExpanded + ? DescriptionExpanded + : DescriptionCollapsed + if (!resource) { + return ( + <> + + + + + + + + ) + } + return ( + + + {(isClamped || clampedOnFirstRender.current) && ( + setExpanded(!isExpanded)} + > + {isExpanded ? "Show less" : "Show more"} + + )} + + ) +} + +export default ResourceDescription diff --git a/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx new file mode 100644 index 0000000000..2f7a28f543 --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx @@ -0,0 +1,98 @@ +import React from "react" +import styled from "@emotion/styled" +import { Skeleton, theme, Typography } from "ol-components" +import { ActionButton } from "@mitodl/smoot-design" +import type { LearningResource } from "api" +import { getReadableResourceType } from "ol-utilities" +import { RiCloseLargeLine } from "@remixicon/react" + +const TitleContainer = styled.div({ + display: "flex", + position: "sticky", + justifyContent: "space-between", + top: "0", + padding: "24px 28px", + gap: "16px", + zIndex: 1, + backgroundColor: theme.custom.colors.white, + [theme.breakpoints.down("md")]: { + padding: "24px 16px", + }, +}) + +const CloseButton = styled(ActionButton)(({ theme }) => ({ + "&&&": { + flexShrink: 0, + backgroundColor: theme.custom.colors.lightGray2, + color: theme.custom.colors.black, + ["&:hover"]: { + backgroundColor: theme.custom.colors.red, + color: theme.custom.colors.white, + }, + }, +})) + +const CloseIcon = styled(RiCloseLargeLine)` + &&& { + width: 18px; + height: 18px; + } +` + +const TitleSection: React.FC<{ + titleId?: string + resource?: LearningResource + closeDrawer: () => void +}> = ({ resource, closeDrawer, titleId }) => { + const closeButton = ( + closeDrawer()} + aria-label="Close" + > + + + ) + + const type = resource ? ( + getReadableResourceType(resource.resource_type) + ) : ( + + ) + const title = resource ? ( + resource.title + ) : ( + + ) + + return ( + + + + {type} + + {title} + + {closeButton} + + ) +} + +export default TitleSection diff --git a/frontends/main/src/page-components/LearningResourceDrawer/VideoFrame.tsx b/frontends/main/src/page-components/LearningResourceExpanded/VideoFrame.tsx similarity index 100% rename from frontends/main/src/page-components/LearningResourceDrawer/VideoFrame.tsx rename to frontends/main/src/page-components/LearningResourceExpanded/VideoFrame.tsx diff --git a/frontends/main/src/page-components/LearningResourceDrawer/testUtils.ts b/frontends/main/src/page-components/LearningResourceExpanded/testUtils.ts similarity index 100% rename from frontends/main/src/page-components/LearningResourceDrawer/testUtils.ts rename to frontends/main/src/page-components/LearningResourceExpanded/testUtils.ts From 2d6d489cfe23e000e920718a7e12bb210554076c Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 12:28:12 +0100 Subject: [PATCH 02/13] Full width / slide down syllabus bot --- .../AiRecommendationBot.tsx | 1 + .../AiRecommendationBotSlideDown.tsx | 219 ----- .../LearningResourceExpanded.tsx | 920 ++++++++++++++++++ .../AiChatSyllabus.tsx | 17 +- .../AiChatSyllabusSlideDown.tsx | 307 ++++++ .../LearningResourceExpanded.tsx | 239 ++--- .../LearningResourceExpanded/TitleSection.tsx | 27 +- 7 files changed, 1355 insertions(+), 375 deletions(-) delete mode 100644 frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotSlideDown.tsx create mode 100644 frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx create mode 100644 frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx diff --git a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBot.tsx b/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBot.tsx index 37b477bee2..cce12928e7 100644 --- a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBot.tsx +++ b/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBot.tsx @@ -8,6 +8,7 @@ const Container = styled.div(({ theme }) => ({ width: "900px", height: "100vh", padding: "16px 24px 24px 24px", + [theme.breakpoints.down("md")]: { width: "100%", }, diff --git a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotSlideDown.tsx b/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotSlideDown.tsx deleted file mode 100644 index 08ab5d3e9a..0000000000 --- a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotSlideDown.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import React, { useState, useRef, useEffect } from "react" -import { Typography, styled, AdornmentButton } from "ol-components" -import { Button, Input } from "@mitodl/smoot-design" -import { RiSparkling2Line, RiSendPlaneFill } from "@remixicon/react" -import type { AiChatMessage } from "@mitodl/smoot-design/ai" -import AiRecommendationBot, { STARTERS } from "./AiRecommendationBot" -import Image from "next/image" -import timLogo from "@/public/images/icons/tim.svg" - -const StyledButton = styled(Button)(({ theme }) => ({ - display: "flex", - flexDirection: "row", - gap: "8px", - minWidth: "auto", - padding: "4px 0", - color: theme.custom.colors.darkGray2, - border: "none", - background: "none", - svg: { - fill: theme.custom.colors.lightRed, - width: "20px", - height: "20px", - }, - "&&": { - ":hover": { - background: "none", - color: theme.custom.colors.mitRed, - p: { - color: theme.custom.colors.mitRed, - }, - }, - }, -})) - -const EntryScreen = styled.div({ - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - gap: "16px", - padding: "104px 32px", -}) - -const TimLogoBox = styled.div(({ theme }) => ({ - position: "relative", - padding: "16px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - borderRadius: "8px", - svg: { - fill: theme.custom.colors.red, - position: "absolute", - top: "-10px", - left: "-10px", - }, -})) - -const TimLogo = styled(Image)({ - display: "block", -}) - -const StyledInput = styled(Input)(({ theme }) => ({ - backgroundColor: theme.custom.colors.lightGray1, - borderRadius: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - margin: "24px 0", - width: "700px", - [theme.breakpoints.down("md")]: { - width: "100%", - }, - "button:disabled": { - backgroundColor: "inherit", - }, -})) - -const SendIcon = styled(RiSendPlaneFill)(({ theme }) => ({ - fill: theme.custom.colors.red, - "button:disabled &": { - fill: theme.custom.colors.silverGray, - }, -})) - -const Starters = styled.div(({ theme }) => ({ - display: "flex", - gap: "16px", - maxWidth: "836px", - marginTop: "12px", - [theme.breakpoints.down("md")]: { - flexDirection: "column", - }, -})) - -const Starter = styled.button(({ theme }) => ({ - flex: 1, - display: "flex", - alignItems: "center", - borderRadius: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - padding: "12px 16px", - color: theme.custom.colors.darkGray2, - backgroundColor: "transparent", - textAlign: "left", - [theme.breakpoints.down("md")]: { - textAlign: "center", - padding: "12px 36px", - }, - ":hover": { - cursor: "pointer", - borderColor: "transparent", - color: theme.custom.colors.white, - backgroundColor: theme.custom.colors.darkGray1, - }, -})) - -const AiRecommendationBotSlideDown = () => { - const [initialPrompt, setInitialPrompt] = useState("") - const [open, setOpen] = useState(false) - const [showEntryScreen, setShowEntryScreen] = useState(true) - const aiChatRef = useRef<{ - append: (message: Omit) => void - }>(null) - - useEffect(() => { - if (!initialPrompt || showEntryScreen) return - const timer = setTimeout(() => { - aiChatRef.current?.append({ - content: initialPrompt, - role: "user", - }) - setInitialPrompt("") - }, 0) - - return () => { - clearTimeout(timer) - setInitialPrompt("") - } - }, [initialPrompt, showEntryScreen]) - - const onPromptChange = (e: React.ChangeEvent) => { - setInitialPrompt(e.target.value) - } - - const onPromptKeyDown: React.KeyboardEventHandler = (e) => { - if (e.key !== "Enter") return - setShowEntryScreen(false) - } - - const onStarterClick = (content: string) => { - setInitialPrompt(content) - setShowEntryScreen(false) - } - - if (!open) - return ( - <> - setOpen(true)} - > - - - AskTIM - - - - ) - - return ( - <> - {showEntryScreen ? ( - - - - - - Welcome! I am TIM the Beaver. - Need assistance getting started? - setShowEntryScreen(false)} - disabled={!initialPrompt} - > - - - } - responsive - /> - Let me know how I can help. - - {STARTERS.map(({ content }, index) => ( - onStarterClick(content)} - tabIndex={index} - onKeyDown={(e) => { - if (e.key === "Enter") { - onStarterClick(content) - } - }} - > - {content} - - ))} - - - ) : ( - - )} - - ) -} - -export default AiRecommendationBotSlideDown diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx new file mode 100644 index 0000000000..9f93a55216 --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx @@ -0,0 +1,920 @@ +import React, { useCallback, useEffect, useRef, useState } from "react" +import styled from "@emotion/styled" +import { + Skeleton, + theme, + PlatformLogo, + PLATFORM_LOGOS, + Link, + Input, + Typography, +} from "ol-components" +import type { ImageConfig, LearningResourceCardProps } from "ol-components" +import { default as NextImage } from "next/image" +import { + ActionButton, + Button, + ButtonLink, + ButtonProps, +} from "@mitodl/smoot-design" +import type { LearningResource } from "api" +import { ResourceTypeEnum, PlatformEnum } from "api" +import { + DEFAULT_RESOURCE_IMG, + getReadableResourceType, + useToggle, +} from "ol-utilities" +import { + RiBookmarkFill, + RiBookmarkLine, + RiCloseLargeLine, + RiExternalLinkLine, + RiFacebookFill, + RiLink, + RiLinkedinFill, + RiMenuAddLine, + RiShareLine, + RiTwitterXLine, + RiSparkling2Line, +} from "@remixicon/react" +import classNames from "classnames" +import InfoSection from "./InfoSection" +import type { User } from "api/hooks/user" +import VideoFrame from "./VideoFrame" +import { FeatureFlags } from "@/common/feature_flags" +import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react" +import AiChatSyllabus from "./AiChatSyllabus" +import { PostHogEvents } from "@/common/constants" + +const DRAWER_WIDTH = "900px" +const showChatClass = "show-chat" +const showChatSelector = `.${showChatClass} &` + +const Outer = styled.div({ + display: "flex", + flexDirection: "column", + flexGrow: 1, + width: "100%", + overflowX: "hidden", +}) + +const TitleContainer = styled.div({ + display: "flex", + position: "sticky", + justifyContent: "space-between", + top: "0", + padding: "24px 28px", + gap: "16px", + zIndex: 1, + backgroundColor: theme.custom.colors.white, + [theme.breakpoints.down("md")]: { + padding: "24px 16px", + }, +}) + +const CHAT_WIDTH = "400px" +const CHAT_RIGHT = "0px" + +const Container = styled.div(({ chatOpen }: { chatOpen: boolean }) => + chatOpen + ? { + paddingRight: `calc(${CHAT_WIDTH} + ${CHAT_RIGHT})`, + [theme.breakpoints.down("md")]: { + paddingRight: 0, + flexGrow: 1, + }, + } + : { + paddingRight: 0, + }, +) + +const TopContainer = styled.div({ + display: "flex", + flexDirection: "column", + padding: "0 28px 24px", + [theme.breakpoints.down("md")]: { + width: "auto", + padding: "0 16px 24px", + }, + [showChatSelector]: { + padding: "0 16px 24px 28px", + [theme.breakpoints.between("sm", "md")]: { + padding: "0 0 16px 24px", + }, + }, +}) + +const BottomContainer = styled.div({ + display: "flex", + flexDirection: "column", + gap: "32px", + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, + background: theme.custom.colors.lightGray1, + "> div": { + width: "100%", + }, + padding: "32px 28px", + [theme.breakpoints.down("md")]: { + padding: "16px 0 16px 16px", + }, + [showChatSelector]: { + [theme.breakpoints.up("md")]: { + padding: "32px 16px 32px 28px", + }, + }, +}) + +const MainCol = styled.div(({ chatOpen }: { chatOpen: boolean }) => ({ + /** + * Note: + * Without a width specified, the carousels will overflow up to 100vw + */ + maxWidth: DRAWER_WIDTH, + flex: 1, + [theme.breakpoints.down("md")]: { + maxWidth: "100%", + display: chatOpen ? "none" : "inherit", + }, +})) + +/** + * Chat offset from top of drawer. + * 48px + 3rem = height of 1-line title plus padding. + * If title is two lines, the chat will overflow into title. + */ +const CHAT_TOP = "calc(48px + 3rem)" + +const ChatCol = styled.div({ + zIndex: 2, + position: "fixed", + top: CHAT_TOP, + right: CHAT_RIGHT, + height: `calc(100vh - ${CHAT_TOP})`, + flex: 1, + boxSizing: "border-box", + padding: "0 16px 16px 16px", + maxWidth: CHAT_WIDTH, + [theme.breakpoints.down("md")]: { + maxWidth: "100%", + position: "static", + }, + [theme.breakpoints.down("sm")]: { + height: "100%", + }, + ".MitAiChat--title": { + paddingTop: "0px", + }, +}) + +const ContentContainer = styled.div({ + display: "flex", + gap: "32px", + [theme.breakpoints.down("md")]: { + flexDirection: "column-reverse", + gap: "16px", + }, + [theme.breakpoints.up("md")]: { + [showChatSelector]: { + flexDirection: "column-reverse", + gap: "16px", + }, + }, +}) + +const ContentLeft = styled.div({ + display: "flex", + flexDirection: "column", + flexGrow: 1, + alignItems: "flex-start", + gap: "24px", + maxWidth: "100%", +}) + +const ContentRight = styled.div({ + display: "flex", + flexDirection: "column", + gap: "24px", +}) + +const ImageContainer = styled.div({ + width: "100%", +}) + +const Image = styled(NextImage)<{ aspect: number }>` + position: relative !important; + border-radius: 8px; + width: 100%; + aspect-ratio: ${({ aspect }) => aspect}; + object-fit: cover; +` + +const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({ + borderRadius: "8px", + paddingBottom: `${100 / aspect.aspect}%`, +})) + +const CallToAction = styled.div({ + display: "flex", + width: "350px", + padding: "16px", + flexDirection: "column", + gap: "10px", + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + boxShadow: "0px 2px 10px 0px rgba(37, 38, 43, 0.10)", + [theme.breakpoints.down("md")]: { + width: "100%", + padding: "0", + border: "none", + boxShadow: "none", + }, + [showChatSelector]: { + [theme.breakpoints.up("sm")]: { + width: "auto", + maxWidth: "424px", + }, + [theme.breakpoints.down("md")]: { + width: "100%", + padding: "0", + border: "none", + boxShadow: "none", + }, + }, +}) + +const ActionsContainer = styled.div({ + display: "flex", + flexDirection: "column", + gap: "16px", + width: "100%", +}) + +const PlatformContainer = styled.div({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "16px", + alignSelf: "stretch", +}) + +const StyledLink = styled(ButtonLink)({ + textAlign: "center", + width: "100%", + [theme.breakpoints.down("sm")]: { + marginTop: "10px", + marginBottom: "10px", + }, +}) + +const Platform = styled.div({ + display: "flex", + justifyContent: "flex-end", + alignItems: "center", + gap: "16px", +}) + +const DescriptionContainer = styled.div({ + display: "flex", + flexDirection: "column", + gap: "4px", + width: "100%", +}) + +const Description = styled.p({ + ...theme.typography.body2, + color: theme.custom.colors.black, + margin: 0, + wordBreak: "break-word", + "> *": { + ":first-child": { + marginTop: 0, + }, + ":last-child": { + marginBottom: 0, + }, + ":empty": { + display: "none", + }, + }, +}) + +const DescriptionCollapsed = styled(Description)({ + display: "-webkit-box", + overflow: "hidden", + maxHeight: `calc(${theme.typography.body2.lineHeight} * 5)`, + "@supports (-webkit-line-clamp: 5)": { + maxHeight: "unset", + WebkitLineClamp: 5, + WebkitBoxOrient: "vertical", + }, +}) + +const DescriptionExpanded = styled(Description)({ + display: "block", +}) + +const StyledPlatformLogo = styled(PlatformLogo)({ + height: "26px", + maxWidth: "180px", +}) + +const OnPlatform = styled.span({ + ...theme.typography.body2, + color: theme.custom.colors.black, +}) + +const ButtonContainer = styled.div({ + display: "flex", + width: "100%", + gap: "8px", + flexGrow: 1, + justifyContent: "center", +}) + +const SelectableButton = styled(Button)<{ selected?: boolean }>((props) => [ + { + flex: 1, + whiteSpace: "nowrap", + }, + props.selected + ? { + backgroundColor: theme.custom.colors.red, + border: `1px solid ${theme.custom.colors.red}`, + color: theme.custom.colors.white, + "&:hover:not(:disabled)": { + backgroundColor: theme.custom.colors.red, + border: `1px solid ${theme.custom.colors.red}`, + color: theme.custom.colors.white, + }, + } + : {}, +]) + +const ShareContainer = styled.div({ + display: "flex", + flexDirection: "column", + alignItems: "center", + alignSelf: "stretch", + padding: "16px 0 8px 0", + gap: "12px", +}) + +const ShareLabel = styled(Typography)({ + ...theme.typography.body3, + color: theme.custom.colors.darkGray1, +}) + +const ShareButtonContainer = styled.div({ + display: "flex", + justifyContent: "center", + alignItems: "center", + alignSelf: "stretch", + gap: "16px", + a: { + height: "18px", + }, +}) + +const ShareLink = styled(Link)({ + color: theme.custom.colors.silverGrayDark, +}) + +const RedLinkIcon = styled(RiLink)({ + color: theme.custom.colors.red, +}) + +const CopyLinkButton = styled(Button)({ + flexGrow: 0, + flexBasis: "112px", +}) + +const TopCarouselContainer = styled.div({ + display: "flex", + flexDirection: "column", + paddingTop: "24px", +}) + +type LearningResourceExpandedProps = { + resourceId: number + titleId?: string + resource?: LearningResource + user?: User + shareUrl?: string + imgConfig: ImageConfig + topCarousels?: React.ReactNode[] + bottomCarousels?: React.ReactNode[] + inLearningPath?: boolean + inUserList?: boolean + onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] + onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] + closeDrawer?: () => void +} + +const CloseButton = styled(ActionButton)(({ theme }) => ({ + "&&&": { + flexShrink: 0, + backgroundColor: theme.custom.colors.lightGray2, + color: theme.custom.colors.black, + ["&:hover"]: { + backgroundColor: theme.custom.colors.red, + color: theme.custom.colors.white, + }, + }, +})) + +const CloseIcon = styled(RiCloseLargeLine)` + &&& { + width: 18px; + height: 18px; + } +` + +const TitleSection: React.FC<{ + titleId?: string + resource?: LearningResource + closeDrawer: () => void +}> = ({ resource, closeDrawer, titleId }) => { + const closeButton = ( + closeDrawer()} + aria-label="Close" + > + + + ) + + const type = resource ? ( + getReadableResourceType(resource.resource_type) + ) : ( + + ) + const title = resource ? ( + resource.title + ) : ( + + ) + + return ( + + + + {type} + + {title} + + {closeButton} + + ) +} + +const ImageSection: React.FC<{ + resource?: LearningResource + config: ImageConfig +}> = ({ resource, config }) => { + const aspect = config.width / config.height + if (resource?.resource_type === "video" && resource?.url) { + return ( + + ) + } else if (resource) { + return ( + + {resource?.image?.alt + + ) + } else { + return ( + + ) + } +} + +const getCallToActionText = (resource: LearningResource): string => { + const accessCourseMaterials = "Access Course Materials" + const watchOnYouTube = "Watch on YouTube" + const listenToPodcast = "Listen to Podcast" + const learnMore = "Learn More" + const callsToAction = { + [ResourceTypeEnum.Course]: learnMore, + [ResourceTypeEnum.Program]: learnMore, + [ResourceTypeEnum.LearningPath]: learnMore, + [ResourceTypeEnum.Video]: watchOnYouTube, + [ResourceTypeEnum.VideoPlaylist]: watchOnYouTube, + [ResourceTypeEnum.Podcast]: listenToPodcast, + [ResourceTypeEnum.PodcastEpisode]: listenToPodcast, + } + if ( + resource?.resource_type === ResourceTypeEnum.Video || + resource?.resource_type === ResourceTypeEnum.VideoPlaylist + ) { + // Video resources should always show "Watch on YouTube" as the CTA + return watchOnYouTube + } else { + if (resource?.platform?.code === PlatformEnum.Ocw) { + // Non-video OCW resources should show "Access Course Materials" as the CTA + return accessCourseMaterials + } else { + // Return the default CTA for the resource type + return callsToAction[resource?.resource_type] || learnMore + } + } +} + +const CallToActionButton: React.FC = ( + props, +) => { + return ( + + ) +} + +const CallToActionSection = ({ + imgConfig, + resource, + hide, + user, + shareUrl, + inUserList, + inLearningPath, + onAddToLearningPathClick, + onAddToUserListClick, +}: { + imgConfig: ImageConfig + resource?: LearningResource + hide?: boolean + user?: User + shareUrl?: string + inUserList?: boolean + inLearningPath?: boolean + onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] + onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] +}) => { + const posthog = usePostHog() + const [shareExpanded, setShareExpanded] = useState(false) + const [copyText, setCopyText] = useState("Copy Link") + if (hide) { + return null + } + + if (!resource) { + return ( + + + + + ) + } + const { platform } = resource! + const offeredBy = resource?.offered_by + const platformCode = + (offeredBy?.code as PlatformEnum) === PlatformEnum.Xpro + ? (offeredBy?.code as PlatformEnum) + : (platform?.code as PlatformEnum) + const platformImage = PLATFORM_LOGOS[platformCode]?.image + const cta = getCallToActionText(resource) + const addToLearningPathLabel = "Add to list" + const bookmarkLabel = "Bookmark" + const shareLabel = "Share" + const socialIconSize = 18 + const facebookShareBaseUrl = "https://www.facebook.com/sharer/sharer.php" + const twitterShareBaseUrl = "https://x.com/share" + const linkedInShareBaseUrl = "https://www.linkedin.com/sharing/share-offsite" + + return ( + + + + } + href={resource.url || ""} + onClick={() => { + if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { + posthog.capture(PostHogEvents.CallToActionClicked, { resource }) + } + }} + data-ph-action="click-cta" + data-ph-offered-by={offeredBy?.code} + data-ph-resource-type={resource.resource_type} + data-ph-resource-id={resource.id} + > + {cta} + + {platformImage ? ( + + + on + + + + ) : null} + + {user?.is_learning_path_editor && ( + } + aria-label={addToLearningPathLabel} + onClick={(event) => + onAddToLearningPathClick + ? onAddToLearningPathClick(event, resource.id) + : null + } + > + {addToLearningPathLabel} + + )} + : } + aria-label={bookmarkLabel} + onClick={ + onAddToUserListClick + ? (event) => onAddToUserListClick?.(event, resource.id) + : undefined + } + > + {bookmarkLabel} + + } + aria-label={shareLabel} + onClick={() => setShareExpanded(!shareExpanded)} + > + {shareLabel} + + + {shareExpanded && shareUrl && ( + + Share a link to this Resource + { + const input = event.currentTarget.querySelector("input") + if (!input) return + input.select() + }} + /> + + + + + + + + + + + } + aria-label={copyText} + onClick={() => { + navigator.clipboard.writeText(shareUrl) + setCopyText("Copied!") + }} + > + {copyText} + + + + )} + + + ) +} + +const ResourceDescription = ({ resource }: { resource?: LearningResource }) => { + const firstRender = useRef(true) + const clampedOnFirstRender = useRef(false) + const [isClamped, setClamped] = useState(false) + const [isExpanded, setExpanded] = useState(false) + const descriptionRendered = useCallback((node: HTMLDivElement) => { + if (node !== null) { + const clamped = node.scrollHeight > node.clientHeight + setClamped(clamped) + if (firstRender.current) { + firstRender.current = false + clampedOnFirstRender.current = clamped + return + } + } + }, []) + const DescriptionText = isExpanded + ? DescriptionExpanded + : DescriptionCollapsed + if (!resource) { + return ( + <> + + + + + + + + ) + } + return ( + + + {(isClamped || clampedOnFirstRender.current) && ( + setExpanded(!isExpanded)} + > + {isExpanded ? "Show less" : "Show more"} + + )} + + ) +} + +const StyledAskButton = styled(Button)(({ theme }) => ({ + display: "flex", + flexDirection: "row", + gap: "2px", + minWidth: "auto", + paddingLeft: "16px", + paddingRight: "24px", + color: theme.custom.colors.darkGray2, + borderColor: theme.custom.colors.lightGray2, + svg: { + fill: theme.custom.colors.red, + }, + "&&": { + ":hover": { + borderColor: "transparent", + color: theme.custom.colors.white, + backgroundColor: theme.custom.colors.darkGray2, + p: { + color: theme.custom.colors.white, + }, + svg: { + fill: theme.custom.colors.white, + }, + }, + }, +})) + +const LearningResourceExpanded: React.FC = ({ + resourceId, + resource, + imgConfig, + user, + shareUrl, + topCarousels, + bottomCarousels, + inUserList, + inLearningPath, + titleId, + onAddToLearningPathClick, + onAddToUserListClick, + closeDrawer, +}) => { + const chatEnabled = true + // useFeatureFlagEnabled(FeatureFlags.LrDrawerChatbot) && + // resource?.resource_type === ResourceTypeEnum.Course + + const [chatExpanded, setChatExpanded] = useToggle(false) + const showChat = chatEnabled && chatExpanded + + const outerContainerRef = useRef(null) + useEffect(() => { + if (outerContainerRef.current && outerContainerRef.current.scrollTo) { + outerContainerRef.current.scrollTo(0, 0) + } + }, [resourceId]) + + useEffect(() => { + if (chatExpanded && resource && !chatEnabled) { + setChatExpanded.off() + } + }, [chatExpanded, resource, chatEnabled, setChatExpanded]) + + const chatOpen = !!(resource && showChat) + + return ( + + {})} + /> + + + + + + + + + + + {chatEnabled && !chatExpanded ? ( + } + > + + Need help? AskTIM + + + ) : null} + + + {topCarousels && ( + + {topCarousels?.map((carousel, index) => ( +
{carousel}
+ ))} +
+ )} +
+ + {bottomCarousels?.map((carousel, index) => ( +
{carousel}
+ ))} +
+
+ + {chatOpen ? ( + + ) : null} + +
+
+ ) +} + +export { LearningResourceExpanded, getCallToActionText } +export type { LearningResourceExpandedProps } diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx index ad8686539e..7298fbc1fc 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import type { AiChatProps } from "@mitodl/smoot-design/ai" +import type { AiChatMessage, AiChatProps } from "@mitodl/smoot-design/ai" import { getCsrfToken } from "@/common/utils" import { LearningResource } from "api" import { useUserMe } from "api/hooks/user" @@ -21,26 +21,28 @@ const getInitialMessage = ( resource: LearningResource, user?: User, ): AiChatProps["initialMessages"] => { - const grettings = user?.profile?.name + const greetings = user?.profile?.name ? `Hello ${user.profile.name}, ` : "Hello and " return [ { - content: `${grettings} welcome to **${resource.title}**. How can I assist you today?`, + content: `${greetings} welcome to **${resource.title}**. How can I assist you today?`, role: "assistant", }, ] } type AiChatSyllabusProps = { - onClose: () => void + // onClose: () => void resource?: LearningResource className?: string + ref?: React.Ref<{ append: (message: Omit) => void }> } const AiChatSyllabus: React.FC = ({ - onClose, + // onClose, resource, + ref, ...props }) => { const user = useUserMe() @@ -52,8 +54,8 @@ const AiChatSyllabus: React.FC = ({ conversationStarters={STARTERS} initialMessages={getInitialMessage(resource, user.data)} chatId={`chat-${resource?.readable_id}`} - askTimTitle="about this course" - onClose={onClose} + // askTimTitle="about this course" + // onClose={onClose} requestOpts={{ apiUrl: process.env.NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT!, fetchOpts: { @@ -67,6 +69,7 @@ const AiChatSyllabus: React.FC = ({ course_id: resource?.readable_id, }), }} + ref={ref} {...props} /> ) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx new file mode 100644 index 0000000000..10a4c45e8a --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx @@ -0,0 +1,307 @@ +import React, { useState, useRef, useEffect } from "react" +import { Typography, styled, AdornmentButton } from "ol-components" +import { Button, Input } from "@mitodl/smoot-design" +import { + RiSparkling2Line, + RiSendPlaneFill, + RiArrowDownSLine, + RiCloseLine, +} from "@remixicon/react" +import type { AiChatMessage } from "@mitodl/smoot-design/ai" +import AiChatSyllabus from "./AiChatSyllabus" +import Image from "next/image" +import timLogo from "@/public/images/icons/tim.svg" +import { LearningResource } from "api" + +const Container = styled.div() + +const SlideDown = styled.div<{ open: boolean }>(({ theme, open }) => ({ + position: "absolute", + top: open ? "0" : "-100%", + width: "100%", + height: "100%", + backgroundColor: theme.custom.colors.white, + transition: "top 0.3s ease-in-out", +})) + +const Opener = styled.div(({ theme }) => ({ + pointerEvents: "auto", + position: "relative", + ":after": { + content: "''", + width: "100%", + height: "50%", + background: theme.custom.colors.white, + display: "block", + position: "absolute", + top: 0, + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, + zIndex: 1, + }, +})) + +const StyledButton = styled(Button)<{ open: boolean }>(({ theme, open }) => ({ + pointerEvents: "auto", + display: "flex", + flexDirection: "row", + gap: "8px", + position: "relative", + zIndex: 2, + width: "360px", + margin: "0 auto", + color: theme.custom.colors.darkGray2, + borderColor: open + ? theme.custom.colors.silverGray + : theme.custom.colors.lightGray2, + overflow: "hidden", + "svg:first-child": { + fill: theme.custom.colors.lightRed, + width: "20px", + height: "20px", + }, + "&&": { + ":hover": { + backgroundColor: theme.custom.colors.white, + borderColor: open + ? theme.custom.colors.darkGray1 + : theme.custom.colors.silverGray, + "svg:last-child": { + backgroundColor: open ? theme.custom.colors.darkGray1 : "transparent", + }, + }, + }, +})) + +const OpenChevron = styled(RiArrowDownSLine)(({ theme }) => ({ + fill: theme.custom.colors.mitRed, + position: "absolute", + right: "9px", +})) + +const CloseButton = styled(RiCloseLine)(({ theme }) => ({ + fill: theme.custom.colors.white, + position: "absolute", + right: "0", + padding: "9px", + boxSizing: "content-box", + backgroundColor: theme.custom.colors.silverGray, +})) + +const EntryScreen = styled.div({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "16px", + padding: "104px 32px", +}) + +const TimLogoBox = styled.div(({ theme }) => ({ + position: "relative", + padding: "16px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "8px", + svg: { + fill: theme.custom.colors.red, + position: "absolute", + top: "-10px", + left: "-10px", + }, +})) + +const TimLogo = styled(Image)({ + display: "block", +}) + +const StyledInput = styled(Input)(({ theme }) => ({ + backgroundColor: theme.custom.colors.lightGray1, + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + margin: "24px 0", + width: "700px", + [theme.breakpoints.down("md")]: { + width: "100%", + }, + "button:disabled": { + backgroundColor: "inherit", + }, +})) + +const SendIcon = styled(RiSendPlaneFill)(({ theme }) => ({ + fill: theme.custom.colors.red, + "button:disabled &": { + fill: theme.custom.colors.silverGray, + }, +})) + +const Starters = styled.div(({ theme }) => ({ + display: "flex", + gap: "16px", + maxWidth: "836px", + marginTop: "12px", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + }, +})) + +const Starter = styled.button(({ theme }) => ({ + flex: 1, + display: "flex", + alignItems: "center", + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + padding: "12px 16px", + color: theme.custom.colors.darkGray2, + backgroundColor: "transparent", + textAlign: "left", + [theme.breakpoints.down("md")]: { + textAlign: "center", + padding: "12px 36px", + }, + ":hover": { + cursor: "pointer", + borderColor: "transparent", + color: theme.custom.colors.white, + backgroundColor: theme.custom.colors.darkGray1, + }, +})) + +const ChatContainer = styled.div({ + padding: "40px 28px 16px", + height: "100%", +}) + +const STARTERS = [ + { + content: "What is this course about?", + }, + { + content: "What are the prerequisites for this course?", + }, + { + content: "How will this course be graded?", + }, +] + +const AiChatSyllabusSlideDown = ({ + resource, + onToggleOpen, +}: { + resource?: LearningResource + onToggleOpen: (open: boolean) => void +}) => { + const [initialPrompt, setInitialPrompt] = useState("") + const [open, setOpen] = useState(false) + const [showEntryScreen, setShowEntryScreen] = useState(true) + + const aiChatRef = useRef<{ + append: (message: Omit) => void + }>(null) + + useEffect(() => { + if (!initialPrompt || showEntryScreen) return + const timer = setTimeout(() => { + aiChatRef.current?.append({ + content: initialPrompt, + role: "user", + }) + setInitialPrompt("") + }, 0) + + return () => { + clearTimeout(timer) + setInitialPrompt("") + } + }, [initialPrompt, showEntryScreen]) + + const onPromptChange = (e: React.ChangeEvent) => { + setInitialPrompt(e.target.value) + } + + const onPromptKeyDown: React.KeyboardEventHandler = (e) => { + if (e.key !== "Enter") return + setShowEntryScreen(false) + } + + const onStarterClick = (content: string) => { + setInitialPrompt(content) + setShowEntryScreen(false) + } + + const toggleOpen = () => { + setOpen(!open) + onToggleOpen(!open) + } + + if (!resource) return null + + return ( + + + + + + AskTIM about this course + + {open ? : } + + + + {showEntryScreen ? ( + + + + + + + What do you want to know about this course? + + setShowEntryScreen(false)} + disabled={!initialPrompt} + > + + + } + responsive + /> + + {STARTERS.map(({ content }, index) => ( + onStarterClick(content)} + tabIndex={index} + onKeyDown={(e) => { + if (e.key === "Enter") { + onStarterClick(content) + } + }} + > + {content} + + ))} + + + ) : ( + + + + )} + + + ) +} + +export default AiChatSyllabusSlideDown diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index 9d1ae9d456..8fb492f516 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -1,18 +1,22 @@ -import React, { useEffect, useRef } from "react" +import React, { useEffect, useRef, useState } from "react" import styled from "@emotion/styled" import { theme } from "ol-components" import type { ImageConfig, LearningResourceCardProps } from "ol-components" import type { LearningResource } from "api" +import { ResourceTypeEnum } from "api" import { useToggle } from "ol-utilities" import InfoSection from "./InfoSection" import type { User } from "api/hooks/user" import TitleSection from "./TitleSection" import CallToActionSection from "./CallToActionSection" import ResourceDescription from "./ResourceDescription" +import { FeatureFlags } from "@/common/feature_flags" +import { useFeatureFlagEnabled } from "posthog-js/react" +import AiSyllabusBotSlideDown from "./AiChatSyllabusSlideDown" const DRAWER_WIDTH = "900px" -const Outer = styled.div({ +const Outer = styled.div<{ chatExpanded: boolean }>(({ chatExpanded }) => ({ display: "flex", flexDirection: "column", flexGrow: 1, @@ -22,44 +26,46 @@ const Outer = styled.div({ [theme.breakpoints.down("md")]: { minWidth: "100%", }, -}) - -// const CHAT_WIDTH = "400px" -// const CHAT_RIGHT = "0px" - -const Layers = styled.div({ - paddingRight: 0, - position: "relative", -}) - -const Layer = styled.div({ - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", -}) - -// const ChatLayer = styled(Layer)({ -// zIndex: 2, -// }) - -const TopContainer = styled.div({ + ...(chatExpanded && { + "&::-webkit-scrollbar": { + display: "none", + }, + msOverflowStyle: "none", + scrollbarWidth: "none", + }), +})) + +const ContentSection = styled.div({ display: "flex", flexDirection: "column", - padding: "0 28px 24px", - [theme.breakpoints.down("md")]: { - width: "auto", - padding: "0 16px 24px", - }, - // [showChatSelector]: { - // padding: "0 16px 24px 28px", - // [theme.breakpoints.between("sm", "md")]: { - // padding: "0 0 16px 24px", - // }, - // }, + flexGrow: 1, + position: "relative", }) +const ChatLayer = styled("div")<{ top: number; chatExpanded: boolean }>( + ({ top, chatExpanded }) => ({ + zIndex: 2, + position: "absolute", + top, + bottom: 0, + left: 0, + right: 0, + pointerEvents: chatExpanded ? "auto" : "none", + }), +) + +const TopContainer = styled.div<{ chatEnabled: boolean }>( + ({ chatEnabled }) => ({ + display: "flex", + flexDirection: "column", + padding: chatEnabled ? "70px 28px 24px" : "0 28px 24px", + [theme.breakpoints.down("md")]: { + width: "auto", + padding: chatEnabled ? "72px 16px 24px" : "0 16px 24px", + }, + }), +) + const BottomContainer = styled.div({ display: "flex", flexDirection: "column", @@ -73,54 +79,8 @@ const BottomContainer = styled.div({ [theme.breakpoints.down("md")]: { padding: "16px 0 16px 16px", }, - // [showChatSelector]: { - // [theme.breakpoints.up("md")]: { - // padding: "32px 16px 32px 28px", - // }, - // }, }) -// const MainCol = styled.div({ -// /** -// * Note: -// * Without a width specified, the carousels will overflow up to 100vw -// */ -// maxWidth: DRAWER_WIDTH, -// flex: 1, -// [theme.breakpoints.down("md")]: { -// maxWidth: "100%", -// }, -// }) - -/** - * Chat offset from top of drawer. - * 48px + 3rem = height of 1-line title plus padding. - * If title is two lines, the chat will overflow into title. - */ -// const CHAT_TOP = "calc(48px + 3rem)" - -// const ChatCol = styled.div({ -// zIndex: 2, -// position: "fixed", -// top: CHAT_TOP, -// right: CHAT_RIGHT, -// height: `calc(100vh - ${CHAT_TOP})`, -// flex: 1, -// boxSizing: "border-box", -// padding: "0 16px 16px 16px", -// maxWidth: CHAT_WIDTH, -// [theme.breakpoints.down("md")]: { -// maxWidth: "100%", -// position: "static", -// }, -// [theme.breakpoints.down("sm")]: { -// height: "100%", -// }, -// ".MitAiChat--title": { -// paddingTop: "0px", -// }, -// }) - const ContentContainer = styled.div({ display: "flex", gap: "32px", @@ -128,12 +88,6 @@ const ContentContainer = styled.div({ flexDirection: "column-reverse", gap: "16px", }, - // [theme.breakpoints.up("md")]: { - // [showChatSelector]: { - // flexDirection: "column-reverse", - // gap: "16px", - // }, - // }, }) const ContentLeft = styled.div({ @@ -188,14 +142,16 @@ const LearningResourceExpanded: React.FC = ({ onAddToUserListClick, closeDrawer, }) => { - const chatEnabled = true // - // useFeatureFlagEnabled(FeatureFlags.LrDrawerChatbot) && - // resource?.resource_type === ResourceTypeEnum.Course + const chatEnabled = + useFeatureFlagEnabled(FeatureFlags.LrDrawerChatbot) && + resource?.resource_type === ResourceTypeEnum.Course const [chatExpanded, setChatExpanded] = useToggle(false) - // const showChat = chatEnabled && chatExpanded const outerContainerRef = useRef(null) + const titleSectionRef = useRef(null) + const [titleSectionHeight, setTitleSectionHeight] = useState(0) + useEffect(() => { if (outerContainerRef.current && outerContainerRef.current.scrollTo) { outerContainerRef.current.scrollTo(0, 0) @@ -203,57 +159,72 @@ const LearningResourceExpanded: React.FC = ({ }, [resourceId]) useEffect(() => { - if (chatExpanded && resource && !chatEnabled) { - setChatExpanded.off() + const updateHeight = () => { + if (titleSectionRef.current) { + setTitleSectionHeight(titleSectionRef.current.offsetHeight) + } } - }, [chatExpanded, resource, chatEnabled, setChatExpanded]) + updateHeight() + const resizeObserver = new ResizeObserver(updateHeight) - // const chatOpen = !!(resource && showChat) + if (titleSectionRef.current) { + resizeObserver.observe(titleSectionRef.current) + } + return () => { + resizeObserver.disconnect() + } + }, []) return ( - + {})} /> - - - - - - - - - - - - - - {topCarousels && ( - - {topCarousels?.map((carousel, index) => ( -
{carousel}
- ))} -
- )} -
- - {bottomCarousels?.map((carousel, index) => ( -
{carousel}
- ))} -
-
-
+ {chatEnabled ? ( + + + + ) : null} + + + + + + + + + + + + {topCarousels && ( + + {topCarousels?.map((carousel, index) => ( +
{carousel}
+ ))} +
+ )} +
+ + {bottomCarousels?.map((carousel, index) => ( +
{carousel}
+ ))} +
+
) } diff --git a/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx index 2f7a28f543..92584438eb 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx @@ -13,7 +13,7 @@ const TitleContainer = styled.div({ top: "0", padding: "24px 28px", gap: "16px", - zIndex: 1, + zIndex: 3, backgroundColor: theme.custom.colors.white, [theme.breakpoints.down("md")]: { padding: "24px 16px", @@ -43,18 +43,8 @@ const TitleSection: React.FC<{ titleId?: string resource?: LearningResource closeDrawer: () => void -}> = ({ resource, closeDrawer, titleId }) => { - const closeButton = ( - closeDrawer()} - aria-label="Close" - > - - - ) - + ref: React.Ref +}> = ({ resource, closeDrawer, titleId, ref }) => { const type = resource ? ( getReadableResourceType(resource.resource_type) ) : ( @@ -75,7 +65,7 @@ const TitleSection: React.FC<{ ) return ( - + {title} - {closeButton} + closeDrawer()} + aria-label="Close" + > + + ) } From da88b27d75602af71de33d25f263d94389d3b3e7 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 17:51:16 +0100 Subject: [PATCH 03/13] Update Posthog replay vars --- .github/workflows/production.yml | 2 +- .github/workflows/release-candidate.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 549be20b07..06bc32ddfc 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -52,7 +52,7 @@ jobs: POSTHOG_API_HOST: https://app.posthog.com POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID_PROD }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY_PROD }} - POSTHOG_ENABLE_SESSION_RECORDING: ${{ secrets.POSTHOG_PROJECT_ENABLE_SESSION_RECORDING_PROD }} + POSTHOG_ENABLE_SESSION_RECORDING: ${{ secrets.POSTHOG_ENABLE_SESSION_RECORDING_PROD }} SENTRY_DSN: ${{ secrets.SENTRY_DSN_PROD }} SENTRY_ENV: ${{ secrets.MITOL_ENVIRONMENT_PROD }} SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE_PROD }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index b01a589575..068bd4ef26 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -52,7 +52,7 @@ jobs: POSTHOG_API_HOST: https://app.posthog.com POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID_RC }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY_RC }} - POSTHOG_ENABLE_SESSION_RECORDING: ${{ secrets.POSTHOG_PROJECT_ENABLE_SESSION_RECORDING_RC }} + POSTHOG_ENABLE_SESSION_RECORDING: ${{ secrets.POSTHOG_ENABLE_SESSION_RECORDING_RC }} SENTRY_DSN: ${{ secrets.SENTRY_DSN_RC }} SENTRY_ENV: ${{ secrets.MITOL_ENVIRONMENT_RC }} SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE_RC }} From 4fa203cdb70fc26264f2151e18bf440da63a76d9 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 17:52:06 +0100 Subject: [PATCH 04/13] Refactor chat with entry screen for reuse --- .../AiChatWithEntryScreen.tsx | 228 +++++++++++++++++ .../AiRecommendationBot.tsx | 70 ------ .../AiRecommendationBotDrawer.tsx | 220 ++++------------- .../AiChatSyllabus.tsx | 78 ------ .../AiChatSyllabusSlideDown.tsx | 231 ++++-------------- 5 files changed, 325 insertions(+), 502 deletions(-) create mode 100644 frontends/main/src/page-components/AiRecommendationBot/AiChatWithEntryScreen.tsx delete mode 100644 frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBot.tsx delete mode 100644 frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx diff --git a/frontends/main/src/page-components/AiRecommendationBot/AiChatWithEntryScreen.tsx b/frontends/main/src/page-components/AiRecommendationBot/AiChatWithEntryScreen.tsx new file mode 100644 index 0000000000..c96961a4c4 --- /dev/null +++ b/frontends/main/src/page-components/AiRecommendationBot/AiChatWithEntryScreen.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useRef, useState } from "react" +import { styled, Typography, AdornmentButton } from "ol-components" +import { AiChat } from "@mitodl/smoot-design/ai" +import type { AiChatMessage, AiChatProps } from "@mitodl/smoot-design/ai" +import { RiSparkling2Line, RiSendPlaneFill } from "@remixicon/react" +import { Input } from "@mitodl/smoot-design" +import Image from "next/image" +import timLogo from "@/public/images/icons/tim.svg" + +const Container = styled.div(({ theme }) => ({ + width: "900px", + height: "100%", + [theme.breakpoints.down("md")]: { + width: "100%", + }, +})) + +const EntryScreen = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "16px", + padding: "136px 40px 24px 40px", + [theme.breakpoints.down("md")]: { + padding: "136px 24px 24px 24px", + width: "100%", + }, +})) + +const TimLogoBox = styled.div(({ theme }) => ({ + position: "relative", + padding: "16px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "8px", + svg: { + fill: theme.custom.colors.red, + position: "absolute", + top: "-10px", + left: "-10px", + }, +})) + +const TimLogo = styled(Image)({ + display: "block", +}) + +const Title = styled(Typography)({ + textAlign: "center", + padding: "0 40px", +}) + +const StyledInput = styled(Input)(({ theme }) => ({ + backgroundColor: theme.custom.colors.lightGray1, + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + margin: "8px 0 24px 0", + "button:disabled": { + backgroundColor: "inherit", + }, + [theme.breakpoints.down("sm")]: { + ".Mit-AdornmentButton": { + padding: 0, + }, + }, +})) + +const SendIcon = styled(RiSendPlaneFill)(({ theme }) => ({ + fill: theme.custom.colors.red, + "button:disabled &": { + fill: theme.custom.colors.silverGray, + }, +})) + +const Starters = styled.div(({ theme }) => ({ + display: "flex", + gap: "16px", + width: "100%", + [theme.breakpoints.down("sm")]: { + flexDirection: "column", + }, +})) + +const Starter = styled.button(({ theme }) => ({ + flex: 1, + display: "flex", + alignItems: "center", + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + padding: "12px 16px", + color: theme.custom.colors.darkGray2, + backgroundColor: "transparent", + textAlign: "left", + [theme.breakpoints.down("sm")]: { + textAlign: "center", + padding: "12px 36px", + }, + ":hover": { + cursor: "pointer", + borderColor: "transparent", + color: theme.custom.colors.white, + backgroundColor: theme.custom.colors.darkGray1, + }, +})) + +const ChatScreen = styled.div(({ theme }) => ({ + padding: "16px 40px 24px", + height: "100%", + [theme.breakpoints.down("md")]: { + padding: "16px 24px 16px", + width: "100%", + }, +})) + +const AiChatWithEntryScreen = ({ + entryTitle, + starters, + initialMessages, + askTimTitle, + requestOpts, + onClose, + chatScreenClassName, + className, +}: { + entryTitle: string + starters: AiChatProps["conversationStarters"] + initialMessages: AiChatProps["initialMessages"] + askTimTitle?: string + requestOpts: AiChatProps["requestOpts"] + onClose?: () => void + className?: string + chatScreenClassName?: string +}) => { + const [initialPrompt, setInitialPrompt] = useState("") + const [showEntryScreen, setShowEntryScreen] = useState(true) + const aiChatRef = useRef<{ + append: (message: Omit) => void + }>(null) + + useEffect(() => { + if (!initialPrompt || showEntryScreen) return + const timer = setTimeout(() => { + aiChatRef.current?.append({ + content: initialPrompt, + role: "user", + }) + setInitialPrompt("") + }, 0) + + return () => { + clearTimeout(timer) + setInitialPrompt("") + } + }, [initialPrompt, showEntryScreen]) + + const onPromptChange = (e: React.ChangeEvent) => { + setInitialPrompt(e.target.value) + } + + const onPromptKeyDown: React.KeyboardEventHandler = (e) => { + if (e.key !== "Enter") return + setShowEntryScreen(false) + } + + const onStarterClick = (content: string) => { + setInitialPrompt(content) + setShowEntryScreen(false) + } + + return ( + + {showEntryScreen ? ( + + + + + + {entryTitle} + setShowEntryScreen(false)} + disabled={!initialPrompt} + > + + + } + responsive + /> + + {starters?.map(({ content }, index) => ( + onStarterClick(content)} + tabIndex={index} + onKeyDown={(e) => { + if (e.key === "Enter") { + onStarterClick(content) + } + }} + > + {content} + + ))} + + + ) : ( + + + + )} + + ) +} + +export default AiChatWithEntryScreen diff --git a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBot.tsx b/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBot.tsx deleted file mode 100644 index cce12928e7..0000000000 --- a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBot.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react" -import { styled } from "ol-components" -import { getCsrfToken } from "@/common/utils" -import { AiChat, AiChatProps } from "@mitodl/smoot-design/ai" -import type { AiChatMessage } from "@mitodl/smoot-design/ai" - -const Container = styled.div(({ theme }) => ({ - width: "900px", - height: "100vh", - padding: "16px 24px 24px 24px", - - [theme.breakpoints.down("md")]: { - width: "100%", - }, -})) - -const INITIAL_MESSAGES: AiChatProps["initialMessages"] = [ - { - content: "What do you want to learn about today?", - role: "assistant", - }, -] - -export const STARTERS = [ - { - content: - "I'm interested in courses on quantum computing that offer certificates.", - }, - { - content: - "I want to learn about global warming, can you recommend any videos?", - }, - { - content: - "I would like to learn about linear regression, preferably at no cost.", - }, -] - -const AiRecommendationBot = ({ - onClose, - ref, -}: { - onClose?: () => void - ref?: React.Ref<{ append: (message: Omit) => void }> -}) => { - return ( - - ({ - message: messages[messages.length - 1].content, - }), - }} - ref={ref} - /> - - ) -} - -export default AiRecommendationBot diff --git a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotDrawer.tsx b/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotDrawer.tsx index 6e84249fbe..8975b68a1e 100644 --- a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotDrawer.tsx +++ b/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotDrawer.tsx @@ -1,29 +1,10 @@ -import React, { useState, useRef, useEffect } from "react" -import { Typography, styled, Drawer, AdornmentButton } from "ol-components" -import { - RiSparkling2Line, - RiSendPlaneFill, - RiCloseLine, -} from "@remixicon/react" -import { Input, ActionButton } from "@mitodl/smoot-design" -import type { AiChatMessage } from "@mitodl/smoot-design/ai" -import AiRecommendationBot, { STARTERS } from "./AiRecommendationBot" -import Image from "next/image" -import timLogo from "@/public/images/icons/tim.svg" - -const EntryScreen = styled.div(({ theme }) => ({ - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - gap: "16px", - padding: "136px 40px 24px 40px", - width: "900px", - [theme.breakpoints.down("md")]: { - padding: "136px 24px 24px 24px", - width: "100%", - }, -})) +import React from "react" +import { styled, Drawer } from "ol-components" +import { RiCloseLine } from "@remixicon/react" +import { ActionButton } from "@mitodl/smoot-design" +import type { AiChatProps } from "@mitodl/smoot-design/ai" +import AiChatWithEntryScreen from "./AiChatWithEntryScreen" +import { getCsrfToken } from "@/common/utils" const CloseButton = styled(ActionButton)(({ theme }) => ({ position: "absolute", @@ -39,79 +20,36 @@ const CloseButton = styled(ActionButton)(({ theme }) => ({ }, })) -const TimLogoBox = styled.div(({ theme }) => ({ - position: "relative", - padding: "16px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - borderRadius: "8px", - svg: { - fill: theme.custom.colors.red, - position: "absolute", - top: "-10px", - left: "-10px", - }, -})) - -const TimLogo = styled(Image)({ - display: "block", -}) - -const Title = styled(Typography)({ - textAlign: "center", - padding: "0 40px", -}) - -const StyledInput = styled(Input)(({ theme }) => ({ - backgroundColor: theme.custom.colors.lightGray1, - borderRadius: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - margin: "8px 0 24px 0", - "button:disabled": { - backgroundColor: "inherit", - }, - [theme.breakpoints.down("sm")]: { - ".Mit-AdornmentButton": { - padding: 0, +const StyledAiChatWithEntryScreen = styled(AiChatWithEntryScreen)( + ({ theme }) => ({ + width: "900px", + [theme.breakpoints.down("md")]: { + width: "100%", }, - }, -})) + }), +) -const SendIcon = styled(RiSendPlaneFill)(({ theme }) => ({ - fill: theme.custom.colors.red, - "button:disabled &": { - fill: theme.custom.colors.silverGray, +const INITIAL_MESSAGES: AiChatProps["initialMessages"] = [ + { + content: "What do you want to learn about today?", + role: "assistant", }, -})) +] -const Starters = styled.div(({ theme }) => ({ - display: "flex", - gap: "16px", - [theme.breakpoints.down("sm")]: { - flexDirection: "column", +const STARTERS = [ + { + content: + "I'm interested in courses on quantum computing that offer certificates.", }, -})) - -const Starter = styled.button(({ theme }) => ({ - flex: 1, - display: "flex", - alignItems: "center", - borderRadius: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - padding: "12px 16px", - color: theme.custom.colors.darkGray2, - backgroundColor: "transparent", - textAlign: "left", - [theme.breakpoints.down("sm")]: { - textAlign: "center", - padding: "12px 36px", + { + content: + "I want to learn about global warming, can you recommend any videos?", }, - ":hover": { - cursor: "pointer", - borderColor: "transparent", - color: theme.custom.colors.white, - backgroundColor: theme.custom.colors.darkGray1, + { + content: + "I would like to learn about linear regression, preferably at no cost.", }, -})) +] const AiRecommendationBotDrawer = ({ open, @@ -120,45 +58,9 @@ const AiRecommendationBotDrawer = ({ open: boolean setOpen: (open: boolean) => void }) => { - const [initialPrompt, setInitialPrompt] = useState("") - const [showEntryScreen, setShowEntryScreen] = useState(true) - const aiChatRef = useRef<{ - append: (message: Omit) => void - }>(null) - - useEffect(() => { - if (!initialPrompt || showEntryScreen) return - const timer = setTimeout(() => { - aiChatRef.current?.append({ - content: initialPrompt, - role: "user", - }) - setInitialPrompt("") - }, 0) - - return () => { - clearTimeout(timer) - setInitialPrompt("") - } - }, [initialPrompt, showEntryScreen]) - - const onPromptChange = (e: React.ChangeEvent) => { - setInitialPrompt(e.target.value) - } - - const onPromptKeyDown: React.KeyboardEventHandler = (e) => { - if (e.key !== "Enter") return - setShowEntryScreen(false) - } - - const onStarterClick = (content: string) => { - setInitialPrompt(content) - setShowEntryScreen(false) - } - const closeDrawer = () => { setOpen(false) - setShowEntryScreen(true) + // setShowEntryScreen(true) } return ( @@ -184,49 +86,23 @@ const AiRecommendationBotDrawer = ({ > - {showEntryScreen ? ( - - - - - - What do you want to learn from MIT? - setShowEntryScreen(false)} - disabled={!initialPrompt} - > - - - } - responsive - /> - - {STARTERS.map(({ content }, index) => ( - onStarterClick(content)} - tabIndex={index} - onKeyDown={(e) => { - if (e.key === "Enter") { - onStarterClick(content) - } - }} - > - {content} - - ))} - - - ) : ( - - )} + ({ + message: messages[messages.length - 1].content, + }), + }} + /> ) } diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx deleted file mode 100644 index 7298fbc1fc..0000000000 --- a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from "react" -import type { AiChatMessage, AiChatProps } from "@mitodl/smoot-design/ai" -import { getCsrfToken } from "@/common/utils" -import { LearningResource } from "api" -import { useUserMe } from "api/hooks/user" -import type { User } from "api/hooks/user" -import dynamic from "next/dynamic" - -const AiChat = dynamic( - () => import("@mitodl/smoot-design/ai").then((mod) => mod.AiChat), - { ssr: false }, -) - -const STARTERS: AiChatProps["conversationStarters"] = [ - { content: "What is this course about?" }, - { content: "What are the prerequisites for this course?" }, - { content: "How will this course be graded?" }, -] - -const getInitialMessage = ( - resource: LearningResource, - user?: User, -): AiChatProps["initialMessages"] => { - const greetings = user?.profile?.name - ? `Hello ${user.profile.name}, ` - : "Hello and " - return [ - { - content: `${greetings} welcome to **${resource.title}**. How can I assist you today?`, - role: "assistant", - }, - ] -} - -type AiChatSyllabusProps = { - // onClose: () => void - resource?: LearningResource - className?: string - ref?: React.Ref<{ append: (message: Omit) => void }> -} - -const AiChatSyllabus: React.FC = ({ - // onClose, - resource, - ref, - ...props -}) => { - const user = useUserMe() - if (!resource) return null - - return ( - ({ - collection_name: "content_files", - message: messages[messages.length - 1].content, - course_id: resource?.readable_id, - }), - }} - ref={ref} - {...props} - /> - ) -} - -export default AiChatSyllabus diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx index 10a4c45e8a..83071bb7fd 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx @@ -1,17 +1,17 @@ -import React, { useState, useRef, useEffect } from "react" -import { Typography, styled, AdornmentButton } from "ol-components" -import { Button, Input } from "@mitodl/smoot-design" +import React, { useState } from "react" +import { Typography, styled } from "ol-components" +import { Button } from "@mitodl/smoot-design" import { RiSparkling2Line, - RiSendPlaneFill, RiArrowDownSLine, RiCloseLine, } from "@remixicon/react" -import type { AiChatMessage } from "@mitodl/smoot-design/ai" -import AiChatSyllabus from "./AiChatSyllabus" -import Image from "next/image" -import timLogo from "@/public/images/icons/tim.svg" +import type { AiChatProps } from "@mitodl/smoot-design/ai" import { LearningResource } from "api" +import { useUserMe } from "api/hooks/user" +import type { User } from "api/hooks/user" +import AiChatWithEntryScreen from "../AiRecommendationBot/AiChatWithEntryScreen" +import { getCsrfToken } from "@/common/utils" const Container = styled.div() @@ -87,102 +87,33 @@ const CloseButton = styled(RiCloseLine)(({ theme }) => ({ backgroundColor: theme.custom.colors.silverGray, })) -const EntryScreen = styled.div({ - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - gap: "16px", - padding: "104px 32px", -}) - -const TimLogoBox = styled.div(({ theme }) => ({ - position: "relative", - padding: "16px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - borderRadius: "8px", - svg: { - fill: theme.custom.colors.red, - position: "absolute", - top: "-10px", - left: "-10px", +const StyledAiChatWithEntryScreen = styled(AiChatWithEntryScreen)({ + ".MitAiChat--messagesContainer": { + marginTop: "14px", }, -})) - -const TimLogo = styled(Image)({ - display: "block", }) -const StyledInput = styled(Input)(({ theme }) => ({ - backgroundColor: theme.custom.colors.lightGray1, - borderRadius: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - margin: "24px 0", - width: "700px", - [theme.breakpoints.down("md")]: { - width: "100%", - }, - "button:disabled": { - backgroundColor: "inherit", - }, -})) - -const SendIcon = styled(RiSendPlaneFill)(({ theme }) => ({ - fill: theme.custom.colors.red, - "button:disabled &": { - fill: theme.custom.colors.silverGray, - }, -})) - -const Starters = styled.div(({ theme }) => ({ - display: "flex", - gap: "16px", - maxWidth: "836px", - marginTop: "12px", - [theme.breakpoints.down("md")]: { - flexDirection: "column", - }, -})) - -const Starter = styled.button(({ theme }) => ({ - flex: 1, - display: "flex", - alignItems: "center", - borderRadius: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - padding: "12px 16px", - color: theme.custom.colors.darkGray2, - backgroundColor: "transparent", - textAlign: "left", - [theme.breakpoints.down("md")]: { - textAlign: "center", - padding: "12px 36px", - }, - ":hover": { - cursor: "pointer", - borderColor: "transparent", - color: theme.custom.colors.white, - backgroundColor: theme.custom.colors.darkGray1, - }, -})) - -const ChatContainer = styled.div({ - padding: "40px 28px 16px", - height: "100%", -}) - -const STARTERS = [ - { - content: "What is this course about?", - }, - { - content: "What are the prerequisites for this course?", - }, - { - content: "How will this course be graded?", - }, +const STARTERS: AiChatProps["conversationStarters"] = [ + { content: "What is this course about?" }, + { content: "What are the prerequisites for this course?" }, + { content: "How will this course be graded?" }, ] +const getInitialMessage = ( + resource: LearningResource, + user?: User, +): AiChatProps["initialMessages"] => { + const greetings = user?.profile?.name + ? `Hello ${user.profile.name}, ` + : "Hello and " + return [ + { + content: `${greetings} welcome to **${resource.title}**. How can I assist you today?`, + role: "assistant", + }, + ] +} + const AiChatSyllabusSlideDown = ({ resource, onToggleOpen, @@ -190,43 +121,8 @@ const AiChatSyllabusSlideDown = ({ resource?: LearningResource onToggleOpen: (open: boolean) => void }) => { - const [initialPrompt, setInitialPrompt] = useState("") const [open, setOpen] = useState(false) - const [showEntryScreen, setShowEntryScreen] = useState(true) - - const aiChatRef = useRef<{ - append: (message: Omit) => void - }>(null) - - useEffect(() => { - if (!initialPrompt || showEntryScreen) return - const timer = setTimeout(() => { - aiChatRef.current?.append({ - content: initialPrompt, - role: "user", - }) - setInitialPrompt("") - }, 0) - - return () => { - clearTimeout(timer) - setInitialPrompt("") - } - }, [initialPrompt, showEntryScreen]) - - const onPromptChange = (e: React.ChangeEvent) => { - setInitialPrompt(e.target.value) - } - - const onPromptKeyDown: React.KeyboardEventHandler = (e) => { - if (e.key !== "Enter") return - setShowEntryScreen(false) - } - - const onStarterClick = (content: string) => { - setInitialPrompt(content) - setShowEntryScreen(false) - } + const user = useUserMe() const toggleOpen = () => { setOpen(!open) @@ -252,53 +148,24 @@ const AiChatSyllabusSlideDown = ({ - {showEntryScreen ? ( - - - - - - - What do you want to know about this course? - - setShowEntryScreen(false)} - disabled={!initialPrompt} - > - - - } - responsive - /> - - {STARTERS.map(({ content }, index) => ( - onStarterClick(content)} - tabIndex={index} - onKeyDown={(e) => { - if (e.key === "Enter") { - onStarterClick(content) - } - }} - > - {content} - - ))} - - - ) : ( - - - - )} + ({ + collection_name: "content_files", + message: messages[messages.length - 1].content, + course_id: resource?.readable_id, + }), + }} + /> ) From 014f59fd88cc64e17e937f15587dda7be7f57061 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 18:09:45 +0100 Subject: [PATCH 05/13] Update test and clean up --- .../AiChatWithEntryScreen.tsx | 0 .../AiRecommendationBotDrawer.tsx | 0 .../AskTimDrawerButton.tsx} | 0 .../page-components/HeroSearch/HeroSearch.tsx | 4 +- .../LearningResourceExpanded.tsx | 920 ------------------ .../AiChatSyllabus.test.tsx | 43 - .../AiChatSyllabusSlideDown.test.tsx | 52 + .../AiChatSyllabusSlideDown.tsx | 2 +- .../LearningResourceExpanded.tsx | 3 +- 9 files changed, 56 insertions(+), 968 deletions(-) rename frontends/main/src/page-components/{AiRecommendationBot => AiChat}/AiChatWithEntryScreen.tsx (100%) rename frontends/main/src/page-components/{AiRecommendationBot => AiChat}/AiRecommendationBotDrawer.tsx (100%) rename frontends/main/src/page-components/{AiRecommendationBot/AskTimButton.tsx => AiChat/AskTimDrawerButton.tsx} (100%) delete mode 100644 frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx delete mode 100644 frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.test.tsx create mode 100644 frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.test.tsx diff --git a/frontends/main/src/page-components/AiRecommendationBot/AiChatWithEntryScreen.tsx b/frontends/main/src/page-components/AiChat/AiChatWithEntryScreen.tsx similarity index 100% rename from frontends/main/src/page-components/AiRecommendationBot/AiChatWithEntryScreen.tsx rename to frontends/main/src/page-components/AiChat/AiChatWithEntryScreen.tsx diff --git a/frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotDrawer.tsx b/frontends/main/src/page-components/AiChat/AiRecommendationBotDrawer.tsx similarity index 100% rename from frontends/main/src/page-components/AiRecommendationBot/AiRecommendationBotDrawer.tsx rename to frontends/main/src/page-components/AiChat/AiRecommendationBotDrawer.tsx diff --git a/frontends/main/src/page-components/AiRecommendationBot/AskTimButton.tsx b/frontends/main/src/page-components/AiChat/AskTimDrawerButton.tsx similarity index 100% rename from frontends/main/src/page-components/AiRecommendationBot/AskTimButton.tsx rename to frontends/main/src/page-components/AiChat/AskTimDrawerButton.tsx diff --git a/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx b/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx index 49e127be86..a0d4267661 100644 --- a/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx +++ b/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback } from "react" import { useRouter } from "next-nprogress-bar" import { FeatureFlags } from "@/common/feature_flags" import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react" -import AskTIMButton from "@/page-components/AiRecommendationBot/AskTimButton" +import AskTimDrawerButton from "@/page-components/AiChat/AskTimDrawerButton" import { Typography, @@ -274,7 +274,7 @@ const HeroSearch: React.FC<{ imageIndex: number }> = ({ imageIndex }) => { {recommendationBotEnabled ? ( <> or - + ) : null} diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx deleted file mode 100644 index 9f93a55216..0000000000 --- a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceExpanded.tsx +++ /dev/null @@ -1,920 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react" -import styled from "@emotion/styled" -import { - Skeleton, - theme, - PlatformLogo, - PLATFORM_LOGOS, - Link, - Input, - Typography, -} from "ol-components" -import type { ImageConfig, LearningResourceCardProps } from "ol-components" -import { default as NextImage } from "next/image" -import { - ActionButton, - Button, - ButtonLink, - ButtonProps, -} from "@mitodl/smoot-design" -import type { LearningResource } from "api" -import { ResourceTypeEnum, PlatformEnum } from "api" -import { - DEFAULT_RESOURCE_IMG, - getReadableResourceType, - useToggle, -} from "ol-utilities" -import { - RiBookmarkFill, - RiBookmarkLine, - RiCloseLargeLine, - RiExternalLinkLine, - RiFacebookFill, - RiLink, - RiLinkedinFill, - RiMenuAddLine, - RiShareLine, - RiTwitterXLine, - RiSparkling2Line, -} from "@remixicon/react" -import classNames from "classnames" -import InfoSection from "./InfoSection" -import type { User } from "api/hooks/user" -import VideoFrame from "./VideoFrame" -import { FeatureFlags } from "@/common/feature_flags" -import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react" -import AiChatSyllabus from "./AiChatSyllabus" -import { PostHogEvents } from "@/common/constants" - -const DRAWER_WIDTH = "900px" -const showChatClass = "show-chat" -const showChatSelector = `.${showChatClass} &` - -const Outer = styled.div({ - display: "flex", - flexDirection: "column", - flexGrow: 1, - width: "100%", - overflowX: "hidden", -}) - -const TitleContainer = styled.div({ - display: "flex", - position: "sticky", - justifyContent: "space-between", - top: "0", - padding: "24px 28px", - gap: "16px", - zIndex: 1, - backgroundColor: theme.custom.colors.white, - [theme.breakpoints.down("md")]: { - padding: "24px 16px", - }, -}) - -const CHAT_WIDTH = "400px" -const CHAT_RIGHT = "0px" - -const Container = styled.div(({ chatOpen }: { chatOpen: boolean }) => - chatOpen - ? { - paddingRight: `calc(${CHAT_WIDTH} + ${CHAT_RIGHT})`, - [theme.breakpoints.down("md")]: { - paddingRight: 0, - flexGrow: 1, - }, - } - : { - paddingRight: 0, - }, -) - -const TopContainer = styled.div({ - display: "flex", - flexDirection: "column", - padding: "0 28px 24px", - [theme.breakpoints.down("md")]: { - width: "auto", - padding: "0 16px 24px", - }, - [showChatSelector]: { - padding: "0 16px 24px 28px", - [theme.breakpoints.between("sm", "md")]: { - padding: "0 0 16px 24px", - }, - }, -}) - -const BottomContainer = styled.div({ - display: "flex", - flexDirection: "column", - gap: "32px", - borderTop: `1px solid ${theme.custom.colors.lightGray2}`, - background: theme.custom.colors.lightGray1, - "> div": { - width: "100%", - }, - padding: "32px 28px", - [theme.breakpoints.down("md")]: { - padding: "16px 0 16px 16px", - }, - [showChatSelector]: { - [theme.breakpoints.up("md")]: { - padding: "32px 16px 32px 28px", - }, - }, -}) - -const MainCol = styled.div(({ chatOpen }: { chatOpen: boolean }) => ({ - /** - * Note: - * Without a width specified, the carousels will overflow up to 100vw - */ - maxWidth: DRAWER_WIDTH, - flex: 1, - [theme.breakpoints.down("md")]: { - maxWidth: "100%", - display: chatOpen ? "none" : "inherit", - }, -})) - -/** - * Chat offset from top of drawer. - * 48px + 3rem = height of 1-line title plus padding. - * If title is two lines, the chat will overflow into title. - */ -const CHAT_TOP = "calc(48px + 3rem)" - -const ChatCol = styled.div({ - zIndex: 2, - position: "fixed", - top: CHAT_TOP, - right: CHAT_RIGHT, - height: `calc(100vh - ${CHAT_TOP})`, - flex: 1, - boxSizing: "border-box", - padding: "0 16px 16px 16px", - maxWidth: CHAT_WIDTH, - [theme.breakpoints.down("md")]: { - maxWidth: "100%", - position: "static", - }, - [theme.breakpoints.down("sm")]: { - height: "100%", - }, - ".MitAiChat--title": { - paddingTop: "0px", - }, -}) - -const ContentContainer = styled.div({ - display: "flex", - gap: "32px", - [theme.breakpoints.down("md")]: { - flexDirection: "column-reverse", - gap: "16px", - }, - [theme.breakpoints.up("md")]: { - [showChatSelector]: { - flexDirection: "column-reverse", - gap: "16px", - }, - }, -}) - -const ContentLeft = styled.div({ - display: "flex", - flexDirection: "column", - flexGrow: 1, - alignItems: "flex-start", - gap: "24px", - maxWidth: "100%", -}) - -const ContentRight = styled.div({ - display: "flex", - flexDirection: "column", - gap: "24px", -}) - -const ImageContainer = styled.div({ - width: "100%", -}) - -const Image = styled(NextImage)<{ aspect: number }>` - position: relative !important; - border-radius: 8px; - width: 100%; - aspect-ratio: ${({ aspect }) => aspect}; - object-fit: cover; -` - -const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({ - borderRadius: "8px", - paddingBottom: `${100 / aspect.aspect}%`, -})) - -const CallToAction = styled.div({ - display: "flex", - width: "350px", - padding: "16px", - flexDirection: "column", - gap: "10px", - borderRadius: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - boxShadow: "0px 2px 10px 0px rgba(37, 38, 43, 0.10)", - [theme.breakpoints.down("md")]: { - width: "100%", - padding: "0", - border: "none", - boxShadow: "none", - }, - [showChatSelector]: { - [theme.breakpoints.up("sm")]: { - width: "auto", - maxWidth: "424px", - }, - [theme.breakpoints.down("md")]: { - width: "100%", - padding: "0", - border: "none", - boxShadow: "none", - }, - }, -}) - -const ActionsContainer = styled.div({ - display: "flex", - flexDirection: "column", - gap: "16px", - width: "100%", -}) - -const PlatformContainer = styled.div({ - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: "16px", - alignSelf: "stretch", -}) - -const StyledLink = styled(ButtonLink)({ - textAlign: "center", - width: "100%", - [theme.breakpoints.down("sm")]: { - marginTop: "10px", - marginBottom: "10px", - }, -}) - -const Platform = styled.div({ - display: "flex", - justifyContent: "flex-end", - alignItems: "center", - gap: "16px", -}) - -const DescriptionContainer = styled.div({ - display: "flex", - flexDirection: "column", - gap: "4px", - width: "100%", -}) - -const Description = styled.p({ - ...theme.typography.body2, - color: theme.custom.colors.black, - margin: 0, - wordBreak: "break-word", - "> *": { - ":first-child": { - marginTop: 0, - }, - ":last-child": { - marginBottom: 0, - }, - ":empty": { - display: "none", - }, - }, -}) - -const DescriptionCollapsed = styled(Description)({ - display: "-webkit-box", - overflow: "hidden", - maxHeight: `calc(${theme.typography.body2.lineHeight} * 5)`, - "@supports (-webkit-line-clamp: 5)": { - maxHeight: "unset", - WebkitLineClamp: 5, - WebkitBoxOrient: "vertical", - }, -}) - -const DescriptionExpanded = styled(Description)({ - display: "block", -}) - -const StyledPlatformLogo = styled(PlatformLogo)({ - height: "26px", - maxWidth: "180px", -}) - -const OnPlatform = styled.span({ - ...theme.typography.body2, - color: theme.custom.colors.black, -}) - -const ButtonContainer = styled.div({ - display: "flex", - width: "100%", - gap: "8px", - flexGrow: 1, - justifyContent: "center", -}) - -const SelectableButton = styled(Button)<{ selected?: boolean }>((props) => [ - { - flex: 1, - whiteSpace: "nowrap", - }, - props.selected - ? { - backgroundColor: theme.custom.colors.red, - border: `1px solid ${theme.custom.colors.red}`, - color: theme.custom.colors.white, - "&:hover:not(:disabled)": { - backgroundColor: theme.custom.colors.red, - border: `1px solid ${theme.custom.colors.red}`, - color: theme.custom.colors.white, - }, - } - : {}, -]) - -const ShareContainer = styled.div({ - display: "flex", - flexDirection: "column", - alignItems: "center", - alignSelf: "stretch", - padding: "16px 0 8px 0", - gap: "12px", -}) - -const ShareLabel = styled(Typography)({ - ...theme.typography.body3, - color: theme.custom.colors.darkGray1, -}) - -const ShareButtonContainer = styled.div({ - display: "flex", - justifyContent: "center", - alignItems: "center", - alignSelf: "stretch", - gap: "16px", - a: { - height: "18px", - }, -}) - -const ShareLink = styled(Link)({ - color: theme.custom.colors.silverGrayDark, -}) - -const RedLinkIcon = styled(RiLink)({ - color: theme.custom.colors.red, -}) - -const CopyLinkButton = styled(Button)({ - flexGrow: 0, - flexBasis: "112px", -}) - -const TopCarouselContainer = styled.div({ - display: "flex", - flexDirection: "column", - paddingTop: "24px", -}) - -type LearningResourceExpandedProps = { - resourceId: number - titleId?: string - resource?: LearningResource - user?: User - shareUrl?: string - imgConfig: ImageConfig - topCarousels?: React.ReactNode[] - bottomCarousels?: React.ReactNode[] - inLearningPath?: boolean - inUserList?: boolean - onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] - onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] - closeDrawer?: () => void -} - -const CloseButton = styled(ActionButton)(({ theme }) => ({ - "&&&": { - flexShrink: 0, - backgroundColor: theme.custom.colors.lightGray2, - color: theme.custom.colors.black, - ["&:hover"]: { - backgroundColor: theme.custom.colors.red, - color: theme.custom.colors.white, - }, - }, -})) - -const CloseIcon = styled(RiCloseLargeLine)` - &&& { - width: 18px; - height: 18px; - } -` - -const TitleSection: React.FC<{ - titleId?: string - resource?: LearningResource - closeDrawer: () => void -}> = ({ resource, closeDrawer, titleId }) => { - const closeButton = ( - closeDrawer()} - aria-label="Close" - > - - - ) - - const type = resource ? ( - getReadableResourceType(resource.resource_type) - ) : ( - - ) - const title = resource ? ( - resource.title - ) : ( - - ) - - return ( - - - - {type} - - {title} - - {closeButton} - - ) -} - -const ImageSection: React.FC<{ - resource?: LearningResource - config: ImageConfig -}> = ({ resource, config }) => { - const aspect = config.width / config.height - if (resource?.resource_type === "video" && resource?.url) { - return ( - - ) - } else if (resource) { - return ( - - {resource?.image?.alt - - ) - } else { - return ( - - ) - } -} - -const getCallToActionText = (resource: LearningResource): string => { - const accessCourseMaterials = "Access Course Materials" - const watchOnYouTube = "Watch on YouTube" - const listenToPodcast = "Listen to Podcast" - const learnMore = "Learn More" - const callsToAction = { - [ResourceTypeEnum.Course]: learnMore, - [ResourceTypeEnum.Program]: learnMore, - [ResourceTypeEnum.LearningPath]: learnMore, - [ResourceTypeEnum.Video]: watchOnYouTube, - [ResourceTypeEnum.VideoPlaylist]: watchOnYouTube, - [ResourceTypeEnum.Podcast]: listenToPodcast, - [ResourceTypeEnum.PodcastEpisode]: listenToPodcast, - } - if ( - resource?.resource_type === ResourceTypeEnum.Video || - resource?.resource_type === ResourceTypeEnum.VideoPlaylist - ) { - // Video resources should always show "Watch on YouTube" as the CTA - return watchOnYouTube - } else { - if (resource?.platform?.code === PlatformEnum.Ocw) { - // Non-video OCW resources should show "Access Course Materials" as the CTA - return accessCourseMaterials - } else { - // Return the default CTA for the resource type - return callsToAction[resource?.resource_type] || learnMore - } - } -} - -const CallToActionButton: React.FC = ( - props, -) => { - return ( - - ) -} - -const CallToActionSection = ({ - imgConfig, - resource, - hide, - user, - shareUrl, - inUserList, - inLearningPath, - onAddToLearningPathClick, - onAddToUserListClick, -}: { - imgConfig: ImageConfig - resource?: LearningResource - hide?: boolean - user?: User - shareUrl?: string - inUserList?: boolean - inLearningPath?: boolean - onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] - onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] -}) => { - const posthog = usePostHog() - const [shareExpanded, setShareExpanded] = useState(false) - const [copyText, setCopyText] = useState("Copy Link") - if (hide) { - return null - } - - if (!resource) { - return ( - - - - - ) - } - const { platform } = resource! - const offeredBy = resource?.offered_by - const platformCode = - (offeredBy?.code as PlatformEnum) === PlatformEnum.Xpro - ? (offeredBy?.code as PlatformEnum) - : (platform?.code as PlatformEnum) - const platformImage = PLATFORM_LOGOS[platformCode]?.image - const cta = getCallToActionText(resource) - const addToLearningPathLabel = "Add to list" - const bookmarkLabel = "Bookmark" - const shareLabel = "Share" - const socialIconSize = 18 - const facebookShareBaseUrl = "https://www.facebook.com/sharer/sharer.php" - const twitterShareBaseUrl = "https://x.com/share" - const linkedInShareBaseUrl = "https://www.linkedin.com/sharing/share-offsite" - - return ( - - - - } - href={resource.url || ""} - onClick={() => { - if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) { - posthog.capture(PostHogEvents.CallToActionClicked, { resource }) - } - }} - data-ph-action="click-cta" - data-ph-offered-by={offeredBy?.code} - data-ph-resource-type={resource.resource_type} - data-ph-resource-id={resource.id} - > - {cta} - - {platformImage ? ( - - - on - - - - ) : null} - - {user?.is_learning_path_editor && ( - } - aria-label={addToLearningPathLabel} - onClick={(event) => - onAddToLearningPathClick - ? onAddToLearningPathClick(event, resource.id) - : null - } - > - {addToLearningPathLabel} - - )} - : } - aria-label={bookmarkLabel} - onClick={ - onAddToUserListClick - ? (event) => onAddToUserListClick?.(event, resource.id) - : undefined - } - > - {bookmarkLabel} - - } - aria-label={shareLabel} - onClick={() => setShareExpanded(!shareExpanded)} - > - {shareLabel} - - - {shareExpanded && shareUrl && ( - - Share a link to this Resource - { - const input = event.currentTarget.querySelector("input") - if (!input) return - input.select() - }} - /> - - - - - - - - - - - } - aria-label={copyText} - onClick={() => { - navigator.clipboard.writeText(shareUrl) - setCopyText("Copied!") - }} - > - {copyText} - - - - )} - - - ) -} - -const ResourceDescription = ({ resource }: { resource?: LearningResource }) => { - const firstRender = useRef(true) - const clampedOnFirstRender = useRef(false) - const [isClamped, setClamped] = useState(false) - const [isExpanded, setExpanded] = useState(false) - const descriptionRendered = useCallback((node: HTMLDivElement) => { - if (node !== null) { - const clamped = node.scrollHeight > node.clientHeight - setClamped(clamped) - if (firstRender.current) { - firstRender.current = false - clampedOnFirstRender.current = clamped - return - } - } - }, []) - const DescriptionText = isExpanded - ? DescriptionExpanded - : DescriptionCollapsed - if (!resource) { - return ( - <> - - - - - - - - ) - } - return ( - - - {(isClamped || clampedOnFirstRender.current) && ( - setExpanded(!isExpanded)} - > - {isExpanded ? "Show less" : "Show more"} - - )} - - ) -} - -const StyledAskButton = styled(Button)(({ theme }) => ({ - display: "flex", - flexDirection: "row", - gap: "2px", - minWidth: "auto", - paddingLeft: "16px", - paddingRight: "24px", - color: theme.custom.colors.darkGray2, - borderColor: theme.custom.colors.lightGray2, - svg: { - fill: theme.custom.colors.red, - }, - "&&": { - ":hover": { - borderColor: "transparent", - color: theme.custom.colors.white, - backgroundColor: theme.custom.colors.darkGray2, - p: { - color: theme.custom.colors.white, - }, - svg: { - fill: theme.custom.colors.white, - }, - }, - }, -})) - -const LearningResourceExpanded: React.FC = ({ - resourceId, - resource, - imgConfig, - user, - shareUrl, - topCarousels, - bottomCarousels, - inUserList, - inLearningPath, - titleId, - onAddToLearningPathClick, - onAddToUserListClick, - closeDrawer, -}) => { - const chatEnabled = true - // useFeatureFlagEnabled(FeatureFlags.LrDrawerChatbot) && - // resource?.resource_type === ResourceTypeEnum.Course - - const [chatExpanded, setChatExpanded] = useToggle(false) - const showChat = chatEnabled && chatExpanded - - const outerContainerRef = useRef(null) - useEffect(() => { - if (outerContainerRef.current && outerContainerRef.current.scrollTo) { - outerContainerRef.current.scrollTo(0, 0) - } - }, [resourceId]) - - useEffect(() => { - if (chatExpanded && resource && !chatEnabled) { - setChatExpanded.off() - } - }, [chatExpanded, resource, chatEnabled, setChatExpanded]) - - const chatOpen = !!(resource && showChat) - - return ( - - {})} - /> - - - - - - - - - - - {chatEnabled && !chatExpanded ? ( - } - > - - Need help? AskTIM - - - ) : null} - - - {topCarousels && ( - - {topCarousels?.map((carousel, index) => ( -
{carousel}
- ))} -
- )} -
- - {bottomCarousels?.map((carousel, index) => ( -
{carousel}
- ))} -
-
- - {chatOpen ? ( - - ) : null} - -
-
- ) -} - -export { LearningResourceExpanded, getCallToActionText } -export type { LearningResourceExpandedProps } diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.test.tsx deleted file mode 100644 index c61ca50794..0000000000 --- a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabus.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react" -import AiChatSyllabus from "./AiChatSyllabus" -import { renderWithProviders, screen } from "@/test-utils" -import { factories, setMockResponse, urls } from "api/test-utils" - -/** - * Note: This component is primarily tested in @mitodl/smoot-design. - * - * Here we just check a few config settings. - */ -describe("AiChatSyllabus", () => { - test("Greets authenticated user by name", async () => { - const resource = factories.learningResources.course() - const user = factories.user.user() - - // Sanity - expect(user.profile.name).toBeTruthy() - - setMockResponse.get(urls.userMe.get(), user) - renderWithProviders( - , - ) - - // byAll because there are two instances, one is SR-only in an aria-live area - // check for username and resource title - await screen.findAllByText( - new RegExp(`Hello ${user.profile.name}.*${resource.title}.*`), - ) - }) - - test("Greets anonymous user generically", async () => { - const resource = factories.learningResources.course() - - setMockResponse.get(urls.userMe.get(), {}, { code: 403 }) - renderWithProviders( - , - ) - - // byAll because there are two instances, one is SR-only in an aria-live area - // check for username and resource title - await screen.findAllByText(new RegExp(`Hello and.*${resource.title}.*`)) - }) -}) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.test.tsx new file mode 100644 index 0000000000..c06675376a --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.test.tsx @@ -0,0 +1,52 @@ +import React from "react" +import AiChatSyllabusSlideDown from "./AiChatSyllabusSlideDown" +import { renderWithProviders, screen, user } from "@/test-utils" +import { factories, setMockResponse, urls } from "api/test-utils" + +/** + * Note: This component is primarily tested in @mitodl/smoot-design. + * + * Here we just check a few config settings. + */ +describe("AiChatSyllabus", () => { + test("User clicks a starter. Greets authenticated user by name", async () => { + const resource = factories.learningResources.course() + const userMe = factories.user.user() + + // Sanity + expect(userMe.profile.name).toBeTruthy() + + setMockResponse.get(urls.userMe.get(), userMe) + renderWithProviders( + , + ) + + await user.click( + screen.getByRole("button", { name: "What is this course about?" }), + ) + + // byAll because there are two instances, one is SR-only in an aria-live area + // check for username and resource title + await screen.findAllByText( + new RegExp(`Hello ${userMe.profile.name}.*${resource.title}.*`), + ) + }) + + test("User enters a prompt. Greets anonymous user generically", async () => { + const resource = factories.learningResources.course() + + setMockResponse.get(urls.userMe.get(), {}, { code: 403 }) + renderWithProviders( + , + ) + + const input = screen.getByRole("textbox") + expect(input).toBeInTheDocument() + + await user.type(input, "tell me more{enter}") + + // byAll because there are two instances, one is SR-only in an aria-live area + // check for username and resource title + await screen.findAllByText(new RegExp(`Hello and.*${resource.title}.*`)) + }) +}) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx index 83071bb7fd..27d4d86550 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx @@ -10,7 +10,7 @@ import type { AiChatProps } from "@mitodl/smoot-design/ai" import { LearningResource } from "api" import { useUserMe } from "api/hooks/user" import type { User } from "api/hooks/user" -import AiChatWithEntryScreen from "../AiRecommendationBot/AiChatWithEntryScreen" +import AiChatWithEntryScreen from "../AiChat/AiChatWithEntryScreen" import { getCsrfToken } from "@/common/utils" const Container = styled.div() diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index 8fb492f516..e903c97426 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -2,8 +2,7 @@ import React, { useEffect, useRef, useState } from "react" import styled from "@emotion/styled" import { theme } from "ol-components" import type { ImageConfig, LearningResourceCardProps } from "ol-components" -import type { LearningResource } from "api" -import { ResourceTypeEnum } from "api" +import type { LearningResource, ResourceTypeEnum } from "api" import { useToggle } from "ol-utilities" import InfoSection from "./InfoSection" import type { User } from "api/hooks/user" From 1a0c12f357347ca78c443c04eb11b9b7fc8b3926 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 18:17:23 +0100 Subject: [PATCH 06/13] Fix enum import --- .../LearningResourceExpanded/LearningResourceExpanded.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index e903c97426..174ef62230 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from "react" import styled from "@emotion/styled" import { theme } from "ol-components" import type { ImageConfig, LearningResourceCardProps } from "ol-components" -import type { LearningResource, ResourceTypeEnum } from "api" +import { ResourceTypeEnum } from "api" +import type { LearningResource } from "api" import { useToggle } from "ol-utilities" import InfoSection from "./InfoSection" import type { User } from "api/hooks/user" From f74dcc905e3fcd9eec1c3e379bad9491fdc87124 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 18:43:57 +0100 Subject: [PATCH 07/13] Test updates --- .../page-components/AiChat/AiChatWithEntryScreen.tsx | 5 ++++- .../LearningResourceDrawer.test.tsx | 6 ++++-- .../LearningResourceExpanded.test.tsx | 11 ++++++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/frontends/main/src/page-components/AiChat/AiChatWithEntryScreen.tsx b/frontends/main/src/page-components/AiChat/AiChatWithEntryScreen.tsx index c96961a4c4..d95beddcc4 100644 --- a/frontends/main/src/page-components/AiChat/AiChatWithEntryScreen.tsx +++ b/frontends/main/src/page-components/AiChat/AiChatWithEntryScreen.tsx @@ -210,7 +210,10 @@ const AiChatWithEntryScreen = ({ ) : ( - + { - const actual = jest.requireActual("./LearningResourceExpanded") +jest.mock("../LearningResourceExpanded/LearningResourceExpanded", () => { + const actual = jest.requireActual( + "../LearningResourceExpanded/LearningResourceExpanded", + ) return { ...actual, LearningResourceExpanded: jest.fn(actual.LearningResourceExpanded), diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx index eb017ab22d..159d82992f 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.test.tsx @@ -368,7 +368,7 @@ describe.each([true, false])( setup({ resource }) const chatButton = screen.queryByRole("button", { - name: "Need help? Ask TIM", + name: "Ask TIM about this course", }) const shouldBeVisible = enabled && resourceType === ResourceTypeEnum.Course @@ -390,9 +390,14 @@ describe.each([true, false])( const { rerender } = setup({ resource: course1 }) await user.click( - screen.getByRole("button", { name: "Need help? Ask TIM" }), + screen.getByRole("button", { name: "Ask TIM about this course" }), ) - const dataTestId = "ai-chat-syllabus" + + const input = screen.getByRole("textbox") + expect(input).toBeInTheDocument() + await user.type(input, "tell me more{enter}") + + const dataTestId = "ai-chat-screen" expect(screen.getByTestId(dataTestId)).toBeInTheDocument() rerender({ resource: course2 }) expect(screen.getByTestId(dataTestId)).toBeInTheDocument() From f883bee521345f44fdde7c90805548e1f7b32ba4 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 20:41:56 +0100 Subject: [PATCH 08/13] Prevent tab to masked content when the chat is open (accessibility) --- .../LearningResourceExpanded/LearningResourceExpanded.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index 174ef62230..a16170fc26 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -191,7 +191,7 @@ const LearningResourceExpanded: React.FC = ({ /> ) : null} - + From 823d9555a86d01c07e47ee143c69406a8557a036 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 22:10:48 +0100 Subject: [PATCH 09/13] Limit scroll to content area when chat enabled --- .../AiChatSyllabusSlideDown.tsx | 6 +-- .../LearningResourceExpanded.tsx | 41 +++++++++---------- .../LearningResourceExpanded/TitleSection.tsx | 2 +- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx index 27d4d86550..5097a83d50 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx @@ -13,8 +13,6 @@ import type { User } from "api/hooks/user" import AiChatWithEntryScreen from "../AiChat/AiChatWithEntryScreen" import { getCsrfToken } from "@/common/utils" -const Container = styled.div() - const SlideDown = styled.div<{ open: boolean }>(({ theme, open }) => ({ position: "absolute", top: open ? "0" : "-100%", @@ -132,7 +130,7 @@ const AiChatSyllabusSlideDown = ({ if (!resource) return null return ( - + <> - + ) } diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index a16170fc26..0e687198ca 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -16,35 +16,34 @@ import AiSyllabusBotSlideDown from "./AiChatSyllabusSlideDown" const DRAWER_WIDTH = "900px" -const Outer = styled.div<{ chatExpanded: boolean }>(({ chatExpanded }) => ({ +const Outer = styled.div<{ chatExpanded: boolean }>({ display: "flex", flexDirection: "column", flexGrow: 1, width: "100%", overflowX: "hidden", + scrollbarGutter: "stable", minWidth: DRAWER_WIDTH, [theme.breakpoints.down("md")]: { minWidth: "100%", }, - ...(chatExpanded && { - "&::-webkit-scrollbar": { - display: "none", - }, - msOverflowStyle: "none", - scrollbarWidth: "none", - }), -})) - -const ContentSection = styled.div({ - display: "flex", - flexDirection: "column", - flexGrow: 1, - position: "relative", }) -const ChatLayer = styled("div")<{ top: number; chatExpanded: boolean }>( +const ContentSection = styled.div<{ chatEnabled?: boolean }>( + ({ chatEnabled }) => ({ + display: "flex", + flexDirection: "column", + flexGrow: 1, + position: "relative", + overflowY: chatEnabled ? "scroll" : "visible", + marginTop: chatEnabled ? "22px" : 0, + }), +) + +const ChatLayer = styled("div")<{ top: number; chatExpanded?: boolean }>( ({ top, chatExpanded }) => ({ zIndex: 2, + overflow: "hidden", position: "absolute", top, bottom: 0, @@ -54,14 +53,14 @@ const ChatLayer = styled("div")<{ top: number; chatExpanded: boolean }>( }), ) -const TopContainer = styled.div<{ chatEnabled: boolean }>( +const TopContainer = styled.div<{ chatEnabled?: boolean }>( ({ chatEnabled }) => ({ display: "flex", flexDirection: "column", - padding: chatEnabled ? "70px 28px 24px" : "0 28px 24px", + padding: chatEnabled ? "48px 28px 24px" : "0 28px 24px", [theme.breakpoints.down("md")]: { width: "auto", - padding: chatEnabled ? "72px 16px 24px" : "0 16px 24px", + padding: chatEnabled ? "50px 16px 24px" : "0 16px 24px", }, }), ) @@ -191,8 +190,8 @@ const LearningResourceExpanded: React.FC = ({ /> ) : null} - - + + diff --git a/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx index 92584438eb..9a33e3a82d 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx @@ -13,7 +13,7 @@ const TitleContainer = styled.div({ top: "0", padding: "24px 28px", gap: "16px", - zIndex: 3, + zIndex: 1, backgroundColor: theme.custom.colors.white, [theme.breakpoints.down("md")]: { padding: "24px 16px", From af081b0e16fd753f5415a0ca30561b5522def46b Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 22:24:37 +0100 Subject: [PATCH 10/13] Remove scrollbar-gutter not needed --- .../LearningResourceExpanded/LearningResourceExpanded.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index 0e687198ca..238a89c239 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -22,7 +22,6 @@ const Outer = styled.div<{ chatExpanded: boolean }>({ flexGrow: 1, width: "100%", overflowX: "hidden", - scrollbarGutter: "stable", minWidth: DRAWER_WIDTH, [theme.breakpoints.down("md")]: { minWidth: "100%", From 6ceba508b34d64e95f833e57db8224e4cd1a2a54 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 20 Feb 2025 22:45:14 +0100 Subject: [PATCH 11/13] Rewind limit scroll to content area when chat enabled This reverts commit 823d9555a86d01c07e47ee143c69406a8557a036. --- .../AiChatSyllabusSlideDown.tsx | 6 ++- .../LearningResourceExpanded.tsx | 40 ++++++++++--------- .../LearningResourceExpanded/TitleSection.tsx | 2 +- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx index 5097a83d50..27d4d86550 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx @@ -13,6 +13,8 @@ import type { User } from "api/hooks/user" import AiChatWithEntryScreen from "../AiChat/AiChatWithEntryScreen" import { getCsrfToken } from "@/common/utils" +const Container = styled.div() + const SlideDown = styled.div<{ open: boolean }>(({ theme, open }) => ({ position: "absolute", top: open ? "0" : "-100%", @@ -130,7 +132,7 @@ const AiChatSyllabusSlideDown = ({ if (!resource) return null return ( - <> + - + ) } diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index 238a89c239..a16170fc26 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -16,7 +16,7 @@ import AiSyllabusBotSlideDown from "./AiChatSyllabusSlideDown" const DRAWER_WIDTH = "900px" -const Outer = styled.div<{ chatExpanded: boolean }>({ +const Outer = styled.div<{ chatExpanded: boolean }>(({ chatExpanded }) => ({ display: "flex", flexDirection: "column", flexGrow: 1, @@ -26,23 +26,25 @@ const Outer = styled.div<{ chatExpanded: boolean }>({ [theme.breakpoints.down("md")]: { minWidth: "100%", }, -}) - -const ContentSection = styled.div<{ chatEnabled?: boolean }>( - ({ chatEnabled }) => ({ - display: "flex", - flexDirection: "column", - flexGrow: 1, - position: "relative", - overflowY: chatEnabled ? "scroll" : "visible", - marginTop: chatEnabled ? "22px" : 0, + ...(chatExpanded && { + "&::-webkit-scrollbar": { + display: "none", + }, + msOverflowStyle: "none", + scrollbarWidth: "none", }), -) +})) + +const ContentSection = styled.div({ + display: "flex", + flexDirection: "column", + flexGrow: 1, + position: "relative", +}) -const ChatLayer = styled("div")<{ top: number; chatExpanded?: boolean }>( +const ChatLayer = styled("div")<{ top: number; chatExpanded: boolean }>( ({ top, chatExpanded }) => ({ zIndex: 2, - overflow: "hidden", position: "absolute", top, bottom: 0, @@ -52,14 +54,14 @@ const ChatLayer = styled("div")<{ top: number; chatExpanded?: boolean }>( }), ) -const TopContainer = styled.div<{ chatEnabled?: boolean }>( +const TopContainer = styled.div<{ chatEnabled: boolean }>( ({ chatEnabled }) => ({ display: "flex", flexDirection: "column", - padding: chatEnabled ? "48px 28px 24px" : "0 28px 24px", + padding: chatEnabled ? "70px 28px 24px" : "0 28px 24px", [theme.breakpoints.down("md")]: { width: "auto", - padding: chatEnabled ? "50px 16px 24px" : "0 16px 24px", + padding: chatEnabled ? "72px 16px 24px" : "0 16px 24px", }, }), ) @@ -189,8 +191,8 @@ const LearningResourceExpanded: React.FC = ({ /> ) : null} - - + + diff --git a/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx index 9a33e3a82d..92584438eb 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/TitleSection.tsx @@ -13,7 +13,7 @@ const TitleContainer = styled.div({ top: "0", padding: "24px 28px", gap: "16px", - zIndex: 1, + zIndex: 3, backgroundColor: theme.custom.colors.white, [theme.breakpoints.down("md")]: { padding: "24px 16px", From 1b39a7d359e6fc72f7d843439daf907a02606174 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Thu, 20 Feb 2025 16:26:45 -0500 Subject: [PATCH 12/13] avoid layout shift, show full scrollbar --- .../LearningResourceExpanded.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index a16170fc26..9bb7bcd520 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -23,15 +23,12 @@ const Outer = styled.div<{ chatExpanded: boolean }>(({ chatExpanded }) => ({ width: "100%", overflowX: "hidden", minWidth: DRAWER_WIDTH, + scrollbarGutter: "stable", [theme.breakpoints.down("md")]: { minWidth: "100%", }, ...(chatExpanded && { - "&::-webkit-scrollbar": { - display: "none", - }, - msOverflowStyle: "none", - scrollbarWidth: "none", + overflow: "hidden", }), })) @@ -51,6 +48,8 @@ const ChatLayer = styled("div")<{ top: number; chatExpanded: boolean }>( left: 0, right: 0, pointerEvents: chatExpanded ? "auto" : "none", + overflow: "hidden", + scrollbarGutter: "stable", }), ) From cd1fe90876afef798ea61d20787d54f9558eaca1 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Fri, 21 Feb 2025 17:04:03 +0100 Subject: [PATCH 13/13] Make chat inert when closed. Fix pointer events stopped at opener container --- .../LearningResourceExpanded/AiChatSyllabusSlideDown.tsx | 5 ++--- .../LearningResourceExpanded/LearningResourceExpanded.tsx | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx index 27d4d86550..3b94c148ce 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/AiChatSyllabusSlideDown.tsx @@ -25,7 +25,6 @@ const SlideDown = styled.div<{ open: boolean }>(({ theme, open }) => ({ })) const Opener = styled.div(({ theme }) => ({ - pointerEvents: "auto", position: "relative", ":after": { content: "''", @@ -147,7 +146,7 @@ const AiChatSyllabusSlideDown = ({ {open ? : } - + ({ collection_name: "content_files", message: messages[messages.length - 1].content, - course_id: resource?.readable_id, + course_id: resource.readable_id, }), }} /> diff --git a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx index 9bb7bcd520..1dc1db6d70 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -49,7 +49,7 @@ const ChatLayer = styled("div")<{ top: number; chatExpanded: boolean }>( right: 0, pointerEvents: chatExpanded ? "auto" : "none", overflow: "hidden", - scrollbarGutter: "stable", + scrollbarGutter: chatExpanded ? "auto" : "stable", }), )