diff --git a/src/features/home/features/upcoming/components/UpcomingEpisodesCollection.tsx b/src/features/home/features/upcoming/components/UpcomingEpisodesCollection.tsx index bacec00..e420f55 100644 --- a/src/features/home/features/upcoming/components/UpcomingEpisodesCollection.tsx +++ b/src/features/home/features/upcoming/components/UpcomingEpisodesCollection.tsx @@ -19,10 +19,17 @@ const UpcomingEpisodesCollection = ({ episodes, loading }: Props) => ( > {episodes.map((episode) => ( - - {DateTime.fromISO(episode.airDate).toFormat('ccc, MMM d')} - - + + {DateTime.fromISO(episode.airDate).toFormat('ccc, MMM d')} + + } + /> ))} diff --git a/src/features/onboarding/components/GenresOnboarding.tsx b/src/features/onboarding/components/GenresOnboarding.tsx index cc1d902..0a3568e 100644 --- a/src/features/onboarding/components/GenresOnboarding.tsx +++ b/src/features/onboarding/components/GenresOnboarding.tsx @@ -11,12 +11,7 @@ const GenresOnboarding = () => { const canRender = genres.length; return ( - + {canRender ? ( genres.map(({ name, externalId }) => { const isSelected = selectedGenres.includes(externalId); diff --git a/src/features/onboarding/components/OnboardingPage.tsx b/src/features/onboarding/components/OnboardingPage.tsx index 4b8ea60..073c217 100644 --- a/src/features/onboarding/components/OnboardingPage.tsx +++ b/src/features/onboarding/components/OnboardingPage.tsx @@ -1,10 +1,39 @@ import React from 'react'; +import type { TypographyProps } from '@mui/material'; +import { Box, Typography } from '@mui/material'; +import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted'; +import MovieIcon from '@mui/icons-material/Movie'; +import { styled } from '@mui/material/styles'; import PageWrapper from '../../../components/PageWrapper'; -import VerticalStepper from './VerticalStepper'; +import GenresStep from './Steps/GenresStep'; +import ShowsStep from './Steps/ShowsStep'; + +const Title = styled(({ ...props }: TypographyProps) => ( + +))` + display: flex; + align-items: center; +`; const OnboardingPage = () => ( - - + + + Let's personalize your profile + + + + + Select your favorite genres + + + + + + + Add some TV Shows to your profile + + + ); diff --git a/src/features/onboarding/components/Steps/ShowsStep.tsx b/src/features/onboarding/components/Steps/ShowsStep.tsx index a97f687..39e5a44 100644 --- a/src/features/onboarding/components/Steps/ShowsStep.tsx +++ b/src/features/onboarding/components/Steps/ShowsStep.tsx @@ -1,18 +1,9 @@ -import { Box, Typography } from '@mui/material'; import ShowsOnboarding from '../ShowsOnboarding'; import ShowsOnboardingProvider from '../../contexts/ShowsOnboardingContext'; -import PartialWatchlistProvider from '../../../watchlist/contexts/PartialWatchlistContext'; const ShowsStep = () => ( - - - - We found some popular items based on your favorite genres. - - - - + ); diff --git a/src/features/onboarding/components/VerticalStepper.tsx b/src/features/onboarding/components/VerticalStepper.tsx deleted file mode 100644 index 611b55f..0000000 --- a/src/features/onboarding/components/VerticalStepper.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { ReactNode, useMemo, useState } from 'react'; -import { - Box, - Button, - Step, - StepButton, - StepContent, - Stepper, - Typography, -} from '@mui/material'; -import { inc } from 'ramda'; -import { useNavigate } from 'react-router-dom'; -import LogoText from '../../logo/components/LogoText'; -import { RoutePath } from '../../router/constants'; -import WelcomeStep from './Steps/WelcomeStep'; -import GenresStep from './Steps/GenresStep'; -import ShowsStep from './Steps/ShowsStep'; - -interface StepType { - label: string; - title: ReactNode; - content: ReactNode; -} - -const VerticalStepper = () => { - const navigate = useNavigate(); - const [activeStep, setActiveStep] = useState(0); - const steps = useMemo( - () => [ - { - label: 'Welcome', - title: ( - - Welcome to - - - ), - content: , - }, - { - label: 'Genres', - title: 'Select your favorite genres', - content: , - }, - { - label: 'TV Shows', - title: 'Add some TV Shows to your profile', - content: , - }, - ], - [], - ); - - const handleNext = () => { - if (activeStep === steps.length - 1) { - navigate(RoutePath.Home); - } else { - setActiveStep(inc); - } - }; - - return ( - - - {steps.map(({ label, title, content }, index) => ( - - setActiveStep(index)}> - {label} - - - - - {title} - - {content} - - - - - {index === steps.length - 1 ? 'Finish' : 'Next step'} - - - - - - ))} - - - ); -}; - -export default VerticalStepper; diff --git a/src/features/shows/components/TallShowCard.tsx b/src/features/shows/components/TallShowCard.tsx index d7fbd80..5241bde 100644 --- a/src/features/shows/components/TallShowCard.tsx +++ b/src/features/shows/components/TallShowCard.tsx @@ -1,15 +1,14 @@ -import { useContext } from 'react'; import { PartialShow, Status } from '../../../generated/graphql'; -import { PartialWatchlistContext } from '../../watchlist/contexts/PartialWatchlistContext'; -import WatchlistAction from './WatchlistAction'; +import useWatchlistActions from '../hooks/useWatchlistActions'; import TallCard from './base/TallCard'; +import WatchlistOverlayAction from './WatchlistOverlayAction'; interface Props { show: PartialShow; } const TallShowCard = ({ show }: Props) => { - const { upsertWatchlistItem } = useContext(PartialWatchlistContext); + const { upsertWatchlistItem } = useWatchlistActions(); const onClick = (status: Status) => { const showId = show.externalId; @@ -18,14 +17,9 @@ const TallShowCard = ({ show }: Props) => { }; return ( - - - > - } - /> + + + ); }; diff --git a/src/features/shows/components/WatchlistAction.tsx b/src/features/shows/components/WatchlistAction.tsx deleted file mode 100644 index 59ad649..0000000 --- a/src/features/shows/components/WatchlistAction.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; -import BookmarkIcon from '@mui/icons-material/Bookmark'; -import { alpha, styled } from '@mui/material/styles'; -import { Status } from '../../../generated/graphql'; -import ActionButton from './base/ActionButton'; - -interface Props { - onClick: (status: Status) => void; - status: Status; -} - -const STATUS_TOGGLE_MAP = { - [Status.InWatchlist]: Status.None, - [Status.None]: Status.InWatchlist, - [Status.StoppedWatching]: Status.InWatchlist, -} as const; - -const StyledActionButton = styled(ActionButton)` - background-color: ${({ theme }) => alpha(theme.palette.common.white, 0.2)}; - &:hover { - background-color: ${({ theme }) => alpha(theme.palette.common.white, 0.4)}; - } -`; - -const WatchlistAction = ({ status, onClick }: Props) => { - const handleClick = () => { - onClick(STATUS_TOGGLE_MAP[status]); - }; - - return ( - - {status === Status.InWatchlist ? ( - - ) : ( - - )} - - ); -}; - -export default WatchlistAction; diff --git a/src/features/shows/components/WatchlistOverlayAction.tsx b/src/features/shows/components/WatchlistOverlayAction.tsx new file mode 100644 index 0000000..e192525 --- /dev/null +++ b/src/features/shows/components/WatchlistOverlayAction.tsx @@ -0,0 +1,66 @@ +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import { alpha, styled } from '@mui/material/styles'; +import { Box, Button } from '@mui/material'; +import classNames from 'classnames'; +import { Status } from '../../../generated/graphql'; + +interface OverlayProps { + status: Status; +} + +const Overlay = styled(Box)` + display: flex; + justify-content: center; + align-items: center; + position: absolute; + width: 100%; + height: 100%; + transition: ${({ theme }) => theme.transitions.create('opacity')}; + opacity: 0; + background-color: ${({ theme }) => alpha(theme.palette.common.black, 0.5)}; + + &.status-in-watchlist { + opacity: 1; + } +`; + +const StyledActionButton = styled(Button)` + padding: ${({ theme }) => theme.spacing(4)}; + transition: ${({ theme }) => theme.transitions.create('opacity')}; + width: 100%; + height: 100%; + + svg { + font-size: ${({ theme }) => theme.typography.h3.fontSize}; + } +`; + +interface Props extends OverlayProps { + onClick: (status: Status) => void; +} + +const STATUS_TOGGLE_MAP = { + [Status.InWatchlist]: Status.None, + [Status.None]: Status.InWatchlist, + [Status.StoppedWatching]: Status.InWatchlist, +} as const; + +const WatchlistOverlayAction = ({ status, onClick }: Props) => { + const handleClick = () => { + onClick(STATUS_TOGGLE_MAP[status]); + }; + + return ( + + + + + + ); +}; + +export default WatchlistOverlayAction; diff --git a/src/features/shows/components/base/TallCard.tsx b/src/features/shows/components/base/TallCard.tsx index 1d1dc8f..3a45812 100644 --- a/src/features/shows/components/base/TallCard.tsx +++ b/src/features/shows/components/base/TallCard.tsx @@ -33,6 +33,7 @@ interface Props { tallImage?: string | null; actions?: ReactNode; onClick?: () => void; + topChildren?: ReactNode; } const TallCard = ({ @@ -40,8 +41,10 @@ const TallCard = ({ actions, onClick, children, + topChildren, }: PropsWithChildren) => ( + {topChildren} {actions} {children} diff --git a/src/features/shows/components/base/WideCard.tsx b/src/features/shows/components/base/WideCard.tsx index 77fdd33..a6538e6 100644 --- a/src/features/shows/components/base/WideCard.tsx +++ b/src/features/shows/components/base/WideCard.tsx @@ -4,23 +4,28 @@ import { PropsWithChildren, ReactNode } from 'react'; import { makeWideSmallImage } from '../../utils/image'; const StyledWrapper = styled(Paper)` - aspect-ratio: 3/2; - position: relative; - top: 0; - transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; cursor: pointer; overflow: hidden; + display: flex; + flex-direction: column; + position: relative; + box-shadow: ${({ theme }) => theme.shadows[3]}; + + &:hover { + box-shadow: ${({ theme }) => theme.shadows[6]}; + } `; const StyledImage = styled('img')` + aspect-ratio: 3/2; width: 100%; - height: 100%; object-fit: cover; `; const StyledActions = styled(Box)` position: absolute; width: 100%; + padding: ${({ theme }) => theme.spacing(0.5)}; text-align: right; `; @@ -36,16 +41,7 @@ const WideCard = ({ onClick, children, }: PropsWithChildren) => ( - + {actions} {children} diff --git a/src/features/shows/components/base/WideCardCollection.tsx b/src/features/shows/components/base/WideCardCollection.tsx index 40cd7c3..542bdf9 100644 --- a/src/features/shows/components/base/WideCardCollection.tsx +++ b/src/features/shows/components/base/WideCardCollection.tsx @@ -1,31 +1,53 @@ import React, { PropsWithChildren } from 'react'; -import { CircularProgress } from '@mui/material'; import { styled } from '@mui/material/styles'; -const StyledWrapper = styled('div')` +interface WrapperProps { + scroll?: boolean; + className?: string; +} + +const StyledWrapper = styled(({ scroll, ...props }: WrapperProps) => ( + +))` + --min-width: 300px; display: grid; - grid-template-columns: repeat(3, 1fr); grid-gap: 1rem; - grid-auto-flow: dense; + overflow-x: scroll; + padding: ${({ theme }) => theme.spacing(1)}; + border-radius: ${({ theme }) => theme.shape.borderRadius}px; + margin-inline: -${({ theme }) => theme.spacing(0.75)}; + grid-template-columns: ${({ scroll }) => + scroll + ? 'repeat(auto-fit, var(--min-width))' + : 'repeat(auto-fit, minmax(var(--min-width), 1fr))'}; + grid-auto-flow: ${({ scroll }) => (scroll ? 'column' : 'dense')}; - ${({ theme }) => theme.breakpoints.up('md')} { - grid-template-columns: repeat(4, 1fr); - } - - ${({ theme }) => theme.breakpoints.up('lg')} { - grid-template-columns: repeat(5, 1fr); + & > * { + min-width: var(--min-width); } `; -interface Props { +interface Props extends WrapperProps { loading: boolean; + PlaceholderComponent: React.JSXElementConstructor; } const WideCardCollection = ({ children, loading, -}: PropsWithChildren) => ( - {loading ? : children} -); + PlaceholderComponent, + scroll = false, + className, +}: PropsWithChildren) => { + const placeholders = Array.from(Array(6).keys()); + + return ( + + {loading + ? placeholders.map((idx) => ) + : children} + + ); +}; export default WideCardCollection; diff --git a/src/features/shows/features/episode/components/TallEpisodeCard.tsx b/src/features/shows/features/episode/components/TallEpisodeCard.tsx index 21c04b7..a3a9428 100644 --- a/src/features/shows/features/episode/components/TallEpisodeCard.tsx +++ b/src/features/shows/features/episode/components/TallEpisodeCard.tsx @@ -1,5 +1,5 @@ import { Box, Tooltip } from '@mui/material'; -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, ReactNode } from 'react'; import TallCard from '../../../components/base/TallCard'; import EllipsisButton from '../../../../../components/EllipsisButton'; import { ActionProps, EpisodeType } from '../types'; @@ -8,11 +8,13 @@ import TallEpisodeCardPlaceholder from './TallEpisodeCardPlaceholder'; interface Props { episode: EpisodeType; actions?: React.JSXElementConstructor[]; + topChildren?: ReactNode; } const TallEpisodeCard = ({ episode, children, + topChildren, actions = [], }: PropsWithChildren) => { if (episode.loading) { @@ -22,7 +24,7 @@ const TallEpisodeCard = ({ const title = `${episode.seasonNumber}x${episode.number} - ${episode.name}`; return ( - + {title} diff --git a/src/features/shows/features/episode/components/UpsertEpisodeAction.tsx b/src/features/shows/features/episode/components/UpsertEpisodeAction.tsx index b5f9480..1269ab4 100644 --- a/src/features/shows/features/episode/components/UpsertEpisodeAction.tsx +++ b/src/features/shows/features/episode/components/UpsertEpisodeAction.tsx @@ -1,6 +1,6 @@ +import { useCallback, useContext } from 'react'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import { useCallback, useContext } from 'react'; import ActionButton from '../../../components/base/ActionButton'; import { UpNextContext } from '../../../../home/features/upnext/contexts/UpNextContext'; import { ActionProps } from '../types'; diff --git a/src/features/shows/features/episode/components/WideEpisodeCard.tsx b/src/features/shows/features/episode/components/WideEpisodeCard.tsx index 59033a9..d63863b 100644 --- a/src/features/shows/features/episode/components/WideEpisodeCard.tsx +++ b/src/features/shows/features/episode/components/WideEpisodeCard.tsx @@ -1,11 +1,39 @@ +import { Box, Tooltip } from '@mui/material'; +import React, { PropsWithChildren } from 'react'; import WideCard from '../../../components/base/WideCard'; +import EllipsisButton from '../../../../../components/EllipsisButton'; +import { ActionProps, EpisodeType } from '../types'; +import TallEpisodeCardPlaceholder from './TallEpisodeCardPlaceholder'; interface Props { - wideImage?: string | null; + episode: EpisodeType; + actions?: React.JSXElementConstructor[]; } -const WideEpisodeCard = ({ wideImage }: Props) => ( - -); +const WideEpisodeCard = ({ + episode, + children, + actions = [], +}: PropsWithChildren) => { + if (episode.loading) { + return ; + } + + const title = `${episode.seasonNumber}x${episode.number} - ${episode.name}`; + + return ( + + + + {title} + + {actions.map((Action) => ( + + ))} + + {children} + + ); +}; export default WideEpisodeCard; diff --git a/src/features/shows/features/episode/components/WideEpisodeCollection.tsx b/src/features/shows/features/episode/components/WideEpisodeCollection.tsx new file mode 100644 index 0000000..cd019fc --- /dev/null +++ b/src/features/shows/features/episode/components/WideEpisodeCollection.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import WideCardCollection from '../../../components/base/WideCardCollection'; +import { ActionProps, EpisodeType } from '../types'; +import WideEpisodeCard from './WideEpisodeCard'; +import TallEpisodeCardPlaceholder from './TallEpisodeCardPlaceholder'; + +interface Props { + loading: boolean; + episodes: Array; + actions?: React.JSXElementConstructor[]; +} + +const WideEpisodeCollection = ({ episodes, loading, actions = [] }: Props) => ( + + {episodes.map((episode) => ( + + ))} + +); + +export default WideEpisodeCollection; diff --git a/src/features/shows/hooks/useWatchlistActions.ts b/src/features/shows/hooks/useWatchlistActions.ts new file mode 100644 index 0000000..822c820 --- /dev/null +++ b/src/features/shows/hooks/useWatchlistActions.ts @@ -0,0 +1,33 @@ +import { useCallback, useContext } from 'react'; +import { + Status, + useUpsertWatchlistItemMutation, +} from '../../../generated/graphql'; +import { ShowsOnboardingContext } from '../../onboarding/contexts/ShowsOnboardingContext'; + +interface UpsertWatchlistItemArgs { + showId: number; + status: Status; +} + +const useWatchlistActions = () => { + const [upsertWatchlistItemMutation] = useUpsertWatchlistItemMutation(); + const { updateShow } = useContext(ShowsOnboardingContext); + + const upsertWatchlistItem = useCallback( + ({ showId, status }: UpsertWatchlistItemArgs) => { + upsertWatchlistItemMutation({ + variables: { + showId, + status, + }, + }); + updateShow(showId, { status }); + }, + [updateShow, upsertWatchlistItemMutation], + ); + + return { upsertWatchlistItem }; +}; + +export default useWatchlistActions; diff --git a/src/features/theme/theme.ts b/src/features/theme/theme.ts index 965f2f0..80a05c4 100644 --- a/src/features/theme/theme.ts +++ b/src/features/theme/theme.ts @@ -1,4 +1,4 @@ -import { blue, red } from '@mui/material/colors'; +import { blue, grey, red } from '@mui/material/colors'; import { createTheme } from '@mui/material'; import type { PaletteMode } from '@mui/material'; import { LinkProps } from '@mui/material/Link/Link.d'; @@ -18,6 +18,10 @@ const theme = ({ mode = 'light' }: Props = {}) => secondary: { main: red[500], }, + background: { + default: mode === 'light' ? grey[50] : grey[900], + paper: mode === 'light' ? grey[50] : grey[900], + }, }, components: { MuiLink: {