diff --git a/package-lock.json b/package-lock.json index 9df8910..935e12f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react-hook-form": "^7.28.1", "react-router-dom": "^6.2.2", "react-scripts": "5.0.0", + "slugify": "^1.6.5", "typescript": "^4.6.2", "yup": "^0.32.11" }, @@ -22864,6 +22865,14 @@ "node": ">=0.10.0" } }, + "node_modules/slugify": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.5.tgz", + "integrity": "sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -41965,6 +41974,11 @@ "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", "dev": true }, + "slugify": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.5.tgz", + "integrity": "sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ==" + }, "snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", diff --git a/package.json b/package.json index 4f664b5..4ee6c4a 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-hook-form": "^7.28.1", "react-router-dom": "^6.2.2", "react-scripts": "5.0.0", + "slugify": "^1.6.5", "typescript": "^4.6.2", "yup": "^0.32.11" }, diff --git a/schema.graphql b/schema.graphql index a0502e7..bcc1bdc 100644 --- a/schema.graphql +++ b/schema.graphql @@ -20,11 +20,34 @@ type Episode { wideImage: String } +type FullShow { + description: String! + details: ShowDetails! + externalId: Int! + firstAirDate: DateTime! + genres: [Genre!]! + name: String! + originCountry: String! + rating: Int! + status: Status! + tallImage: String! + wideImage: String! +} + +input FullShowInput { + externalId: Int! +} + type Genre { externalId: Int! name: String! } +input GetSeasonEpisodesInput { + seasonNumber: Int! + showId: Int! +} + input JoinWithGoogleInput { token: String! } @@ -41,8 +64,11 @@ type Mutation { type PartialShow { description: String! externalId: Int! + firstAirDate: DateTime! genres: [Genre!]! name: String! + originCountry: String! + rating: Int! status: Status! tallImage: String! wideImage: String! @@ -55,7 +81,9 @@ type Preference { type Query { allUsers: User! discoverShows(input: DiscoverShowsInput!): [PartialShow!]! + fullShow(input: FullShowInput!): FullShow! getPreferences: Preference + getSeasonEpisodes(input: GetSeasonEpisodesInput!): [Episode!]! getWatchlist: [Watchlist!]! listGenres: [Genre!] listUpNext: [Episode!]! @@ -64,12 +92,21 @@ type Query { } type Season { + airDate: DateTime! description: String + episodeCount: String! name: String! number: Int! tallImage: String! } +type ShowDetails { + episodeRuntime: Int! + isInProduction: Boolean! + seasons: [Season!]! + tagline: String +} + enum Status { InWatchlist None diff --git a/src/components/Section.tsx b/src/components/Section.tsx new file mode 100644 index 0000000..541ad11 --- /dev/null +++ b/src/components/Section.tsx @@ -0,0 +1,42 @@ +import { PropsWithChildren, ReactNode } from 'react'; +import { Box, Divider, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +const StyledWrapper = styled(Box)` + width: 100%; + margin-block: ${({ theme }) => theme.spacing(2)}; +`; + +interface Props { + title: ReactNode; + icon?: ReactNode; + divider?: boolean; +} + +const Section = ({ + title, + icon, + divider, + children, +}: PropsWithChildren) => ( + + + {icon && ( + palette.primary.main, + }} + > + {icon} + + )} + {title} + + {divider && } + {children} + +); + +export default Section; diff --git a/src/features/home/components/LoginAlert.tsx b/src/features/home/components/LoginAlert.tsx index 169c5c1..06d20e5 100644 --- a/src/features/home/components/LoginAlert.tsx +++ b/src/features/home/components/LoginAlert.tsx @@ -1,12 +1,12 @@ import { Alert, Link, Typography } from '@mui/material'; -import { RoutePath } from '../../router/constants'; +import { StaticRoute } from '../../router/constants'; const LoginAlert = () => ( - + You're browsing anonyomously. You can{' '} - login or{' '} - register to for the full + login or{' '} + register to for the full experience. diff --git a/src/features/home/components/Section.tsx b/src/features/home/components/Section.tsx deleted file mode 100644 index 5b852e2..0000000 --- a/src/features/home/components/Section.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { PropsWithChildren, ReactNode } from 'react'; -import { Box, Typography } from '@mui/material'; -import { styled } from '@mui/material/styles'; - -const StyledWrapper = styled(Box)` - width: 100%; - margin-block: ${({ theme }) => theme.spacing(2)}; -`; - -interface Props { - title: string; - icon: ReactNode; -} - -const Section = ({ title, icon, children }: PropsWithChildren) => ( - - - palette.primary.main, - }} - > - {icon} - - {title} - - {children} - -); - -export default Section; diff --git a/src/features/home/features/upcoming/components/UpcomingEpisodesCollection.tsx b/src/features/home/features/upcoming/components/UpcomingEpisodesCollection.tsx index e420f55..fa1d766 100644 --- a/src/features/home/features/upcoming/components/UpcomingEpisodesCollection.tsx +++ b/src/features/home/features/upcoming/components/UpcomingEpisodesCollection.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { Box, Typography } from '@mui/material'; import { DateTime } from 'luxon'; -import TallCardCollection from '../../../../shows/components/base/TallCardCollection'; +import TallCardCollection from '../../../../shows/components/card/TallCardCollection'; import TallEpisodeCardPlaceholder from '../../../../shows/features/episode/components/TallEpisodeCardPlaceholder'; import TallEpisodeCard from '../../../../shows/features/episode/components/TallEpisodeCard'; -import { EpisodeType } from '../../../../shows/features/episode/types'; +import { EpisodeWithShowType } from '../../../../shows/features/episode/types'; interface Props { loading: boolean; - episodes: Array; + episodes: Array; } const UpcomingEpisodesCollection = ({ episodes, loading }: Props) => ( diff --git a/src/features/home/features/upcoming/components/UpcomingSection.tsx b/src/features/home/features/upcoming/components/UpcomingSection.tsx index 6a9af14..5cb4360 100644 --- a/src/features/home/features/upcoming/components/UpcomingSection.tsx +++ b/src/features/home/features/upcoming/components/UpcomingSection.tsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; -import Section from '../../../components/Section'; +import Section from '../../../../../components/Section'; import { UpcomingContext } from '../contexts/UpcomingContext'; import UpcomingEpisodesCollection from './UpcomingEpisodesCollection'; diff --git a/src/features/home/features/upcoming/contexts/UpcomingContext.tsx b/src/features/home/features/upcoming/contexts/UpcomingContext.tsx index 6931f9b..7fdb4a2 100644 --- a/src/features/home/features/upcoming/contexts/UpcomingContext.tsx +++ b/src/features/home/features/upcoming/contexts/UpcomingContext.tsx @@ -8,10 +8,10 @@ import React, { import { useListUpcomingLazyQuery } from '../../../../../generated/graphql'; import { UserContext } from '../../../../user/contexts/UserContext'; import { UserState } from '../../../../user/constants'; -import { EpisodeType } from '../../../../shows/features/episode/types'; +import { EpisodeWithShowType } from '../../../../shows/features/episode/types'; interface ContextType { - episodes: EpisodeType[]; + episodes: EpisodeWithShowType[]; loading: boolean; } @@ -26,7 +26,7 @@ const UpcomingContext = createContext({ const UpcomingProvider = ({ children }: Props) => { const { userState } = useContext(UserContext); const [fetchUpcomingEpisodes, { loading }] = useListUpcomingLazyQuery(); - const [episodes, setEpisodes] = useState([]); + const [episodes, setEpisodes] = useState([]); useEffect(() => { if (userState !== UserState.LoggedIn) { diff --git a/src/features/home/features/upcoming/queries.ts b/src/features/home/features/upcoming/queries.ts index 27aecb9..dc686d5 100644 --- a/src/features/home/features/upcoming/queries.ts +++ b/src/features/home/features/upcoming/queries.ts @@ -3,7 +3,7 @@ import { gql } from '@apollo/client'; export const QUERY_LIST_UPCOMING_NEXT = gql` query ListUpcoming { listUpcoming { - ...Episode + ...EpisodeWithShow } } `; diff --git a/src/features/home/features/upnext/components/UpNextSection.tsx b/src/features/home/features/upnext/components/UpNextSection.tsx index 07577d3..ec1fd5a 100644 --- a/src/features/home/features/upnext/components/UpNextSection.tsx +++ b/src/features/home/features/upnext/components/UpNextSection.tsx @@ -3,8 +3,8 @@ import AccessTimeIcon from '@mui/icons-material/AccessTime'; import { Alert, AlertTitle } from '@mui/material'; import TallEpisodeCollection from '../../../../shows/features/episode/components/TallEpisodeCollection'; import { UpNextContext } from '../contexts/UpNextContext'; -import UpsertEpisodeAction from '../../../../shows/features/episode/components/UpsertEpisodeAction'; -import Section from '../../../components/Section'; +import Section from '../../../../../components/Section'; +import UpsertUpNextEpisodeAction from './UpsertUpNextEpisodeAction'; const UpNextSection = () => { const { episodes, loading } = useContext(UpNextContext); @@ -15,7 +15,7 @@ const UpNextSection = () => { ) : ( diff --git a/src/features/home/features/upnext/components/UpsertUpNextEpisodeAction.tsx b/src/features/home/features/upnext/components/UpsertUpNextEpisodeAction.tsx new file mode 100644 index 0000000..ac1813c --- /dev/null +++ b/src/features/home/features/upnext/components/UpsertUpNextEpisodeAction.tsx @@ -0,0 +1,18 @@ +import { useCallback, useContext } from 'react'; +import { UpNextContext } from '../contexts/UpNextContext'; +import UpsertEpisodeAction from '../../../../shows/features/episode/components/UpsertEpisodeAction'; +import { EpisodeActionProps } from '../../../../shows/features/episode/types'; + +const UpsertUpNextEpisodeAction = ({ + episodeId, + isWatched, +}: EpisodeActionProps) => { + const { watchEpisode } = useContext(UpNextContext); + const onWatchEpisode = useCallback(() => { + watchEpisode(episodeId); + }, [episodeId, watchEpisode]); + + return ; +}; + +export default UpsertUpNextEpisodeAction; diff --git a/src/features/home/features/upnext/contexts/UpNextContext.tsx b/src/features/home/features/upnext/contexts/UpNextContext.tsx index 4b0e1b1..2c6dce3 100644 --- a/src/features/home/features/upnext/contexts/UpNextContext.tsx +++ b/src/features/home/features/upnext/contexts/UpNextContext.tsx @@ -14,10 +14,10 @@ import { } from '../../../../../generated/graphql'; import { UserContext } from '../../../../user/contexts/UserContext'; import { UserState } from '../../../../user/constants'; -import { EpisodeType } from '../../../../shows/features/episode/types'; +import { EpisodeWithShowType } from '../../../../shows/features/episode/types'; interface ContextType { - episodes: EpisodeType[]; + episodes: EpisodeWithShowType[]; watchEpisode: (episodeId: number) => void; loading: boolean; } @@ -39,7 +39,7 @@ const UpNextProvider = ({ children }: Props) => { const { userState } = useContext(UserContext); const [fetchUpNextEpisodes, { loading }] = useListUpNextLazyQuery(); const [upsertEpisode] = useUpsertEpisodeMutation(); - const [episodes, setEpisodes] = useState([]); + const [episodes, setEpisodes] = useState([]); const watchEpisode = useCallback( async (episodeId: number) => { diff --git a/src/features/home/features/upnext/queries.ts b/src/features/home/features/upnext/queries.ts index 166e33c..48e5400 100644 --- a/src/features/home/features/upnext/queries.ts +++ b/src/features/home/features/upnext/queries.ts @@ -3,7 +3,7 @@ import { gql } from '@apollo/client'; export const QUERY_LIST_UP_NEXT = gql` query ListUpNext { listUpNext { - ...Episode + ...EpisodeWithShow } } `; diff --git a/src/features/join/components/LoginForm.tsx b/src/features/join/components/LoginForm.tsx index 2dcd9f7..9a986df 100644 --- a/src/features/join/components/LoginForm.tsx +++ b/src/features/join/components/LoginForm.tsx @@ -10,7 +10,7 @@ import { import { useForm } from 'react-hook-form'; import * as yup from 'yup'; import useYupValidationResolver from '../hooks/useYupValidationResolver'; -import { RoutePath } from '../../router/constants'; +import { StaticRoute } from '../../router/constants'; type LoginFormInput = { email: string; @@ -81,7 +81,7 @@ const LoginForm = () => { sx={{ color: (theme) => theme.palette.text.secondary }} > Don't have an account{' '} - + Create an account diff --git a/src/features/join/components/RegisterForm.tsx b/src/features/join/components/RegisterForm.tsx index 48af506..c648856 100644 --- a/src/features/join/components/RegisterForm.tsx +++ b/src/features/join/components/RegisterForm.tsx @@ -3,7 +3,7 @@ import { useForm } from 'react-hook-form'; import * as yup from 'yup'; import useYupValidationResolver from '../hooks/useYupValidationResolver'; import FormRow from '../../forms/components/FormRow'; -import { RoutePath } from '../../router/constants'; +import { StaticRoute } from '../../router/constants'; type LoginFormInput = { name: string; @@ -80,7 +80,7 @@ const RegisterForm = () => { sx={{ color: (theme) => theme.palette.text.secondary }} > Already have an account?{' '} - + Log in diff --git a/src/features/join/hooks/useHandleLoggedInUsers.ts b/src/features/join/hooks/useHandleLoggedInUsers.ts index 37e21ce..28f325b 100644 --- a/src/features/join/hooks/useHandleLoggedInUsers.ts +++ b/src/features/join/hooks/useHandleLoggedInUsers.ts @@ -2,7 +2,7 @@ import { useContext, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { UserContext } from '../../user/contexts/UserContext'; import { UserState } from '../../user/constants'; -import { RoutePath } from '../../router/constants'; +import { StaticRoute } from '../../router/constants'; const useHandleLoggedInUsers = () => { const { userState } = useContext(UserContext); @@ -13,7 +13,7 @@ const useHandleLoggedInUsers = () => { return; } - navigate(RoutePath.Home); + navigate(StaticRoute.Home); }, [navigate, userState]); }; diff --git a/src/features/navigation/components/ElevationScroll.tsx b/src/features/navigation/components/ElevationScroll.tsx index f61f15a..efc5c20 100644 --- a/src/features/navigation/components/ElevationScroll.tsx +++ b/src/features/navigation/components/ElevationScroll.tsx @@ -1,8 +1,8 @@ -import * as React from 'react'; +import { cloneElement, ReactElement } from 'react'; import { useScrollTrigger } from '@mui/material'; interface Props { - children: React.ReactElement; + children: ReactElement; } const ElevationScroll = ({ children }: Props) => { @@ -11,7 +11,7 @@ const ElevationScroll = ({ children }: Props) => { threshold: 0, }); - return React.cloneElement(children, { + return cloneElement(children, { elevation: trigger ? 4 : 1, }); }; diff --git a/src/features/navigation/components/Navigation.tsx b/src/features/navigation/components/Navigation.tsx index f35df98..036f364 100644 --- a/src/features/navigation/components/Navigation.tsx +++ b/src/features/navigation/components/Navigation.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React from 'react'; import { AppBar, Box, Button, Toolbar } from '@mui/material'; import { styled } from '@mui/material/styles'; import NavigationProvider from '../contexts/NavigationContext'; diff --git a/src/features/navigation/components/Search.tsx b/src/features/navigation/components/Search.tsx index 9ae93c6..8d1a885 100644 --- a/src/features/navigation/components/Search.tsx +++ b/src/features/navigation/components/Search.tsx @@ -1,5 +1,4 @@ -import * as React from 'react'; -import { useState } from 'react'; +import React, { ChangeEvent, useState } from 'react'; import { styled } from '@mui/material/styles'; import { Box, OutlinedInput } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; @@ -25,7 +24,7 @@ const StyledInputBase = styled(OutlinedInput)(({ theme }) => ({ const Search = () => { const [search, setSearch] = useState(''); - const onChange = (event: React.ChangeEvent) => + const onChange = (event: ChangeEvent) => setSearch(event.target.value); return ( diff --git a/src/features/navigation/components/UserItem/AnonymoysUserMenu.tsx b/src/features/navigation/components/UserItem/AnonymoysUserMenu.tsx index ea123c1..4378fb1 100644 --- a/src/features/navigation/components/UserItem/AnonymoysUserMenu.tsx +++ b/src/features/navigation/components/UserItem/AnonymoysUserMenu.tsx @@ -1,7 +1,7 @@ -import * as React from 'react'; +import React from 'react'; import { MenuItem } from '@mui/material'; import { useNavigate } from 'react-router-dom'; -import { RoutePath } from '../../../router/constants'; +import { StaticRoute } from '../../../router/constants'; import UserMenu from './UserMenu'; const AnonymoysUserMenu = () => { @@ -9,8 +9,10 @@ const AnonymoysUserMenu = () => { return ( - navigate(RoutePath.Login)}>Login - navigate(RoutePath.Register)}>Register + navigate(StaticRoute.Login)}>Login + navigate(StaticRoute.Register)}> + Register + ); }; diff --git a/src/features/navigation/components/UserItem/AuthenticatedUserMenu.tsx b/src/features/navigation/components/UserItem/AuthenticatedUserMenu.tsx index ad9e5c6..261ff22 100644 --- a/src/features/navigation/components/UserItem/AuthenticatedUserMenu.tsx +++ b/src/features/navigation/components/UserItem/AuthenticatedUserMenu.tsx @@ -1,5 +1,4 @@ -import * as React from 'react'; -import { useContext } from 'react'; +import React, { useContext } from 'react'; import { MenuItem, Divider } from '@mui/material'; import LogoutIcon from '@mui/icons-material/Logout'; import { UserContext } from '../../../user/contexts/UserContext'; diff --git a/src/features/navigation/components/UserItem/DropdownMenu.tsx b/src/features/navigation/components/UserItem/DropdownMenu.tsx new file mode 100644 index 0000000..22b44a0 --- /dev/null +++ b/src/features/navigation/components/UserItem/DropdownMenu.tsx @@ -0,0 +1,77 @@ +import React, { ReactNode } from 'react'; +import type { MenuProps } from '@mui/material'; +import { Menu, MenuItem } from '@mui/material'; +import { alpha, styled } from '@mui/material/styles'; + +const StyledMenu = styled((props: MenuProps) => ( + +))(({ theme }) => ({ + '& .MuiPaper-root': { + borderRadius: 6, + marginTop: theme.spacing(1), + minWidth: 180, + color: theme.palette.text.primary, + boxShadow: theme.shadows[3], + '& .MuiMenu-list': { + padding: '4px 0', + }, + '& .MuiMenuItem-root': { + '& .MuiSvgIcon-root': { + color: theme.palette.text.secondary, + marginRight: theme.spacing(1.5), + }, + '&:active': { + backgroundColor: alpha( + theme.palette.primary.main, + theme.palette.action.selectedOpacity, + ), + }, + }, + }, +})); + +interface Props { + children: ReactNode; + trigger: ReactNode; + anchorEl: HTMLElement | null; + onClose: () => void; +} + +const DropdownMenu = ({ children, anchorEl, trigger, onClose }: Props) => { + const isOpen = Boolean(anchorEl); + + return ( +
+ {trigger} + + {React.Children.map(children, (item: ReactNode) => { + const child = item as React.ReactElement; + + if (child.type !== MenuItem) { + return child; + } + + return React.cloneElement(child, { + disableRipple: true, + onClick: async () => { + onClose(); + await child.props?.onClick?.(); + }, + }); + })} + +
+ ); +}; + +export default DropdownMenu; diff --git a/src/features/navigation/components/UserItem/UserItem.tsx b/src/features/navigation/components/UserItem/UserItem.tsx index cc104bd..b55653a 100644 --- a/src/features/navigation/components/UserItem/UserItem.tsx +++ b/src/features/navigation/components/UserItem/UserItem.tsx @@ -1,5 +1,4 @@ -import * as React from 'react'; -import { useContext } from 'react'; +import React, { useContext } from 'react'; import { UserContext } from '../../../user/contexts/UserContext'; import { UserState } from '../../../user/constants'; import AnonymoysUserMenu from './AnonymoysUserMenu'; diff --git a/src/features/navigation/components/UserItem/UserMenu.tsx b/src/features/navigation/components/UserItem/UserMenu.tsx index 7a3d3d5..a9ca3bd 100644 --- a/src/features/navigation/components/UserItem/UserMenu.tsx +++ b/src/features/navigation/components/UserItem/UserMenu.tsx @@ -1,46 +1,8 @@ -import * as React from 'react'; -import { ReactNode } from 'react'; -import type { MenuProps } from '@mui/material'; -import { Button, Menu, Avatar, MenuItem } from '@mui/material'; -import { alpha, styled } from '@mui/material/styles'; +import React, { ReactNode } from 'react'; +import { Avatar, Button } from '@mui/material'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; - -const StyledMenu = styled((props: MenuProps) => ( - -))(({ theme }) => ({ - '& .MuiPaper-root': { - borderRadius: 6, - marginTop: theme.spacing(1), - minWidth: 180, - color: theme.palette.text.primary, - boxShadow: theme.shadows[3], - '& .MuiMenu-list': { - padding: '4px 0', - }, - '& .MuiMenuItem-root': { - '& .MuiSvgIcon-root': { - color: theme.palette.text.secondary, - marginRight: theme.spacing(1.5), - }, - '&:active': { - backgroundColor: alpha( - theme.palette.primary.main, - theme.palette.action.selectedOpacity, - ), - }, - }, - }, -})); +import useMenu from '../../hooks/useMenu'; +import DropdownMenu from './DropdownMenu'; interface Props { children: ReactNode[]; @@ -48,39 +10,27 @@ interface Props { } const UserMenu = ({ children, avatar }: Props) => { - const [anchorEl, setAnchorEl] = React.useState(null); - const isOpen = Boolean(anchorEl); - const handleClick = (event: React.MouseEvent) => - setAnchorEl(event.currentTarget); - const close = () => setAnchorEl(null); + const { onClose, onOpen, anchorEl } = useMenu(); return ( -
- - - {React.Children.map(children, (item: ReactNode) => { - const child = item as React.ReactElement; - - if (child.type !== MenuItem) { - return child; - } - - return React.cloneElement(child, { - disableRipple: true, - onClick: async () => { - close(); - await child.props?.onClick?.(); - }, - }); - })} - -
+ } + > + + + } + > + {children} + ); }; diff --git a/src/features/navigation/hooks/useMenu.ts b/src/features/navigation/hooks/useMenu.ts new file mode 100644 index 0000000..db5a14b --- /dev/null +++ b/src/features/navigation/hooks/useMenu.ts @@ -0,0 +1,12 @@ +import { useState, MouseEvent } from 'react'; + +const useMenu = () => { + const [anchorEl, setAnchorEl] = useState(null); + const onOpen = (event: MouseEvent) => + setAnchorEl(event.currentTarget); + const onClose = () => setAnchorEl(null); + + return { anchorEl, onClose, onOpen }; +}; + +export default useMenu; diff --git a/src/features/onboarding/contexts/ShowsOnboardingContext.tsx b/src/features/onboarding/contexts/ShowsOnboardingContext.tsx index c567993..2f53e45 100644 --- a/src/features/onboarding/contexts/ShowsOnboardingContext.tsx +++ b/src/features/onboarding/contexts/ShowsOnboardingContext.tsx @@ -11,7 +11,7 @@ import { noop } from '../../../utils/fp'; import { PreferencesContext } from '../../preferences/contexts/PreferencesContext'; import { DEFAULT_GENRE_RECOMMENDATION } from '../constants'; -interface ShowPreferenceContextType { +interface ContextType { shows: PartialShow[]; loading: boolean; updateShow: (showId: number, data: Partial) => void; @@ -21,7 +21,7 @@ interface Props { children: ReactNode; } -const ShowsOnboardingContext = createContext({ +const ShowsOnboardingContext = createContext({ shows: [], loading: true, updateShow: noop, diff --git a/src/features/onboarding/queries.ts b/src/features/onboarding/queries.ts index b151ba8..af9ecda 100644 --- a/src/features/onboarding/queries.ts +++ b/src/features/onboarding/queries.ts @@ -3,7 +3,7 @@ import { gql } from '@apollo/client'; export const QUERY_LIST_SHOW_SUGESTIONS = gql` query DiscoverShows($genreIds: [Int!]!) { discoverShows(input: { genreIds: $genreIds }) { - ...ShowFragment + ...PartialShow } } `; diff --git a/src/features/router/components/PrivateOutlet.tsx b/src/features/router/components/PrivateOutlet.tsx index 82ca108..89d7bdc 100644 --- a/src/features/router/components/PrivateOutlet.tsx +++ b/src/features/router/components/PrivateOutlet.tsx @@ -2,7 +2,7 @@ import { Navigate, Outlet } from 'react-router-dom'; import { useContext } from 'react'; import { UserContext } from '../../user/contexts/UserContext'; import { UserState } from '../../user/constants'; -import { RoutePath } from '../constants'; +import { StaticRoute } from '../constants'; const PrivateOutlet = () => { const { userState } = useContext(UserContext); @@ -15,7 +15,7 @@ const PrivateOutlet = () => { return ; } - return ; + return ; }; export default PrivateOutlet; diff --git a/src/features/router/components/Router.tsx b/src/features/router/components/Router.tsx index e80873c..3ef4921 100644 --- a/src/features/router/components/Router.tsx +++ b/src/features/router/components/Router.tsx @@ -3,17 +3,19 @@ import HomePage from '../../home/components/HomePage'; import OnboardingPage from '../../onboarding/components/OnboardingPage'; import LoginPage from '../../join/components/LoginPage'; import RegisterPage from '../../join/components/RegisterPage'; -import { RoutePath } from '../constants'; +import { __DynamicRoute, StaticRoute } from '../constants'; +import ShowPage from '../../shows/components/ShowPage'; import PrivateOutlet from './PrivateOutlet'; const Router = () => ( - } /> - } /> - } /> - }> - } /> + } /> + } /> + } /> + }> + } /> + } /> ); diff --git a/src/features/router/constants.ts b/src/features/router/constants.ts index a29a317..95ff6ee 100644 --- a/src/features/router/constants.ts +++ b/src/features/router/constants.ts @@ -1,6 +1,15 @@ -export enum RoutePath { +export enum StaticRoute { Home = '/', Welcome = '/welcome', Login = '/login', Register = '/join', } + +export enum __DynamicRoute { + Show = '/show/:slug', +} + +export const DynamicRoute = { + Show: (id: string | number) => + __DynamicRoute.Show.replace(':slug', id.toString()), +}; diff --git a/src/features/shows/components/ShowContent.tsx b/src/features/shows/components/ShowContent.tsx new file mode 100644 index 0000000..b237d1a --- /dev/null +++ b/src/features/shows/components/ShowContent.tsx @@ -0,0 +1,10 @@ +import { Container } from '@mui/material'; +import SeasonsSection from './seasons/SeasonsSection'; + +const ShowContent = () => ( + + + +); + +export default ShowContent; diff --git a/src/features/shows/components/ShowPage.tsx b/src/features/shows/components/ShowPage.tsx new file mode 100644 index 0000000..78f4bc5 --- /dev/null +++ b/src/features/shows/components/ShowPage.tsx @@ -0,0 +1,23 @@ +import type { Params } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; +import PageWrapper from '../../../components/PageWrapper'; +import { deslugifyShow } from '../utils/slugify'; +import ShowPageProvider from '../contexts/ShowPageContext'; +import ShowContent from './ShowContent'; +import ShowHeroCard from './hero/ShowHeroCard'; + +const ShowPage = () => { + const { slug } = useParams>(); + const externalId = deslugifyShow(slug); + + return ( + + + + + + + ); +}; + +export default ShowPage; diff --git a/src/features/shows/components/TallShowCard.tsx b/src/features/shows/components/TallShowCard.tsx index 5241bde..53b90c7 100644 --- a/src/features/shows/components/TallShowCard.tsx +++ b/src/features/shows/components/TallShowCard.tsx @@ -1,6 +1,8 @@ +import { useContext } from 'react'; import { PartialShow, Status } from '../../../generated/graphql'; import useWatchlistActions from '../hooks/useWatchlistActions'; -import TallCard from './base/TallCard'; +import { ShowsOnboardingContext } from '../../onboarding/contexts/ShowsOnboardingContext'; +import TallCard from './card/TallCard'; import WatchlistOverlayAction from './WatchlistOverlayAction'; interface Props { @@ -9,11 +11,13 @@ interface Props { const TallShowCard = ({ show }: Props) => { const { upsertWatchlistItem } = useWatchlistActions(); + const { updateShow } = useContext(ShowsOnboardingContext); const onClick = (status: Status) => { const showId = show.externalId; upsertWatchlistItem({ showId, status }); + updateShow(showId, { status }); }; return ( diff --git a/src/features/shows/components/TallShowCollection.tsx b/src/features/shows/components/TallShowCollection.tsx index 0580ce5..e0c376b 100644 --- a/src/features/shows/components/TallShowCollection.tsx +++ b/src/features/shows/components/TallShowCollection.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { PartialShow } from '../../../generated/graphql'; import TallShowCard from './TallShowCard'; -import TallCardCollection from './base/TallCardCollection'; -import TallCardPlaceholder from './base/TallCardPlaceholder'; +import TallCardCollection from './card/TallCardCollection'; +import TallCardPlaceholder from './card/TallCardPlaceholder'; interface Props { shows: PartialShow[]; diff --git a/src/features/shows/components/base/WideCard.tsx b/src/features/shows/components/base/WideCard.tsx deleted file mode 100644 index a6538e6..0000000 --- a/src/features/shows/components/base/WideCard.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Box, Paper } from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { PropsWithChildren, ReactNode } from 'react'; -import { makeWideSmallImage } from '../../utils/image'; - -const StyledWrapper = styled(Paper)` - 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%; - object-fit: cover; -`; - -const StyledActions = styled(Box)` - position: absolute; - width: 100%; - padding: ${({ theme }) => theme.spacing(0.5)}; - text-align: right; -`; - -interface Props { - wideImage?: string | null; - actions?: ReactNode; - onClick?: () => void; -} - -const WideCard = ({ - wideImage, - actions, - onClick, - children, -}: PropsWithChildren) => ( - - {actions} - - {children} - -); - -export default WideCard; diff --git a/src/features/shows/components/base/WideCardCollection.tsx b/src/features/shows/components/base/WideCardCollection.tsx deleted file mode 100644 index 542bdf9..0000000 --- a/src/features/shows/components/base/WideCardCollection.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { PropsWithChildren } from 'react'; -import { styled } from '@mui/material/styles'; - -interface WrapperProps { - scroll?: boolean; - className?: string; -} - -const StyledWrapper = styled(({ scroll, ...props }: WrapperProps) => ( -
-))` - --min-width: 300px; - display: grid; - grid-gap: 1rem; - 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')}; - - & > * { - min-width: var(--min-width); - } -`; - -interface Props extends WrapperProps { - loading: boolean; - PlaceholderComponent: React.JSXElementConstructor; -} - -const WideCardCollection = ({ - children, - loading, - 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/components/base/ActionButton.tsx b/src/features/shows/components/card/ActionButton.tsx similarity index 100% rename from src/features/shows/components/base/ActionButton.tsx rename to src/features/shows/components/card/ActionButton.tsx diff --git a/src/features/shows/components/base/CardTitle.tsx b/src/features/shows/components/card/CardTitle.tsx similarity index 100% rename from src/features/shows/components/base/CardTitle.tsx rename to src/features/shows/components/card/CardTitle.tsx diff --git a/src/features/shows/components/base/TallCard.tsx b/src/features/shows/components/card/TallCard.tsx similarity index 63% rename from src/features/shows/components/base/TallCard.tsx rename to src/features/shows/components/card/TallCard.tsx index 3a45812..97b040a 100644 --- a/src/features/shows/components/base/TallCard.tsx +++ b/src/features/shows/components/card/TallCard.tsx @@ -1,7 +1,7 @@ -import { Box, Paper } from '@mui/material'; +import { Box, Link, Paper } from '@mui/material'; import { styled } from '@mui/material/styles'; import { PropsWithChildren, ReactNode } from 'react'; -import { makeTallSmallImage } from '../../utils/image'; +import { makeTallSmImage } from '../../utils/image'; const StyledWrapper = styled(Paper)` cursor: pointer; @@ -34,6 +34,7 @@ interface Props { actions?: ReactNode; onClick?: () => void; topChildren?: ReactNode; + href?: string; } const TallCard = ({ @@ -42,13 +43,18 @@ const TallCard = ({ onClick, children, topChildren, -}: PropsWithChildren) => ( - - {topChildren} - {actions} - - {children} - -); + href, +}: PropsWithChildren) => { + const image = ; + + return ( + + {topChildren} + {actions} + {href ? {image} : image} + {children} + + ); +}; export default TallCard; diff --git a/src/features/shows/components/base/TallCardCollection.tsx b/src/features/shows/components/card/TallCardCollection.tsx similarity index 100% rename from src/features/shows/components/base/TallCardCollection.tsx rename to src/features/shows/components/card/TallCardCollection.tsx diff --git a/src/features/shows/components/base/TallCardPlaceholder.tsx b/src/features/shows/components/card/TallCardPlaceholder.tsx similarity index 100% rename from src/features/shows/components/base/TallCardPlaceholder.tsx rename to src/features/shows/components/card/TallCardPlaceholder.tsx diff --git a/src/features/shows/components/hero/GenresList.tsx b/src/features/shows/components/hero/GenresList.tsx new file mode 100644 index 0000000..4d88c21 --- /dev/null +++ b/src/features/shows/components/hero/GenresList.tsx @@ -0,0 +1,38 @@ +import { Box, Button } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useContext } from 'react'; +import { ShowPageContext } from '../../contexts/ShowPageContext'; + +const Wrapper = styled(Box)` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; + flex-wrap: wrap; +`; + +const GenresList = () => { + const { show } = useContext(ShowPageContext); + const genres = show?.genres ?? []; + + return ( + + {genres.map(({ name, externalId }) => ( + + + + ))} + + ); +}; + +export default GenresList; diff --git a/src/features/shows/components/hero/ShowHeroCard.tsx b/src/features/shows/components/hero/ShowHeroCard.tsx new file mode 100644 index 0000000..0e9c6f3 --- /dev/null +++ b/src/features/shows/components/hero/ShowHeroCard.tsx @@ -0,0 +1,90 @@ +import { Box, Container, Typography } from '@mui/material'; +import { alpha, styled } from '@mui/material/styles'; +import React, { useContext } from 'react'; +import { makeTallMdImage, makeWideLgImage } from '../../utils/image'; +import { ShowPageContext } from '../../contexts/ShowPageContext'; +import GenresList from './GenresList'; +import ShowTitle from './ShowTitle'; +import ShowSubtitle from './ShowSubtitle'; +import WatchlistActionButton from './WatchlistActionButton'; +import ShowRating from './ShowRating'; + +const Wrapper = styled('div')` + width: 100%; + position: relative; + background-size: cover; + min-height: 50vh; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: ${({ theme }) => alpha(theme.palette.common.black, 0.85)}; + } +`; + +const Content = styled(Container)` + position: relative; + width: 100%; + display: grid; + gap: ${({ theme }) => theme.spacing(3)}; + align-items: center; + + ${({ theme }) => theme.breakpoints.up('sm')} { + grid-template-columns: minmax(270px, 1fr) 3fr; + } +`; + +const TallImage = styled('img')` + border-radius: ${({ theme }) => theme.shape.borderRadius}px; + width: 100%; +`; + +const ShowHeroCard = () => { + const { show } = useContext(ShowPageContext); + + return ( + + + + + + + + + + + + + {show?.description} + + + + + ); +}; + +export default ShowHeroCard; diff --git a/src/features/shows/components/hero/ShowRating.tsx b/src/features/shows/components/hero/ShowRating.tsx new file mode 100644 index 0000000..6f9e3eb --- /dev/null +++ b/src/features/shows/components/hero/ShowRating.tsx @@ -0,0 +1,29 @@ +import { Rating, Box, Typography } from '@mui/material'; +import { useState } from 'react'; + +const ShowRating = () => { + const [value, setValue] = useState(2); + + return ( + + + Your Rating + + setValue(newValue)} + /> + + components?.MuiRating?.styleOverrides?.iconFilled as string + } + sx={{ ml: 1, fontWeight: 'bold' }} + > + {value}/5 + + + ); +}; + +export default ShowRating; diff --git a/src/features/shows/components/hero/ShowSubtitle.tsx b/src/features/shows/components/hero/ShowSubtitle.tsx new file mode 100644 index 0000000..fdf2aee --- /dev/null +++ b/src/features/shows/components/hero/ShowSubtitle.tsx @@ -0,0 +1,55 @@ +import React, { useContext } from 'react'; +import { Box, Typography } from '@mui/material'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import StarIcon from '@mui/icons-material/Star'; +import { DateTime } from 'luxon'; +import { styled } from '@mui/material/styles'; +import { ShowPageContext } from '../../contexts/ShowPageContext'; + +const Wrapper = styled(Box)` + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const Item = styled(Box)` + display: grid; + grid-template-columns: repeat(2, auto); + gap: ${({ theme }) => theme.spacing(0.5)}; + align-items: center; +`; + +const ShowSubtitle = () => { + const { show } = useContext(ShowPageContext); + const items = [ + { + icon: , + text: `${show?.rating}`, + }, + { + icon: , + text: + DateTime.fromISO(show?.firstAirDate).toFormat('d MMM yyyy') + + (show?.originCountry ? ` (${show?.originCountry})` : ''), + }, + { + icon: , + text: `${show?.details?.episodeRuntime} min`, + }, + ]; + + return ( + + {items.map(({ icon, text }) => ( + + {icon} + + {text} + + + ))} + + ); +}; + +export default ShowSubtitle; diff --git a/src/features/shows/components/hero/ShowTitle.tsx b/src/features/shows/components/hero/ShowTitle.tsx new file mode 100644 index 0000000..94a5296 --- /dev/null +++ b/src/features/shows/components/hero/ShowTitle.tsx @@ -0,0 +1,17 @@ +import { Box, Typography } from '@mui/material'; +import { useContext } from 'react'; +import { ShowPageContext } from '../../contexts/ShowPageContext'; + +const ShowTitle = () => { + const { show } = useContext(ShowPageContext); + + return ( + + + {show?.name} + + + ); +}; + +export default ShowTitle; diff --git a/src/features/shows/components/hero/UserActionsBox.tsx b/src/features/shows/components/hero/UserActionsBox.tsx new file mode 100644 index 0000000..ee1f4a6 --- /dev/null +++ b/src/features/shows/components/hero/UserActionsBox.tsx @@ -0,0 +1,23 @@ +import { alpha, styled } from '@mui/material/styles'; +import { Box, Typography } from '@mui/material'; +import ShowRating from './ShowRating'; + +const Wrapper = styled('div')` + height: 100%; + background-color: ${alpha('#000', 0.5)}; + border-radius: ${({ theme }) => theme.shape.borderRadius}px; + padding: ${({ theme }) => theme.spacing(2)}; +`; + +const UserActionsBox = () => ( + + + + Your rating: + + + + +); + +export default UserActionsBox; diff --git a/src/features/shows/components/hero/WatchlistActionButton.tsx b/src/features/shows/components/hero/WatchlistActionButton.tsx new file mode 100644 index 0000000..af7e372 --- /dev/null +++ b/src/features/shows/components/hero/WatchlistActionButton.tsx @@ -0,0 +1,97 @@ +import React, { useContext } from 'react'; +import { styled } from '@mui/material/styles'; +import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; +import PlaylistRemoveIcon from '@mui/icons-material/PlaylistRemove'; +import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck'; +import { LoadingButton } from '@mui/lab'; +import type { ButtonProps } from '@mui/material'; +import { ShowPageContext } from '../../contexts/ShowPageContext'; +import { Status } from '../../../../generated/graphql'; +import useHover from '../../../../hooks/useHover'; +import useWatchlistActions from '../../hooks/useWatchlistActions'; +import { ShowStatusToggleMap } from '../../constants'; + +const StyledButton = styled(LoadingButton)` + font-weight: bold; + + &.MuiButton-outlined { + padding: 6px; + + &, + &:hover, + &:disabled { + border-width: 2px; + } + } + + &:disabled { + background-color: ${({ theme }) => theme.palette.grey[500]}; + } +`; + +const getActionProps = ( + status: Status, + isHovered: boolean, +): { color: ButtonProps['color']; icon: JSX.Element; text: string } => { + if (status === Status.InWatchlist) { + if (isHovered) { + return { + text: 'Remove from watchlist', + icon: , + color: 'error', + }; + } + + return { + text: 'In watchlist', + icon: , + color: 'primary', + }; + } + + return { + text: 'Add to watchlist', + icon: , + color: 'primary', + }; +}; + +const WatchlistActionButton = () => { + const { upsertWatchlistItem, loading } = useWatchlistActions(); + const [hoverRef, isHovered] = useHover(); + const { show, update } = useContext(ShowPageContext); + const status = show?.status; + + if (!status) { + return null; + } + + const onClick = () => { + if (!show) { + return; + } + const showId = show.externalId; + const toStatus = ShowStatusToggleMap[status]; + + update({ status: toStatus }); + upsertWatchlistItem({ showId, status: toStatus }); + }; + + const { icon, text, color } = getActionProps(status, isHovered); + + return ( + + {text} + + ); +}; + +export default WatchlistActionButton; diff --git a/src/features/shows/components/seasons/EpisodeWatchActionButton.tsx b/src/features/shows/components/seasons/EpisodeWatchActionButton.tsx new file mode 100644 index 0000000..5b9128f --- /dev/null +++ b/src/features/shows/components/seasons/EpisodeWatchActionButton.tsx @@ -0,0 +1,30 @@ +import React, { useCallback, useContext } from 'react'; +import UpsertEpisodeAction from '../../features/episode/components/UpsertEpisodeAction'; +import { ShowPageContext } from '../../contexts/ShowPageContext'; + +interface Props { + seasonNumber: number; + episodeId: number; + isWatched: boolean; +} + +const EpisodeWatchActionButton = ({ + seasonNumber, + episodeId, + isWatched, +}: Props) => { + const { watchEpisode } = useContext(ShowPageContext); + const onWatchEpisode = useCallback(() => { + watchEpisode(seasonNumber, episodeId, !isWatched); + }, [episodeId, isWatched, seasonNumber, watchEpisode]); + + return ( + + ); +}; + +export default EpisodeWatchActionButton; diff --git a/src/features/shows/components/seasons/SeasonDetails.tsx b/src/features/shows/components/seasons/SeasonDetails.tsx new file mode 100644 index 0000000..267aa58 --- /dev/null +++ b/src/features/shows/components/seasons/SeasonDetails.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { + Box, + CircularProgress, + List, + ListItem, + ListItemAvatar, + ListItemSecondaryAction, + ListItemText, + Stack, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DateTime } from 'luxon'; +import { EpisodeWithoutShow } from '../../features/episode/types'; +import { makeWideSmImage } from '../../utils/image'; +import EpisodeWatchActionButton from './EpisodeWatchActionButton'; + +const StyledImage = styled('img')` + aspect-ratio: 3/2; + height: 100px; + object-fit: cover; +`; + +interface Props { + seasonNumber: number; + episodes?: EpisodeWithoutShow[]; +} + +const SeasonDetails = ({ seasonNumber, episodes }: Props) => + !episodes ? ( + + + + ) : ( + + {episodes.map( + ({ id, wideImage, name, description, isWatched, airDate, number }) => ( + + + + + + + {DateTime.fromISO(airDate).toFormat('d MMM, yyyy')} + + {description} + + } + sx={{ my: 0, pr: 2 }} + /> + + + + + ), + )} + + ); + +export default SeasonDetails; diff --git a/src/features/shows/components/seasons/SeasonSummary.tsx b/src/features/shows/components/seasons/SeasonSummary.tsx new file mode 100644 index 0000000..98f5f97 --- /dev/null +++ b/src/features/shows/components/seasons/SeasonSummary.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import Typography from '@mui/material/Typography'; +import { DateTime } from 'luxon'; +import { Box, Divider } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Season } from '../../../../generated/graphql'; +import { makeTallSmImage } from '../../utils/image'; + +const StyledImage = styled('img')` + aspect-ratio: 2/3; + width: 80px; + object-fit: cover; +`; + +interface Props { + season: Season; +} + +const SeasonSummary = ({ + season: { description, airDate, name, tallImage, episodeCount }, +}: Props) => ( + + + + + {name} + + + {DateTime.fromISO(airDate).toFormat('d MMM, yyyy')} + + + + {episodeCount} episodes + + + + + {description} + + + + +); + +export default SeasonSummary; diff --git a/src/features/shows/components/seasons/SeasonsAccordion.tsx b/src/features/shows/components/seasons/SeasonsAccordion.tsx new file mode 100644 index 0000000..f7ed3cd --- /dev/null +++ b/src/features/shows/components/seasons/SeasonsAccordion.tsx @@ -0,0 +1,54 @@ +import React, { SyntheticEvent, useContext, useState } from 'react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { AccordionSummary } from '@mui/material'; +import { ShowPageContext } from '../../contexts/ShowPageContext'; +import SeasonSummary from './SeasonSummary'; +import SeasonDetails from './SeasonDetails'; + +const SeasonsAccordion = () => { + const [expanded, setExpanded] = useState(); + const { show, episodesMap, fetchSeason } = useContext(ShowPageContext); + const seasons = show?.details?.seasons; + const fetchEpisodes = (seasonNumber: number) => { + fetchSeason(seasonNumber); + }; + + if (!seasons) { + return null; + } + + const handleChange = + (panel: number) => (event: SyntheticEvent, isExpanded: boolean) => { + if (isExpanded) { + fetchEpisodes(panel); + } + + setExpanded(isExpanded ? panel : undefined); + }; + + return ( +
+ {seasons.map((season) => ( + + }> + + + + + + + ))} +
+ ); +}; + +export default SeasonsAccordion; diff --git a/src/features/shows/components/seasons/SeasonsSection.tsx b/src/features/shows/components/seasons/SeasonsSection.tsx new file mode 100644 index 0000000..dccdb8b --- /dev/null +++ b/src/features/shows/components/seasons/SeasonsSection.tsx @@ -0,0 +1,25 @@ +import { Box } from '@mui/material'; +import { useContext } from 'react'; +import { ShowPageContext } from '../../contexts/ShowPageContext'; +import Section from '../../../../components/Section'; +import SeasonsAccordion from './SeasonsAccordion'; + +const SeasonsSection = () => { + const { show } = useContext(ShowPageContext); + const seasons = show?.details?.seasons; + + if (!seasons) { + return null; + } + + return ( + +
+ + +
+
+ ); +}; + +export default SeasonsSection; diff --git a/src/features/shows/constants.ts b/src/features/shows/constants.ts index 1ba6e4c..3ea4044 100644 --- a/src/features/shows/constants.ts +++ b/src/features/shows/constants.ts @@ -1,5 +1,7 @@ -export enum ShowStatus { - None = 'NONE', - InWatchlist = 'IN_WATCHLIST', - StoppedWatching = 'STOPPED_WATCHING', -} +import { Status } from '../../generated/graphql'; + +export const ShowStatusToggleMap = { + [Status.InWatchlist]: Status.None, + [Status.None]: Status.InWatchlist, + [Status.StoppedWatching]: Status.InWatchlist, +} as const; diff --git a/src/features/shows/contexts/ShowPageContext.tsx b/src/features/shows/contexts/ShowPageContext.tsx new file mode 100644 index 0000000..0fecd0b --- /dev/null +++ b/src/features/shows/contexts/ShowPageContext.tsx @@ -0,0 +1,124 @@ +import { + createContext, + ReactNode, + useCallback, + useEffect, + useState, +} from 'react'; +import { + FullShow, + useFullShowQuery, + useGetSeasonEpisodesLazyQuery, + useUpsertSeasonEpisodeMutation, +} from '../../../generated/graphql'; +import { noop } from '../../../utils/fp'; +import { EpisodeWithoutShow } from '../features/episode/types'; + +interface ContextType { + show?: FullShow; + episodesMap: Record; + loading: boolean; + update: (data: Partial) => void; + fetchSeason: (seasonNumber: number) => void; + watchEpisode: ( + seasonNumber: number, + episodeId: number, + isWatched: boolean, + ) => void; +} + +interface Props { + externalId: number; + children: ReactNode; +} + +const ShowPageContext = createContext({ + show: undefined, + episodesMap: {}, + loading: true, + update: noop, + fetchSeason: noop, + watchEpisode: noop, +}); + +const ShowPageProvider = ({ children, externalId }: Props) => { + const { data, loading } = useFullShowQuery({ variables: { externalId } }); + const [fetchSeasonEpisodes] = useGetSeasonEpisodesLazyQuery(); + const [show, setShow] = useState(); + const [episodesMap, setEpisodesMap] = useState< + Record + >({}); + const [upsertEpisode] = useUpsertSeasonEpisodeMutation(); + + const update = useCallback( + (data: Partial) => { + if (!show) { + return; + } + + setShow({ ...show, ...data }); + }, + [show], + ); + + const fetchSeason = useCallback( + async (seasonNumber: number) => { + if (episodesMap[seasonNumber]) { + return; + } + + const { data } = await fetchSeasonEpisodes({ + variables: { showId: externalId, seasonNumber }, + }); + + if (!data?.getSeasonEpisodes) { + return; + } + + setEpisodesMap({ + ...episodesMap, + [seasonNumber]: data.getSeasonEpisodes, + }); + }, + [episodesMap, externalId, fetchSeasonEpisodes], + ); + + const watchEpisode = useCallback( + async (seasonNumber: number, episodeId: number, isWatched: boolean) => { + setEpisodesMap((episodesMap) => ({ + ...episodesMap, + [seasonNumber]: episodesMap[seasonNumber].map((episode) => + episode.id !== episodeId ? episode : { ...episode, isWatched }, + ), + })); + + await upsertEpisode({ variables: { episodeId, isWatched } }); + }, + [upsertEpisode], + ); + + useEffect(() => { + if (data?.fullShow) { + setShow(data.fullShow); + } + }, [data]); + + return ( + + {children} + + ); +}; + +export { ShowPageContext }; + +export default ShowPageProvider; diff --git a/src/features/shows/features/episode/components/TallEpisodeCard.tsx b/src/features/shows/features/episode/components/TallEpisodeCard.tsx index a3a9428..245598a 100644 --- a/src/features/shows/features/episode/components/TallEpisodeCard.tsx +++ b/src/features/shows/features/episode/components/TallEpisodeCard.tsx @@ -1,13 +1,15 @@ import { Box, Tooltip } from '@mui/material'; import React, { PropsWithChildren, ReactNode } from 'react'; -import TallCard from '../../../components/base/TallCard'; +import TallCard from '../../../components/card/TallCard'; import EllipsisButton from '../../../../../components/EllipsisButton'; -import { ActionProps, EpisodeType } from '../types'; +import { EpisodeActionProps, EpisodeWithShowType } from '../types'; +import { DynamicRoute } from '../../../../router/constants'; +import { slugifyShow } from '../../../utils/slugify'; import TallEpisodeCardPlaceholder from './TallEpisodeCardPlaceholder'; interface Props { - episode: EpisodeType; - actions?: React.JSXElementConstructor[]; + episode: EpisodeWithShowType; + actions?: React.JSXElementConstructor[]; topChildren?: ReactNode; } @@ -24,13 +26,21 @@ const TallEpisodeCard = ({ const title = `${episode.seasonNumber}x${episode.number} - ${episode.name}`; return ( - + {title} {actions.map((Action) => ( - + ))} {children} diff --git a/src/features/shows/features/episode/components/TallEpisodeCollection.tsx b/src/features/shows/features/episode/components/TallEpisodeCollection.tsx index de2c947..06591ec 100644 --- a/src/features/shows/features/episode/components/TallEpisodeCollection.tsx +++ b/src/features/shows/features/episode/components/TallEpisodeCollection.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import TallCardCollection from '../../../components/base/TallCardCollection'; -import { ActionProps, EpisodeType } from '../types'; +import TallCardCollection from '../../../components/card/TallCardCollection'; +import { EpisodeActionProps, EpisodeWithShowType } from '../types'; import TallEpisodeCard from './TallEpisodeCard'; import TallEpisodeCardPlaceholder from './TallEpisodeCardPlaceholder'; interface Props { loading: boolean; - episodes: Array; - actions?: React.JSXElementConstructor[]; + episodes: Array; + actions?: React.JSXElementConstructor[]; } const TallEpisodeCollection = ({ episodes, loading, actions = [] }: Props) => ( diff --git a/src/features/shows/features/episode/components/UpsertEpisodeAction.tsx b/src/features/shows/features/episode/components/UpsertEpisodeAction.tsx index 1269ab4..4369a44 100644 --- a/src/features/shows/features/episode/components/UpsertEpisodeAction.tsx +++ b/src/features/shows/features/episode/components/UpsertEpisodeAction.tsx @@ -1,25 +1,28 @@ -import { useCallback, useContext } from 'react'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import ActionButton from '../../../components/base/ActionButton'; -import { UpNextContext } from '../../../../home/features/upnext/contexts/UpNextContext'; -import { ActionProps } from '../types'; +import ActionButton from '../../../components/card/ActionButton'; -const UpsertEpisodeAction = ({ episode }: ActionProps) => { - const { watchEpisode } = useContext(UpNextContext); - const onWatchEpisode = useCallback(() => { - watchEpisode(episode.id); - }, [episode, watchEpisode]); +interface Props { + isWatched: boolean; + onClick: () => void; + size?: 'small' | 'large'; +} - return ( - - {episode.isWatched ? : } - - ); -}; +const UpsertEpisodeAction = ({ isWatched, onClick, size = 'small' }: Props) => ( + + {isWatched ? ( + + ) : ( + + )} + +); export default UpsertEpisodeAction; diff --git a/src/features/shows/features/episode/components/WideEpisodeCard.tsx b/src/features/shows/features/episode/components/WideEpisodeCard.tsx deleted file mode 100644 index d63863b..0000000 --- a/src/features/shows/features/episode/components/WideEpisodeCard.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 { - episode: EpisodeType; - actions?: React.JSXElementConstructor[]; -} - -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 deleted file mode 100644 index cd019fc..0000000 --- a/src/features/shows/features/episode/components/WideEpisodeCollection.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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/features/episode/queries.ts b/src/features/shows/features/episode/queries.ts index f92f12c..77f775d 100644 --- a/src/features/shows/features/episode/queries.ts +++ b/src/features/shows/features/episode/queries.ts @@ -3,13 +3,13 @@ import { gql } from '@apollo/client'; export const MUTATION_UPSERT_EPISODE = gql` mutation UpsertEpisode($episodeId: Int!, $isWatched: Boolean = true) { upsertEpisode(input: { episodeId: $episodeId, isWatched: $isWatched }) { - ...Episode + ...EpisodeWithShow } } `; export const EPISODE_FRAGMENT = gql` - fragment Episode on Episode { + fragment EpisodeWithShow on Episode { id externalId number @@ -27,3 +27,19 @@ export const EPISODE_FRAGMENT = gql` } } `; + +export const QUERY_GET_SEASON_EPISODES = gql` + query GetSeasonEpisodes($showId: Int!, $seasonNumber: Int!) { + getSeasonEpisodes(input: { showId: $showId, seasonNumber: $seasonNumber }) { + id + externalId + number + seasonNumber + isWatched + name + description + wideImage + airDate + } + } +`; diff --git a/src/features/shows/features/episode/types.ts b/src/features/shows/features/episode/types.ts index 8e9daf3..55133ee 100644 --- a/src/features/shows/features/episode/types.ts +++ b/src/features/shows/features/episode/types.ts @@ -1,9 +1,15 @@ -import { EpisodeFragment } from '../../../../generated/graphql'; +import { + Episode, + EpisodeWithShowFragment, +} from '../../../../generated/graphql'; -export type EpisodeType = EpisodeFragment & { +export type EpisodeWithShowType = EpisodeWithShowFragment & { loading?: boolean; }; -export interface ActionProps { - episode: EpisodeType; +export type EpisodeWithoutShow = Omit; + +export interface EpisodeActionProps { + isWatched: boolean; + episodeId: number; } diff --git a/src/features/shows/hooks/useWatchlistActions.ts b/src/features/shows/hooks/useWatchlistActions.ts index 822c820..9990d99 100644 --- a/src/features/shows/hooks/useWatchlistActions.ts +++ b/src/features/shows/hooks/useWatchlistActions.ts @@ -1,9 +1,8 @@ -import { useCallback, useContext } from 'react'; +import { useCallback, useState } from 'react'; import { Status, useUpsertWatchlistItemMutation, } from '../../../generated/graphql'; -import { ShowsOnboardingContext } from '../../onboarding/contexts/ShowsOnboardingContext'; interface UpsertWatchlistItemArgs { showId: number; @@ -12,22 +11,23 @@ interface UpsertWatchlistItemArgs { const useWatchlistActions = () => { const [upsertWatchlistItemMutation] = useUpsertWatchlistItemMutation(); - const { updateShow } = useContext(ShowsOnboardingContext); + const [loading, setLoading] = useState(false); const upsertWatchlistItem = useCallback( - ({ showId, status }: UpsertWatchlistItemArgs) => { - upsertWatchlistItemMutation({ + async ({ showId, status }: UpsertWatchlistItemArgs) => { + setLoading(true); + await upsertWatchlistItemMutation({ variables: { showId, status, }, }); - updateShow(showId, { status }); + setLoading(false); }, - [updateShow, upsertWatchlistItemMutation], + [upsertWatchlistItemMutation], ); - return { upsertWatchlistItem }; + return { upsertWatchlistItem, loading }; }; export default useWatchlistActions; diff --git a/src/features/shows/queries.ts b/src/features/shows/queries.ts index d140098..a6548f3 100644 --- a/src/features/shows/queries.ts +++ b/src/features/shows/queries.ts @@ -19,17 +19,60 @@ export const QUERY_GET_PARTIAL_WATCHLIST = gql` } `; +export const QUERY_FULL_SHOW = gql` + query FullShow($externalId: Int!) { + fullShow(input: { externalId: $externalId }) { + externalId + name + description + wideImage + tallImage + firstAirDate + originCountry + status + rating + genres { + externalId + name + } + details { + episodeRuntime + isInProduction + seasons { + number + description + name + tallImage + episodeCount + airDate + } + } + } + } +`; + export const SHOW_FRAGMENT = gql` - fragment ShowFragment on PartialShow { + fragment PartialShow on PartialShow { externalId name description wideImage tallImage + firstAirDate + originCountry status + rating genres { externalId name } } `; + +export const MUTATION_UPSERT_SEASON_EPISODE = gql` + mutation UpsertSeasonEpisode($episodeId: Int!, $isWatched: Boolean = true) { + upsertEpisode(input: { episodeId: $episodeId, isWatched: $isWatched }) { + __typename + } + } +`; diff --git a/src/features/shows/utils/image.ts b/src/features/shows/utils/image.ts index 105f70f..e90d8ca 100644 --- a/src/features/shows/utils/image.ts +++ b/src/features/shows/utils/image.ts @@ -21,5 +21,7 @@ enum WideImageSize { const makeImageUrl = compose(concat, concat(IMAGE_URL), concat('/')); -export const makeTallSmallImage = makeImageUrl(TallImageSizes.w185); -export const makeWideSmallImage = makeImageUrl(WideImageSize.w300); +export const makeTallSmImage = makeImageUrl(TallImageSizes.w185); +export const makeWideSmImage = makeImageUrl(WideImageSize.w300); +export const makeTallMdImage = makeImageUrl(WideImageSize.w780); +export const makeWideLgImage = makeImageUrl(WideImageSize.original); diff --git a/src/features/shows/utils/slugify.ts b/src/features/shows/utils/slugify.ts new file mode 100644 index 0000000..231b3e2 --- /dev/null +++ b/src/features/shows/utils/slugify.ts @@ -0,0 +1,7 @@ +import slugify from 'slugify'; +import { PartialShow } from '../../../generated/graphql'; + +export const slugifyShow = (show: Partial): string => + slugify(`${show.externalId} - ${show.name}`, { lower: true }); + +export const deslugifyShow = (slug?: string) => Number(slug?.split('-')?.[0]); diff --git a/src/features/theme/theme.ts b/src/features/theme/theme.ts index 80a05c4..a591ee0 100644 --- a/src/features/theme/theme.ts +++ b/src/features/theme/theme.ts @@ -1,4 +1,4 @@ -import { blue, grey, red } from '@mui/material/colors'; +import { blue, grey, yellow, red } from '@mui/material/colors'; import { createTheme } from '@mui/material'; import type { PaletteMode } from '@mui/material'; import { LinkProps } from '@mui/material/Link/Link.d'; @@ -41,6 +41,16 @@ const theme = ({ mode = 'light' }: Props = {}) => }, }, }, + MuiRating: { + styleOverrides: { + iconEmpty: { + color: 'white', + }, + iconFilled: { + color: yellow[500], + }, + }, + }, }, typography: { fontFamily: '"Open Sans", Helvetica, Arial, sans-serif', diff --git a/src/features/theme/types.ts b/src/features/theme/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/generated/graphql.tsx b/src/generated/graphql.tsx index 3427395..f521850 100644 --- a/src/generated/graphql.tsx +++ b/src/generated/graphql.tsx @@ -42,12 +42,36 @@ export type Episode = { wideImage?: Maybe; }; +export type FullShow = { + __typename?: 'FullShow'; + description: Scalars['String']; + details: ShowDetails; + externalId: Scalars['Int']; + firstAirDate: Scalars['DateTime']; + genres: Array; + name: Scalars['String']; + originCountry: Scalars['String']; + rating: Scalars['Int']; + status: Status; + tallImage: Scalars['String']; + wideImage: Scalars['String']; +}; + +export type FullShowInput = { + externalId: Scalars['Int']; +}; + export type Genre = { __typename?: 'Genre'; externalId: Scalars['Int']; name: Scalars['String']; }; +export type GetSeasonEpisodesInput = { + seasonNumber: Scalars['Int']; + showId: Scalars['Int']; +}; + export type JoinWithGoogleInput = { token: Scalars['String']; }; @@ -82,8 +106,11 @@ export type PartialShow = { __typename?: 'PartialShow'; description: Scalars['String']; externalId: Scalars['Int']; + firstAirDate: Scalars['DateTime']; genres: Array; name: Scalars['String']; + originCountry: Scalars['String']; + rating: Scalars['Int']; status: Status; tallImage: Scalars['String']; wideImage: Scalars['String']; @@ -98,7 +125,9 @@ export type Query = { __typename?: 'Query'; allUsers: User; discoverShows: Array; + fullShow: FullShow; getPreferences?: Maybe; + getSeasonEpisodes: Array; getWatchlist: Array; listGenres?: Maybe>; listUpNext: Array; @@ -110,14 +139,32 @@ export type QueryDiscoverShowsArgs = { input: DiscoverShowsInput; }; +export type QueryFullShowArgs = { + input: FullShowInput; +}; + +export type QueryGetSeasonEpisodesArgs = { + input: GetSeasonEpisodesInput; +}; + export type Season = { __typename?: 'Season'; + airDate: Scalars['DateTime']; description?: Maybe; + episodeCount: Scalars['String']; name: Scalars['String']; number: Scalars['Int']; tallImage: Scalars['String']; }; +export type ShowDetails = { + __typename?: 'ShowDetails'; + episodeRuntime: Scalars['Int']; + isInProduction: Scalars['Boolean']; + seasons: Array; + tagline?: Maybe; +}; + export enum Status { InWatchlist = 'InWatchlist', None = 'None', @@ -240,7 +287,10 @@ export type DiscoverShowsQuery = { description: string; wideImage: string; tallImage: string; + firstAirDate: any; + originCountry: string; status: Status; + rating: number; genres: Array<{ __typename?: 'Genre'; externalId: number; name: string }>; }>; }; @@ -292,7 +342,7 @@ export type UpsertEpisodeMutation = { } | null; }; -export type EpisodeFragment = { +export type EpisodeWithShowFragment = { __typename?: 'Episode'; id: number; externalId: number; @@ -312,6 +362,27 @@ export type EpisodeFragment = { }; }; +export type GetSeasonEpisodesQueryVariables = Exact<{ + showId: Scalars['Int']; + seasonNumber: Scalars['Int']; +}>; + +export type GetSeasonEpisodesQuery = { + __typename?: 'Query'; + getSeasonEpisodes: Array<{ + __typename?: 'Episode'; + id: number; + externalId: number; + number: number; + seasonNumber: number; + isWatched: boolean; + name: string; + description?: string | null; + wideImage?: string | null; + airDate?: any | null; + }>; +}; + export type UpsertWatchlistItemMutationVariables = Exact<{ showId: Scalars['Int']; status: Status; @@ -333,17 +404,65 @@ export type GetPartialWatchlistQuery = { }>; }; -export type ShowFragmentFragment = { +export type FullShowQueryVariables = Exact<{ + externalId: Scalars['Int']; +}>; + +export type FullShowQuery = { + __typename?: 'Query'; + fullShow: { + __typename?: 'FullShow'; + externalId: number; + name: string; + description: string; + wideImage: string; + tallImage: string; + firstAirDate: any; + originCountry: string; + status: Status; + rating: number; + genres: Array<{ __typename?: 'Genre'; externalId: number; name: string }>; + details: { + __typename?: 'ShowDetails'; + episodeRuntime: number; + isInProduction: boolean; + seasons: Array<{ + __typename?: 'Season'; + number: number; + description?: string | null; + name: string; + tallImage: string; + episodeCount: string; + airDate: any; + }>; + }; + }; +}; + +export type PartialShowFragment = { __typename?: 'PartialShow'; externalId: number; name: string; description: string; wideImage: string; tallImage: string; + firstAirDate: any; + originCountry: string; status: Status; + rating: number; genres: Array<{ __typename?: 'Genre'; externalId: number; name: string }>; }; +export type UpsertSeasonEpisodeMutationVariables = Exact<{ + episodeId: Scalars['Int']; + isWatched?: InputMaybe; +}>; + +export type UpsertSeasonEpisodeMutation = { + __typename?: 'Mutation'; + upsertEpisode?: { __typename: 'Episode' } | null; +}; + export type MeQueryVariables = Exact<{ [key: string]: never }>; export type MeQuery = { @@ -375,8 +494,8 @@ export type LogoutMutation = { logout: { __typename: 'Void' }; }; -export const EpisodeFragmentDoc = gql` - fragment Episode on Episode { +export const EpisodeWithShowFragmentDoc = gql` + fragment EpisodeWithShow on Episode { id externalId number @@ -394,14 +513,17 @@ export const EpisodeFragmentDoc = gql` } } `; -export const ShowFragmentFragmentDoc = gql` - fragment ShowFragment on PartialShow { +export const PartialShowFragmentDoc = gql` + fragment PartialShow on PartialShow { externalId name description wideImage tallImage + firstAirDate + originCountry status + rating genres { externalId name @@ -469,10 +591,10 @@ export type ListGenresQueryResult = Apollo.QueryResult< export const ListUpcomingDocument = gql` query ListUpcoming { listUpcoming { - ...Episode + ...EpisodeWithShow } } - ${EpisodeFragmentDoc} + ${EpisodeWithShowFragmentDoc} `; /** @@ -529,10 +651,10 @@ export type ListUpcomingQueryResult = Apollo.QueryResult< export const ListUpNextDocument = gql` query ListUpNext { listUpNext { - ...Episode + ...EpisodeWithShow } } - ${EpisodeFragmentDoc} + ${EpisodeWithShowFragmentDoc} `; /** @@ -639,10 +761,10 @@ export type JoinWithGoogleMutationOptions = Apollo.BaseMutationOptions< export const DiscoverShowsDocument = gql` query DiscoverShows($genreIds: [Int!]!) { discoverShows(input: { genreIds: $genreIds }) { - ...ShowFragment + ...PartialShow } } - ${ShowFragmentFragmentDoc} + ${PartialShowFragmentDoc} `; /** @@ -812,10 +934,10 @@ export type GetPreferencesQueryResult = Apollo.QueryResult< export const UpsertEpisodeDocument = gql` mutation UpsertEpisode($episodeId: Int!, $isWatched: Boolean = true) { upsertEpisode(input: { episodeId: $episodeId, isWatched: $isWatched }) { - ...Episode + ...EpisodeWithShow } } - ${EpisodeFragmentDoc} + ${EpisodeWithShowFragmentDoc} `; export type UpsertEpisodeMutationFn = Apollo.MutationFunction< UpsertEpisodeMutation, @@ -862,6 +984,75 @@ export type UpsertEpisodeMutationOptions = Apollo.BaseMutationOptions< UpsertEpisodeMutation, UpsertEpisodeMutationVariables >; +export const GetSeasonEpisodesDocument = gql` + query GetSeasonEpisodes($showId: Int!, $seasonNumber: Int!) { + getSeasonEpisodes(input: { showId: $showId, seasonNumber: $seasonNumber }) { + id + externalId + number + seasonNumber + isWatched + name + description + wideImage + airDate + } + } +`; + +/** + * __useGetSeasonEpisodesQuery__ + * + * To run a query within a React component, call `useGetSeasonEpisodesQuery` and pass it any options that fit your needs. + * When your component renders, `useGetSeasonEpisodesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetSeasonEpisodesQuery({ + * variables: { + * showId: // value for 'showId' + * seasonNumber: // value for 'seasonNumber' + * }, + * }); + */ +export function useGetSeasonEpisodesQuery( + baseOptions: Apollo.QueryHookOptions< + GetSeasonEpisodesQuery, + GetSeasonEpisodesQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + + return Apollo.useQuery< + GetSeasonEpisodesQuery, + GetSeasonEpisodesQueryVariables + >(GetSeasonEpisodesDocument, options); +} +export function useGetSeasonEpisodesLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + GetSeasonEpisodesQuery, + GetSeasonEpisodesQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + + return Apollo.useLazyQuery< + GetSeasonEpisodesQuery, + GetSeasonEpisodesQueryVariables + >(GetSeasonEpisodesDocument, options); +} +export type GetSeasonEpisodesQueryHookResult = ReturnType< + typeof useGetSeasonEpisodesQuery +>; +export type GetSeasonEpisodesLazyQueryHookResult = ReturnType< + typeof useGetSeasonEpisodesLazyQuery +>; +export type GetSeasonEpisodesQueryResult = Apollo.QueryResult< + GetSeasonEpisodesQuery, + GetSeasonEpisodesQueryVariables +>; export const UpsertWatchlistItemDocument = gql` mutation UpsertWatchlistItem($showId: Int!, $status: Status!) { upsertWatchlistItem(input: { showId: $showId, status: $status }) { @@ -976,6 +1167,137 @@ export type GetPartialWatchlistQueryResult = Apollo.QueryResult< GetPartialWatchlistQuery, GetPartialWatchlistQueryVariables >; +export const FullShowDocument = gql` + query FullShow($externalId: Int!) { + fullShow(input: { externalId: $externalId }) { + externalId + name + description + wideImage + tallImage + firstAirDate + originCountry + status + rating + genres { + externalId + name + } + details { + episodeRuntime + isInProduction + seasons { + number + description + name + tallImage + episodeCount + airDate + } + } + } + } +`; + +/** + * __useFullShowQuery__ + * + * To run a query within a React component, call `useFullShowQuery` and pass it any options that fit your needs. + * When your component renders, `useFullShowQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFullShowQuery({ + * variables: { + * externalId: // value for 'externalId' + * }, + * }); + */ +export function useFullShowQuery( + baseOptions: Apollo.QueryHookOptions, +) { + const options = { ...defaultOptions, ...baseOptions }; + + return Apollo.useQuery( + FullShowDocument, + options, + ); +} +export function useFullShowLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + FullShowQuery, + FullShowQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + + return Apollo.useLazyQuery( + FullShowDocument, + options, + ); +} +export type FullShowQueryHookResult = ReturnType; +export type FullShowLazyQueryHookResult = ReturnType< + typeof useFullShowLazyQuery +>; +export type FullShowQueryResult = Apollo.QueryResult< + FullShowQuery, + FullShowQueryVariables +>; +export const UpsertSeasonEpisodeDocument = gql` + mutation UpsertSeasonEpisode($episodeId: Int!, $isWatched: Boolean = true) { + upsertEpisode(input: { episodeId: $episodeId, isWatched: $isWatched }) { + __typename + } + } +`; +export type UpsertSeasonEpisodeMutationFn = Apollo.MutationFunction< + UpsertSeasonEpisodeMutation, + UpsertSeasonEpisodeMutationVariables +>; + +/** + * __useUpsertSeasonEpisodeMutation__ + * + * To run a mutation, you first call `useUpsertSeasonEpisodeMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpsertSeasonEpisodeMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [upsertSeasonEpisodeMutation, { data, loading, error }] = useUpsertSeasonEpisodeMutation({ + * variables: { + * episodeId: // value for 'episodeId' + * isWatched: // value for 'isWatched' + * }, + * }); + */ +export function useUpsertSeasonEpisodeMutation( + baseOptions?: Apollo.MutationHookOptions< + UpsertSeasonEpisodeMutation, + UpsertSeasonEpisodeMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + + return Apollo.useMutation< + UpsertSeasonEpisodeMutation, + UpsertSeasonEpisodeMutationVariables + >(UpsertSeasonEpisodeDocument, options); +} +export type UpsertSeasonEpisodeMutationHookResult = ReturnType< + typeof useUpsertSeasonEpisodeMutation +>; +export type UpsertSeasonEpisodeMutationResult = + Apollo.MutationResult; +export type UpsertSeasonEpisodeMutationOptions = Apollo.BaseMutationOptions< + UpsertSeasonEpisodeMutation, + UpsertSeasonEpisodeMutationVariables +>; export const MeDocument = gql` query Me { me { diff --git a/src/hooks/useHandlePreferences.ts b/src/hooks/useHandlePreferences.ts index bbfa0b9..86e1a73 100644 --- a/src/hooks/useHandlePreferences.ts +++ b/src/hooks/useHandlePreferences.ts @@ -1,6 +1,6 @@ import { useCallback, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; -import { RoutePath } from '../features/router/constants'; +import { StaticRoute } from '../features/router/constants'; import { PreferencesContext } from '../features/preferences/contexts/PreferencesContext'; const useHandlePreferences = () => { @@ -10,14 +10,12 @@ const useHandlePreferences = () => { return useCallback( ({ genreIds }: { genreIds: number[] }) => { if (!genreIds.length) { - navigate(RoutePath.Welcome); + navigate(StaticRoute.Welcome); return; } setSelectedGenres(genreIds); - - navigate(RoutePath.Home); }, [navigate, setSelectedGenres], ); diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts new file mode 100644 index 0000000..e5a148d --- /dev/null +++ b/src/hooks/useHover.ts @@ -0,0 +1,26 @@ +import { MutableRefObject, useEffect, useRef, useState } from 'react'; + +function useHover(): [MutableRefObject, boolean] { + const [value, setValue] = useState(false); + const ref: any = useRef(null); + const handleMouseOver = (): void => setValue(true); + const handleMouseOut = (): void => setValue(false); + + useEffect(() => { + const node: any = ref.current; + + if (node) { + node.addEventListener('mouseover', handleMouseOver); + node.addEventListener('mouseout', handleMouseOut); + + return () => { + node.removeEventListener('mouseover', handleMouseOver); + node.removeEventListener('mouseout', handleMouseOut); + }; + } + }, []); + + return [ref, value]; +} + +export default useHover;