Repository: jason-liu22/netflix-clone-react-typescript Branch: main Commit: 0a0ad44894ab Files: 82 Total size: 108.0 KB Directory structure: gitextract_evgpsfy5/ ├── .dockerignore ├── .firebaserc ├── .github/ │ └── workflows/ │ ├── firebase-hosting-merge.yml │ └── firebase-hosting-pull-request.yml ├── .gitignore ├── Dockerfile ├── README.md ├── firebase.json ├── index.html ├── package.json ├── public/ │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src/ │ ├── CustomClassNameSetup.ts │ ├── components/ │ │ ├── AgeLimitChip.tsx │ │ ├── DetailModal.tsx │ │ ├── GenreBreadcrumbs.tsx │ │ ├── GridPage.tsx │ │ ├── GridWithInfiniteScroll.tsx │ │ ├── HeroSection.tsx │ │ ├── Logo.tsx │ │ ├── MainLoadingScreen.tsx │ │ ├── MaturityRate.tsx │ │ ├── MaxLineTypography.tsx │ │ ├── MoreInfoButton.tsx │ │ ├── NetflixIconButton.tsx │ │ ├── NetflixNavigationLink.tsx │ │ ├── PlayButton.tsx │ │ ├── QualityChip.tsx │ │ ├── SearchBox.tsx │ │ ├── SimilarVideoCard.tsx │ │ ├── VideoCardPortal.tsx │ │ ├── VideoItemWithHover.tsx │ │ ├── VideoItemWithHoverPure.tsx │ │ ├── VideoPortalContainer.tsx │ │ ├── VideoSlider.tsx │ │ ├── animate/ │ │ │ ├── MotionContainer.tsx │ │ │ └── variants/ │ │ │ ├── Wrap.ts │ │ │ ├── fade/ │ │ │ │ ├── FadeIn.ts │ │ │ │ └── FadeOut.ts │ │ │ └── zoom/ │ │ │ └── ZoomIn.ts │ │ ├── layouts/ │ │ │ ├── Footer.tsx │ │ │ ├── MainHeader.tsx │ │ │ └── index.ts │ │ ├── slick-slider/ │ │ │ ├── CustomNavigation.tsx │ │ │ └── SlickSlider.tsx │ │ └── watch/ │ │ ├── PlayerControlButton.tsx │ │ ├── PlayerSeekbar.tsx │ │ ├── VideoJSPlayer.tsx │ │ └── VolumeControllers.tsx │ ├── constant/ │ │ └── index.ts │ ├── hoc/ │ │ └── withPagination.tsx │ ├── hooks/ │ │ ├── redux.ts │ │ ├── useIntersectionObserver.ts │ │ ├── useOffSetTop.ts │ │ └── useWindowSize.ts │ ├── layouts/ │ │ └── MainLayout.tsx │ ├── lib/ │ │ └── createSafeContext.ts │ ├── main.tsx │ ├── pages/ │ │ ├── GenreExplore.tsx │ │ ├── HomePage.tsx │ │ └── WatchPage.tsx │ ├── providers/ │ │ ├── DetailModalProvider.tsx │ │ └── PortalProvider.tsx │ ├── routes/ │ │ └── index.tsx │ ├── store/ │ │ ├── index.ts │ │ └── slices/ │ │ ├── apiSlice.ts │ │ ├── configuration.ts │ │ ├── discover.ts │ │ └── genre.ts │ ├── theme/ │ │ └── palette.ts │ ├── types/ │ │ ├── Common.ts │ │ ├── Genre.ts │ │ └── Movie.ts │ ├── utils/ │ │ ├── common.ts │ │ └── index.ts │ ├── videojs-youtube.d.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .* build node_modules ================================================ FILE: .firebaserc ================================================ { "projects": { "default": "websites-f0426" } } ================================================ FILE: .github/workflows/firebase-hosting-merge.yml ================================================ # This file was auto-generated by the Firebase CLI # https://github.com/firebase/firebase-tools name: Deploy to Firebase Hosting on merge 'on': push: branches: - main jobs: build_and_deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - run: npm ci && npm run build env: VITE_APP_API_ENDPOINT_URL: '${{ secrets.VITE_APP_API_ENDPOINT_URL }}' VITE_APP_TMDB_V3_API_KEY: '${{ secrets.VITE_APP_TMDB_V3_API_KEY }}' - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: '${{ secrets.GITHUB_TOKEN }}' firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_WEBSITES_F0426 }}' channelId: live projectId: websites-f0426 ================================================ FILE: .github/workflows/firebase-hosting-pull-request.yml ================================================ # This file was auto-generated by the Firebase CLI # https://github.com/firebase/firebase-tools name: Deploy to Firebase Hosting on PR 'on': pull_request jobs: build_and_preview: if: '${{ github.event.pull_request.head.repo.full_name == github.repository }}' runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - run: npm ci && npm run build - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: '${{ secrets.GITHUB_TOKEN }}' firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_WEBSITES_F0426 }}' projectId: websites-f0426 ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # env .env # testing /coverage # production /build /dist # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn.lock yarn-debug.log* yarn-error.log* ================================================ FILE: Dockerfile ================================================ FROM node:16.17.0-alpine as builder WORKDIR /app COPY ./package.json . COPY ./yarn.lock . RUN yarn install COPY . . ARG TMDB_V3_API_KEY ENV VITE_APP_TMDB_V3_API_KEY=${TMDB_V3_API_KEY} ENV VITE_APP_API_ENDPOINT_URL="https://api.themoviedb.org/3" RUN yarn build FROM nginx:stable-alpine WORKDIR /usr/share/nginx/html RUN rm -rf ./* COPY --from=builder /app/dist . EXPOSE 80 ENTRYPOINT ["nginx", "-g", "daemon off;"] ================================================ FILE: README.md ================================================
Logo

Netflix Clone

View Demo · Report Bug · Request Feature

Table of Contents
  1. Prerequests
  2. Which features this project deals with
  3. Third Party libraries used except for React and RTK
  4. Contact

Logo

Home Page

Logo

Mini Portal

Logo

Detail Modal

Logo

Grid Genre Page

Logo

Watch Page with customer contol bar

## Prerequests - Create an account if you don't have on [TMDB](https://www.themoviedb.org/). Because I use its free API to consume movie/tv data. - And then follow the [documentation](https://developers.themoviedb.org/3/getting-started/introduction) to create API Key - Finally, if you use v3 of TMDB API, create a file named `.env`, and copy and paste the content of `.env.example`. And then paste the API Key you just created. ## Which features this project deal with - How to create and use [Custom Hooks](https://reactjs.org/docs/hooks-custom.html) - How to use [Context](https://reactjs.org/docs/context.html) and its provider - How to use lazy and Suspense for [Code-Splitting](https://reactjs.org/docs/code-splitting.html) - How to use a new [lazy](https://reactrouter.com/en/main/route/lazy) feature of react-router to reduce bundle size. - How to use data [loader](https://reactrouter.com/en/main/route/loader) of react-router, and how to use redux dispatch in the loader to fetch data before rendering component. - How to use [Portal](https://reactjs.org/docs/portals.html) - How to use [Fowarding Refs](https://reactjs.org/docs/forwarding-refs.html) to make components reusuable - How to create and use [HOC](https://reactjs.org/docs/higher-order-components.html) - How to customize default theme of [MUI](https://mui.com/) - How to use [RTK](https://redux-toolkit.js.org/introduction/getting-started) - How to use [RTK Query](https://redux-toolkit.js.org/rtk-query/overview) - How to customize default classname of [MUI](https://mui.com/material-ui/experimental-api/classname-generator) - Infinite Scrolling(using [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)) - How to make awesome carousel using [slick-carousel](https://react-slick.neostack.com) ## Third Party libraries used except for React and RTK - [react-router-dom@v6.9](https://reactrouter.com/en/main) - [MUI(Material UI)](https://mui.com/) - [framer-motion](https://www.framer.com/docs/) - [video.js](https://videojs.com) - [react-slick](https://react-slick.neostack.com/) ## Install with Docker ```sh docker build --build-arg TMDB_V3_API_KEY=your_api_key_here -t netflix-clone . docker run --name netflix-clone-website --rm -d -p 80:80 netflix-clone ``` ## Todo - Make the animation of video card portal more similar to Netflix. - Improve performance. I am using `context` and `provider` but all components subscribed to the context's value are re-rendered. These re-renders happen even if the part of the value is not used in render of the component. there are [several ways](https://blog.axlight.com/posts/4-options-to-prevent-extra-rerenders-with-react-context/) to prevent the re-renders from these behaviours. In addition to them, there may be several performance issues. - Replace bundler([Vite](https://vitejs.dev/guide)) with [Turbopack](https://turbo.build/pack/docs/why-turbopack). Turbopack is introduced in Next.js conf recently. It's very fast but it's nor ready to use right now. it just support Next.js, and they plan to support all others as soon as possible. so if it's ready to use, replace [Vite](https://vitejs.dev/guide) with [Turbopack](https://turbo.build/pack/docs/why-turbopack). - Add accessibilities for better UX. - Add Tests. ================================================ FILE: firebase.json ================================================ { "hosting": { "public": "dist", "ignore": [ "firebase.json", "**/.*", "**/node_modules/**" ], "rewrites": [ { "source": "**", "destination": "/index.html" } ] } } ================================================ FILE: index.html ================================================ Netflix
================================================ FILE: package.json ================================================ { "name": "netflix-clone-using-react-typescript-mui", "version": "0.1.1", "type": "module", "private": true, "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" }, "dependencies": { "@emotion/react": "^11.10.0", "@emotion/styled": "^11.10.0", "@mui/icons-material": "^5.8.4", "@mui/material": "^5.10.0", "@reduxjs/toolkit": "^1.8.3", "framer-motion": "^7.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^8.0.2", "react-router-dom": "^6.9.0", "react-slick": "^0.29.0", "slick-carousel": "^1.8.1", "video.js": "^8.3.0", "videojs-youtube": "^3.0.1", "vite-tsconfig-paths": "^3.5.2" }, "devDependencies": { "@types/react": "^18.0.24", "@types/react-dom": "^18.0.8", "@types/react-slick": "^0.23.10", "@vitejs/plugin-react": "^2.2.0", "typescript": "^4.6.4", "vite": "^3.2.1" } } ================================================ FILE: public/index.html ================================================ Netflix
================================================ FILE: public/manifest.json ================================================ { "short_name": "React App", "name": "Create React App Sample", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" }, { "src": "logo192.png", "type": "image/png", "sizes": "192x192" }, { "src": "logo512.png", "type": "image/png", "sizes": "512x512" } ], "start_url": ".", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } ================================================ FILE: public/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: ================================================ FILE: src/CustomClassNameSetup.ts ================================================ import { unstable_ClassNameGenerator as ClassNameGenerator } from "@mui/material/className"; ClassNameGenerator.configure((componentName) => { let newComponentName = componentName; // you can replace Mui, default prefix of every component with new one you want newComponentName = newComponentName.replace("Mui", "Netflix"); // you can replace default classname of component with new one you want newComponentName = newComponentName.replace("Button", "Btn"); return newComponentName; }); ================================================ FILE: src/components/AgeLimitChip.tsx ================================================ import Chip, { ChipProps } from "@mui/material/Chip"; export default function AgeLimitChip({ sx, ...others }: ChipProps) { return ( span": { p: 0 }, ...sx, }} variant="outlined" /> ); } ================================================ FILE: src/components/DetailModal.tsx ================================================ import { forwardRef, useCallback, useRef, useState } from "react"; import Box from "@mui/material/Box"; import Grid from "@mui/material/Grid"; import Container from "@mui/material/Container"; import Stack from "@mui/material/Stack"; import IconButton from "@mui/material/IconButton"; import Typography from "@mui/material/Typography"; import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import Slide from "@mui/material/Slide"; import { TransitionProps } from "@mui/material/transitions"; import CloseIcon from "@mui/icons-material/Close"; import AddIcon from "@mui/icons-material/Add"; import ThumbUpOffAltIcon from "@mui/icons-material/ThumbUpOffAlt"; import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import VolumeOffIcon from "@mui/icons-material/VolumeOff"; import Player from "video.js/dist/types/player"; import MaxLineTypography from "./MaxLineTypography"; import PlayButton from "./PlayButton"; import NetflixIconButton from "./NetflixIconButton"; import AgeLimitChip from "./AgeLimitChip"; import QualityChip from "./QualityChip"; import { formatMinuteToReadable, getRandomNumber } from "src/utils/common"; import SimilarVideoCard from "./SimilarVideoCard"; import { useDetailModal } from "src/providers/DetailModalProvider"; import { useGetSimilarVideosQuery } from "src/store/slices/discover"; import { MEDIA_TYPE } from "src/types/Common"; import VideoJSPlayer from "./watch/VideoJSPlayer"; const Transition = forwardRef(function Transition( props: TransitionProps & { children: React.ReactElement; }, ref: React.Ref ) { return ; }); export default function DetailModal() { const { detail, setDetailType } = useDetailModal(); const { data: similarVideos } = useGetSimilarVideosQuery( { mediaType: detail.mediaType ?? MEDIA_TYPE.Movie, id: detail.id ?? 0 }, { skip: !detail.id } ); const playerRef = useRef(null); const [muted, setMuted] = useState(true); const handleReady = useCallback((player: Player) => { playerRef.current = player; setMuted(player.muted()); }, []); const handleMute = useCallback((status: boolean) => { if (playerRef.current) { playerRef.current.muted(!status); setMuted(!status); } }, []); if (detail.mediaDetail) { return ( { setDetailType({ mediaType: undefined, id: undefined }); }} sx={{ top: 15, right: 15, position: "absolute", bgcolor: "#181818", width: { xs: 22, sm: 40 }, height: { xs: 22, sm: 40 }, "&:hover": { bgcolor: "primary.main", }, }} > {detail.mediaDetail?.title} handleMute(muted)} sx={{ zIndex: 2 }} > {!muted ? : } {`${getRandomNumber(100)}% Match`} {detail.mediaDetail?.release_date.substring(0, 4)} {`${formatMinuteToReadable( getRandomNumber(180) )}`} {detail.mediaDetail?.overview} {`Genres : ${detail.mediaDetail?.genres .map((g) => g.name) .join(", ")}`} {`Available in : ${detail.mediaDetail?.spoken_languages .map((l) => l.name) .join(", ")}`} {similarVideos && similarVideos.results.length > 0 && ( More Like This {similarVideos.results.map((sm) => ( ))} )} ); } return null; } ================================================ FILE: src/components/GenreBreadcrumbs.tsx ================================================ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Breadcrumbs, { BreadcrumbsProps } from "@mui/material/Breadcrumbs"; const Separator = ( ); interface GenreBreadcrumbsProps extends BreadcrumbsProps { genres: string[]; } export default function GenreBreadcrumbs({ genres, ...others }: GenreBreadcrumbsProps) { return ( {genres.map((genre, idx) => ( {genre} ))} ); } ================================================ FILE: src/components/GridPage.tsx ================================================ import withPagination from "src/hoc/withPagination"; import { MEDIA_TYPE } from "src/types/Common"; import { CustomGenre, Genre } from "src/types/Genre"; import GridWithInfiniteScroll from "./GridWithInfiniteScroll"; interface GridPageProps { genre: Genre | CustomGenre; mediaType: MEDIA_TYPE; } export default function GridPage({ genre, mediaType }: GridPageProps) { const Component = withPagination( GridWithInfiniteScroll, mediaType, genre ); return ; } ================================================ FILE: src/components/GridWithInfiniteScroll.tsx ================================================ import { useRef, useEffect } from "react"; import Grid from "@mui/material/Grid"; import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; import Typography from "@mui/material/Typography"; import VideoItemWithHover from "./VideoItemWithHover"; import { CustomGenre, Genre } from "src/types/Genre"; import { PaginatedMovieResult } from "src/types/Common"; import useIntersectionObserver from "src/hooks/useIntersectionObserver"; interface GridWithInfiniteScrollProps { genre: Genre | CustomGenre; data: PaginatedMovieResult; handleNext: (page: number) => void; } export default function GridWithInfiniteScroll({ genre, data, handleNext, }: GridWithInfiniteScrollProps) { const intersectionRef = useRef(null); const intersection = useIntersectionObserver(intersectionRef); useEffect(() => { if ( intersection && intersection.intersectionRatio === 1 && data.page < data.total_pages ) { handleNext(data.page + 1); } }, [intersection]); return ( <> {`${genre.name} Movies`} {data.results .filter((v) => !!v.backdrop_path) .map((video, idx) => ( ))} ); } ================================================ FILE: src/components/HeroSection.tsx ================================================ import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import VolumeOffIcon from "@mui/icons-material/VolumeOff"; import Player from "video.js/dist/types/player"; import { getRandomNumber } from "src/utils/common"; import MaxLineTypography from "./MaxLineTypography"; import PlayButton from "./PlayButton"; import MoreInfoButton from "./MoreInfoButton"; import NetflixIconButton from "./NetflixIconButton"; import MaturityRate from "./MaturityRate"; import useOffSetTop from "src/hooks/useOffSetTop"; import { useDetailModal } from "src/providers/DetailModalProvider"; import { MEDIA_TYPE } from "src/types/Common"; import { useGetVideosByMediaTypeAndCustomGenreQuery, useLazyGetAppendedVideosQuery, } from "src/store/slices/discover"; import { Movie } from "src/types/Movie"; import VideoJSPlayer from "./watch/VideoJSPlayer"; interface TopTrailerProps { mediaType: MEDIA_TYPE; } export default function TopTrailer({ mediaType }: TopTrailerProps) { const { data } = useGetVideosByMediaTypeAndCustomGenreQuery({ mediaType, apiString: "popular", page: 1, }); const [getVideoDetail, { data: detail }] = useLazyGetAppendedVideosQuery(); const [video, setVideo] = useState(null); const [muted, setMuted] = useState(true); const playerRef = useRef(null); const isOffset = useOffSetTop(window.innerWidth * 0.5625); const { setDetailType } = useDetailModal(); const maturityRate = useMemo(() => { return getRandomNumber(20); }, []); const handleReady = useCallback((player: Player) => { playerRef.current = player; }, []); useEffect(() => { if (playerRef.current) { if (isOffset) { playerRef.current.pause(); } else { if (playerRef.current.paused()) { playerRef.current.play(); } } } }, [isOffset]); useEffect(() => { if (data && data.results) { const videos = data.results.filter((item) => !!item.backdrop_path); setVideo(videos[getRandomNumber(videos.length)]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]); useEffect(() => { if (video) { getVideoDetail({ mediaType, id: video.id }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [video]); const handleMute = useCallback((status: boolean) => { if (playerRef.current) { playerRef.current.muted(!status); setMuted(!status); } }, []); return ( {video && ( <> {detail && ( )} handleMute(muted)} sx={{ zIndex: 2 }} > {!muted ? : } {`${maturityRate}+`} {video.title} {video.overview} { setDetailType({ mediaType, id: video.id }); }} /> )} ); } ================================================ FILE: src/components/Logo.tsx ================================================ import Box, { BoxProps } from "@mui/material/Box"; import { Link as RouterLink } from "react-router-dom"; import { MAIN_PATH } from "src/constant"; export default function Logo({ sx }: BoxProps) { return ( ); } ================================================ FILE: src/components/MainLoadingScreen.tsx ================================================ import CircularProgress from "@mui/material/CircularProgress"; function MainLoadingScreen() { return (
); } export default MainLoadingScreen; ================================================ FILE: src/components/MaturityRate.tsx ================================================ import Box from "@mui/material/Box"; import { ReactNode } from "react"; export default function MaturityRate({ children }: { children: ReactNode }) { return ( {children} ); } ================================================ FILE: src/components/MaxLineTypography.tsx ================================================ import { forwardRef } from "react"; import Typography, { TypographyProps } from "@mui/material/Typography"; const MaxLineTypography = forwardRef< HTMLDivElement, TypographyProps & { maxLine: number } >(({ maxLine, children, sx, ...others }, ref) => { return ( {children} ); }); export default MaxLineTypography; ================================================ FILE: src/components/MoreInfoButton.tsx ================================================ import Button, { ButtonProps } from "@mui/material/Button"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; export default function MoreInfoButton({ sx, ...others }: ButtonProps) { return ( ); } ================================================ FILE: src/components/NetflixIconButton.tsx ================================================ import { forwardRef } from "react"; import IconButton, { IconButtonProps } from "@mui/material/IconButton"; const NetflixIconButton = forwardRef( ({ children, sx, ...others }, ref) => { return ( {children} ); } ); export default NetflixIconButton; ================================================ FILE: src/components/NetflixNavigationLink.tsx ================================================ import { Link as RouterLink, LinkProps as RouterLinkProps, } from "react-router-dom"; import Link, { LinkProps } from "@mui/material/Link"; export default function NetflixNavigationLink({ sx, children, ...others }: LinkProps & RouterLinkProps) { return ( {children} ); } ================================================ FILE: src/components/PlayButton.tsx ================================================ import Button, { ButtonProps } from "@mui/material/Button"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import { useNavigate } from "react-router-dom"; import { MAIN_PATH } from "src/constant"; export default function PlayButton({ sx, ...others }: ButtonProps) { const navigate = useNavigate(); return ( ); } ================================================ FILE: src/components/QualityChip.tsx ================================================ import Chip, { ChipProps } from "@mui/material/Chip"; export default function QualityChip({ sx, ...others }: ChipProps) { return ( span": { p: 0 }, ...sx, }} /> ); } ================================================ FILE: src/components/SearchBox.tsx ================================================ import { useState, useRef } from "react"; import { styled } from "@mui/material/styles"; import InputBase from "@mui/material/InputBase"; import SearchIcon from "@mui/icons-material/Search"; const Search = styled("div")(({ theme }) => ({ position: "relative", width: "100%", display: "flex", alignItems: "center", })); const SearchIconWrapper = styled("div")(({ theme }) => ({ cursor: "pointer", padding: theme.spacing(0, 1), height: "100%", display: "flex", alignItems: "center", justifyContent: "center", })); const StyledInputBase = styled(InputBase)(({ theme }) => ({ color: "inherit", "& .NetflixInputBase-input": { width: 0, transition: theme.transitions.create("width", { duration: theme.transitions.duration.complex, easing: theme.transitions.easing.easeIn, }), "&:focus": { width: "auto", }, }, })); export default function SearchBox() { const [isFocused, setIsFocused] = useState(false); const searchInputRef = useRef(); const handleClickSearchIcon = () => { if (!isFocused) { searchInputRef.current?.focus(); } }; return ( { setIsFocused(true); }, onBlur: () => { setIsFocused(false); }, }} /> ); } ================================================ FILE: src/components/SimilarVideoCard.tsx ================================================ import Stack from "@mui/material/Stack"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Typography from "@mui/material/Typography"; import AddIcon from "@mui/icons-material/Add"; import { Movie } from "src/types/Movie"; import NetflixIconButton from "./NetflixIconButton"; import MaxLineTypography from "./MaxLineTypography"; import { formatMinuteToReadable, getRandomNumber } from "src/utils/common"; import AgeLimitChip from "./AgeLimitChip"; import { useGetConfigurationQuery } from "src/store/slices/configuration"; interface SimilarVideoCardProps { video: Movie; } export default function SimilarVideoCard({ video }: SimilarVideoCardProps) { const { data: configuration } = useGetConfigurationQuery(undefined); return (
{`${formatMinuteToReadable( getRandomNumber(180) )}`}
{video.title}
{`${getRandomNumber(100)}% Match`} {video.release_date.substring(0, 4)}
{video.overview} ); } ================================================ FILE: src/components/VideoCardPortal.tsx ================================================ import { useNavigate } from "react-router-dom"; import Stack from "@mui/material/Stack"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Typography from "@mui/material/Typography"; import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import PlayCircleIcon from "@mui/icons-material/PlayCircle"; import ThumbUpOffAltIcon from "@mui/icons-material/ThumbUpOffAlt"; import AddIcon from "@mui/icons-material/Add"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { Movie } from "src/types/Movie"; import { usePortal } from "src/providers/PortalProvider"; import { useDetailModal } from "src/providers/DetailModalProvider"; import { formatMinuteToReadable, getRandomNumber } from "src/utils/common"; import NetflixIconButton from "./NetflixIconButton"; import MaxLineTypography from "./MaxLineTypography"; import AgeLimitChip from "./AgeLimitChip"; import QualityChip from "./QualityChip"; import GenreBreadcrumbs from "./GenreBreadcrumbs"; import { useGetConfigurationQuery } from "src/store/slices/configuration"; import { MEDIA_TYPE } from "src/types/Common"; import { useGetGenresQuery } from "src/store/slices/genre"; import { MAIN_PATH } from "src/constant"; interface VideoCardModalProps { video: Movie; anchorElement: HTMLElement; } export default function VideoCardModal({ video, anchorElement, }: VideoCardModalProps) { const navigate = useNavigate(); const { data: configuration } = useGetConfigurationQuery(undefined); const { data: genres } = useGetGenresQuery(MEDIA_TYPE.Movie); const setPortal = usePortal(); const rect = anchorElement.getBoundingClientRect(); const { setDetailType } = useDetailModal(); return ( { setPortal(null, null); }} sx={{ width: rect.width * 1.5, height: "100%", }} >
{video.title}
navigate(`/${MAIN_PATH.watch}`)} >
{ setDetailType({ mediaType: MEDIA_TYPE.Movie, id: video.id }); }} > {`${getRandomNumber(100)}% Match`} {`${formatMinuteToReadable( getRandomNumber(180) )}`} {genres && ( video.genre_ids.includes(genre.id)) .map((genre) => genre.name)} /> )} ); } ================================================ FILE: src/components/VideoItemWithHover.tsx ================================================ import { useEffect, useState, useRef } from "react"; import { Movie } from "src/types/Movie"; import { usePortal } from "src/providers/PortalProvider"; import { useGetConfigurationQuery } from "src/store/slices/configuration"; import VideoItemWithHoverPure from "./VideoItemWithHoverPure"; interface VideoItemWithHoverProps { video: Movie; } export default function VideoItemWithHover({ video }: VideoItemWithHoverProps) { const setPortal = usePortal(); const elementRef = useRef(null); const [isHovered, setIsHovered] = useState(false); const { data: configuration } = useGetConfigurationQuery(undefined); useEffect(() => { if (isHovered) { setPortal(elementRef.current, video); } }, [isHovered]); return ( ); } ================================================ FILE: src/components/VideoItemWithHoverPure.tsx ================================================ import { PureComponent, ForwardedRef, forwardRef } from "react"; type VideoItemWithHoverPureType = { src: string; innerRef: ForwardedRef; handleHover: (value: boolean) => void; }; class VideoItemWithHoverPure extends PureComponent { render() { return (
{ // console.log("onPointerEnter"); this.props.handleHover(true); }} onPointerLeave={() => { // console.log("onPointerLeave"); this.props.handleHover(false); }} />
); } } const VideoItemWithHoverRef = forwardRef< HTMLDivElement, Omit >((props, ref) => ); VideoItemWithHoverRef.displayName = "VideoItemWithHoverRef"; export default VideoItemWithHoverRef; ================================================ FILE: src/components/VideoPortalContainer.tsx ================================================ import { useRef } from "react"; import { motion } from "framer-motion"; import Portal from "@mui/material/Portal"; import VideoCardPortal from "./VideoCardPortal"; import MotionContainer from "./animate/MotionContainer"; import { varZoomIn, varZoomInLeft, varZoomInRight, } from "./animate/variants/zoom/ZoomIn"; import { usePortalData } from "src/providers/PortalProvider"; export default function VideoPortalContainer() { const { miniModalMediaData, anchorElement } = usePortalData(); const container = useRef(null); const rect = anchorElement?.getBoundingClientRect(); const hasToRender = !!miniModalMediaData && !!anchorElement; let isFirstElement = false; let isLastElement = false; let variant = varZoomIn; if (hasToRender) { const parentElement = anchorElement.closest(".slick-active"); const nextSiblingOfParentElement = parentElement?.nextElementSibling; const previousSiblingOfParentElement = parentElement?.previousElementSibling; if (!previousSiblingOfParentElement?.classList.contains("slick-active")) { isFirstElement = true; variant = varZoomInLeft; } else if ( !nextSiblingOfParentElement?.classList.contains("slick-active") ) { isLastElement = true; variant = varZoomInRight; } } return ( <> {hasToRender && ( )} ); } ================================================ FILE: src/components/VideoSlider.tsx ================================================ import withPagination from "src/hoc/withPagination"; import { MEDIA_TYPE } from "src/types/Common"; import { CustomGenre, Genre } from "src/types/Genre"; import SlickSlider from "./slick-slider/SlickSlider"; interface SliderRowForGenreProps { genre: Genre | CustomGenre; mediaType: MEDIA_TYPE; } export default function SliderRowForGenre({ genre, mediaType, }: SliderRowForGenreProps) { const Component = withPagination(SlickSlider, mediaType, genre); return ; } ================================================ FILE: src/components/animate/MotionContainer.tsx ================================================ import { motion } from "framer-motion"; import Box, { BoxProps } from "@mui/material/Box"; import { varWrapBoth } from "./variants/Wrap"; interface MotionContainerProps extends BoxProps { initial?: boolean | string; open?: boolean; } export default function MotionContainer({ open, children, ...other }: MotionContainerProps) { return ( {children} ); } ================================================ FILE: src/components/animate/variants/Wrap.ts ================================================ export const varWrapEnter = { animate: { transition: { staggerChildren: 0.1 }, }, }; export const varWrapExit = { exit: { transition: { staggerChildren: 0.1 }, }, }; export const varWrapBoth = { animate: { transition: { staggerChildren: 0.07, delayChildren: 0.1 }, }, exit: { transition: { staggerChildren: 0.05, staggerDirection: -1 }, }, }; ================================================ FILE: src/components/animate/variants/fade/FadeIn.ts ================================================ const TRANSITION_ENTER = { duration: 0.64, ease: [0.43, 0.13, 0.23, 0.96], }; const TRANSITION_EXIT = { duration: 0.48, ease: [0.43, 0.13, 0.23, 0.96], }; export const varFadeIn = { initial: { opacity: 0 }, animate: { opacity: 1, transition: TRANSITION_ENTER }, exit: { opacity: 0, transition: TRANSITION_EXIT }, }; ================================================ FILE: src/components/animate/variants/fade/FadeOut.ts ================================================ const TRANSITION_ENTER = { duration: 0.64, ease: [0.43, 0.13, 0.23, 0.96], }; const TRANSITION_EXIT = { duration: 0.48, ease: [0.43, 0.13, 0.23, 0.96], }; export const varFadeOut = { initial: { opacity: 1 }, animate: { opacity: 0, transition: TRANSITION_ENTER }, exit: { opacity: 1, transition: TRANSITION_EXIT }, }; ================================================ FILE: src/components/animate/variants/zoom/ZoomIn.ts ================================================ const DISTANCE = 0; const IN = { scale: 1, opacity: 1 }; const OUT = { scale: 0, opacity: 0 }; const TRANSITION_ENTER = { duration: 1, ease: [0.43, 0.13, 0.23, 0.96], }; const TRANSITION_EXIT = { duration: 1, ease: [0.43, 0.13, 0.23, 0.96], }; export const varZoomIn = { initial: OUT, animate: { ...IN, transition: TRANSITION_ENTER }, exit: { ...OUT, transition: TRANSITION_EXIT }, }; export const varZoomInLeft = { initial: { ...OUT, translateX: -DISTANCE }, animate: { ...IN, translateX: 0, transition: TRANSITION_ENTER }, exit: { ...OUT, translateX: -DISTANCE, transition: TRANSITION_EXIT }, }; export const varZoomInRight = { initial: { ...OUT, translateX: DISTANCE }, animate: { ...IN, translateX: 0, transition: TRANSITION_ENTER }, exit: { ...OUT, translateX: DISTANCE, transition: TRANSITION_EXIT }, }; ================================================ FILE: src/components/layouts/Footer.tsx ================================================ import Box from "@mui/material/Box"; import Link from "@mui/material/Link"; import Typography from "@mui/material/Typography"; import Divider from "@mui/material/Divider"; export default function Footer() { return ( Developed by{" "} Crazy Man ); } ================================================ FILE: src/components/layouts/MainHeader.tsx ================================================ import * as React from "react"; import AppBar from "@mui/material/AppBar"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Toolbar from "@mui/material/Toolbar"; import IconButton from "@mui/material/IconButton"; import Typography from "@mui/material/Typography"; import Menu from "@mui/material/Menu"; import MenuIcon from "@mui/icons-material/Menu"; import Avatar from "@mui/material/Avatar"; import Tooltip from "@mui/material/Tooltip"; import MenuItem from "@mui/material/MenuItem"; import useOffSetTop from "src/hooks/useOffSetTop"; import { APP_BAR_HEIGHT } from "src/constant"; import Logo from "../Logo"; import SearchBox from "../SearchBox"; import NetflixNavigationLink from "../NetflixNavigationLink"; const pages = ["My List", "Movies", "Tv Shows"]; const MainHeader = () => { const isOffset = useOffSetTop(APP_BAR_HEIGHT); const [anchorElNav, setAnchorElNav] = React.useState( null ); const [anchorElUser, setAnchorElUser] = React.useState( null ); const handleOpenNavMenu = (event: React.MouseEvent) => { setAnchorElNav(event.currentTarget); }; const handleOpenUserMenu = (event: React.MouseEvent) => { setAnchorElUser(event.currentTarget); }; const handleCloseNavMenu = () => { setAnchorElNav(null); }; const handleCloseUserMenu = () => { setAnchorElUser(null); }; return ( theme.shadows[4], } : { boxShadow: 0, bgcolor: "transparent" }), }} > {pages.map((page) => ( {page} ))} Netflix {pages.map((page) => ( {page} ))} {["Account", "Logout"].map((setting) => ( {setting} ))} ); }; export default MainHeader; ================================================ FILE: src/components/layouts/index.ts ================================================ export { default as MainHeader } from "./MainHeader"; export { default as Footer } from "./Footer"; ================================================ FILE: src/components/slick-slider/CustomNavigation.tsx ================================================ import { styled } from "@mui/material/styles"; import Box from "@mui/material/Box"; import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; import { MouseEventHandler, ReactNode } from "react"; const ArrowStyle = styled(Box)(({ theme }) => ({ top: 0, bottom: 0, position: "absolute", zIndex: 9, height: "100%", opacity: 0.48, display: "flex", cursor: "pointer", alignItems: "center", justifyContent: "center", color: theme.palette.common.white, // background: theme.palette.grey[700], transition: theme.transitions.create("opacity"), "&:hover": { opacity: 0.8, background: theme.palette.grey[900], }, [theme.breakpoints.down("sm")]: { display: "none", }, })); interface CustomNaviationProps { isEnd: boolean; arrowWidth: number; children: ReactNode; activeSlideIndex: number; onNext: MouseEventHandler; onPrevious: MouseEventHandler; } export default function CustomNavigation({ isEnd, onNext, children, onPrevious, arrowWidth, activeSlideIndex, }: CustomNaviationProps) { return ( <> {activeSlideIndex > 0 && ( // `linear-gradient(to right, ${theme.palette.background.default} 0%, rgba(0,0,0,0) 100%)`, }} > )} {children} {!isEnd && ( // `linear-gradient(to left, ${theme.palette.background.default} 0%, rgba(0,0,0,0) 100%)`, }} > )} ); } ================================================ FILE: src/components/slick-slider/SlickSlider.tsx ================================================ import { useState, useRef } from "react"; import Slider, { Settings } from "react-slick"; import { motion } from "framer-motion"; import { styled, Theme, useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import CustomNavigation from "./CustomNavigation"; import VideoItemWithHover from "src/components/VideoItemWithHover"; import { ARROW_MAX_WIDTH } from "src/constant"; import NetflixNavigationLink from "src/components/NetflixNavigationLink"; import MotionContainer from "src/components/animate/MotionContainer"; import { varFadeIn } from "src/components/animate/variants/fade/FadeIn"; import { CustomGenre, Genre } from "src/types/Genre"; import { Movie } from "src/types/Movie"; import { PaginatedMovieResult } from "src/types/Common"; const RootStyle = styled("div")(() => ({ position: "relative", overflow: "inherit", })); const StyledSlider = styled(Slider)( ({ theme, padding }: { theme: Theme; padding: number }) => ({ display: "flex !important", justifyContent: "center", overflow: "initial !important", "& > .slick-list": { overflow: "visible", }, [theme.breakpoints.up("sm")]: { "& > .slick-list": { width: `calc(100% - ${2 * padding}px)`, }, "& .slick-list > .slick-track": { margin: "0px !important", }, "& .slick-list > .slick-track > .slick-current > div > .NetflixBox-root > .NetflixPaper-root:hover": { transformOrigin: "0% 50% !important", }, }, [theme.breakpoints.down("sm")]: { "& > .slick-list": { width: `calc(100% - ${padding}px)`, }, }, }) ); interface SlideItemProps { item: Movie; } function SlideItem({ item }: SlideItemProps) { return ( ); } interface SlickSliderProps { data: PaginatedMovieResult; genre: Genre | CustomGenre; handleNext: (page: number) => void; } export default function SlickSlider({ data, genre }: SlickSliderProps) { const sliderRef = useRef(null); const [activeSlideIndex, setActiveSlideIndex] = useState(0); const [showExplore, setShowExplore] = useState(false); const [isEnd, setIsEnd] = useState(false); const theme = useTheme(); const beforeChange = async (currentIndex: number, nextIndex: number) => { if (currentIndex < nextIndex) { setActiveSlideIndex(nextIndex); } else if (currentIndex > nextIndex) { setIsEnd(false); } setActiveSlideIndex(nextIndex); }; const settings: Settings = { speed: 500, arrows: false, infinite: false, lazyLoad: "ondemand", slidesToShow: 6, slidesToScroll: 6, // afterChange: (current) => { // console.log("After Change", current); // }, beforeChange, // onEdge: (direction) => { // console.log("Edge: ", direction); // }, responsive: [ { breakpoint: 1536, settings: { slidesToShow: 5, slidesToScroll: 5, }, }, { breakpoint: 1200, settings: { slidesToShow: 4, slidesToScroll: 4, }, }, { breakpoint: 900, settings: { slidesToShow: 3, slidesToScroll: 3, }, }, { breakpoint: 600, settings: { slidesToShow: 2, slidesToScroll: 2, }, }, ], }; const handlePrevious = () => { sliderRef.current?.slickPrev(); }; const handleNext = () => { sliderRef.current?.slickNext(); }; return ( {data.results.length > 0 && ( <> { setShowExplore(true); }} onMouseLeave={() => { setShowExplore(false); }} > {`${genre.name} Movies `} {"Explore All".split("").map((letter, index) => ( {letter} ))} {data.results .filter((i) => !!i.backdrop_path) .map((item) => ( ))} )} ); } ================================================ FILE: src/components/watch/PlayerControlButton.tsx ================================================ import { forwardRef } from "react"; import IconButton, { IconButtonProps } from "@mui/material/IconButton"; const PlayerControlButton = forwardRef( ({ children, ...others }, ref) => ( {children} ) ); export default PlayerControlButton; ================================================ FILE: src/components/watch/PlayerSeekbar.tsx ================================================ import Slider from "@mui/material/Slider"; import { styled } from "@mui/material/styles"; import { formatTime } from "src/utils/common"; const StyledSlider = styled(Slider)({ borderRadius: 0, "& .NetflixSlider-track": { backgroundColor: "red !important", border: 0, }, "& .NetflixSlider-rail": { border: "none", backgroundColor: "white !important", opacity: 0.85, }, "& .NetflixSlider-thumb": { borderRadius: "50%", height: 10, width: 10, backgroundColor: "red", "&:focus, &:hover, &.Netflix-active, &.Netflix-focusVisible": { boxShadow: "inherit", height: 15, width: 15, }, "&:before": { display: "none", boxShadow: "0 2px 2px 0 #fff", height: 10, width: 10, }, }, // "& .NetflixSlider-valueLabel": { // lineHeight: 1.2, // fontSize: 12, // background: "unset", // padding: 0, // width: 32, // height: 32, // borderRadius: "50% 50% 50% 0", // backgroundColor: "#52af77", // transformOrigin: "bottom left", // transform: "translate(50%, -100%) rotate(-45deg) scale(0)", // "&:before": { display: "none" }, // "&.NetflixSlider-valueLabelOpen": { // transform: "translate(50%, -100%) rotate(-45deg) scale(1)", // }, // "& > *": { // transform: "rotate(45deg)", // }, // }, }); function PlayerSeekbar({ playedSeconds, duration, seekTo, }: { playedSeconds: number; duration: number; seekTo: (value: number) => void; }) { return ( formatTime(v)} // components={{ // ValueLabel: ValueLabelComponent, // }} value={playedSeconds} max={duration} onChange={(_, value) => { seekTo(value as number); }} /> ); } export default PlayerSeekbar; ================================================ FILE: src/components/watch/VideoJSPlayer.tsx ================================================ import { useEffect, useRef } from "react"; import Player from "video.js/dist/types/player"; import videojs from "video.js"; import "videojs-youtube"; import "video.js/dist/video-js.css"; export default function VideoJSPlayer({ options, onReady, }: { options: any; onReady: (player: Player) => void; }) { const videoRef = useRef(null); const playerRef = useRef(null); useEffect(() => { (async function handleVideojs() { // Make sure Video.js player is only initialized once if (!playerRef.current) { // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode. const videoElement = document.createElement("video-js"); // videoElement.classList.add("vjs-big-play-centered", "vjs-16-9"); videoRef.current?.appendChild(videoElement); const player = (playerRef.current = videojs( videoElement, options, () => { onReady && onReady(player); } )); // import("video.js").then(async ({ default: videojs }) => { // await import("video.js/dist/video-js.css"); // if (options["techOrder"] && options["techOrder"].includes("youtube")) { // // eslint-disable-next-line // await import("videojs-youtube"); // } // const player = (playerRef.current = videojs( // videoElement, // options, // () => { // onReady && onReady(player); // } // )); // }); // await import("video.js/dist/video-js.css"); // const videojs = await import("video.js"); // if (options["techOrder"] && options["techOrder"].includes("youtube")) { // // eslint-disable-next-line // await import("videojs-youtube"); // } // const player = (playerRef.current = videojs.default( // videoElement, // options, // () => { // onReady && onReady(player); // } // )); // You could update an existing player in the `else` block here // on prop change, for example: } else { const player = playerRef.current; // player.autoplay(options.autoplay); player.width(options.width); player.height(options.height); } })(); }, [options, videoRef]); // Dispose the Video.js player when the functional component unmounts useEffect(() => { const player = playerRef.current; return () => { if (player && !player.isDisposed()) { player.dispose(); playerRef.current = null; } }; }, [playerRef]); return (
); } ================================================ FILE: src/components/watch/VolumeControllers.tsx ================================================ import { Stack } from "@mui/material"; import Slider from "@mui/material/Slider"; import { styled } from "@mui/material/styles"; import { SliderUnstyledOwnProps } from "@mui/base/SliderUnstyled"; import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import VolumeOffIcon from "@mui/icons-material/VolumeOff"; import PlayerControlButton from "./PlayerControlButton"; const StyledSlider = styled(Slider)({ height: 5, borderRadius: 0, padding: 0, "& .NetflixSlider-track": { border: "none", backgroundColor: "red", }, "& .NetflixSlider-rail": { border: "none", backgroundColor: "white", opacity: 0.85, }, "& .NetflixSlider-thumb": { height: 10, width: 10, backgroundColor: "red", "&:focus, &:hover, &.Netflix-active, &.Netflix-focusVisible": { boxShadow: "inherit", height: 15, width: 15, }, "&:before": { display: "none", }, }, }); export default function VolumeControllers({ value, handleVolume, handleVolumeToggle, muted, }: { value: number; handleVolume: SliderUnstyledOwnProps["onChange"]; handleVolumeToggle: React.MouseEventHandler; muted: boolean; }) { return ( {!muted ? : } x} onChange={handleVolume} sx={{ width: { xs: 60, sm: 80, md: 100 } }} /> ); } ================================================ FILE: src/constant/index.ts ================================================ import { CustomGenre } from "src/types/Genre"; export const API_ENDPOINT_URL = import.meta.env.VITE_APP_API_ENDPOINT_URL; export const TMDB_V3_API_KEY = import.meta.env.VITE_APP_TMDB_V3_API_KEY; export const MAIN_PATH = { root: "", browse: "browse", genreExplore: "genre", watch: "watch", }; export const ARROW_MAX_WIDTH = 60; export const COMMON_TITLES: CustomGenre[] = [ { name: "Popular", apiString: "popular" }, { name: "Top Rated", apiString: "top_rated" }, { name: "Now Playing", apiString: "now_playing" }, { name: "Upcoming", apiString: "upcoming" }, ]; export const YOUTUBE_URL = "https://www.youtube.com/watch?v="; export const APP_BAR_HEIGHT = 70; export const INITIAL_DETAIL_STATE = { id: undefined, mediaType: undefined, mediaDetail: undefined, }; ================================================ FILE: src/hoc/withPagination.tsx ================================================ import { ElementType, useCallback, useEffect } from "react"; import MainLoadingScreen from "src/components/MainLoadingScreen"; import { useAppDispatch, useAppSelector } from "src/hooks/redux"; import { initiateItem, useLazyGetVideosByMediaTypeAndGenreIdQuery, useLazyGetVideosByMediaTypeAndCustomGenreQuery, } from "src/store/slices/discover"; import { MEDIA_TYPE } from "src/types/Common"; import { CustomGenre, Genre } from "src/types/Genre"; export default function withPagination( Component: ElementType, mediaType: MEDIA_TYPE, genre: Genre | CustomGenre ) { return function WithPagination() { const dispatch = useAppDispatch(); const itemKey = genre.id ?? (genre as CustomGenre).apiString; const mediaState = useAppSelector((state) => state.discover[mediaType]); const pageState = mediaState ? mediaState[itemKey] : undefined; const [getVideosByMediaTypeAndGenreId] = useLazyGetVideosByMediaTypeAndGenreIdQuery(); const [getVideosByMediaTypeAndCustomGenre] = useLazyGetVideosByMediaTypeAndCustomGenreQuery(); useEffect(() => { if (!mediaState || !pageState) { dispatch(initiateItem({ mediaType, itemKey })); } }, [mediaState, pageState]); useEffect(() => { if (pageState && pageState.page === 0) { handleNext(pageState.page + 1); } }, [pageState]); const handleNext = useCallback((page: number) => { if (genre.id) { getVideosByMediaTypeAndGenreId({ mediaType, genreId: genre.id, page, }); } else { getVideosByMediaTypeAndCustomGenre({ mediaType, apiString: (genre as CustomGenre).apiString, page, }); } // dispatch(setNextPage({ mediaType, itemKey })); }, []); if (pageState) { return ( ); } return ; }; } ================================================ FILE: src/hooks/redux.ts ================================================ import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import type { RootState, AppDispatch } from "../store"; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppSelector: TypedUseSelectorHook = useSelector; ================================================ FILE: src/hooks/useIntersectionObserver.ts ================================================ import { RefObject, useEffect, useState } from "react"; // import { buildThresholdList } from "src/utils"; export default function useIntersectionObserver( ref: RefObject, options?: IntersectionObserverInit ) { const [entry, setEntry] = useState(null); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { setEntry(entry); }, options ?? { root: null, rootMargin: "0px", // threshold: buildThresholdList(), threshold: 1, } ); if (ref.current) { observer.observe(ref.current); } return () => { if (ref.current) { observer.unobserve(ref.current); } }; }, []); return entry; } ================================================ FILE: src/hooks/useOffSetTop.ts ================================================ import { useState, useEffect, useCallback } from "react"; export default function useOffSetTop(top: number) { const [offsetTop, setOffSetTop] = useState(false); const onScroll = useCallback(() => { if (window.pageYOffset > top) { setOffSetTop(true); } else { setOffSetTop(false); } }, [top]); useEffect(() => { window.addEventListener("scroll", onScroll); return () => { window.removeEventListener("scroll", onScroll); }; }, [top]); return offsetTop; } ================================================ FILE: src/hooks/useWindowSize.ts ================================================ import { useState, useEffect } from "react"; export default function useWindowSize() { // Initialize state with undefined width/height so server and client renders match // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/ const [windowSize, setWindowSize] = useState<{ width: number | undefined; height: number | undefined; }>({ width: undefined, height: undefined, }); useEffect(() => { // Handler to call on window resize function handleResize() { // Set window width/height to state setWindowSize({ width: window.innerWidth, height: window.innerHeight, }); } // Add event listener window.addEventListener("resize", handleResize); // Call handler right away so state gets updated with initial window size handleResize(); // Remove event listener on cleanup return () => window.removeEventListener("resize", handleResize); }, []); // Empty array ensures that effect is only run on mount return windowSize; } ================================================ FILE: src/layouts/MainLayout.tsx ================================================ import { Outlet, useLocation, useNavigation } from "react-router-dom"; import Box from "@mui/material/Box"; import DetailModal from "src/components/DetailModal"; import VideoPortalContainer from "src/components/VideoPortalContainer"; import DetailModalProvider from "src/providers/DetailModalProvider"; import PortalProvider from "src/providers/PortalProvider"; import { MAIN_PATH } from "src/constant"; import { Footer, MainHeader } from "src/components/layouts"; import MainLoadingScreen from "src/components/MainLoadingScreen"; export default function MainLayout() { const location = useLocation(); const navigation = useNavigation(); // console.log("Nav Stat: ", navigation.state); return ( {navigation.state !== "idle" && } {/* */} {location.pathname !== `/${MAIN_PATH.watch}` &&