diff --git a/frontends/main/public/images/Vector.svg b/frontends/main/public/images/Vector.svg new file mode 100644 index 0000000000..f5f64d5881 --- /dev/null +++ b/frontends/main/public/images/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontends/main/public/images/backgrounds/background_podcast.png b/frontends/main/public/images/backgrounds/background_podcast.png new file mode 100644 index 0000000000..c366b768e0 Binary files /dev/null and b/frontends/main/public/images/backgrounds/background_podcast.png differ diff --git a/frontends/main/public/images/backgrounds/background_podcast_mobile.png b/frontends/main/public/images/backgrounds/background_podcast_mobile.png new file mode 100644 index 0000000000..968554a51c Binary files /dev/null and b/frontends/main/public/images/backgrounds/background_podcast_mobile.png differ diff --git a/frontends/main/public/images/circles.svg b/frontends/main/public/images/circles.svg new file mode 100644 index 0000000000..98a1ada293 --- /dev/null +++ b/frontends/main/public/images/circles.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontends/main/public/images/rectangle_small.svg b/frontends/main/public/images/rectangle_small.svg new file mode 100644 index 0000000000..19dc6bcf3d --- /dev/null +++ b/frontends/main/public/images/rectangle_small.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastPage.tsx new file mode 100644 index 0000000000..b865d7776a --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastPage.tsx @@ -0,0 +1,27 @@ +"use client" + +import React from "react" +import PodcastPageTemplate from "./PodcastPageTemplate" +import { useLearningResourcesList } from "api/hooks/learningResources" +import { LearningResource, PodcastEpisodeResource } from "api" + +const PodcastPage: React.FC = () => { + const { data: episodesData } = useLearningResourcesList({ + resource_type: ["podcast_episode"], + limit: 4, + sortby: "new", + }) + + const { data: podcastsData } = useLearningResourcesList({ + resource_type: ["podcast"], + limit: 15, + sortby: "new", + }) + + const episodes = (episodesData?.results ?? []) as PodcastEpisodeResource[] + const podcasts = (podcastsData?.results ?? []) as LearningResource[] + + return +} + +export default PodcastPage diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastPageTemplate.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastPageTemplate.tsx new file mode 100644 index 0000000000..2890675bf5 --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastPageTemplate.tsx @@ -0,0 +1,339 @@ +import React from "react" +import { + styled, + Container, + Breadcrumbs, + Stack, + BannerBackground, + Grid2, +} from "ol-components" +import { LearningResource, PodcastEpisodeResource, ResourceTypeEnum } from "api" +import { RiMailLine } from "@remixicon/react" +import { HOME as HOME_URL } from "../../common/urls" +import RecentEpisodesPanel from "./RecentEpisodesPanel" +import PodcastSubscribePopover from "./PodcastSubscribePopover" +import { ResourceCard } from "@/page-components/ResourceCard/ResourceCard" +const DEFAULT_BACKGROUND_IMAGE_URL = + "/images/backgrounds/background_podcast.png" +const DEFAULT_BACKGROUND_IMAGE_MOBILE_URL = + "/images/backgrounds/background_podcast_mobile.png" + +const StyledBannerBackground = styled(BannerBackground)(({ theme }) => ({ + position: "relative", + overflow: "hidden", + padding: "48px 0 64px 0", + [theme.breakpoints.down("md")]: { + padding: "40px 0 16px 0", + }, + [theme.breakpoints.down("sm")]: { + backgroundImage: `linear-gradient(rgba(0 0 0 / 50%), rgba(0 0 0 / 50%)), url('${DEFAULT_BACKGROUND_IMAGE_MOBILE_URL}')`, + backgroundAttachment: "scroll", + backgroundPosition: "center center", + backgroundSize: "cover", + }, +})) + +const SubscriptionButtonContainer = styled.div(({ theme }) => ({ + position: "relative", + minHeight: "38px", + display: "flex", + marginTop: "32px", + [theme.breakpoints.down("sm")]: { + marginTop: "24px", + }, +})) + +const BackgroundVector = styled("img")(({ theme }) => ({ + position: "absolute", + top: 0, + right: 0, + width: 325, + maxWidth: "45%", + height: "auto", + pointerEvents: "none", + zIndex: -99, + transform: "translate(60%, -15%)", + [theme.breakpoints.down("md")]: { + width: 100, + maxWidth: "55%", + transform: "none", + }, + [theme.breakpoints.down("md")]: { + width: "200px", + top: "0px", + right: "100px", + }, + [theme.breakpoints.down("sm")]: { + display: "none", + }, +})) + +const BackgroundVectorMobile = styled("img")(({ theme }) => ({ + position: "absolute", + top: -25, + right: 10, + width: "auto", + height: "auto", + pointerEvents: "none", + zIndex: -99, + display: "none", + [theme.breakpoints.down("sm")]: { + display: "block", + }, +})) + +const BannerForeground = styled.div({ + position: "relative", + zIndex: 1, +}) + +const BackgroundCircles = styled("img")(({ theme }) => ({ + position: "absolute", + bottom: 0, + left: 0, + width: 800, + height: "auto", + pointerEvents: "none", + zIndex: 0, + transform: "translate(-75%, 57%)", + [theme.breakpoints.down("md")]: { + width: 320, + }, +})) + +const BannerTitle = styled("h1")(({ theme }) => ({ + color: theme.custom.colors.lightRed, + ...theme.typography.h1, + margin: 0, + [theme.breakpoints.down("md")]: { + ...theme.typography.h2, + }, + [theme.breakpoints.down("sm")]: { + ...theme.typography.h3, + marginTop: "8px", + }, +})) + +const BannerDescription = styled("p")(({ theme }) => ({ + color: theme.custom.colors.white, + ...theme.typography.h3, + margin: 0, + [theme.breakpoints.down("md")]: { + ...theme.typography.h2, + }, + [theme.breakpoints.down("sm")]: { + ...theme.typography.h5, + marginTop: "8px", + }, +})) + +const BannerContent = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + }, +})) + +const LeftGrid = styled(Grid2)({ + alignItems: "center", + display: "flex", +}) + +const ContentContainer = styled(Container)(({ theme }) => ({ + position: "relative", + [theme.breakpoints.down("md")]: { + paddingLeft: "24px", + paddingRight: "24px", + }, +})) + +const LearningResourceCard = styled(ResourceCard)(({ theme }) => ({ + marginBottom: "32px", + [theme.breakpoints.down("sm")]: { + marginBottom: 0, + gap: "16px", + }, +})) + +const PodcastsSection = styled.div(({ theme }) => ({ + padding: "48px 0 64px", + backgroundColor: theme.custom.colors.white, + [theme.breakpoints.down("md")]: { + padding: "32px 0 40px", + }, + [theme.breakpoints.down("sm")]: { + padding: "24px 0 40px", + }, +})) + +const BelowMdOnly = styled.div(({ theme }) => ({ + [theme.breakpoints.up("md")]: { + display: "none", + }, +})) + +const AboveMdOnly = styled.div(({ theme }) => ({ + [theme.breakpoints.down("md")]: { + display: "none", + }, +})) + +const MobilePodcastList = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "12px", + padding: `0 ${theme.spacing(2)}`, + [theme.breakpoints.down("sm")]: { + gap: "16px", + }, +})) + +const PodcastsGrid = styled.div(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "repeat(5, 1fr)", + gap: "24px", + [theme.breakpoints.down("lg")]: { + gridTemplateColumns: "repeat(4, 1fr)", + }, + [theme.breakpoints.down("md")]: { + gridTemplateColumns: "repeat(4, 1fr)", + gap: "16px", + }, + [theme.breakpoints.down("sm")]: { + gridTemplateColumns: "repeat(2, 1fr)", + gap: "12px", + }, +})) + +interface PodcastPageTemplateProps { + children?: React.ReactNode + episodes: PodcastEpisodeResource[] + podcasts: LearningResource[] +} + +const PodcastPageTemplate: React.FC = ({ + episodes, + podcasts, +}) => { + return ( + <> + + + + + + + + {podcasts.length > 0 ? ( + + + + {podcasts.map((resource) => ( + + ))} + + + + + + {podcasts.map((resource) => ( + + ))} + + + + + ) : null} + + ) +} + +export default PodcastPageTemplate diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx new file mode 100644 index 0000000000..152e329ef8 --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx @@ -0,0 +1,545 @@ +"use client" + +import React, { + forwardRef, + useImperativeHandle, + useRef, + useState, + useEffect, +} from "react" +import { styled, Typography, LoadingSpinner } from "ol-components" +import { + RiPlayCircleFill, + RiPauseCircleFill, + RiPlayCircleLine, + RiPauseCircleLine, + RiReplay10Line, + RiForward30Line, + RiCloseLine, +} from "@remixicon/react" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type PodcastTrack = { + audioUrl: string + title: string + podcastName: string +} + +export type PodcastPlayerHandle = { + togglePlayPause: () => Promise +} + +type PodcastPlayerProps = { + track: PodcastTrack + onClose: () => void + onPlayStateChange?: (isPlaying: boolean) => void +} + +// ─── Styled components ──────────────────────────────────────────────────────── + +const PlayerBar = styled.div(({ theme }) => ({ + position: "fixed", + bottom: 0, + left: 0, + right: 0, + zIndex: theme.zIndex.appBar + 10, + display: "flex", + alignItems: "center", + gap: "24px", + padding: "16px 32px", + background: "linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)", + borderTop: `2px solid ${theme.custom.colors.mitRed}`, + boxShadow: "0 -4px 16px rgba(0,0,0,0.12)", + [theme.breakpoints.down("sm")]: { + display: "none", + }, +})) + +// ─── Mobile card styles ─────────────────────────────────────────────────────── + +const MobileCard = styled.div(({ theme }) => ({ + position: "fixed", + bottom: 0, + left: 0, + right: 0, + zIndex: theme.zIndex.appBar + 10, + display: "none", + flexDirection: "column", + gap: "16px", + padding: "24px", + background: "linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)", + borderTop: `2px solid ${theme.custom.colors.mitRed}`, + borderRadius: "12px 12px 0 0", + boxShadow: "0 -4px 24px rgba(0,0,0,0.15)", + [theme.breakpoints.down("sm")]: { + display: "flex", + }, +})) + +const MobileTopRow = styled.div({ + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "12px", +}) + +const MobileTitleArea = styled.div({ + display: "flex", + flexDirection: "column", + gap: "2px", + flex: 1, + minWidth: 0, +}) + +const MobileControls = styled.div({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "32px", +}) + +const MobileSkipButton = styled.button(({ theme }) => ({ + background: "none", + border: "none", + cursor: "pointer", + padding: "8px", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "2px", + color: theme.custom.colors.silverGray, + "&:hover": { color: theme.custom.colors.mitRed }, +})) + +const MobilePlayPauseButton = styled.button(({ theme }) => ({ + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + display: "flex", + alignItems: "center", + color: theme.custom.colors.mitRed, + "&:hover": { opacity: 0.8 }, +})) + +const MobileProgressRow = styled.div({ + display: "flex", + alignItems: "center", + gap: "8px", +}) + +const TrackInfo = styled.div({ + display: "flex", + flexDirection: "column", + minWidth: 0, + width: 220, + flexShrink: 0, +}) + +const Divider = styled.div(({ theme }) => ({ + width: "1px", + height: "40px", + backgroundColor: theme.custom.colors.lightGray2, + flexShrink: 0, +})) + +const Controls = styled.div({ + display: "flex", + alignItems: "center", + gap: "12px", + flexShrink: 0, +}) + +const IconButton = styled.button(({ theme }) => ({ + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + display: "flex", + alignItems: "center", + color: theme.custom.colors.silverGray, + "&:hover": { color: theme.custom.colors.mitRed }, +})) + +const PlayPauseButton = styled.button(({ theme }) => ({ + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + display: "flex", + alignItems: "center", + color: theme.custom.colors.mitRed, + "&:hover": { opacity: 0.8 }, +})) + +const TimeLabel = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + whiteSpace: "nowrap", + flexShrink: 0, + minWidth: "38px", + textAlign: "center", +})) + +const TrackTitle = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.black, +})) + +const ProgressWrapper = styled.div({ + flex: 1, + display: "flex", + alignItems: "center", + gap: "12px", + minWidth: 0, +}) + +const ProgressTrack = styled.div(({ theme }) => ({ + flex: 1, + height: "6px", + borderRadius: "3px", + backgroundColor: theme.custom.colors.lightGray2, + position: "relative", + cursor: "pointer", + "&:hover .thumb": { opacity: 1 }, +})) + +const ProgressFill = styled.div<{ percent: number }>(({ theme, percent }) => ({ + height: "100%", + borderRadius: "3px", + width: `${percent}%`, + backgroundColor: theme.custom.colors.mitRed, + position: "relative", +})) + +const SpeedButton = styled.button(({ theme }) => ({ + background: "white", + border: `1px solid ${theme.custom.colors.silverGrayLight}`, + backgroundColor: theme.custom.colors.lightGray1, + borderRadius: "4px", + padding: "2px 8px", + cursor: "pointer", + ...theme.typography.body3, + color: theme.custom.colors.darkGray2, + flexShrink: 0, + "&:hover": { + borderColor: theme.custom.colors.mitRed, + color: theme.custom.colors.mitRed, + }, +})) + +const CloseButton = styled.button(({ theme }) => ({ + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + display: "flex", + alignItems: "center", + color: theme.custom.colors.darkGray2, + marginLeft: "auto", + flexShrink: 0, + "&:hover": { color: theme.custom.colors.mitRed }, +})) + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] + +const formatTime = (seconds: number): string => { + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +const PodcastPlayer = forwardRef( + ({ track, onClose, onPlayStateChange }, ref) => { + const audioRef = useRef(null) + const [isPlaying, setIsPlaying] = useState(false) + const [isBuffering, setIsBuffering] = useState(true) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [speedIndex, setSpeedIndex] = useState(1) // default 1x + + // Auto-play when a new track is loaded + useEffect(() => { + setCurrentTime(0) + setIsPlaying(false) + setIsBuffering(true) + const audio = audioRef.current + if (!audio) return + audio.load() + audio + .play() + .then(() => setIsPlaying(true)) + .catch(() => {}) + }, [track.audioUrl]) + + const handlePlayPause = async () => { + const audio = audioRef.current + if (!audio) return + if (isPlaying) { + audio.pause() + setIsPlaying(false) + } else { + try { + await audio.play() + setIsPlaying(true) + } catch { + setIsPlaying(false) + } + } + } + + useImperativeHandle(ref, () => ({ + togglePlayPause: handlePlayPause, + })) + + useEffect(() => { + onPlayStateChange?.(isPlaying) + }, [isPlaying, onPlayStateChange]) + + const handleSkip = (seconds: number) => { + const audio = audioRef.current + if (!audio) return + audio.currentTime = Math.max( + 0, + Math.min(audio.currentTime + seconds, duration), + ) + } + + const handleSpeedCycle = () => { + const nextIndex = (speedIndex + 1) % SPEED_OPTIONS.length + setSpeedIndex(nextIndex) + if (audioRef.current) { + audioRef.current.playbackRate = SPEED_OPTIONS[nextIndex] + } + } + + const handleProgressClick = (e: React.MouseEvent) => { + const audio = audioRef.current + if (!audio || !duration) return + const rect = e.currentTarget.getBoundingClientRect() + const ratio = (e.clientX - rect.left) / rect.width + audio.currentTime = ratio * duration + } + + const percent = duration ? (currentTime / duration) * 100 : 0 + return ( + <> + {/* Shared audio element */} + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +