├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── components.json ├── declarative-routing.config.json ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public └── tmdb-logo.svg ├── src ├── actions │ └── fetch-tmdb.ts ├── app │ ├── (pages) │ │ ├── error │ │ │ ├── page.info.ts │ │ │ └── page.tsx │ │ ├── home │ │ │ ├── page.info.ts │ │ │ └── page.tsx │ │ ├── movies │ │ │ ├── page.info.ts │ │ │ └── page.tsx │ │ ├── search │ │ │ ├── page.info.ts │ │ │ └── page.tsx │ │ └── tv │ │ │ ├── page.info.ts │ │ │ └── page.tsx │ ├── @modal │ │ └── [...slug] │ │ │ ├── page.info.ts │ │ │ └── page.tsx │ ├── api │ │ └── search │ │ │ ├── route.info.ts │ │ │ └── route.ts │ ├── default.tsx │ ├── error.tsx │ ├── globals.css │ └── layout.tsx ├── components │ ├── divider.tsx │ ├── epic-stage.tsx │ ├── fonts.tsx │ ├── footer.tsx │ ├── home-page.tsx │ ├── icons.tsx │ ├── media-modal │ │ ├── genre │ │ │ ├── genre-modal.tsx │ │ │ ├── high-rated.tsx │ │ │ ├── new-movie-tv.tsx │ │ │ └── spotlight.tsx │ │ ├── media-modal.tsx │ │ ├── movie-tv │ │ │ ├── backdrop.tsx │ │ │ ├── bonus-content.tsx │ │ │ ├── cast.tsx │ │ │ ├── external-links.tsx │ │ │ ├── label.tsx │ │ │ ├── metadata.tsx │ │ │ ├── more-like-this.tsx │ │ │ ├── movie-tv-modal.tsx │ │ │ └── trailers.tsx │ │ ├── overlay.tsx │ │ └── person │ │ │ ├── person-modal.tsx │ │ │ ├── person-movie-tv.tsx │ │ │ └── person-spotlight.tsx │ ├── nav-bar.tsx │ ├── search │ │ ├── search-input.tsx │ │ └── search-result.tsx │ ├── skeletons.tsx │ ├── slider │ │ ├── media-header │ │ │ ├── media-header.tsx │ │ │ └── page-indicator.tsx │ │ ├── paginate-button │ │ │ ├── paginate-button.tsx │ │ │ ├── paginate-left-button.tsx │ │ │ └── paginate-right-button.tsx │ │ ├── slider.css │ │ ├── slider.tsx │ │ └── tiles │ │ │ ├── thumbnails │ │ │ ├── bonus-trailer-thumbnail.tsx │ │ │ ├── cast-thumbnail.tsx │ │ │ ├── genres.tsx │ │ │ ├── movie-thumbnail.tsx │ │ │ ├── spotlight-thumbnail.tsx │ │ │ └── tv-thumbnail.tsx │ │ │ ├── tile-container.tsx │ │ │ └── tile-item.tsx │ ├── thumbnail-wrapper.tsx │ └── ui │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ └── input.tsx ├── hooks │ ├── use-animation.ts │ ├── use-effect-once.ts │ ├── use-map-pages.ts │ ├── use-page-utils.ts │ ├── use-pagination.ts │ ├── use-resize-direction.ts │ ├── use-resize-window.ts │ ├── use-slide.ts │ ├── use-tiles.ts │ └── use-validators.ts ├── lib │ ├── constants.ts │ ├── env.ts │ ├── logger.ts │ └── utils.ts ├── providers │ ├── navigation │ │ └── navigation-provider.tsx │ ├── providers.tsx │ ├── search │ │ └── search-provider.tsx │ └── slider │ │ ├── create-slider-store.ts │ │ ├── ref-provider.tsx │ │ └── slider-provider.tsx ├── routes │ ├── README.md │ ├── hooks.ts │ ├── index.ts │ └── makeRoute.tsx └── types │ ├── global-types.ts │ └── tmdb-types.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_NODE_ENV= 2 | TMDB_API_KEY= 3 | TMDB_READ_ACCESS_TOKEN= 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:react-hooks/recommended", 7 | "plugin:@typescript-eslint/recommended" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "plugins": [ 11 | "react", 12 | "react-hooks", 13 | "@typescript-eslint" 14 | ], 15 | "rules": { 16 | "no-restricted-imports": [ 17 | "error", 18 | { 19 | "paths": [{ 20 | "name": "@/providers/slider-provider", 21 | "importNames": ["useSliderStore"], 22 | "message": "Import `useSliderStore` within custom hooks only." 23 | }] 24 | } 25 | ], 26 | "semi": "off", 27 | "no-extra-semi": "off", 28 | "no-console": ["warn", { "allow": ["error"] }], 29 | "react/no-unescaped-entities": "off", 30 | "react/react-in-jsx-scope": "off", 31 | "@next/next/no-page-custom-font": "off", 32 | "no-unused-vars": "off", 33 | "@typescript-eslint/no-unused-vars": [ 34 | "warn", { 35 | "varsIgnorePattern": "^_", 36 | "argsIgnorePattern": "^_", 37 | "ignoreRestSiblings": true 38 | } 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | frontendmentor 22 | data.json 23 | .idea 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | .env 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "printWidth": 80, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "semi": true, 8 | "jsxSingleQuote": true, 9 | "bracketSpacing": true, 10 | "jsxBracketSameLine": false, 11 | "arrowParens": "avoid", 12 | "endOfLine": "auto", 13 | "importOrder": [ 14 | "^(react/(.*)$)|^(react$)", 15 | "^(next/(.*)$)|^(next$)", 16 | "", 17 | "", 18 | "^types$", 19 | "^@/env(.*)$", 20 | "^@/types/(.*)$", 21 | "^@/config/(.*)$", 22 | "^@/store/(.*)$", 23 | "^@/database/(.*)$", 24 | "^@/lib/(.*)$", 25 | "^@/hooks/(.*)$", 26 | "^@/components/ui/(.*)$", 27 | "^@/components/(.*)$", 28 | "^@radix-ui/(.*)$", 29 | "^@/styles/(.*)$", 30 | "^@/app/(.*)$", 31 | "", 32 | "^[./]" 33 | ], 34 | "importOrderSeparation": false, 35 | "importOrderSortSpecifiers": true, 36 | "importOrderBuiltinModulesToTop": true, 37 | "importOrderParserPlugins": [ 38 | "typescript", 39 | "jsx", 40 | "decorators-legacy" 41 | ], 42 | "importOrderMergeDuplicateImports": true, 43 | "importOrderCombineTypeAndValueImports": true, 44 | "plugins": [ 45 | "@ianvs/prettier-plugin-sort-imports", 46 | "prettier-plugin-tailwindcss" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | Nextjs Logo 5 |
6 | 7 |

Next Movie

8 | 9 |

🍿 Live Preview www.next-movie-adam-ridhwan.vercel.app

10 | 11 |

🔰 About

✨ Screenshots 16 | 17 | screenshot 1 18 | 19 |
20 |
21 | 22 | screenshot 2 23 | 24 |
25 |
26 | 27 | screenshot 3 28 | 29 |
30 |
31 | 32 |
33 | screenshot 4 34 |
35 | 36 |
37 |
38 | 39 | ![Slider demo](https://github.com/adam-ridhwan/next-imdb/assets/76563028/15e2c630-8726-4a6a-9a9e-c1dc07701a4b) 40 | 41 |

Features

42 | 43 | - 🎞️ Complex Carousel Slider 44 | - 📡 Server Action Data Fetching 45 | - 🔍 Searching 46 | - 🔀 Parallel Route Modals 47 | - 🛡️ Type Safety with Zod 48 | - 🪝 Custom React hooks 49 | - 🗃️ Zustand State Management 50 | - 📱 Responsive Design 51 | 52 |

🚀 Getting Started

53 | 54 |

Prequisites

55 | 56 | 1) Install pnpm 57 | ```bash 58 | npm install -g pnpm 59 | ``` 60 | 61 | 2) Add TMDB API Key and Read Access Token to `.env.local` file. Keys can be retrieved from [TMDB](https://developer.themoviedb.org/docs/getting-started) webstie 62 | ```bash 63 | NEXT_PUBLIC_NODE_ENV= 64 | TMDB_API_KEY= 65 | TMDB_READ_ACCESS_TOKEN= 66 | ``` 67 | 68 | 3) Install dependencies 69 | ```bash 70 | pnpm install 71 | ``` 72 | 73 | 4) Testing development code 74 | ```bash 75 | pnpm dev 76 | ``` 77 | 78 | 5) Testing production code 79 | ```bash 80 | pnpm prod 81 | ``` 82 | 83 |

⭐️ Acknowledgements

84 | 85 | TMDB Logo 86 | 87 | Data provided by [TMDB](https://www.themoviedb.org/) 88 | 89 |

Author

90 | 91 |

Adam Ridhwan

92 | 93 | - GitHub: [@adam-ridhwan](https://github.com/adam-ridhwan) 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /declarative-routing.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "nextjs", 3 | "src": "./src/app", 4 | "routes": "./src/routes" 5 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [{ hostname: 'image.tmdb.org' }], 5 | }, 6 | async redirects() { 7 | return [ 8 | { 9 | source: '/', 10 | destination: '/home', 11 | permanent: true, 12 | }, 13 | ]; 14 | }, 15 | }; 16 | 17 | module.exports = nextConfig; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-movie", 3 | "version": "0.1.0", 4 | "private": false, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "prod": "next build && next start", 10 | "lint": "next lint", 11 | "dr:build": "npx declarative-routing build", 12 | "dr:build:watch": "npx declarative-routing build --watch" 13 | }, 14 | "dependencies": { 15 | "@radix-ui/react-dialog": "^1.0.5", 16 | "@radix-ui/react-icons": "^1.3.0", 17 | "@radix-ui/react-slot": "^1.0.2", 18 | "@vercel/speed-insights": "^1.0.10", 19 | "chalk": "^5.3.0", 20 | "class-variance-authority": "^0.7.0", 21 | "clsx": "^2.1.0", 22 | "envalid": "^8.0.0", 23 | "eslint-plugin-import": "^2.29.1", 24 | "lucide-react": "^0.360.0", 25 | "next": "^14.1.1", 26 | "pnpm": "^8.15.7", 27 | "prop-types": "^15.8.1", 28 | "query-string": "^9.0.0", 29 | "react": "^18.2.0", 30 | "react-dom": "^18.2.0", 31 | "sharp": "^0.33.3", 32 | "sonner": "^1.4.41", 33 | "swr": "^2.2.5", 34 | "tailwind-merge": "^1.14.0", 35 | "tailwindcss-animate": "^1.0.7", 36 | "usehooks-ts": "^3.1.0", 37 | "uuid": "^9.0.1", 38 | "zod": "^3.22.4", 39 | "zustand": "^4.5.2" 40 | }, 41 | "devDependencies": { 42 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 43 | "@types/lodash": "^4.17.1", 44 | "@types/node": "^20.11.30", 45 | "@types/react": "^18", 46 | "@types/react-dom": "^18", 47 | "@types/uuid": "^9.0.8", 48 | "@typescript-eslint/eslint-plugin": "^7.4.0", 49 | "@typescript-eslint/parser": "^7.4.0", 50 | "autoprefixer": "^10", 51 | "eslint": "^8.57.0", 52 | "eslint-config-next": "13.5.5", 53 | "eslint-plugin-react": "^7.34.1", 54 | "eslint-plugin-react-hooks": "^4.6.0", 55 | "postcss": "^8", 56 | "prettier-plugin-tailwindcss": "^0.5.6", 57 | "tailwindcss": "^3", 58 | "typescript": "^5" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/tmdb-logo.svg: -------------------------------------------------------------------------------- 1 | Asset 3 -------------------------------------------------------------------------------- /src/actions/fetch-tmdb.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { redirect } from 'next/navigation'; 4 | import { ErrorPage } from '@/routes'; 5 | import { Schema } from 'zod'; 6 | 7 | import { FetchTMDBParams } from '@/types/global-types'; 8 | import { env } from '@/lib/env'; 9 | 10 | const { TMDB_READ_ACCESS_TOKEN } = env; 11 | 12 | const BASE_URL = 'https://api.themoviedb.org/3'; 13 | 14 | const createUrl = (params: FetchTMDBParams): string => { 15 | if (params.mediaType === 'movie' || params.mediaType === 'tv') { 16 | switch (params.category) { 17 | case 'popular': 18 | return `${BASE_URL}/${params.mediaType}/${params.category}?language=en-US&page=1`; 19 | 20 | case 'trending': 21 | return `${BASE_URL}/${params.category}/${params.mediaType}/day`; 22 | 23 | case 'discover': { 24 | const url = new URL( 25 | `${BASE_URL}/${params.category}/${params.mediaType}` 26 | ); 27 | url.searchParams.append( 28 | 'with_genres', 29 | params.genreId.toString() || '28' 30 | ); 31 | url.searchParams.append('page', params.page?.toString() || '1'); 32 | url.searchParams.append('with_original_language', 'en'); 33 | url.searchParams.append('sort_by', 'popularity.desc'); 34 | url.searchParams.append('with_origin_country', 'US'); 35 | url.searchParams.append( 36 | 'vote_average.gte', 37 | params.vote_average_gte?.toString() || '0' 38 | ); 39 | 40 | if ( 41 | params.mediaType === 'movie' && 42 | params.primary_release_date_gte && 43 | params.primary_release_date_lte 44 | ) { 45 | url.searchParams.append( 46 | 'primary_release_date.gte', 47 | params.primary_release_date_gte 48 | ); 49 | url.searchParams.append( 50 | 'primary_release_date.lte', 51 | params.primary_release_date_lte 52 | ); 53 | } 54 | 55 | if ( 56 | params.mediaType === 'tv' && 57 | params.first_air_date_gte && 58 | params.first_air_date_lte 59 | ) { 60 | url.searchParams.append( 61 | 'first_air_date.gte', 62 | params.first_air_date_gte 63 | ); 64 | url.searchParams.append( 65 | 'first_air_date.lte', 66 | params.first_air_date_lte 67 | ); 68 | } 69 | 70 | return url.href; 71 | } 72 | 73 | case 'search': { 74 | const url = new URL( 75 | `${BASE_URL}/${params.category}/${params.mediaType}` 76 | ); 77 | url.searchParams.append('query', params.q); 78 | url.searchParams.append('include_adult', 'false'); 79 | url.searchParams.append('language', 'en-US'); 80 | url.searchParams.append('page', '1'); 81 | return url.href; 82 | } 83 | 84 | case 'details': 85 | return `${BASE_URL}/${params.mediaType}/${params.id}?language=en-US`; 86 | 87 | case 'credits': 88 | case 'recommendations': 89 | case 'keywords': 90 | case 'similar': 91 | case 'videos': 92 | case 'images': 93 | return `${BASE_URL}/${params.mediaType}/${params.id}/${params.category}?language=en-US`; 94 | 95 | case 'external_ids': 96 | return `${BASE_URL}/${params.mediaType}/${params.id}/${params.category}`; 97 | 98 | default: 99 | throw new Error('fetchTMDB() Invalid URL configuration'); 100 | } 101 | } 102 | 103 | if (params.mediaType === 'person') { 104 | switch (params.category) { 105 | case 'details': 106 | return `${BASE_URL}/${params.mediaType}/${params.personId}?language=en-US`; 107 | 108 | case 'combined_credits': 109 | return `${BASE_URL}/${params.mediaType}/${params.personId}/${params.category}?language=en-US`; 110 | 111 | case 'external_ids': 112 | return `${BASE_URL}/${params.mediaType}/${params.personId}/${params.category}`; 113 | 114 | default: 115 | throw new Error('fetchTMDB() Invalid URL configuration'); 116 | } 117 | } 118 | 119 | throw new Error('fetchTMDB() Invalid mediaType'); 120 | }; 121 | 122 | export const fetchTMDB = async ( 123 | schema: Schema, 124 | params: FetchTMDBParams 125 | ): Promise => { 126 | try { 127 | const url = createUrl(params); 128 | 129 | const options = { 130 | method: 'GET', 131 | headers: { 132 | accept: 'application/json', 133 | Authorization: `Bearer ${TMDB_READ_ACCESS_TOKEN}`, 134 | }, 135 | }; 136 | 137 | const response = await fetch(url, options); 138 | if (!response.ok) { 139 | throw new Error(`HTTP error ${response.status} ${response}`); 140 | } 141 | 142 | const data = await response.json(); 143 | return schema.parse(data); 144 | } catch (error) { 145 | console.error(error); 146 | redirect(ErrorPage()); 147 | } 148 | }; 149 | -------------------------------------------------------------------------------- /src/app/(pages)/error/page.info.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const Route = { 4 | name: 'ErrorPage', 5 | params: z.object({}), 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/(pages)/error/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | import { Home } from '@/routes'; 5 | 6 | import { Button } from '@/components/ui/button'; 7 | import { HeadingLarge, HeadingMedium } from '@/components/fonts'; 8 | 9 | const Error = () => { 10 | const { replace } = useRouter(); 11 | 12 | return ( 13 |
14 | 15 | Sorry 16 | 17 | 18 | We've encountered an error processing this request. 19 | 20 | 23 |
24 | ); 25 | }; 26 | 27 | export default Error; 28 | -------------------------------------------------------------------------------- /src/app/(pages)/home/page.info.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const Route = { 4 | name: 'Home', 5 | params: z.object({}), 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/(pages)/home/page.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from '@/components/home-page'; 2 | 3 | const Home = () => ; 4 | export default Home; 5 | -------------------------------------------------------------------------------- /src/app/(pages)/movies/page.info.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const Route = { 4 | name: 'Movies', 5 | params: z.object({}), 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/(pages)/movies/page.tsx: -------------------------------------------------------------------------------- 1 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 2 | import { SliderProvider } from '@/providers/slider/slider-provider'; 3 | 4 | import { FetchTMDBParams, MOVIE_GENRES } from '@/types/global-types'; 5 | import { MovieResponse } from '@/types/tmdb-types'; 6 | import Slider from '@/components/slider/slider'; 7 | 8 | const MoviesPage = async () => { 9 | const fetchTMDBParams: Array = [ 10 | { 11 | label: 'Action Movies', 12 | category: 'discover', 13 | mediaType: 'movie', 14 | genreId: 28, 15 | page: 2, 16 | }, 17 | { 18 | label: 'Sci-Fi Movies', 19 | category: 'discover', 20 | mediaType: 'movie', 21 | genreId: 878, 22 | page: 3, 23 | }, 24 | { 25 | label: 'Trending: Movies', 26 | category: 'trending', 27 | mediaType: 'movie', 28 | }, 29 | { 30 | label: 'Popular: Movies', 31 | category: 'popular', 32 | mediaType: 'movie', 33 | }, 34 | ]; 35 | 36 | const content = await Promise.all( 37 | fetchTMDBParams.map(async params => { 38 | const { results } = await fetchTMDB(MovieResponse, { ...params }); 39 | return { ...params, results }; 40 | }) 41 | ); 42 | 43 | const genresArray = Object.entries(MOVIE_GENRES).map(([key, value]) => { 44 | return { id: key, slug: value, mediaType: 'movies' }; 45 | }); 46 | 47 | return ( 48 |
49 | 55 | 56 | 57 | 58 | {content.map(content => ( 59 | 65 | 66 | 67 | ))} 68 |
69 | ); 70 | }; 71 | 72 | export default MoviesPage; 73 | -------------------------------------------------------------------------------- /src/app/(pages)/search/page.info.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const Route = { 4 | name: 'Search', 5 | params: z.object({}), 6 | search: z.object({ 7 | q: z.string().optional(), 8 | }), 9 | }; 10 | -------------------------------------------------------------------------------- /src/app/(pages)/search/page.tsx: -------------------------------------------------------------------------------- 1 | import SearchResult from '@/components/search/search-result'; 2 | 3 | export default async function SearchPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(pages)/tv/page.info.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const Route = { 4 | name: 'Tv', 5 | params: z.object({}), 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/(pages)/tv/page.tsx: -------------------------------------------------------------------------------- 1 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 2 | import { SliderProvider } from '@/providers/slider/slider-provider'; 3 | 4 | import { FetchTMDBParams, TV_GENRES } from '@/types/global-types'; 5 | import { TvResponse } from '@/types/tmdb-types'; 6 | import Slider from '@/components/slider/slider'; 7 | 8 | const TvPage = async () => { 9 | const fetchTMDBParams: Array = [ 10 | { 11 | label: 'Action TV', 12 | category: 'discover', 13 | mediaType: 'tv', 14 | genreId: 10759, 15 | page: 2, 16 | }, 17 | { 18 | label: 'Sci-Fi TV', 19 | category: 'discover', 20 | mediaType: 'tv', 21 | genreId: 10765, 22 | page: 3, 23 | }, 24 | { 25 | label: 'Trending: TV', 26 | category: 'trending', 27 | mediaType: 'tv', 28 | }, 29 | { 30 | label: 'Popular: TV', 31 | category: 'popular', 32 | mediaType: 'tv', 33 | }, 34 | ]; 35 | 36 | const content = await Promise.all( 37 | fetchTMDBParams.map(async params => { 38 | const { results } = await fetchTMDB(TvResponse, { ...params }); 39 | return { ...params, results }; 40 | }) 41 | ); 42 | 43 | const genresArray = Object.entries(TV_GENRES).map(([key, value]) => { 44 | return { id: key, slug: value, mediaType: 'tv' }; 45 | }); 46 | 47 | return ( 48 |
49 | 55 | 56 | 57 | 58 | {content.map(content => ( 59 | 65 | 66 | 67 | ))} 68 |
69 | ); 70 | }; 71 | 72 | export default TvPage; 73 | -------------------------------------------------------------------------------- /src/app/@modal/[...slug]/page.info.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { MediaModalSlug } from '@/types/global-types'; 4 | 5 | export const Route = { 6 | name: 'MediaModal', 7 | params: z.object({ 8 | slug: MediaModalSlug, 9 | }), 10 | }; 11 | -------------------------------------------------------------------------------- /src/app/@modal/[...slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import { ErrorPage } from '@/routes'; 3 | 4 | import { 5 | GenreSlug, 6 | MediaModalSlug, 7 | MediaType, 8 | PersonSlug, 9 | TODO, 10 | } from '@/types/global-types'; 11 | import { extractGenreMediaTypeSlugs, getGenreIdBySlug } from '@/lib/utils'; 12 | import GenreModal from '@/components/media-modal/genre/genre-modal'; 13 | import MovieTvModal from '@/components/media-modal/movie-tv/movie-tv-modal'; 14 | import PersonModal from '@/components/media-modal/person/person-modal'; 15 | 16 | type MediaPageProps = { 17 | params: { 18 | slug: MediaModalSlug; 19 | }; 20 | }; 21 | 22 | /** 23 | * First slug: [movie, tv, genre] 24 | * - movie 25 | * - tv 26 | * - [genre]-movies | [genre]-tv 27 | * 28 | * Second slug: [id] 29 | * - movie/[id] 30 | * - tv/[id] 31 | * 32 | * */ 33 | 34 | const isEmpty = (obj: TODO) => Object.keys(obj).length === 0; 35 | 36 | const MediaModalPage = async ({ params }: MediaPageProps) => { 37 | if (isEmpty(params)) return null; 38 | 39 | const parsedMediaModalSlug = MediaModalSlug.safeParse(params.slug); 40 | if (!parsedMediaModalSlug.success) { 41 | redirect(ErrorPage()); 42 | } 43 | 44 | const mediaCategorySlug = parsedMediaModalSlug.data[0]; 45 | const mediaIdSlug = parsedMediaModalSlug?.data[1] || ''; 46 | 47 | const parsedMediaType = MediaType.safeParse(mediaCategorySlug); 48 | if (parsedMediaType.success) { 49 | return ( 50 | 51 | ); 52 | } 53 | 54 | const parsedGenreSlug = GenreSlug.safeParse(mediaCategorySlug); 55 | if (parsedGenreSlug.success) { 56 | const [genre, mediaType] = extractGenreMediaTypeSlugs(parsedGenreSlug.data); 57 | 58 | const genreIdSlug = getGenreIdBySlug(genre, mediaType); 59 | if (!genreIdSlug) redirect(ErrorPage()); 60 | 61 | return ( 62 | 63 | ); 64 | } 65 | 66 | const parsedPersonSlug = PersonSlug.safeParse(mediaCategorySlug); 67 | if (parsedPersonSlug.success) { 68 | return ; 69 | } 70 | }; 71 | export default MediaModalPage; 72 | -------------------------------------------------------------------------------- /src/app/api/search/route.info.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const Route = { 4 | name: "ApiSearch", 5 | params: z.object({ 6 | }) 7 | }; 8 | 9 | export const GET = { 10 | result: z.object({}), 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/api/search/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | 4 | import { 5 | MovieResponse, 6 | SearchResultsResponse, 7 | TvResponse, 8 | } from '@/types/tmdb-types'; 9 | import { q } from '@/lib/constants'; 10 | 11 | export async function GET(request: NextRequest): Promise { 12 | const searchParam = request.nextUrl.searchParams.get(q); 13 | if (!searchParam) { 14 | return NextResponse.json( 15 | { 'Bad request': 'No search query provided' }, 16 | { status: 400 } 17 | ); 18 | } 19 | 20 | const movieAndTv = await Promise.all([ 21 | fetchTMDB(MovieResponse, { 22 | category: 'search', 23 | q: searchParam.toString(), 24 | mediaType: 'movie', 25 | }), 26 | fetchTMDB(TvResponse, { 27 | category: 'search', 28 | q: searchParam.toString(), 29 | mediaType: 'tv', 30 | }), 31 | ]); 32 | 33 | const results = { 34 | movieData: movieAndTv[0], 35 | tvData: movieAndTv[1], 36 | }; 37 | 38 | const { success, data, error } = SearchResultsResponse.safeParse(results); 39 | if (!success) { 40 | return NextResponse.json( 41 | { error: 'Validation failed', details: error }, 42 | { status: 400 } 43 | ); 44 | } 45 | 46 | return NextResponse.json({ data }, { status: 200 }); 47 | } 48 | -------------------------------------------------------------------------------- /src/app/default.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from '@/components/home-page'; 2 | 3 | const Default = () => ; 4 | export default Default; 5 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { startTransition } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | 6 | import { Button } from '@/components/ui/button'; 7 | import { HeadingLarge } from '@/components/fonts'; 8 | 9 | type ErrorProps = { 10 | reset: () => void; 11 | }; 12 | 13 | export default function Error({ reset }: ErrorProps) { 14 | const router = useRouter(); 15 | 16 | const refreshAndReset = () => { 17 | startTransition(() => { 18 | router.refresh(); 19 | reset(); 20 | }); 21 | }; 22 | 23 | return ( 24 |
25 | Something went wrong! 26 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .text-genre { 7 | font-size: clamp(1px, 1.1vw, 14px); 8 | color: var(--muted-foreground); 9 | opacity: 0.5; 10 | font-weight: 600; 11 | } 12 | 13 | .text-overview { 14 | font-size: clamp(1px, 1.3vw, 15px); 15 | color: var(--primary-foreground); 16 | opacity: 0.8; 17 | } 18 | } 19 | 20 | @layer base { 21 | .dark { 22 | --background: 240 10% 3.9%; 23 | --foreground: 0 0% 98%; 24 | 25 | --card: 240 10% 3.9%; 26 | --card-foreground: 0 0% 98%; 27 | 28 | --popover: 240 10% 3.9%; 29 | --popover-foreground: 0 0% 98%; 30 | 31 | --primary: 0 0% 98%; 32 | --primary-foreground: 240 5.9% 10%; 33 | 34 | --secondary: 240 3.7% 15.9%; 35 | --secondary-foreground: 0 0% 98%; 36 | 37 | --muted: 240 3.7% 10.9%; 38 | --muted-foreground: 240 5% 64.9%; 39 | 40 | --accent: 240 3.7% 15.9%; 41 | --accent-foreground: 0 0% 98%; 42 | 43 | --destructive: 0 62.8% 30.6%; 44 | --destructive-foreground: 0 0% 98%; 45 | 46 | --border: 240 3.7% 15.9%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 240 4.9% 83.9%; 49 | 50 | --radius: 0.5rem; 51 | } 52 | } 53 | 54 | @layer base { 55 | * { 56 | @apply border-border; 57 | } 58 | body { 59 | @apply bg-background text-foreground; 60 | } 61 | } 62 | 63 | .hide-scrollbar::-webkit-scrollbar { 64 | display: none; 65 | } 66 | 67 | #search-input:focus { 68 | outline: none; 69 | } 70 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css'; 2 | 3 | import { ReactNode } from 'react'; 4 | import type { Metadata } from 'next'; 5 | import { Inter } from 'next/font/google'; 6 | import Providers from '@/providers/providers'; 7 | 8 | import { cn } from '@/lib/utils'; 9 | import Footer from '@/components/footer'; 10 | import NavBar from '@/components/nav-bar'; 11 | 12 | const inter = Inter({ subsets: ['latin'] }); 13 | 14 | export const metadata: Metadata = { 15 | title: 'Next Imdb', 16 | description: 'Imdb clone built with Next.js', 17 | }; 18 | 19 | type RootLayoutProps = { 20 | children: ReactNode; 21 | modal: ReactNode; 22 | }; 23 | 24 | const RootLayout = async ({ children, modal }: RootLayoutProps) => { 25 | return ( 26 | 27 | 32 |
33 | 34 | 35 |
36 | {children} 37 | {modal} 38 |
39 |
40 | 41 |
42 | 43 | 44 | ); 45 | }; 46 | export default RootLayout; 47 | -------------------------------------------------------------------------------- /src/components/divider.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | 3 | type DividerProps = { 4 | className?: string; 5 | }; 6 | 7 | export const Divider = ({ className }: DividerProps) => { 8 | return
; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/epic-stage.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | import { MediaModal } from '@/routes'; 4 | import { Dot, Info } from 'lucide-react'; 5 | 6 | import { TODO } from '@/types/global-types'; 7 | import { MovieResponse } from '@/types/tmdb-types'; 8 | import { deslugify, getGenreSlugById, isNullish } from '@/lib/utils'; 9 | import { Button } from '@/components/ui/button'; 10 | import { BodyMedium, HeadingLarge } from '@/components/fonts'; 11 | 12 | // TODO: Create carousel component for epic stage 13 | const EpicStage = async () => { 14 | const { results } = await fetchTMDB(MovieResponse, { 15 | mediaType: 'movie', 16 | category: 'popular', 17 | }); 18 | 19 | const firstResult = results[0]; 20 | const alt = isNullish(firstResult.title, firstResult.original_title); 21 | const title = isNullish(firstResult.title); 22 | const genreIds = firstResult.genre_ids ?? []; 23 | 24 | return ( 25 |
26 | {alt} 34 | 35 | {alt} 43 | 44 |
45 | 46 |
47 | {title} 48 | 49 |
    50 | {genreIds.map((genreId: TODO, i: number) => ( 51 |
  • 52 | 53 | {deslugify(getGenreSlugById(genreId, 'movie'))} 54 | 55 | {i < genreIds.length - 1 && } 56 |
  • 57 | ))} 58 |
59 | 60 | 64 | 68 | 69 |
70 |
71 | ); 72 | }; 73 | 74 | export default EpicStage; 75 | -------------------------------------------------------------------------------- /src/components/fonts.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | 3 | type FontProps = { 4 | children: string; 5 | className?: string; 6 | }; 7 | 8 | const HeadingLarge = ({ children, className }: FontProps) => ( 9 | 12 | {children} 13 | 14 | ); 15 | 16 | const HeadingMedium = ({ children, className }: FontProps) => ( 17 | {children} 18 | ); 19 | 20 | const HeadingSmall = ({ children, className }: FontProps) => ( 21 | {children} 22 | ); 23 | 24 | const HeadingExtraSmall = ({ children, className }: FontProps) => ( 25 | {children} 26 | ); 27 | 28 | const BodyMedium = ({ children, className }: FontProps) => ( 29 | 30 | {children} 31 | 32 | ); 33 | 34 | const BodySmall = ({ children, className }: FontProps) => ( 35 | 36 | {children} 37 | 38 | ); 39 | 40 | const NumberLabel = ({ children, className }: FontProps) => ( 41 | 42 | {children} 43 | 44 | ); 45 | 46 | export { 47 | HeadingLarge, 48 | HeadingMedium, 49 | HeadingSmall, 50 | HeadingExtraSmall, 51 | BodyMedium, 52 | BodySmall, 53 | NumberLabel, 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | import { BodySmall } from '@/components/fonts'; 4 | import { GithubIcon, NextjsIcon, VercelIcon } from '@/components/icons'; 5 | 6 | const Footer = () => ( 7 | 48 | ); 49 | 50 | export default Footer; 51 | -------------------------------------------------------------------------------- /src/components/home-page.tsx: -------------------------------------------------------------------------------- 1 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 2 | import { SliderProvider } from '@/providers/slider/slider-provider'; 3 | 4 | import { FetchTMDBParams, Section } from '@/types/global-types'; 5 | import { MovieResponse, TvResponse } from '@/types/tmdb-types'; 6 | import EpicStage from '@/components/epic-stage'; 7 | import Slider from '@/components/slider/slider'; 8 | 9 | type HomePageParams = Array< 10 | FetchTMDBParams & { label: string; section: Section } 11 | >; 12 | 13 | const HomePage = async () => { 14 | const homepageParams: HomePageParams = [ 15 | { 16 | label: 'Trending: Movies', 17 | section: 'movie', 18 | category: 'trending', 19 | mediaType: 'movie', 20 | }, 21 | { 22 | label: 'Trending: TV Shows', 23 | section: 'tv', 24 | category: 'trending', 25 | mediaType: 'tv', 26 | }, 27 | { 28 | label: 'Action Movies', 29 | section: 'movie', 30 | category: 'discover', 31 | mediaType: 'movie', 32 | genreId: 28, 33 | }, 34 | { 35 | label: 'Drama Movies', 36 | section: 'movie', 37 | category: 'discover', 38 | mediaType: 'movie', 39 | genreId: 18, 40 | }, 41 | ]; 42 | 43 | const homepageContent = await Promise.all( 44 | homepageParams.map(async params => { 45 | if (params.mediaType === 'movie') { 46 | const { results } = await fetchTMDB(MovieResponse, { ...params }); 47 | return { ...params, results }; 48 | } 49 | 50 | if (params.mediaType === 'tv') { 51 | const { results } = await fetchTMDB(TvResponse, { ...params }); 52 | return { ...params, results }; 53 | } 54 | 55 | return undefined; 56 | }) 57 | ); 58 | 59 | const filteredContent = homepageContent.filter( 60 | (content): content is NonNullable => content !== undefined 61 | ); 62 | 63 | return ( 64 |
65 | 66 | 67 | {filteredContent.map(content => ( 68 | 74 | 75 | 76 | ))} 77 |
78 | ); 79 | }; 80 | 81 | export default HomePage; 82 | -------------------------------------------------------------------------------- /src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from 'lucide-react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | export type SVGProps = { 6 | isActive?: boolean; 7 | className?: string; 8 | id?: string; 9 | }; 10 | 11 | export const LogoIcon = ({ className }: SVGProps) => ( 12 | 18 | 19 | 20 | ); 21 | 22 | export const SearchIcon = ({ className }: SVGProps) => ( 23 | 30 | 31 | 32 | ); 33 | 34 | export const LoadingIcon = ({ className }: SVGProps) => ( 35 | 36 | ); 37 | 38 | export const ChevronLeftIcon = ({ className }: SVGProps) => ( 39 | 49 | 50 | 51 | ); 52 | 53 | export const ChevronRightIcon = ({ className }: SVGProps) => ( 54 | 64 | 65 | 66 | ); 67 | 68 | export const PageIndicatorIcon = ({ isActive, className }: SVGProps) => ( 69 | 83 | 84 | 85 | ); 86 | 87 | export const ArrowRightCircleIcon = ({ className }: SVGProps) => ( 88 | 94 | 95 | 96 | ); 97 | 98 | export const ImdbIcon = ({ className, id }: SVGProps) => ( 99 | 100 | 109 | 110 | 111 | 112 | ); 113 | 114 | export const InstagramIcon = ({ className, id }: SVGProps) => ( 115 | 116 | 125 | 126 | 127 | 128 | ); 129 | 130 | export const TwitterIcon = ({ className, id }: SVGProps) => ( 131 | 132 | 141 | 142 | 143 | 144 | ); 145 | 146 | export const FacebookIcon = ({ className, id }: SVGProps) => ( 147 | 148 | 157 | 158 | 159 | 160 | ); 161 | 162 | export const NextjsIcon = ({ className }: SVGProps) => ( 163 | 168 | 169 | 174 | 175 | 176 | ); 177 | 178 | export const VercelIcon = ({ className }: SVGProps) => ( 179 | 184 | 185 | 186 | ); 187 | 188 | export const GithubIcon = ({ className }: SVGProps) => ( 189 | 194 | 195 | 196 | 197 | ); 198 | -------------------------------------------------------------------------------- /src/components/media-modal/genre/genre-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { GenreId, GenreSlug, MediaType } from '@/types/global-types'; 4 | import HighRated from '@/components/media-modal/genre/high-rated'; 5 | import NewMovieTv from '@/components/media-modal/genre/new-movie-tv'; 6 | import Spotlight from '@/components/media-modal/genre/spotlight'; 7 | import MediaModal from '@/components/media-modal/media-modal'; 8 | import Overlay from '@/components/media-modal/overlay'; 9 | 10 | type GenreModalProps = { 11 | slug: GenreSlug; 12 | mediaType: MediaType; 13 | genreId: GenreId; 14 | }; 15 | 16 | // Movies 17 | // - (Released this year) - Filter by date gte and lte 18 | // release_date.gte=2024-01-01 19 | // release_date.lte=2024-05-01 20 | // - 21 | 22 | const GenreModal = async ({ slug, genreId, mediaType }: GenreModalProps) => { 23 | return ( 24 | <> 25 | 26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 |
41 | 42 | ); 43 | }; 44 | 45 | export default GenreModal; 46 | -------------------------------------------------------------------------------- /src/components/media-modal/genre/high-rated.tsx: -------------------------------------------------------------------------------- 1 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 2 | import { SliderProvider } from '@/providers/slider/slider-provider'; 3 | 4 | import { GenreId, MediaType } from '@/types/global-types'; 5 | import { Movie, MovieResponse, Tv, TvResponse } from '@/types/tmdb-types'; 6 | import { 7 | capitalizeMedia, 8 | deslugify, 9 | isMovieGenreId, 10 | isTvGenreId, 11 | } from '@/lib/utils'; 12 | import Slider from '@/components/slider/slider'; 13 | 14 | type SpotlightProps = { 15 | slug: string; 16 | mediaType: MediaType; 17 | genreId: GenreId; 18 | }; 19 | 20 | const fetchMedia = async ( 21 | mediaType: MediaType, 22 | genreId: GenreId 23 | ): Promise => { 24 | if (mediaType === 'movie' && isMovieGenreId(genreId)) { 25 | const { results } = await fetchTMDB(MovieResponse, { 26 | category: 'discover', 27 | mediaType: 'movie', 28 | vote_average_gte: 8, 29 | page: 1, 30 | genreId, 31 | }); 32 | return results; 33 | } 34 | 35 | if (mediaType === 'tv' && isTvGenreId(genreId)) { 36 | const { results } = await fetchTMDB(TvResponse, { 37 | category: 'discover', 38 | mediaType: 'tv', 39 | vote_average_gte: 8, 40 | page: 1, 41 | genreId, 42 | }); 43 | return results; 44 | } 45 | 46 | return null; 47 | }; 48 | 49 | const NewMovieTv = async ({ slug, mediaType, genreId }: SpotlightProps) => { 50 | const results = await fetchMedia(mediaType, genreId); 51 | if (!results || !results.length) return null; 52 | 53 | return ( 54 | 55 | 58 | 59 | ); 60 | }; 61 | 62 | export default NewMovieTv; 63 | -------------------------------------------------------------------------------- /src/components/media-modal/genre/new-movie-tv.tsx: -------------------------------------------------------------------------------- 1 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 2 | import { SliderProvider } from '@/providers/slider/slider-provider'; 3 | 4 | import { GenreId, MediaType } from '@/types/global-types'; 5 | import { Movie, MovieResponse, Tv, TvResponse } from '@/types/tmdb-types'; 6 | import { 7 | capitalizeMedia, 8 | deslugify, 9 | isMovieGenreId, 10 | isTvGenreId, 11 | } from '@/lib/utils'; 12 | import Slider from '@/components/slider/slider'; 13 | 14 | type SpotlightProps = { 15 | slug: string; 16 | mediaType: MediaType; 17 | genreId: GenreId; 18 | }; 19 | 20 | const fetchMedia = async ( 21 | mediaType: MediaType, 22 | genreId: GenreId 23 | ): Promise => { 24 | const currentDate = new Date().toLocaleDateString('en-CA'); 25 | 26 | if (mediaType === 'movie' && isMovieGenreId(genreId)) { 27 | const { results } = await fetchTMDB(MovieResponse, { 28 | category: 'discover', 29 | primary_release_date_gte: '2024-01-01', 30 | primary_release_date_lte: currentDate, 31 | mediaType: 'movie', 32 | page: 2, 33 | genreId, 34 | }); 35 | return results; 36 | } 37 | 38 | if (mediaType === 'tv' && isTvGenreId(genreId)) { 39 | const { results } = await fetchTMDB(TvResponse, { 40 | category: 'discover', 41 | first_air_date_gte: '2024-01-01', 42 | first_air_date_lte: currentDate, 43 | mediaType: 'tv', 44 | page: 2, 45 | genreId, 46 | }); 47 | return results; 48 | } 49 | 50 | return null; 51 | }; 52 | 53 | const NewMovieTv = async ({ slug, mediaType, genreId }: SpotlightProps) => { 54 | const newMovies = await fetchMedia(mediaType, genreId); 55 | if (!newMovies || !newMovies.length) return null; 56 | 57 | return ( 58 | 63 | 66 | 67 | ); 68 | }; 69 | 70 | export default NewMovieTv; 71 | -------------------------------------------------------------------------------- /src/components/media-modal/genre/spotlight.tsx: -------------------------------------------------------------------------------- 1 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 2 | import { SliderProvider } from '@/providers/slider/slider-provider'; 3 | 4 | import { GenreId, MediaType } from '@/types/global-types'; 5 | import { Movie, MovieResponse, Tv, TvResponse } from '@/types/tmdb-types'; 6 | import { 7 | capitalizeMedia, 8 | deslugify, 9 | isMovieGenreId, 10 | isTvGenreId, 11 | } from '@/lib/utils'; 12 | import Slider from '@/components/slider/slider'; 13 | 14 | type SpotlightProps = { 15 | slug: string; 16 | mediaType: MediaType; 17 | genreId: GenreId; 18 | }; 19 | 20 | const fetchMedia = async ( 21 | mediaType: MediaType, 22 | genreId: GenreId 23 | ): Promise => { 24 | const currentDate = new Date().toLocaleDateString('en-CA'); 25 | 26 | if (mediaType === 'movie' && isMovieGenreId(genreId)) { 27 | const { results } = await fetchTMDB(MovieResponse, { 28 | category: 'discover', 29 | primary_release_date_gte: '2024-01-01', 30 | primary_release_date_lte: currentDate, 31 | mediaType: 'movie', 32 | genreId, 33 | }); 34 | return results; 35 | } 36 | 37 | if (mediaType === 'tv' && isTvGenreId(genreId)) { 38 | const { results } = await fetchTMDB(TvResponse, { 39 | category: 'discover', 40 | first_air_date_gte: '2024-01-01', 41 | first_air_date_lte: currentDate, 42 | mediaType: 'tv', 43 | genreId, 44 | }); 45 | return results; 46 | } 47 | 48 | return null; 49 | }; 50 | 51 | const Spotlight = async ({ slug, mediaType, genreId }: SpotlightProps) => { 52 | const results = await fetchMedia(mediaType, genreId); 53 | if (!results || !results.length) return null; 54 | 55 | return ( 56 | 57 | 60 | 61 | ); 62 | }; 63 | 64 | export default Spotlight; 65 | -------------------------------------------------------------------------------- /src/components/media-modal/media-modal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { ReactNode, useState } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useNavigationStore } from '@/providers/navigation/navigation-provider'; 6 | import { Home } from '@/routes'; 7 | 8 | import { useEffectOnce } from '@/hooks/use-effect-once'; 9 | import { Dialog, DialogContent } from '@/components/ui/dialog'; 10 | 11 | const Media = ({ children }: { children: ReactNode }) => { 12 | const router = useRouter(); 13 | const [isMounted, setIsMounted] = useState(false); 14 | 15 | const { lastActiveRoute } = useNavigationStore(); 16 | 17 | useEffectOnce(() => setIsMounted(true)); 18 | 19 | return ( 20 | 23 | router.push(lastActiveRoute ?? Home(), { scroll: false }) 24 | } 25 | > 26 | {isMounted && {children}} 27 | 28 | ); 29 | }; 30 | 31 | export default Media; 32 | -------------------------------------------------------------------------------- /src/components/media-modal/movie-tv/backdrop.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { redirect } from 'next/navigation'; 3 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 4 | import { ErrorPage } from '@/routes'; 5 | 6 | import { ContentRouteParams } from '@/types/global-types'; 7 | import { DetailsMovieResponse, DetailsTvResponse } from '@/types/tmdb-types'; 8 | import { isMovie, isNullish } from '@/lib/utils'; 9 | 10 | export default async function Backdrop({ mediaType, id }: ContentRouteParams) { 11 | try { 12 | let details: DetailsMovieResponse | DetailsTvResponse | null = null; 13 | 14 | if (mediaType === 'movie') { 15 | details = await fetchTMDB(DetailsMovieResponse, { 16 | mediaType: 'movie', 17 | id, 18 | category: 'details', 19 | }); 20 | } 21 | 22 | if (mediaType === 'tv') { 23 | details = await fetchTMDB(DetailsTvResponse, { 24 | mediaType: 'tv', 25 | id, 26 | category: 'details', 27 | }); 28 | } 29 | 30 | if (!details) throw new Error('No details found'); 31 | 32 | const title = isMovie( 33 | details, 34 | mediaType 35 | ) 36 | ? isNullish(details.title, details.original_title) 37 | : isNullish(details.name, details.original_name); 38 | 39 | return ( 40 |
41 | {title 49 |
50 |
51 | ); 52 | } catch (err) { 53 | redirect(ErrorPage()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/media-modal/movie-tv/bonus-content.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | import { SliderProvider } from '@/providers/slider/slider-provider'; 4 | import { ErrorPage } from '@/routes'; 5 | 6 | import { ContentRouteParams } from '@/types/global-types'; 7 | import { VideoResponse } from '@/types/tmdb-types'; 8 | import Slider from '@/components/slider/slider'; 9 | 10 | export default async function Trailers({ mediaType, id }: ContentRouteParams) { 11 | try { 12 | const { results } = await fetchTMDB(VideoResponse, { 13 | mediaType, 14 | id, 15 | category: 'videos', 16 | }); 17 | 18 | const bonusContent = results.filter( 19 | video => 20 | video.official && 21 | video.type === 'Featurette' && 22 | video.site === 'YouTube' 23 | ); 24 | 25 | if (!bonusContent.length) return null; 26 | 27 | return ( 28 | 33 | 34 | 35 | ); 36 | } catch (err) { 37 | redirect(ErrorPage()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/media-modal/movie-tv/cast.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | import { SliderProvider } from '@/providers/slider/slider-provider'; 4 | import { ErrorPage } from '@/routes'; 5 | 6 | import { ContentRouteParams } from '@/types/global-types'; 7 | import { CreditsResponse } from '@/types/tmdb-types'; 8 | import Slider from '@/components/slider/slider'; 9 | 10 | export default async function Cast({ id, mediaType }: ContentRouteParams) { 11 | try { 12 | const { cast } = await fetchTMDB(CreditsResponse, { 13 | mediaType, 14 | id, 15 | category: 'credits', 16 | }); 17 | 18 | const actors = cast 19 | .filter(({ known_for_department }) => known_for_department === 'Acting') 20 | .slice(0, 15); 21 | 22 | if (!actors.length) return null; 23 | 24 | return ( 25 | 26 | 27 | 28 | ); 29 | } catch (err) { 30 | redirect(ErrorPage()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/media-modal/movie-tv/external-links.tsx: -------------------------------------------------------------------------------- 1 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 2 | 3 | import { ContentRouteParams } from '@/types/global-types'; 4 | import { ExternalIds } from '@/types/tmdb-types'; 5 | import { 6 | FacebookIcon, 7 | ImdbIcon, 8 | InstagramIcon, 9 | TwitterIcon, 10 | } from '@/components/icons'; 11 | 12 | const ExternalLinks = async ({ id, mediaType }: ContentRouteParams) => { 13 | const { imdb_id, instagram_id, twitter_id, facebook_id } = await fetchTMDB( 14 | ExternalIds, 15 | { mediaType, id, category: 'external_ids' } 16 | ); 17 | 18 | return ( 19 |
20 | {facebook_id && } 21 | {instagram_id && } 22 | {twitter_id && } 23 | {imdb_id && } 24 |
25 | ); 26 | }; 27 | 28 | export default ExternalLinks; 29 | -------------------------------------------------------------------------------- /src/components/media-modal/movie-tv/label.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | import { ErrorPage } from '@/routes'; 4 | 5 | import { ContentRouteParams } from '@/types/global-types'; 6 | import { DetailsMovieResponse, DetailsTvResponse } from '@/types/tmdb-types'; 7 | import { isMovie, isNullish } from '@/lib/utils'; 8 | import { HeadingLarge } from '@/components/fonts'; 9 | 10 | export async function Label({ mediaType, id }: ContentRouteParams) { 11 | try { 12 | let details: DetailsMovieResponse | DetailsTvResponse | null = null; 13 | 14 | if (mediaType === 'movie') { 15 | details = await fetchTMDB(DetailsMovieResponse, { 16 | mediaType: 'movie', 17 | id, 18 | category: 'details', 19 | }); 20 | } 21 | 22 | if (mediaType === 'tv') { 23 | details = await fetchTMDB(DetailsTvResponse, { 24 | mediaType: 'tv', 25 | id, 26 | category: 'details', 27 | }); 28 | } 29 | 30 | if (!details) throw new Error('No details found'); 31 | 32 | const title = isMovie( 33 | details, 34 | mediaType 35 | ) 36 | ? isNullish(details.title, details.original_title) 37 | : isNullish(details.name, details.original_name); 38 | 39 | return ( 40 | <> 41 | {title} 42 |

{details.overview}

43 | 44 | ); 45 | } catch (err) { 46 | redirect(ErrorPage()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/media-modal/movie-tv/metadata.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | import { ErrorPage } from '@/routes'; 4 | 5 | import { ContentRouteParams } from '@/types/global-types'; 6 | import { 7 | CreditsResponse, 8 | DetailsMovieResponse, 9 | DetailsTvResponse, 10 | KeywordsMovieResponse, 11 | KeywordsTvResponse, 12 | } from '@/types/tmdb-types'; 13 | import { capitalize, extractYear, isMovie, isMovieDetails } from '@/lib/utils'; 14 | 15 | export async function Actors({ mediaType, id }: ContentRouteParams) { 16 | try { 17 | const { cast } = await fetchTMDB(CreditsResponse, { 18 | mediaType, 19 | id, 20 | category: 'credits', 21 | }); 22 | 23 | const actors = cast 24 | .filter(({ known_for_department }) => known_for_department === 'Acting') 25 | .slice(0, 3) 26 | .map(({ name }) => name ?? ''); 27 | if (!actors.length) return null; 28 | 29 | return ; 30 | } catch (err) { 31 | redirect(ErrorPage()); 32 | } 33 | } 34 | 35 | export async function Genres({ mediaType, id }: ContentRouteParams) { 36 | try { 37 | let details: DetailsMovieResponse | DetailsTvResponse | null = null; 38 | 39 | if (mediaType === 'movie') { 40 | details = await fetchTMDB(DetailsMovieResponse, { 41 | mediaType: 'movie', 42 | id, 43 | category: 'details', 44 | }); 45 | } 46 | 47 | if (mediaType === 'tv') { 48 | details = await fetchTMDB(DetailsTvResponse, { 49 | mediaType: 'tv', 50 | id, 51 | category: 'details', 52 | }); 53 | } 54 | 55 | if (!details) throw new Error('No details found'); 56 | if (!details.genres) return null; 57 | 58 | const genres = details.genres.map(({ name }) => name).slice(0, 3); 59 | if (!genres.length) return null; 60 | 61 | return ; 62 | } catch (err) { 63 | redirect(ErrorPage()); 64 | } 65 | } 66 | 67 | export async function Keywords({ mediaType, id }: ContentRouteParams) { 68 | try { 69 | let keywordsResponse: KeywordsMovieResponse | KeywordsTvResponse | null = 70 | null; 71 | 72 | if (mediaType === 'movie') { 73 | keywordsResponse = await fetchTMDB(KeywordsMovieResponse, { 74 | mediaType: 'movie', 75 | id, 76 | category: 'keywords', 77 | }); 78 | } 79 | 80 | if (mediaType === 'tv') { 81 | keywordsResponse = await fetchTMDB(KeywordsTvResponse, { 82 | mediaType: 'tv', 83 | id, 84 | category: 'keywords', 85 | }); 86 | } 87 | 88 | if (!keywordsResponse) throw new Error('No keywords found'); 89 | 90 | const parsedKeywords = isMovie( 91 | keywordsResponse, 92 | mediaType 93 | ) 94 | ? keywordsResponse.keywords 95 | : keywordsResponse.results; 96 | if (!parsedKeywords.length) return null; 97 | 98 | const firstThreeKeywords = parsedKeywords 99 | .map(({ name }) => name) 100 | .slice(0, 3); 101 | 102 | return ; 103 | } catch (err) { 104 | redirect(ErrorPage()); 105 | } 106 | } 107 | 108 | export async function ReleaseDate({ mediaType, id }: ContentRouteParams) { 109 | try { 110 | let details: DetailsMovieResponse | DetailsTvResponse | null = null; 111 | 112 | if (mediaType === 'movie') { 113 | details = await fetchTMDB(DetailsMovieResponse, { 114 | mediaType: 'movie', 115 | id, 116 | category: 'details', 117 | }); 118 | } 119 | 120 | if (mediaType === 'tv') { 121 | details = await fetchTMDB(DetailsTvResponse, { 122 | mediaType: 'tv', 123 | id, 124 | category: 'details', 125 | }); 126 | } 127 | 128 | if (!details) throw new Error('No details found'); 129 | if (!details.genres) return null; 130 | 131 | const releaseDate = isMovieDetails(details) 132 | ? details.release_date 133 | : details.first_air_date; 134 | if (!releaseDate) return null; 135 | 136 | return ; 137 | } catch (err) { 138 | redirect(ErrorPage()); 139 | } 140 | } 141 | 142 | type MetadataProps = { 143 | label: string; 144 | metadata: Array; 145 | }; 146 | 147 | export function Metadata({ label, metadata }: MetadataProps) { 148 | if (!metadata.length) return null; 149 | 150 | return ( 151 |
152 | {label}: 153 | 154 | {metadata.map((data: string, index: number) => ( 155 | 156 | {capitalize(data)} 157 | {index < metadata.length - 1 ? ', ' : ''} 158 | 159 | ))} 160 |
161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /src/components/media-modal/movie-tv/more-like-this.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | import { SliderProvider } from '@/providers/slider/slider-provider'; 4 | import { ErrorPage } from '@/routes'; 5 | 6 | import { ContentRouteParams, FetchTMDBParams } from '@/types/global-types'; 7 | import { MovieResponse, TvResponse } from '@/types/tmdb-types'; 8 | import Slider from '@/components/slider/slider'; 9 | 10 | export default async function MoreLikeThis({ 11 | mediaType, 12 | id, 13 | }: ContentRouteParams) { 14 | try { 15 | const content: FetchTMDBParams[] = [ 16 | { mediaType, id, category: 'recommendations' }, 17 | { mediaType, id, category: 'similar' }, 18 | ]; 19 | 20 | const contentPromises = content.map(async content => { 21 | if (content.mediaType === 'movie') { 22 | const { results } = await fetchTMDB(MovieResponse, { ...content }); 23 | return { ...content, results }; 24 | } 25 | 26 | if (content.mediaType === 'tv') { 27 | const { results } = await fetchTMDB(TvResponse, { ...content }); 28 | return { ...content, results }; 29 | } 30 | }); 31 | 32 | const [recommendations, similar] = await Promise.all(contentPromises); 33 | if (!recommendations || !similar) { 34 | throw new Error('No recommendations found'); 35 | } 36 | 37 | const moreLikesThis = recommendations.results.length 38 | ? recommendations.results 39 | : similar.results; 40 | if (!moreLikesThis.length) return null; 41 | 42 | return ( 43 | 48 | 49 | 50 | ); 51 | } catch (err) { 52 | redirect(ErrorPage()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/media-modal/movie-tv/movie-tv-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { MediaType } from '@/types/global-types'; 4 | import MediaModal from '@/components/media-modal/media-modal'; 5 | import Backdrop from '@/components/media-modal/movie-tv/backdrop'; 6 | import BonusContent from '@/components/media-modal/movie-tv/bonus-content'; 7 | import Cast from '@/components/media-modal/movie-tv/cast'; 8 | import ExternalLinks from '@/components/media-modal/movie-tv/external-links'; 9 | import { Label } from '@/components/media-modal/movie-tv/label'; 10 | import { 11 | Actors, 12 | Genres, 13 | Keywords, 14 | ReleaseDate, 15 | } from '@/components/media-modal/movie-tv/metadata'; 16 | import MoreLikeThis from '@/components/media-modal/movie-tv/more-like-this'; 17 | import Trailers from '@/components/media-modal/movie-tv/trailers'; 18 | import Overlay from '@/components/media-modal/overlay'; 19 | import { 20 | BackdropSkeleton, 21 | HeadshotsSkeleton, 22 | MetadataSkeleton, 23 | OverviewSkeleton, 24 | TileLoadingSkeleton, 25 | } from '@/components/skeletons'; 26 | 27 | type MediaModalProps = { 28 | mediaType: MediaType; 29 | mediaId: string; 30 | }; 31 | 32 | const MovieTvModal = ({ mediaType, mediaId }: MediaModalProps) => ( 33 | <> 34 | 35 | 36 | }> 37 | 38 | 39 | 40 |
41 |
42 | }> 43 | 46 |
47 | 48 |
49 | }> 50 | 51 | 52 | 53 | 54 | 55 |
56 |
57 | 58 | }> 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | }> 71 | 72 | 73 |
74 | 75 | ); 76 | 77 | export default MovieTvModal; 78 | -------------------------------------------------------------------------------- /src/components/media-modal/movie-tv/trailers.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | import { SliderProvider } from '@/providers/slider/slider-provider'; 4 | import { ErrorPage } from '@/routes'; 5 | 6 | import { ContentRouteParams } from '@/types/global-types'; 7 | import { VideoResponse } from '@/types/tmdb-types'; 8 | import Slider from '@/components/slider/slider'; 9 | 10 | export default async function Trailers({ mediaType, id }: ContentRouteParams) { 11 | try { 12 | const { results } = await fetchTMDB(VideoResponse, { 13 | mediaType, 14 | id, 15 | category: 'videos', 16 | }); 17 | 18 | const trailers = results.filter( 19 | video => 20 | video.official && video.type === 'Trailer' && video.site === 'YouTube' 21 | ); 22 | if (!trailers.length) return null; 23 | 24 | return ( 25 | 30 | 31 | 32 | ); 33 | } catch (err) { 34 | redirect(ErrorPage()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/media-modal/overlay.tsx: -------------------------------------------------------------------------------- 1 | const Overlay = () => { 2 | return ( 3 |
8 | ); 9 | }; 10 | 11 | export default Overlay; 12 | -------------------------------------------------------------------------------- /src/components/media-modal/person/person-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import MediaModal from '@/components/media-modal/media-modal'; 4 | import Overlay from '@/components/media-modal/overlay'; 5 | import PersonMovieTv from '@/components/media-modal/person/person-movie-tv'; 6 | import PersonSpotlight from '@/components/media-modal/person/person-spotlight'; 7 | 8 | type PersonModalProps = { 9 | personId: string; 10 | }; 11 | 12 | const PersonModal = async ({ personId }: PersonModalProps) => { 13 | return ( 14 | <> 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {/**/} 27 | {/* */} 28 | {/**/} 29 |
30 |
31 | 32 | ); 33 | }; 34 | 35 | export default PersonModal; 36 | -------------------------------------------------------------------------------- /src/components/media-modal/person/person-movie-tv.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | import { Dot } from 'lucide-react'; 4 | 5 | import { 6 | CombinedCreditsResponse, 7 | MoviePersonCredits, 8 | TvPersonCredits, 9 | } from '@/types/tmdb-types'; 10 | import { cn, extractYear, isMovie, isNullish } from '@/lib/utils'; 11 | import { BodyMedium, BodySmall, HeadingExtraSmall } from '@/components/fonts'; 12 | 13 | type PersonMovieTvProps = { 14 | personId: string; 15 | }; 16 | 17 | const PersonMovieTv = async ({ personId }: PersonMovieTvProps) => { 18 | const results = await fetchTMDB(CombinedCreditsResponse, { 19 | mediaType: 'person', 20 | category: 'combined_credits', 21 | personId, 22 | }); 23 | if (!results) return null; 24 | 25 | const uniqueMedia: Array = []; 26 | const ids = new Set(); 27 | 28 | results.cast.forEach(movie => { 29 | if (!ids.has(movie.id)) { 30 | ids.add(movie.id); 31 | uniqueMedia.push(movie); 32 | } 33 | }); 34 | 35 | return ( 36 |
45 | {uniqueMedia.map(tile => { 46 | const title = isMovie( 47 | tile, 48 | tile.media_type 49 | ) 50 | ? isNullish(tile.title, tile.original_title) 51 | : isNullish(tile.name, tile.original_name); 52 | 53 | const releaseDate = isMovie( 54 | tile, 55 | tile.media_type 56 | ) 57 | ? isNullish(tile.release_date) 58 | : isNullish(tile.first_air_date); 59 | 60 | const mediaType = isMovie( 61 | tile, 62 | tile.media_type 63 | ) 64 | ? 'Movie' 65 | : 'TV Series'; 66 | 67 | return ( 68 |
69 |
73 | {tile.backdrop_path || tile.poster_path ? ( 74 | <> 75 | {title} 83 | {title} 91 | 92 | ) : ( 93 |
94 | 95 | {title} 96 | 97 |
98 | )} 99 |
100 | 101 |
102 |
103 | {title} 104 |
105 | 106 | {extractYear(releaseDate)} 107 | 108 | 109 | {mediaType} 110 |
111 |
112 |
113 |
114 | ); 115 | })} 116 |
117 | ); 118 | }; 119 | 120 | export default PersonMovieTv; 121 | -------------------------------------------------------------------------------- /src/components/media-modal/person/person-spotlight.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { fetchTMDB } from '@/actions/fetch-tmdb'; 3 | 4 | import { DetailsPersonResponse } from '@/types/tmdb-types'; 5 | import { extractInitials } from '@/lib/utils'; 6 | import { BodyMedium, HeadingLarge } from '@/components/fonts'; 7 | 8 | type SpotlightProps = { 9 | personId: string; 10 | }; 11 | 12 | const PersonSpotlight = async ({ personId }: SpotlightProps) => { 13 | const results = await fetchTMDB(DetailsPersonResponse, { 14 | mediaType: 'person', 15 | category: 'details', 16 | personId, 17 | }); 18 | if (!results) return null; 19 | 20 | return ( 21 |
22 |
23 | {results.profile_path ? ( 24 | {results.id.toString() 32 | ) : ( 33 |
34 | 35 | {extractInitials(results.name || '')} 36 | 37 |
38 | )} 39 |
40 | 41 |
42 | {results.name || ''} 43 | 44 | {results.biography || ''} 45 | 46 |
47 |
48 | ); 49 | }; 50 | 51 | export default PersonSpotlight; 52 | -------------------------------------------------------------------------------- /src/components/nav-bar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSearchStore } from '@/providers/search/search-provider'; 4 | import { Home, Movies, Tv } from '@/routes'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | import { BodySmall } from '@/components/fonts'; 8 | import { LogoIcon } from '@/components/icons'; 9 | import SearchInput from '@/components/search/search-input'; 10 | 11 | const tabs = [ 12 | { label: 'Home', route: Home(), Link: Home.Link }, 13 | { label: 'Movies', route: Movies(), Link: Movies.Link }, 14 | { label: 'TV Shows', route: Tv(), Link: Tv.Link }, 15 | ]; 16 | 17 | const NavBar = () => { 18 | const { 19 | state: { lastActiveRoute }, 20 | actions: { handleNavigate }, 21 | } = useSearchStore(); 22 | 23 | const isActiveRoute = (route: string) => route === lastActiveRoute; 24 | 25 | return ( 26 |
27 |
28 |
29 | 30 | 31 | 49 | 50 | 51 |
52 |
53 |
54 | ); 55 | }; 56 | 57 | export default NavBar; 58 | -------------------------------------------------------------------------------- /src/components/search/search-input.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchStore } from '@/providers/search/search-provider'; 2 | import { X } from 'lucide-react'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | import { SearchIcon } from '@/components/icons'; 6 | 7 | const SearchInput = () => { 8 | const { 9 | state: { query, isSearchInputExpanded, isSearchInputFocused }, 10 | actions: { handleFocus, handleSearch, handleClear }, 11 | refs: { searchInputRef, searchContainerRef }, 12 | } = useSearchStore(); 13 | 14 | return ( 15 |
21 | 29 | 30 | 33 | 34 |
42 | handleSearch(e.target.value)} 49 | placeholder='Search' 50 | className={cn('w-full bg-black text-sm')} 51 | /> 52 | 53 | 62 |
63 |
64 | ); 65 | }; 66 | 67 | export default SearchInput; 68 | -------------------------------------------------------------------------------- /src/components/search/search-result.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | import { useSearchStore } from '@/providers/search/search-provider'; 5 | import { MediaModal } from '@/routes'; 6 | import useSWR from 'swr'; 7 | import { useDebounceValue } from 'usehooks-ts'; 8 | 9 | import { MediaType } from '@/types/global-types'; 10 | import { 11 | Movie, 12 | MovieResponse, 13 | SearchResultsResponse, 14 | Tv, 15 | TvResponse, 16 | } from '@/types/tmdb-types'; 17 | import { cn, extractYear, fetcher, isMovie, isNullish } from '@/lib/utils'; 18 | import { 19 | BodyMedium, 20 | BodySmall, 21 | HeadingExtraSmall, 22 | HeadingSmall, 23 | } from '@/components/fonts'; 24 | 25 | import ThumbnailWrapper from '../thumbnail-wrapper'; 26 | 27 | const SearchResult = () => { 28 | const { 29 | state: { query }, 30 | } = useSearchStore(); 31 | 32 | const [debouncedQuery] = useDebounceValue(query, 500); 33 | 34 | const { 35 | data: swrData, 36 | error: swrError, 37 | isLoading, 38 | } = useSWR( 39 | debouncedQuery 40 | ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` 41 | : null, 42 | fetcher 43 | ); 44 | 45 | if (isLoading || !swrData) return null; 46 | if (swrError) throw new Error('Failed to load search results'); 47 | 48 | const { 49 | success, 50 | data: mediaData, 51 | error, 52 | } = SearchResultsResponse.safeParse(swrData.data); 53 | if (!success) { 54 | throw new Error( 55 | `SearchResult() Invalid search results schema: ${error.message}` 56 | ); 57 | } 58 | 59 | return ( 60 |
61 |
62 | 63 | Search results for: 64 | 65 | {query} 66 |
67 | 68 |
77 | 78 | 79 |
80 |
81 | ); 82 | }; 83 | 84 | export default SearchResult; 85 | 86 | type TilesProps = { 87 | data: MovieResponse | TvResponse; 88 | mediaType: MediaType; 89 | }; 90 | 91 | const Tiles = ({ data, mediaType }: TilesProps) => { 92 | const { results } = data; 93 | 94 | return results.map(tile => { 95 | const title = isMovie(tile, mediaType) 96 | ? isNullish(tile.title, tile.original_title) 97 | : isNullish(tile.name, tile.original_name); 98 | 99 | const releaseDate = isMovie(tile, mediaType) 100 | ? isNullish(tile.release_date) 101 | : isNullish(tile.first_air_date); 102 | 103 | return ( 104 | 109 | 110 |
111 | {tile.backdrop_path || tile.poster_path ? ( 112 | <> 113 | {title} 121 | {title} 129 | 130 | ) : ( 131 |
132 | 133 | {title} 134 | 135 |
136 | )} 137 |
138 |
139 | 140 |
141 |
142 | {title} 143 | 144 | {extractYear(releaseDate)} 145 | 146 |
147 |
148 |
149 | ); 150 | }); 151 | }; 152 | -------------------------------------------------------------------------------- /src/components/skeletons.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | 3 | export const TileLoadingSkeleton = ({ count }: { count: number }) => { 4 | return ( 5 |
6 | {Array.from({ length: count }).map((_, i) => ( 7 |
8 | {Array.from({ length: 6 }).map((_, i) => ( 9 |
14 |
15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ))} 24 |
25 | ))} 26 |
27 | ); 28 | }; 29 | 30 | export const BackdropSkeleton = () => { 31 | return ( 32 |
33 |
34 |
35 |
36 | ); 37 | }; 38 | 39 | export const MetadataSkeleton = () => { 40 | return ( 41 | <> 42 |
43 |
44 |
45 |
46 | 47 | ); 48 | }; 49 | 50 | export const OverviewSkeleton = () => { 51 | return ( 52 | <> 53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | 61 | ); 62 | }; 63 | 64 | export const HeadshotsSkeleton = () => { 65 | return ( 66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/components/slider/media-header/media-header.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | import PageIndicator from '@/components/slider/media-header/page-indicator'; 3 | 4 | type MediaHeaderProps = { 5 | children: string; 6 | className?: string; 7 | }; 8 | 9 | const MediaHeader = ({ children, className }: MediaHeaderProps) => ( 10 | <> 11 |
12 |

13 | {children} 14 |

15 | 16 |
17 | 18 | ); 19 | 20 | export default MediaHeader; 21 | -------------------------------------------------------------------------------- /src/components/slider/media-header/page-indicator.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-imports: 0 */ 2 | 3 | 'use client'; 4 | 5 | import { useSliderStore } from '@/providers/slider/slider-provider'; 6 | 7 | import { usePageUtils } from '@/hooks/use-page-utils'; 8 | import { PageIndicatorIcon } from '@/components/icons'; 9 | 10 | const PageIndicator = () => { 11 | const CONTENT = useSliderStore(state => state.CONTENT); 12 | const pages = useSliderStore(state => state.pages); 13 | const currentPage = useSliderStore(state => state.currentPage); 14 | const { actions: { getTileCountPerPage } } = usePageUtils(); // prettier-ignore 15 | 16 | if (CONTENT.length <= getTileCountPerPage()) return null; 17 | 18 | const pageNumbers = Array.from(pages.entries()); 19 | 20 | return ( 21 |
22 | {pageNumbers.map(([key], i) => { 23 | if (i === 0 || i === pageNumbers.length - 1) return null; 24 | return ; 25 | })} 26 |
27 | ); 28 | }; 29 | 30 | export default PageIndicator; 31 | -------------------------------------------------------------------------------- /src/components/slider/paginate-button/paginate-button.tsx: -------------------------------------------------------------------------------- 1 | import { SlideDirection } from '@/lib/constants'; 2 | import { cn } from '@/lib/utils'; 3 | import { useAnimation } from '@/hooks/use-animation'; 4 | import { usePageUtils } from '@/hooks/use-page-utils'; 5 | import { usePagination } from '@/hooks/use-pagination'; 6 | import { ChevronLeftIcon, ChevronRightIcon } from '@/components/icons'; 7 | 8 | type PaginationButtonProps = { 9 | direction: SlideDirection; 10 | onClick: () => void; 11 | className?: string; 12 | }; 13 | 14 | const PaginateButton = ({ direction, onClick, className }: PaginationButtonProps) => { 15 | const { state: { CONTENT } } = usePagination(); // prettier-ignore 16 | const { actions: { getTileCountPerPage } } = usePageUtils(); // prettier-ignore 17 | const { isAnimating } = useAnimation(); 18 | 19 | if (CONTENT.length <= getTileCountPerPage()) { 20 | return
; 21 | } 22 | 23 | const iconClass = cn( 24 | 'opacity-0 transition-transform max-w-[40px] group-hover/button:scale-125 group-hover/slider:opacity-100', 25 | { 'opacity-100 group-hover/button:scale-125 ': isAnimating } 26 | ); 27 | 28 | const isRight = direction === 'right'; 29 | const isLeft = direction === 'left'; 30 | 31 | return ( 32 | 46 | ); 47 | }; 48 | 49 | export default PaginateButton; 50 | -------------------------------------------------------------------------------- /src/components/slider/paginate-button/paginate-left-button.tsx: -------------------------------------------------------------------------------- 1 | import { TIMEOUT_DURATION } from '@/lib/constants'; 2 | import { cn, wait } from '@/lib/utils'; 3 | import { useAnimation } from '@/hooks/use-animation'; 4 | import { usePageUtils } from '@/hooks/use-page-utils'; 5 | import { usePagination } from '@/hooks/use-pagination'; 6 | import { useSlide } from '@/hooks/use-slide'; 7 | import PaginateButton from '@/components/slider/paginate-button/paginate-button'; 8 | 9 | const PaginateLeftButton = () => { 10 | const { 11 | status: { isFirstPage, isSecondPage }, 12 | actions: { goToFirstPage, goToLastPage, goToPrevPage }, 13 | } = usePagination(); 14 | const { state: { hasPaginated } } = usePageUtils(); // prettier-ignore 15 | const { slide, getSlideAmount } = useSlide(); 16 | const { enableAnimation, disableAnimation } = useAnimation(); 17 | 18 | const handlePaginateLeft = async () => { 19 | enableAnimation(); 20 | const newSlideAmount = getSlideAmount({ 21 | direction: 'left', 22 | isSecondPage, 23 | }); 24 | slide(newSlideAmount); 25 | 26 | await wait(TIMEOUT_DURATION); 27 | 28 | disableAnimation(); 29 | slide(0); 30 | if (isSecondPage) return goToFirstPage(); 31 | if (isFirstPage) return goToLastPage(); 32 | goToPrevPage(); 33 | }; 34 | 35 | return ( 36 | 44 | ); 45 | }; 46 | 47 | export default PaginateLeftButton; 48 | -------------------------------------------------------------------------------- /src/components/slider/paginate-button/paginate-right-button.tsx: -------------------------------------------------------------------------------- 1 | import { TIMEOUT_DURATION } from '@/lib/constants'; 2 | import { wait } from '@/lib/utils'; 3 | import { useAnimation } from '@/hooks/use-animation'; 4 | import { usePageUtils } from '@/hooks/use-page-utils'; 5 | import { usePagination } from '@/hooks/use-pagination'; 6 | import { useSlide } from '@/hooks/use-slide'; 7 | import PaginateButton from '@/components/slider/paginate-button/paginate-button'; 8 | 9 | const PaginateRightButton = () => { 10 | const { 11 | status: { isLastPage, isSecondToLastPage }, 12 | actions: { goToFirstPage, goToLastPage, goToNextPage }, 13 | } = usePagination(); 14 | const { 15 | state: { hasPaginated }, 16 | actions: { markAsPaginated }, 17 | } = usePageUtils(); 18 | const { slide, getSlideAmount } = useSlide(); 19 | const { enableAnimation, disableAnimation } = useAnimation(); 20 | 21 | const handlePaginateRight = async () => { 22 | enableAnimation(); 23 | const slideAmount = getSlideAmount({ 24 | direction: 'right', 25 | isSecondToLastPage, 26 | }); 27 | slide(slideAmount); 28 | 29 | await wait(TIMEOUT_DURATION); 30 | 31 | if (!hasPaginated) markAsPaginated(); 32 | disableAnimation(); 33 | slide(0); 34 | if (isSecondToLastPage) return goToLastPage(); 35 | if (isLastPage) return goToFirstPage(); 36 | goToNextPage(); 37 | }; 38 | 39 | return ; 40 | }; 41 | 42 | export default PaginateRightButton; 43 | -------------------------------------------------------------------------------- /src/components/slider/slider.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --2-tiles: calc(1 / 2 * 100%); 3 | --3-tiles: calc(1 / 3 * 100%); 4 | --4-tiles: calc(1 / 4 * 100%); 5 | --5-tiles: calc(1 / 5 * 100%); 6 | --6-tiles: calc(1 / 6 * 100%); 7 | --7-tiles: calc(1 / 7 * 100%); 8 | --8-tiles: calc(1 / 8 * 100%); 9 | } 10 | 11 | .slider-tile { 12 | padding-inline: min(2%, 6px); 13 | } 14 | 15 | .slider-tile--movie, 16 | .slider-tile--tv, 17 | .slider-tile--trailer, 18 | .slider-tile--bonus, 19 | .slider-tile--genre, 20 | .slider-tile--spotlight { 21 | min-width: var(--2-tiles); 22 | max-width: var(--2-tiles); 23 | } 24 | 25 | .slider-tile--cast { 26 | min-width: var(--5-tiles); 27 | max-width: var(--5-tiles); 28 | } 29 | 30 | @media (max-width: 640px) { 31 | .slider-tile { 32 | padding-right: 2%; 33 | padding-bottom: 8px; 34 | } 35 | 36 | .slider-tile--movie, 37 | .slider-tile--tv, 38 | .slider-tile--spotlight { 39 | min-width: var(--3-tiles); 40 | max-width: var(--3-tiles); 41 | } 42 | 43 | .slider-tile--trailer, 44 | .slider-tile--bonus { 45 | min-width: var(--2-tiles); 46 | max-width: var(--2-tiles); 47 | } 48 | 49 | .slider-tile--cast { 50 | min-width: var(--4-tiles); 51 | max-width: var(--4-tiles); 52 | } 53 | } 54 | 55 | @media (min-width: 800px) { 56 | .slider-tile--movie, 57 | .slider-tile--tv, 58 | .slider-tile--trailer, 59 | .slider-tile--bonus, 60 | .slider-tile--genre, 61 | .slider-tile--spotlight { 62 | min-width: var(--3-tiles); 63 | max-width: var(--3-tiles); 64 | } 65 | 66 | .slider-tile--cast { 67 | min-width: var(--6-tiles); 68 | max-width: var(--6-tiles); 69 | } 70 | } 71 | 72 | @media (min-width: 1000px) { 73 | .slider-tile--movie, 74 | .slider-tile--tv, 75 | .slider-tile--trailer, 76 | .slider-tile--bonus, 77 | .slider-tile--genre, 78 | .slider-tile--spotlight { 79 | min-width: var(--4-tiles); 80 | max-width: var(--4-tiles); 81 | } 82 | 83 | .slider-tile--cast { 84 | min-width: var(--7-tiles); 85 | max-width: var(--7-tiles); 86 | } 87 | } 88 | 89 | @media (min-width: 1300px) { 90 | .slider-tile--movie, 91 | .slider-tile--tv, 92 | .slider-tile--trailer, 93 | .slider-tile--bonus, 94 | .slider-tile--genre, 95 | .slider-tile--spotlight { 96 | min-width: var(--5-tiles); 97 | max-width: var(--5-tiles); 98 | } 99 | 100 | .slider-tile--cast { 101 | min-width: var(--8-tiles); 102 | max-width: var(--8-tiles); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/components/slider/slider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffectOnce } from '@/hooks/use-effect-once'; 4 | import { usePagination } from '@/hooks/use-pagination'; 5 | import { useResizeWindow } from '@/hooks/use-resize-window'; 6 | import { Divider } from '@/components/divider'; 7 | import MediaHeader from '@/components/slider/media-header/media-header'; 8 | import PaginateLeftButton from '@/components/slider/paginate-button/paginate-left-button'; 9 | import PaginateRightButton from '@/components/slider/paginate-button/paginate-right-button'; 10 | import TileContainer from '@/components/slider/tiles/tile-container'; 11 | 12 | type SliderProps = { 13 | headerTitle: string; 14 | }; 15 | 16 | const Slider = ({ headerTitle }: SliderProps) => { 17 | const { 18 | actions: { goToFirstPage }, 19 | } = usePagination(); 20 | 21 | useEffectOnce(() => goToFirstPage()); 22 | useResizeWindow(); 23 | 24 | return ( 25 |
26 | {headerTitle} 27 | 28 |
29 | 30 | 31 | 32 |
33 | 34 | 35 |
36 | ); 37 | }; 38 | 39 | export default Slider; 40 | -------------------------------------------------------------------------------- /src/components/slider/tiles/thumbnails/bonus-trailer-thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import Link from 'next/link'; 3 | 4 | import { TODO } from '@/types/global-types'; 5 | import { BodyMedium, HeadingExtraSmall } from '@/components/fonts'; 6 | import { ArrowRightCircleIcon } from '@/components/icons'; 7 | 8 | type BonusTrailerThumbnailProps = { 9 | tile: TODO; 10 | isVisible: boolean; 11 | }; 12 | 13 | export const BonusTrailerThumbnail = ({ 14 | tile, 15 | isVisible, 16 | }: BonusTrailerThumbnailProps) => ( 17 | 25 |
26 |
27 | 28 | 29 | {tile.key ? ( 30 | {'Image 38 | ) : ( 39 |
40 | 41 | {`${tile.name || tile.original_title || tile.original_name || ''}`} 42 | 43 |
44 | )} 45 |
46 | 47 |
48 |
49 | {tile.name || ''} 50 |
51 |
52 | 53 | ); 54 | -------------------------------------------------------------------------------- /src/components/slider/tiles/thumbnails/cast-thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { MediaModal } from '@/routes'; 3 | 4 | import { TODO } from '@/types/global-types'; 5 | import { extractInitials } from '@/lib/utils'; 6 | import { BodyMedium, BodySmall, HeadingLarge } from '@/components/fonts'; 7 | 8 | type CastThumbnailProps = { 9 | tile: TODO; 10 | isVisible: boolean; 11 | }; 12 | 13 | export const CastThumbnail = ({ tile, isVisible }: CastThumbnailProps) => ( 14 | 20 |
21 | {/*
*/} 22 | {/**/} 23 | {tile.profile_path ? ( 24 | {tile.id.toString() 32 | ) : ( 33 |
34 | 35 | {extractInitials(tile.name || tile.original_name)} 36 | 37 |
38 | )} 39 |
40 | 41 |
42 |
43 | 44 | {tile.name || ''} 45 | 46 | 47 | {tile.character || ''} 48 | 49 |
50 |
51 | 52 | ); 53 | -------------------------------------------------------------------------------- /src/components/slider/tiles/thumbnails/genres.tsx: -------------------------------------------------------------------------------- 1 | import { MediaModal } from '@/routes'; 2 | 3 | import { TODO } from '@/types/global-types'; 4 | import { deslugify, slugify } from '@/lib/utils'; 5 | import { HeadingMedium } from '@/components/fonts'; 6 | import { ArrowRightCircleIcon } from '@/components/icons'; 7 | 8 | type GenresThumbnailProps = { 9 | tile: TODO; 10 | isVisible: boolean; 11 | }; 12 | 13 | const GenresThumbnail = ({ tile, isVisible }: GenresThumbnailProps) => ( 14 | 19 |
20 | 21 | {deslugify(tile.slug)} 22 | 23 | 24 |
25 |
26 |
27 | 28 | ); 29 | 30 | export default GenresThumbnail; 31 | -------------------------------------------------------------------------------- /src/components/slider/tiles/thumbnails/movie-thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { MediaModal } from '@/routes'; 3 | 4 | import { TODO } from '@/types/global-types'; 5 | import { extractYear } from '@/lib/utils'; 6 | import { BodyMedium, BodySmall, HeadingExtraSmall } from '@/components/fonts'; 7 | import ThumbnailWrapper from '@/components/thumbnail-wrapper'; 8 | 9 | type MovieThumbnailProps = { 10 | tile: TODO; 11 | isVisible: boolean; 12 | }; 13 | 14 | export const MovieThumbnail = ({ tile, isVisible }: MovieThumbnailProps) => ( 15 | 21 | 22 | {tile.backdrop_path || tile.poster_path ? ( 23 | <> 24 | {tile.title 32 | {tile.title 40 | 41 | ) : ( 42 |
43 | 44 | {tile.name || tile.original_title || tile.original_name} 45 | 46 |
47 | )} 48 |
49 | 50 |
51 |
52 | 53 | {tile.name || tile.title || tile.original_title} 54 | 55 | 56 | {extractYear(tile.release_date || tile.first_air_date)} 57 | 58 |
59 |
60 |
61 | ); 62 | -------------------------------------------------------------------------------- /src/components/slider/tiles/thumbnails/spotlight-thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { MediaModal } from '@/routes'; 3 | 4 | import { TODO } from '@/types/global-types'; 5 | import { usePagination } from '@/hooks/use-pagination'; 6 | import { HeadingExtraSmall } from '@/components/fonts'; 7 | import { ArrowRightCircleIcon } from '@/components/icons'; 8 | 9 | type SpotlightThumbnailProps = { 10 | tile: TODO; 11 | isVisible: boolean; 12 | }; 13 | 14 | export const SpotlightThumbnail = ({ 15 | tile, 16 | isVisible, 17 | }: SpotlightThumbnailProps) => { 18 | const { state: { mediaType } } = usePagination(); // prettier-ignore 19 | 20 | return ( 21 | 27 |
28 |
29 | 30 | 31 | {tile.poster_path || tile.backdrop_path ? ( 32 | {tile.title 40 | ) : ( 41 |
42 | 43 | {tile.name || 44 | tile.original_title || 45 | tile.title || 46 | tile.original_name} 47 | 48 |
49 | )} 50 |
51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/slider/tiles/thumbnails/tv-thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { MediaModal } from '@/routes'; 3 | 4 | import { TODO } from '@/types/global-types'; 5 | import { extractYear } from '@/lib/utils'; 6 | import { BodyMedium, BodySmall, HeadingExtraSmall } from '@/components/fonts'; 7 | import ThumbnailWrapper from '@/components/thumbnail-wrapper'; 8 | 9 | type TvThumbnailProps = { 10 | tile: TODO; 11 | isVisible: boolean; 12 | }; 13 | 14 | export const TvThumbnail = ({ tile, isVisible }: TvThumbnailProps) => ( 15 | 21 | 22 | {tile.backdrop_path || tile.poster_path ? ( 23 | <> 24 | {tile.title 32 | {tile.title 40 | 41 | ) : ( 42 |
43 | 44 | {tile.name || tile.original_title || tile.original_name} 45 | 46 |
47 | )} 48 |
49 | 50 |
51 |
52 | 53 | {tile.name || tile.title || tile.original_title} 54 | 55 | 56 | {extractYear(tile.release_date || tile.first_air_date)} 57 | 58 |
59 |
60 |
61 | ); 62 | -------------------------------------------------------------------------------- /src/components/slider/tiles/tile-container.tsx: -------------------------------------------------------------------------------- 1 | import { useRefContext } from '@/providers/slider/ref-provider'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | import { useAnimation } from '@/hooks/use-animation'; 5 | import { usePageUtils } from '@/hooks/use-page-utils'; 6 | import { usePagination } from '@/hooks/use-pagination'; 7 | import { useSlide } from '@/hooks/use-slide'; 8 | import { useTiles } from '@/hooks/use-tiles'; 9 | import TileItem from '@/components/slider/tiles/tile-item'; 10 | 11 | const TileContainer = () => { 12 | const { state: { CONTENT } } = usePagination(); // prettier-ignore 13 | const { state: { hasPaginated } } = usePageUtils(); // prettier-ignore 14 | const { tilesToRender } = useTiles(); 15 | const { slideAmount } = useSlide(); 16 | const { isAnimating } = useAnimation(); 17 | const { tileContainerRef, tileItemRef } = useRefContext(); 18 | 19 | return ( 20 |
21 | {/* Desktop */} 22 |
36 | {tilesToRender.map((tile, i) => ( 37 | 43 | ))} 44 |
45 | 46 | {/* Mobile */} 47 | {/* prettier-ignore */} 48 |
52 | {CONTENT.map((tile, i) => ( 53 | 54 | ))} 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default TileContainer; 61 | -------------------------------------------------------------------------------- /src/components/slider/tiles/tile-item.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | 3 | import { Section, TODO } from '@/types/global-types'; 4 | import { cn } from '@/lib/utils'; 5 | import { useAnimation } from '@/hooks/use-animation'; 6 | import { usePageUtils } from '@/hooks/use-page-utils'; 7 | import { usePagination } from '@/hooks/use-pagination'; 8 | import { BonusTrailerThumbnail } from '@/components/slider/tiles/thumbnails/bonus-trailer-thumbnail'; 9 | import { CastThumbnail } from '@/components/slider/tiles/thumbnails/cast-thumbnail'; 10 | import GenresThumbnail from '@/components/slider/tiles/thumbnails/genres'; 11 | import { MovieThumbnail } from '@/components/slider/tiles/thumbnails/movie-thumbnail'; 12 | import { TvThumbnail } from '@/components/slider/tiles/thumbnails/tv-thumbnail'; 13 | 14 | import '../slider.css'; 15 | 16 | import { SpotlightThumbnail } from '@/components/slider/tiles/thumbnails/spotlight-thumbnail'; 17 | 18 | type TileItemProps = { 19 | tile: TODO; 20 | i: number; 21 | }; 22 | 23 | // eslint-disable-next-line react/display-name 24 | const TileItem = forwardRef( 25 | ({ tile, i }, ref) => { 26 | const { state: { section } } = usePagination(); // prettier-ignore 27 | const { state: { isMounted } } = usePageUtils(); // prettier-ignore 28 | const { 29 | state: { hasPaginated }, 30 | actions: { getTileCountPerPage }, 31 | } = usePageUtils(); 32 | const { isAnimating } = useAnimation(); 33 | 34 | const tilesPerPage = getTileCountPerPage(); 35 | 36 | const isTileVisible = (i: number) => { 37 | const lowerBound = tilesPerPage - 1; 38 | const upperBound = tilesPerPage * 2; 39 | return lowerBound < i && i < upperBound; 40 | }; 41 | 42 | // FIXME: This is not showing the first few tiles on the first render 43 | const displayNumber = hasPaginated ? i - tilesPerPage : i; 44 | const isVisible = isTileVisible(i) && isMounted; 45 | const label = isVisible ? displayNumber : ''; 46 | 47 | const thumbnailStyles = { 48 | 'slider-tile--movie': section === 'movie', 49 | 'slider-tile--genre': section === 'genre', 50 | 'slider-tile--tv': section === 'tv', 51 | 'slider-tile--trailer': section === 'trailer', 52 | 'slider-tile--bonus': section === 'bonus', 53 | 'slider-tile--cast': section === 'cast', 54 | 'slider-tile--spotlight': section === 'spotlight', 55 | 'pointer-events-none': isAnimating, 56 | }; 57 | 58 | return ( 59 |
63 | 68 |
69 | ); 70 | } 71 | ); 72 | 73 | export default TileItem; 74 | 75 | type ThumbnailSelectorProps = { 76 | section: Section; 77 | tile: TODO; 78 | isVisible: boolean; 79 | }; 80 | 81 | const ThumbnailSelector = ({ 82 | section, 83 | tile, 84 | isVisible, 85 | }: ThumbnailSelectorProps) => { 86 | switch (section) { 87 | case 'movie': 88 | return ; 89 | 90 | case 'tv': 91 | return ; 92 | 93 | case 'trailer': 94 | case 'bonus': 95 | return ; 96 | 97 | case 'cast': 98 | return ; 99 | 100 | case 'genre': 101 | return ; 102 | 103 | case 'spotlight': 104 | return ; 105 | 106 | default: 107 | return null; 108 | } 109 | }; 110 | -------------------------------------------------------------------------------- /src/components/thumbnail-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { ArrowRightCircleIcon } from '@/components/icons'; 4 | 5 | const ThumbnailWrapper = ({ children }: { children: ReactNode }) => { 6 | return ( 7 |
8 |
9 | 10 | {children} 11 |
12 | ); 13 | }; 14 | 15 | export default ThumbnailWrapper; 16 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | import { Slot } from '@radix-ui/react-slot'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 13 | destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 14 | outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 15 | secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 16 | ghost: 'hover:bg-accent hover:text-accent-foreground', 17 | link: 'text-primary underline-offset-4 hover:underline', 18 | accent: 'bg-red text-primary hover:bg-red/90', 19 | }, 20 | size: { 21 | default: 'h-9 px-4 py-2', 22 | sm: 'h-8 rounded-md px-3 text-xs', 23 | lg: 'h-10 rounded-md px-6', 24 | icon: 'h-9 w-9', 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: 'default', 29 | size: 'default', 30 | }, 31 | } 32 | ); 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps { 37 | asChild?: boolean; 38 | } 39 | 40 | const Button = React.forwardRef( 41 | ({ className, variant, size, asChild = false, ...props }, ref) => { 42 | const Comp = asChild ? Slot : 'button'; 43 | return ; 44 | } 45 | ); 46 | Button.displayName = 'Button'; 47 | 48 | export { Button, buttonVariants }; 49 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | 'use client'; 4 | 5 | import * as React from 'react'; 6 | import { X } from 'lucide-react'; 7 | 8 | import { cn } from '@/lib/utils'; 9 | import * as DialogPrimitive from '@radix-ui/react-dialog'; 10 | import { Cross2Icon } from '@radix-ui/react-icons'; 11 | 12 | const Dialog = DialogPrimitive.Root; 13 | 14 | const DialogTrigger = DialogPrimitive.Trigger; 15 | 16 | const DialogPortal = DialogPrimitive.Portal; 17 | 18 | const DialogClose = DialogPrimitive.Close; 19 | 20 | const DialogOverlay = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef 23 | >(({ className, ...props }, ref) => ( 24 | 35 | )); 36 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 37 | 38 | const DialogContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, children, ...props }, ref) => ( 42 | 43 | 44 | { 47 | // https://github.com/radix-ui/primitives/issues/1280#issuecomment-1198248523 48 | const currentTarget = e.currentTarget as HTMLElement; 49 | if (e.detail.originalEvent.offsetX > currentTarget.clientWidth) { 50 | e.preventDefault(); 51 | } 52 | }} 53 | className={cn( 54 | // prettier-ignore 55 | `relative h-fit min-h-full w-full md:w-[85%] max-w-[1300px] bg-appBackground 56 | shadow-lg rounded-t-2xl overflow-x-hidden pb-20 57 | data-[state=open]:animate-in data-[state=closed]:animate-out 58 | data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 59 | data-[state=closed]:zoom-out-75 data-[state=open]:zoom-in-75`, 60 | className 61 | )} 62 | {...props} 63 | > 64 | {children} 65 | 66 | 73 | 74 | Close 75 | 76 | 77 | 78 | 79 | )); 80 | DialogContent.displayName = DialogPrimitive.Content.displayName; 81 | 82 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( 83 |
84 | ); 85 | DialogHeader.displayName = 'DialogHeader'; 86 | 87 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( 88 |
92 | ); 93 | DialogFooter.displayName = 'DialogFooter'; 94 | 95 | const DialogTitle = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 104 | )); 105 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 106 | 107 | const DialogDescription = React.forwardRef< 108 | React.ElementRef, 109 | React.ComponentPropsWithoutRef 110 | >(({ className, ...props }, ref) => ( 111 | 116 | )); 117 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 118 | 119 | export { 120 | Dialog, 121 | DialogPortal, 122 | DialogOverlay, 123 | DialogTrigger, 124 | DialogClose, 125 | DialogContent, 126 | DialogHeader, 127 | DialogFooter, 128 | DialogTitle, 129 | DialogDescription, 130 | }; 131 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | export interface InputProps extends React.InputHTMLAttributes {} 6 | 7 | const Input = React.forwardRef( 8 | ({ className, type, ...props }, ref) => { 9 | return ( 10 | 19 | ); 20 | } 21 | ); 22 | Input.displayName = 'Input'; 23 | 24 | export { Input }; 25 | -------------------------------------------------------------------------------- /src/hooks/use-animation.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-imports: 0 */ 2 | 3 | import { useSliderStore } from '@/providers/slider/slider-provider'; 4 | 5 | type UseAnimationReturn = { 6 | isAnimating: boolean; 7 | enableAnimation: () => void; 8 | disableAnimation: () => void; 9 | }; 10 | 11 | export const useAnimation = (): UseAnimationReturn => { 12 | const isAnimating = useSliderStore(state => state.isAnimating); 13 | const setIsAnimating = useSliderStore(state => state.setIsAnimating); 14 | 15 | const enableAnimation = () => setIsAnimating(true); 16 | const disableAnimation = () => setIsAnimating(false); 17 | 18 | return { isAnimating, enableAnimation, disableAnimation }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/hooks/use-effect-once.ts: -------------------------------------------------------------------------------- 1 | import { EffectCallback, useEffect } from 'react'; 2 | 3 | export const useEffectOnce = (effect: EffectCallback) => { 4 | // eslint-disable-next-line react-hooks/exhaustive-deps 5 | useEffect(effect, []); 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/use-map-pages.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-imports: 0 */ 2 | 3 | import { useSliderStore } from '@/providers/slider/slider-provider'; 4 | import { v4 as uuid } from 'uuid'; 5 | 6 | import { Pages, TODO } from '@/types/global-types'; 7 | import { getMapValue } from '@/lib/utils'; 8 | import { usePageUtils } from '@/hooks/use-page-utils'; 9 | import { useValidators } from '@/hooks/use-validators'; 10 | 11 | type SetMapTilesParams = { 12 | firstTileCurrentPageIndex: number; 13 | isFirstPage?: boolean; 14 | isLastPage?: boolean; 15 | }; 16 | 17 | export const useMapPages = () => { 18 | const CONTENT = useSliderStore(state => state.CONTENT); 19 | const pages = useSliderStore(state => state.pages); 20 | const setPages = useSliderStore(state => state.setPages); 21 | const currentPage = useSliderStore(state => state.currentPage); 22 | 23 | const { validatePages } = useValidators(); 24 | const { 25 | actions: { 26 | getTileCountPerPage, 27 | getTileCountBidirectional, 28 | getStartIndex, 29 | updateUuids, 30 | }, 31 | } = usePageUtils(); 32 | 33 | const setMapPages = ({ 34 | firstTileCurrentPageIndex, 35 | isFirstPage, 36 | isLastPage, 37 | }: SetMapTilesParams) => { 38 | const newPages: Pages = new Map(); 39 | const newTileCountPerPage = getTileCountPerPage(); 40 | let newFirstPageLength = newTileCountPerPage; 41 | let newLastPageLength = newTileCountPerPage; 42 | 43 | const [firstTileCurrentPage] = getMapValue({ 44 | label: 'setMapPages(): firstTileCurrentPage', 45 | map: pages, 46 | key: currentPage, 47 | }); 48 | 49 | const leftTileCount = getTileCountBidirectional( 50 | firstTileCurrentPageIndex / newTileCountPerPage 51 | ); 52 | const rightTileCount = getTileCountBidirectional( 53 | (CONTENT.length - firstTileCurrentPageIndex) / newTileCountPerPage 54 | ); 55 | 56 | const newTileCount = leftTileCount + rightTileCount; 57 | const newMaxPages = newTileCount / newTileCountPerPage; 58 | let newCurrentPage = -1; 59 | 60 | let startIndex = getStartIndex(firstTileCurrentPageIndex, leftTileCount); 61 | let newContentList: TODO[] = []; 62 | 63 | for (let i = 0; i < newTileCount; i++) { 64 | if (startIndex >= CONTENT.length) startIndex = 0; 65 | 66 | const pageNumber = Math.floor(i / newTileCountPerPage); 67 | const isNewFirstPage = pageNumber === 1; 68 | const isNewLastPage = pageNumber === newMaxPages - 2; 69 | const isLeftPlaceholder = pageNumber === 0; 70 | const isRightPlaceholder = pageNumber === newMaxPages - 1; 71 | 72 | const idMatches = newContentList.some( 73 | tile => tile.id === firstTileCurrentPage.id 74 | ); 75 | if (idMatches && pageNumber > 1 && newCurrentPage === -1) { 76 | newCurrentPage = pageNumber; 77 | } 78 | 79 | const newContentItem = CONTENT[startIndex++]; 80 | 81 | newContentList.push( 82 | isLeftPlaceholder || isRightPlaceholder 83 | ? { ...newContentItem, uuid: uuid() } 84 | : newContentItem 85 | ); 86 | 87 | if (newContentList.length !== newTileCountPerPage) continue; 88 | 89 | const firstTileIndex = newContentList.findIndex( 90 | tile => tile.id === CONTENT[0].id 91 | ); 92 | 93 | if (isNewFirstPage && firstTileIndex > 0) { 94 | newFirstPageLength = newTileCountPerPage - firstTileIndex; 95 | newContentList = updateUuids({ 96 | newContentList, 97 | firstTileIndex, 98 | isFirstPage: true, 99 | }); 100 | } 101 | 102 | if (isNewLastPage && firstTileIndex > 0) { 103 | newLastPageLength = firstTileIndex; 104 | newContentList = updateUuids({ 105 | newContentList, 106 | firstTileIndex, 107 | isLastPage: true, 108 | }); 109 | } 110 | 111 | newPages.set(pageNumber, newContentList); 112 | newContentList = []; 113 | } 114 | 115 | // [...newPages.entries()] 116 | // // .sort((a, b) => a[0] - b[0]) 117 | // .forEach(([pageIndex, tiles]) => { 118 | // console.log( 119 | // `Page ${pageIndex}:`, 120 | // tiles.map(card => (card ? card.id : undefined)) 121 | // ); 122 | // }); 123 | 124 | // validatePages({ 125 | // label: 'useMapPages()', 126 | // pages: newPages, 127 | // expectedMaxPages: newMaxPages, 128 | // expectedTilesPerPage: newTileCountPerPage, 129 | // }); 130 | 131 | const getNewCurrentPage = () => { 132 | if (isFirstPage) return 1; 133 | if (isLastPage) return newMaxPages - 2; 134 | return newCurrentPage; 135 | }; 136 | 137 | // console.table({ 138 | // startIndex: startIndex, 139 | // newCurrentPage: getNewCurrentPage(), 140 | // tilesPerPage: newTileCountPerPage, 141 | // leftTileCount: leftTileCount, 142 | // rightTileCount: rightTileCount, 143 | // totalTiles: leftTileCount + rightTileCount, 144 | // newMaxPages: newMaxPages, 145 | // newFirstPageLength: newFirstPageLength, 146 | // newLastPageLength: newLastPageLength, 147 | // }); 148 | 149 | setPages({ 150 | pages: newPages, 151 | currentPage: getNewCurrentPage(), 152 | maxPages: newMaxPages, 153 | tileCountPerPage: newTileCountPerPage, 154 | firstPageLength: newFirstPageLength, 155 | lastPageLength: newLastPageLength, 156 | isMounted: true, 157 | }); 158 | }; 159 | 160 | return { setMapPages }; 161 | }; 162 | -------------------------------------------------------------------------------- /src/hooks/use-page-utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-imports: 0 */ 2 | 3 | import { useSliderStore } from '@/providers/slider/slider-provider'; 4 | import { v4 as uuid } from 'uuid'; 5 | 6 | import { TODO } from '@/types/global-types'; 7 | import { MEDIA_QUERY } from '@/lib/constants'; 8 | 9 | type UsePageUtilsReturn = { 10 | state: { 11 | firstPageLength: number; 12 | lastPageLength: number; 13 | hasPaginated: boolean; 14 | isMounted: boolean; 15 | }; 16 | actions: { 17 | markAsPaginated: () => void; 18 | getTileCountPerPage: () => number; 19 | getTileCountBidirectional: (num: number) => number; 20 | getStartIndex: (currentIndex: number, leftTilesTotal: number) => number; 21 | updateUuids: (params: UpdateUuidsParams) => TODO[]; 22 | }; 23 | }; 24 | 25 | type UpdateUuidsParams = { 26 | newContentList: TODO[]; 27 | firstTileIndex: number; 28 | isFirstPage?: boolean; 29 | isLastPage?: boolean; 30 | }; 31 | 32 | export const usePageUtils = (): UsePageUtilsReturn => { 33 | const CONTENT = useSliderStore(state => state.CONTENT); 34 | const section = useSliderStore(state => state.section); 35 | const firstPageLength = useSliderStore(state => state.firstPageLength); 36 | const lastPageLength = useSliderStore(state => state.lastPageLength); 37 | const hasPaginated = useSliderStore(state => state.hasPaginated); 38 | const markAsPaginated = useSliderStore(state => state.markAsPaginated); 39 | const isMounted = useSliderStore(state => state.isMounted); 40 | 41 | const getTileCountPerPage = () => { 42 | const windowWidth = typeof window === 'undefined' ? 0 : window.innerWidth; 43 | 44 | if (section === 'cast') { 45 | if (windowWidth < MEDIA_QUERY.SM) return 5; 46 | if (windowWidth < MEDIA_QUERY.MD) return 6; 47 | if (windowWidth < MEDIA_QUERY.LG) return 7; 48 | return 8; 49 | } 50 | 51 | if (windowWidth < MEDIA_QUERY.SM) return 2; 52 | if (windowWidth < MEDIA_QUERY.MD) return 3; 53 | if (windowWidth < MEDIA_QUERY.LG) return 4; 54 | return 5; 55 | }; 56 | 57 | // +1 for left/right placeholders 58 | const getTileCountBidirectional = (num: number) => (Math.ceil(num) + 1) * getTileCountPerPage(); 59 | 60 | const getStartIndex = (currentIndex: number, leftTilesTotal: number) => { 61 | // Prevents negative modulo 62 | return ( 63 | (((currentIndex - leftTilesTotal + CONTENT.length) % CONTENT.length) + CONTENT.length) % CONTENT.length 64 | ); 65 | }; 66 | 67 | const updateUuids = ({ 68 | newContentList, 69 | firstTileIndex, 70 | isFirstPage = false, 71 | isLastPage = false, 72 | }: UpdateUuidsParams) => { 73 | if (isFirstPage) { 74 | const updatedFirstPageElements = newContentList 75 | .slice(0, firstTileIndex) 76 | .map(tile => ({ ...tile, uuid: uuid() })); 77 | return [...updatedFirstPageElements, ...newContentList.slice(firstTileIndex)]; 78 | } 79 | 80 | if (isLastPage) { 81 | const updatedLastPageElements = newContentList 82 | .slice(firstTileIndex) 83 | .map(tile => ({ ...tile, uuid: uuid() })); 84 | return [...newContentList.slice(0, firstTileIndex), ...updatedLastPageElements]; 85 | } 86 | 87 | return newContentList.map(tile => ({ ...tile, uuid: uuid() })); 88 | }; 89 | 90 | return { 91 | state: { 92 | firstPageLength, 93 | lastPageLength, 94 | hasPaginated, 95 | isMounted, 96 | }, 97 | actions: { 98 | markAsPaginated, 99 | getTileCountPerPage, 100 | getTileCountBidirectional, 101 | getStartIndex, 102 | updateUuids, 103 | }, 104 | }; 105 | }; 106 | -------------------------------------------------------------------------------- /src/hooks/use-pagination.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-imports: 0 */ 2 | 3 | import { useSliderStore } from '@/providers/slider/slider-provider'; 4 | 5 | import { MediaType, Pages, Section, TODO } from '@/types/global-types'; 6 | import { usePaginationLogger } from '@/lib/logger'; 7 | import { findIndexByKey, getMapValue } from '@/lib/utils'; 8 | import { useMapPages } from '@/hooks/use-map-pages'; 9 | import { usePageUtils } from '@/hooks/use-page-utils'; 10 | 11 | type UsePaginationReturn = { 12 | state: { 13 | CONTENT: TODO[]; 14 | pages: Pages; 15 | mediaType: MediaType; 16 | section: Section; 17 | currentPage: number; 18 | maxPages: number; 19 | }; 20 | status: { 21 | isFirstPage: boolean; 22 | isSecondPage: boolean; 23 | isLastPage: boolean; 24 | isSecondToLastPage: boolean; 25 | }; 26 | actions: { 27 | goToFirstPage: () => void; 28 | goToLastPage: () => void; 29 | goToPrevPage: () => void; 30 | goToNextPage: () => void; 31 | goToMaximizedPage: () => void; 32 | goToMinimizedPage: () => void; 33 | }; 34 | }; 35 | 36 | export const usePagination = (): UsePaginationReturn => { 37 | const CONTENT = useSliderStore(state => state.CONTENT); 38 | const mediaType = useSliderStore(state => state.mediaType); 39 | const section = useSliderStore(state => state.section); 40 | const pages = useSliderStore(state => state.pages); 41 | const currentPage = useSliderStore(state => state.currentPage); 42 | const setCurrentPage = useSliderStore(state => state.setCurrentPage); 43 | const tileCountPerPage = useSliderStore(state => state.tileCountPerPage); 44 | const lastPageLength = useSliderStore(state => state.lastPageLength); 45 | const maxPages = useSliderStore(state => state.maxPages); 46 | const hasPaginated = useSliderStore(state => state.hasPaginated); 47 | const markAsPaginated = useSliderStore(state => state.markAsPaginated); 48 | 49 | const { setMapPages } = useMapPages(); 50 | const { actions: { getTileCountPerPage, } } = usePageUtils(); // prettier-ignore 51 | 52 | const isFirstPage = currentPage === 1; 53 | const isSecondPage = currentPage === 2; 54 | const isLastPage = currentPage === maxPages - 2; 55 | const isSecondToLastPage = currentPage === maxPages - 3; 56 | 57 | const goToFirstPage = () => { 58 | usePaginationLogger.first(); 59 | setMapPages({ 60 | firstTileCurrentPageIndex: 0, 61 | isFirstPage: true, 62 | }); 63 | }; 64 | 65 | const goToLastPage = () => { 66 | usePaginationLogger.last(); 67 | setMapPages({ 68 | firstTileCurrentPageIndex: CONTENT.length - getTileCountPerPage(), 69 | isLastPage: true, 70 | }); 71 | }; 72 | 73 | const goToNextPage = () => { 74 | usePaginationLogger.next(); 75 | if (!hasPaginated) markAsPaginated(); 76 | setCurrentPage(currentPage + 1); 77 | }; 78 | 79 | const goToPrevPage = () => { 80 | usePaginationLogger.prev(); 81 | setCurrentPage(currentPage - 1); 82 | }; 83 | 84 | // Visuals: https://www.notion.so/useMinimizedPage-bb19b5abfc4a4c6585f397d5c26b627d?pvs=4 85 | const goToMinimizedPage = () => { 86 | usePaginationLogger.minimized(); 87 | 88 | const [firstTileCurrentPage] = getMapValue({ 89 | label: 'goToMinimizedPage(): firstTileCurrentPage', 90 | map: pages, 91 | key: currentPage, 92 | }); 93 | 94 | const firstTileCurrentPageIndex = findIndexByKey({ 95 | label: 'goToMinimizedPage(): firstTileCurrentPageIndex', 96 | array: CONTENT, 97 | key: 'id', 98 | value: firstTileCurrentPage.id, 99 | }); 100 | 101 | setMapPages({ firstTileCurrentPageIndex }); 102 | }; 103 | 104 | // Visuals: https://www.notion.so/useMaximizedPage-2b1d82d0e1df40bdaa22d5b49b65e4a5?pvs=4 105 | const goToMaximizedPage = () => { 106 | usePaginationLogger.maximized(); 107 | 108 | const [firstTileCurrentPage] = getMapValue({ 109 | label: 'goToMaximizedPage(): firstTileCurrentPage', 110 | map: pages, 111 | key: currentPage, 112 | }); 113 | 114 | const firstTileCurrentPageIndex = findIndexByKey({ 115 | label: 'goToMaximizedPage(): firstTileCurrentPageIndex', 116 | array: CONTENT, 117 | key: 'id', 118 | value: firstTileCurrentPage.id, 119 | }); 120 | 121 | const tilesToDecrement = getTileCountPerPage() - tileCountPerPage; 122 | const isLastPage = currentPage === maxPages - 2; 123 | const isSecondToLastPage = currentPage === maxPages - 3; 124 | 125 | if (isLastPage) { 126 | const indexForLastPage = firstTileCurrentPageIndex - tilesToDecrement; 127 | return setMapPages({ firstTileCurrentPageIndex: indexForLastPage }); 128 | } 129 | 130 | if (isSecondToLastPage) { 131 | const indexForSecondToLastPage = 132 | lastPageLength >= tilesToDecrement 133 | ? firstTileCurrentPageIndex 134 | : firstTileCurrentPageIndex - tilesToDecrement + lastPageLength; 135 | return setMapPages({ firstTileCurrentPageIndex: indexForSecondToLastPage }); 136 | } 137 | 138 | setMapPages({ firstTileCurrentPageIndex }); 139 | }; 140 | 141 | return { 142 | state: { 143 | CONTENT, 144 | mediaType, 145 | pages, 146 | section, 147 | currentPage, 148 | maxPages, 149 | }, 150 | status: { 151 | isFirstPage, 152 | isSecondPage, 153 | isLastPage, 154 | isSecondToLastPage, 155 | }, 156 | actions: { 157 | goToFirstPage, 158 | goToLastPage, 159 | goToPrevPage, 160 | goToNextPage, 161 | goToMaximizedPage, 162 | goToMinimizedPage, 163 | }, 164 | }; 165 | }; 166 | -------------------------------------------------------------------------------- /src/hooks/use-resize-direction.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | import { ResizeDirection } from '@/lib/constants'; 4 | 5 | export const useResizeDirection = () => { 6 | const [resizeDirection, setResizeDirection] = useState(null); 7 | const prevWindowWidth = useRef(typeof window === 'undefined' ? 0 : window.innerWidth); 8 | 9 | useEffect(() => { 10 | const handleResize = () => { 11 | const currentWidth = window.innerWidth; 12 | 13 | // prettier-ignore 14 | const direction: ResizeDirection = 15 | currentWidth > prevWindowWidth.current 16 | ? 'maximizing' 17 | : 'minimizing'; 18 | setResizeDirection(direction); 19 | 20 | prevWindowWidth.current = currentWidth; 21 | }; 22 | 23 | window.addEventListener('resize', handleResize); 24 | return () => window.removeEventListener('resize', handleResize); 25 | }, []); 26 | 27 | return { resizeDirection }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/hooks/use-resize-window.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-imports: 0 */ 2 | 3 | import { useCallback, useEffect, useRef } from 'react'; 4 | import chalk from 'chalk'; 5 | 6 | import { logger } from '@/lib/logger'; 7 | import { usePageUtils } from '@/hooks/use-page-utils'; 8 | import { usePagination } from '@/hooks/use-pagination'; 9 | import { useResizeDirection } from '@/hooks/use-resize-direction'; 10 | 11 | const log = (label: string) => logger(chalk.bgHex('#FC86F3').black(`${label}`)); 12 | 13 | export const useResizeWindow = () => { 14 | const { 15 | state: { currentPage }, 16 | actions: { goToFirstPage, goToMinimizedPage, goToMaximizedPage }, 17 | } = usePagination(); 18 | const { 19 | actions: { getTileCountPerPage }, 20 | } = usePageUtils(); 21 | const { resizeDirection } = useResizeDirection(); 22 | 23 | const prevTilesPerPage = useRef(getTileCountPerPage()); 24 | const prevWindowWidth = useRef(typeof window === 'undefined' ? 0 : window.innerWidth); 25 | 26 | const handleResize = useCallback(() => { 27 | const currentWidth = window.innerWidth; 28 | const newTileCountPerPage = getTileCountPerPage(); 29 | 30 | if (newTileCountPerPage === prevTilesPerPage.current) return; 31 | log(' USE WINDOW RESIZE '); 32 | prevTilesPerPage.current = newTileCountPerPage; 33 | prevWindowWidth.current = currentWidth; 34 | 35 | if (currentPage === 1) return goToFirstPage(); 36 | return resizeDirection === 'minimizing' ? goToMinimizedPage() : goToMaximizedPage(); 37 | }, [ 38 | currentPage, 39 | resizeDirection, 40 | getTileCountPerPage, 41 | goToFirstPage, 42 | goToMaximizedPage, 43 | goToMinimizedPage, 44 | ]); 45 | 46 | useEffect(() => { 47 | const resizeObserver = new ResizeObserver(() => handleResize()); 48 | resizeObserver.observe(document.body); 49 | return () => resizeObserver.disconnect(); 50 | }, [handleResize]); 51 | }; 52 | -------------------------------------------------------------------------------- /src/hooks/use-slide.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-imports: 0 */ 2 | 3 | import { useRefContext } from '@/providers/slider/ref-provider'; 4 | import { useSliderStore } from '@/providers/slider/slider-provider'; 5 | 6 | import { SlideDirection } from '@/lib/constants'; 7 | 8 | export type GetSlideAmountParams = { 9 | direction?: SlideDirection; 10 | isSecondPage?: boolean; 11 | isSecondToLastPage?: boolean; 12 | }; 13 | 14 | type UseSlideReturn = { 15 | slideAmount: number; 16 | slide: (amount: number) => void; 17 | getSlideAmount: (params: GetSlideAmountParams) => number; 18 | }; 19 | 20 | export const useSlide = (): UseSlideReturn => { 21 | const slideAmount = useSliderStore(state => state.slideAmount); 22 | const setSlideAmount = useSliderStore(state => state.setSlideAmount); 23 | const firstPageLength = useSliderStore(state => state.firstPageLength); 24 | const lastPageLength = useSliderStore(state => state.lastPageLength); 25 | 26 | const { tileContainerRef, tileItemRef } = useRefContext(); 27 | 28 | const slide = (amount: number) => setSlideAmount(amount); 29 | 30 | const getSlideAmount = ({ 31 | direction, 32 | isSecondPage = false, 33 | isSecondToLastPage = false, 34 | }: GetSlideAmountParams) => { 35 | if (!tileContainerRef.current) throw new Error('tileContainerRef is missing'); 36 | if (!tileItemRef.current) throw new Error('tileItemRef is missing'); 37 | 38 | const { offsetWidth: tileContainerWidth } = tileContainerRef.current; 39 | const { offsetWidth: tileItemWidth } = tileItemRef.current; 40 | 41 | const pageLength = isSecondPage ? firstPageLength : lastPageLength; 42 | const trailingPercentage = ((pageLength * tileItemWidth) / tileContainerWidth) * 100; 43 | 44 | if (isSecondPage && trailingPercentage) return trailingPercentage; 45 | if (isSecondToLastPage && trailingPercentage) return -trailingPercentage; 46 | 47 | return direction === 'right' ? -100 : 100; 48 | }; 49 | 50 | return { 51 | slide, 52 | slideAmount, 53 | getSlideAmount, 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /src/hooks/use-tiles.ts: -------------------------------------------------------------------------------- 1 | import { TODO } from '@/types/global-types'; 2 | import { getMapValue } from '@/lib/utils'; 3 | import { usePageUtils } from '@/hooks/use-page-utils'; 4 | import { usePagination } from '@/hooks/use-pagination'; 5 | 6 | export const useTiles = () => { 7 | const { state: { CONTENT, pages, currentPage } } = usePagination(); // prettier-ignore 8 | const { state: { isMounted, hasPaginated }, actions:{ getTileCountPerPage } } = usePageUtils(); // prettier-ignore 9 | 10 | if (CONTENT.length <= getTileCountPerPage()) return { tilesToRender: CONTENT }; 11 | 12 | const getPrevPageTiles = () => { 13 | if (!isMounted || !hasPaginated) return []; 14 | return getMapValue({ 15 | label: 'useTiles: getMapValue', 16 | map: pages, 17 | key: currentPage - 1, 18 | }); 19 | }; 20 | 21 | const getCurrentPageTiles = () => { 22 | return getMapValue({ 23 | label: 'useTiles: getCurrentPageTiles', 24 | map: pages, 25 | key: currentPage, 26 | }); 27 | }; 28 | 29 | const getNextPageTiles = () => { 30 | if (!isMounted) return []; 31 | return getMapValue({ 32 | label: 'useTiles: getNextPageTiles', 33 | map: pages, 34 | key: currentPage + 1, 35 | }); 36 | }; 37 | 38 | const tilesToRender: TODO[] = [ 39 | ...getPrevPageTiles(), 40 | ...getCurrentPageTiles(), 41 | ...getNextPageTiles() 42 | ]; // prettier-ignore 43 | 44 | return { tilesToRender }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/hooks/use-validators.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-restricted-imports: 0 */ 2 | 3 | import { Pages } from '@/types/global-types'; 4 | 5 | type validatePagesParams = { 6 | label: string; 7 | pages: Pages; 8 | expectedMaxPages: number; 9 | expectedTilesPerPage: number; 10 | }; 11 | 12 | export const useValidators = () => { 13 | const validatePages = ({ 14 | label, 15 | pages, 16 | expectedMaxPages, 17 | expectedTilesPerPage, 18 | }: validatePagesParams): void => { 19 | // if (pages.size !== expectedMaxPages) { 20 | // throw new Error(`${label} Expected ${expectedMaxPages} pages, found ${pages.size}.`); 21 | // } 22 | // 23 | // pages.forEach((tiles, pageIndex) => { 24 | // const result = nonEmptyTilesSchema.safeParse(tiles); 25 | // 26 | // if (!result.success) { 27 | // throw new Error(`${label} Validation failed for page ${pageIndex}: ${result.error}`); 28 | // } 29 | // 30 | // if (tiles.length !== expectedTilesPerPage) { 31 | // throw new Error( 32 | // `${label} Page ${pageIndex} has ${tiles.length} tiles, expected ${expectedTilesPerPage}` 33 | // ); 34 | // } 35 | // }); 36 | }; 37 | return { validatePages }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEVELOPMENT_MODE = 2 | process.env.NEXT_PUBLIC_NODE_ENV === 'development'; 3 | 4 | export const TIMEOUT_DURATION = 700; 5 | 6 | export const q = 'q'; 7 | 8 | export const MEDIA_QUERY = { 9 | XS: 640, 10 | SM: 800, 11 | MD: 1000, 12 | LG: 1300, 13 | } as const; 14 | 15 | 16 | export type SlideDirection = 'left' | 'right'; 17 | export type ResizeDirection = 'maximizing' | 'minimizing'; 18 | -------------------------------------------------------------------------------- /src/lib/env.ts: -------------------------------------------------------------------------------- 1 | import { cleanEnv, str } from 'envalid'; 2 | 3 | // Only works in server side 4 | export const env = cleanEnv(process.env, { 5 | NEXT_PUBLIC_NODE_ENV: str(), 6 | TMDB_API_KEY: str(), 7 | TMDB_READ_ACCESS_TOKEN: str(), 8 | }); 9 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import chalk from 'chalk'; 4 | 5 | import { DEVELOPMENT_MODE } from '@/lib/constants'; 6 | 7 | export const logger = (message: string) => (DEVELOPMENT_MODE ? console.log(message) : null); 8 | 9 | const bold = (text: string) => chalk.underline.bold(text); 10 | const GO_TO = ' GO TO'; 11 | const PAGE = 'PAGE '; 12 | const VIEW = ' VIEW'; 13 | 14 | export const usePaginationLogger = { 15 | first: () => logger(chalk.bgGreenBright.black(GO_TO, bold('FIRST'), PAGE)), 16 | last: () => logger(chalk.bgBlueBright.black(GO_TO, bold('LAST'), PAGE)), 17 | next: () => logger(chalk.bgYellowBright.black(GO_TO, bold('NEXT'), PAGE)), 18 | prev: () => logger(chalk.bgMagentaBright.black(GO_TO, bold('PREV'), PAGE)), 19 | maximized: () => logger(chalk.bgCyanBright.black(VIEW, bold('MAXIMIZED'), '')), 20 | minimized: () => logger(chalk.bgRedBright.black(VIEW, bold('MINIMIZED'), '')), 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { clsx, type ClassValue } from 'clsx'; 4 | import { twMerge } from 'tailwind-merge'; 5 | 6 | import { 7 | Genre, 8 | GenreId, 9 | GenreSlug, 10 | MediaType, 11 | MOVIE_GENRES, 12 | MovieGenreId, 13 | NAV_ROUTES, 14 | NavRoute, 15 | TV_GENRES, 16 | TvGenreId, 17 | } from '@/types/global-types'; 18 | import { DetailsMovieResponse, DetailsTvResponse } from '@/types/tmdb-types'; 19 | 20 | export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); 21 | 22 | export const wait = (ms: number): Promise => { 23 | return new Promise(resolve => setTimeout(resolve, ms)); 24 | }; 25 | 26 | export const fetcher = (url: string) => fetch(url).then(res => res.json()); 27 | 28 | export type KeyOf = keyof T; 29 | export type ValueOf = T[keyof T]; 30 | 31 | export type Prettify = { 32 | [K in keyof T]: T[K]; 33 | } & {}; // eslint-disable-line @typescript-eslint/ban-types 34 | 35 | export type KeysOfValue = { 36 | [K in keyof T]: T[K] extends TCondition ? K : never; 37 | }[keyof T]; 38 | 39 | export const capitalize = (str: string): string => { 40 | const words = str.split(' '); 41 | words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1).toLowerCase(); 42 | return words.join(' '); 43 | }; 44 | 45 | export const capitalizeMedia = (mediaType: MediaType): string => { 46 | return mediaType === 'movie' ? 'Movies' : 'TV Shows'; 47 | }; 48 | 49 | export const extractYear = (dateString: string | undefined): string => { 50 | if (typeof dateString !== 'string') return '-'; 51 | 52 | const regex = /^\d{4}/; 53 | const match = dateString.match(regex); 54 | return match ? match[0] : '-'; 55 | }; 56 | 57 | export const extractInitials = (name: string): string => { 58 | return name 59 | .split(' ') 60 | .map(n => n[0]) 61 | .slice(0, 2) 62 | .join(''); 63 | }; 64 | 65 | export const getGenreIdBySlug = ( 66 | slug: GenreSlug, 67 | mediaType: MediaType 68 | ): GenreId | undefined => { 69 | const genreObj = mediaType === 'movie' ? MOVIE_GENRES : TV_GENRES; 70 | return objectKeys(genreObj).find(key => genreObj[key] === slug); 71 | }; 72 | 73 | const objectKeys = (obj: TObj): (keyof TObj)[] => { 74 | return Object.keys(obj) as (keyof TObj)[]; 75 | }; 76 | 77 | export const getGenreSlugById = (id: GenreId, mediaType: MediaType): Genre => { 78 | if (mediaType === 'movie' && isMovieGenreId(id)) { 79 | return MOVIE_GENRES[id]; 80 | } 81 | if (mediaType === 'tv' && isTvGenreId(id)) { 82 | return TV_GENRES[id]; 83 | } 84 | throw new Error(`getGenreSlugById() Invalid media type: ${mediaType}`); 85 | }; 86 | 87 | export const extractGenreMediaTypeSlugs = ( 88 | slug: string 89 | ): [GenreSlug, MediaType] => { 90 | const match = slug.match(/^(.+?)-(movies|tv)$/); 91 | if (!match) 92 | throw new Error(`extractGenreMediaTypeSlugs() Invalid slug: ${slug}`); 93 | 94 | let genre = match[1]; 95 | let mediaType = match[2]; 96 | 97 | if (mediaType === 'movies' && mediaType.endsWith('s')) { 98 | mediaType = mediaType.slice(0, -1); 99 | } 100 | 101 | const parsedGenreSlug = Genre.safeParse(genre); 102 | if (!parsedGenreSlug.success) 103 | throw new Error(`extractGenreMediaTypeSlugs() Invalid genre: ${genre}`); 104 | 105 | const parsedMediaType = MediaType.safeParse(mediaType); 106 | if (!parsedMediaType.success) { 107 | throw new Error( 108 | `extractGenreMediaTypeSlugs() Invalid media type: ${mediaType}` 109 | ); 110 | } 111 | 112 | return [parsedGenreSlug.data, parsedMediaType.data]; 113 | }; 114 | 115 | export const getFirstSentence = (text: string) => { 116 | const match = text.match(/^(.*?[.])\s/); 117 | return match ? match[1] : text; 118 | }; 119 | 120 | export const getMapValue = ({ 121 | label, 122 | map, 123 | key, 124 | }: { 125 | label: string; 126 | map: Map; 127 | key: K; 128 | }): V => { 129 | const result = map.get(key); 130 | if (result === undefined) throw new Error(`${label}: Key not found: ${key}`); 131 | return result; 132 | }; 133 | 134 | export const findIndexByKey = ({ 135 | label, 136 | array, 137 | key, 138 | value, 139 | }: { 140 | label: string; 141 | array: T[]; 142 | key: K; 143 | value: T[K] | undefined; 144 | }): number => { 145 | if (value === undefined) throw new Error(`${label}: Value is undefined`); 146 | const index = array.findIndex(item => item[key] === value); 147 | if (index === -1) 148 | throw new Error(`${label}: Index of item not found for value: ${value}`); 149 | return index; 150 | }; 151 | 152 | export const isNullish = (...values: any[]): string => { 153 | return values.find(value => value !== undefined) ?? '-'; 154 | }; 155 | 156 | export const isMovie = ( 157 | media: ZMovie | ZTv, 158 | mediaType: MediaType 159 | ): media is ZMovie => { 160 | return mediaType === 'movie'; 161 | }; 162 | 163 | export const isMovieGenreId = (genreId: GenreId): genreId is MovieGenreId => { 164 | return genreId in MOVIE_GENRES; 165 | }; 166 | 167 | export const isTvGenreId = (genreId: any): genreId is TvGenreId => { 168 | return genreId in TV_GENRES; 169 | }; 170 | 171 | export const isMovieDetails = ( 172 | data: DetailsMovieResponse | DetailsTvResponse 173 | ): data is DetailsMovieResponse => { 174 | return data.hasOwnProperty('release_date'); 175 | }; 176 | 177 | export const isValidRoute = (route: string): route is NavRoute => { 178 | return ( 179 | Object.values(NAV_ROUTES).find(navRoute => navRoute === route) !== undefined 180 | ); 181 | }; 182 | 183 | export const slugify = (...args: string[]) => { 184 | return args 185 | .map(part => { 186 | return part 187 | .toString() 188 | .toLowerCase() 189 | .replace(/[^a-z0-9 -]/g, '') 190 | .replace(/\s+/g, '-') 191 | .replace(/-+/g, '-'); 192 | }) 193 | .join('-'); 194 | }; 195 | 196 | export const deslugify = (slug: string): string => { 197 | return slug 198 | .split('-') 199 | .map(word => word.charAt(0).toUpperCase() + word.slice(1)) 200 | .join(' '); 201 | }; 202 | -------------------------------------------------------------------------------- /src/providers/navigation/navigation-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | createContext, 5 | Dispatch, 6 | ReactNode, 7 | SetStateAction, 8 | useContext, 9 | useEffect, 10 | useState, 11 | } from 'react'; 12 | import { usePathname } from 'next/navigation'; 13 | import { Home, Movies, Search, Tv } from '@/routes'; 14 | 15 | import { NAV_ROUTES, NavRoute, TODO } from '@/types/global-types'; 16 | import { isValidRoute } from '@/lib/utils'; 17 | 18 | type NavigationContextProps = { 19 | lastActiveRoute: TODO; 20 | setLastActiveRoute: Dispatch>; 21 | } | null; 22 | 23 | type NavigationProviderProps = { children: ReactNode }; 24 | 25 | const NavigationContext = createContext(null); 26 | 27 | const ROUTE_HANDLERS = { 28 | [NAV_ROUTES.home]: () => Home(), 29 | [NAV_ROUTES.tv]: () => Tv(), 30 | [NAV_ROUTES.movies]: () => Movies(), 31 | [NAV_ROUTES.search]: () => Search(), 32 | }; 33 | 34 | export const NavigationProvider = ({ children }: NavigationProviderProps) => { 35 | const pathname = usePathname(); 36 | const [lastActiveRoute, setLastActiveRoute] = useState(); 37 | 38 | useEffect(() => { 39 | const isValid = isValidRoute(pathname); 40 | if (isValid && ROUTE_HANDLERS[pathname]) setLastActiveRoute(pathname); 41 | }, [pathname, setLastActiveRoute]); 42 | 43 | return ( 44 | 45 | {children} 46 | 47 | ); 48 | }; 49 | 50 | export const useNavigationStore = () => { 51 | const context = useContext(NavigationContext); 52 | if (!context) { 53 | throw new Error( 54 | 'useNavigationStore must be used within a NavigationProvider' 55 | ); 56 | } 57 | return context; 58 | }; 59 | -------------------------------------------------------------------------------- /src/providers/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ReactNode, Suspense } from 'react'; 4 | import { NavigationProvider } from '@/providers/navigation/navigation-provider'; 5 | import { SearchProvider } from '@/providers/search/search-provider'; 6 | 7 | type ProvidersProps = { 8 | children: ReactNode; 9 | }; 10 | 11 | const Providers = ({ children }: ProvidersProps) => ( 12 | 13 | 14 | {children} 15 | 16 | 17 | ); 18 | 19 | export default Providers; 20 | -------------------------------------------------------------------------------- /src/providers/search/search-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | createContext, 5 | ReactNode, 6 | RefObject, 7 | useContext, 8 | useEffect, 9 | useRef, 10 | useState, 11 | } from 'react'; 12 | import { usePathname, useRouter } from 'next/navigation'; 13 | import { Home, Search } from '@/routes'; 14 | import { useBoolean, useOnClickOutside } from 'usehooks-ts'; 15 | 16 | import { useEffectOnce } from '@/hooks/use-effect-once'; 17 | 18 | type SearchContextProps = { 19 | state: { 20 | isSearchInputExpanded: boolean; 21 | isSearchInputFocused: boolean; 22 | lastActiveRoute: string; 23 | query: string; 24 | }; 25 | actions: { 26 | handleFocus: () => void; 27 | handleSearch: (query: string) => void; 28 | handleClear: () => void; 29 | handleNavigate: () => void; 30 | }; 31 | refs: { 32 | searchContainerRef: RefObject; 33 | searchInputRef: RefObject; 34 | }; 35 | } | null; 36 | 37 | type SearchProviderProps = { children: ReactNode }; 38 | 39 | const SearchContext = createContext(null); 40 | 41 | export const SearchProvider = ({ children }: SearchProviderProps) => { 42 | const { replace } = useRouter(); 43 | const pathname = usePathname(); 44 | 45 | const [lastActiveRoute, setLastActiveRoute] = useState(Home()); 46 | 47 | const [query, setQuery] = useState(''); 48 | 49 | const searchContainerRef = useRef(null); 50 | const searchInputRef = useRef(null); 51 | 52 | const { 53 | value: isSearchInputExpanded, 54 | setTrue: expandSearchInput, 55 | setFalse: collapseSearchInput, 56 | } = useBoolean(false); 57 | 58 | const { 59 | value: isSearchInputFocused, 60 | setTrue: focusSearchInput, 61 | setFalse: blurSearchInput, 62 | } = useBoolean(false); 63 | 64 | useEffectOnce(() => { 65 | if (!query && pathname === Search()) replace(Home()); 66 | }); 67 | 68 | useEffect(() => { 69 | if (isSearchInputFocused && searchInputRef.current) { 70 | searchInputRef.current.focus(); 71 | } 72 | if (pathname === Search()) return; 73 | setLastActiveRoute(pathname); 74 | }, [isSearchInputFocused, pathname]); 75 | 76 | useOnClickOutside(searchContainerRef, () => { 77 | if (query) return; 78 | blurSearchInput(); 79 | collapseSearchInput(); 80 | }); 81 | 82 | const handleFocusSearchInput = () => { 83 | if (query) return; 84 | focusSearchInput(); 85 | }; 86 | 87 | const handleBlurSearchInput = () => { 88 | if (!searchInputRef.current) return; 89 | searchInputRef.current.blur(); 90 | setQuery(''); 91 | blurSearchInput(); 92 | }; 93 | 94 | const handleFocus = () => { 95 | expandSearchInput(); 96 | if (isSearchInputFocused) handleBlurSearchInput(); 97 | else handleFocusSearchInput(); 98 | collapseSearchInput(); 99 | }; 100 | 101 | const handleSearch = (inputQuery: string) => { 102 | setQuery(inputQuery); 103 | if (inputQuery.length === 0) return replace(lastActiveRoute); 104 | replace(Search()); 105 | }; 106 | 107 | const handleClear = () => { 108 | setQuery(''); 109 | replace(lastActiveRoute); 110 | }; 111 | 112 | const handleNavigate = () => { 113 | setQuery(''); 114 | blurSearchInput(); 115 | replace(lastActiveRoute); 116 | }; 117 | 118 | return ( 119 | 139 | {children} 140 | 141 | ); 142 | }; 143 | 144 | export const useSearchStore = () => { 145 | const context = useContext(SearchContext); 146 | if (!context) { 147 | throw new Error('useSearchStore must be used within a SearchProvider'); 148 | } 149 | return context; 150 | }; 151 | -------------------------------------------------------------------------------- /src/providers/slider/create-slider-store.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | import { MediaType, Pages, Section, TODO } from '@/types/global-types'; 4 | 5 | type SetPagesParams = { 6 | pages: Pages; 7 | maxPages: number; 8 | currentPage: number; 9 | tileCountPerPage: number; 10 | firstPageLength: number; 11 | lastPageLength: number; 12 | isMounted?: boolean; 13 | }; 14 | 15 | type SliderState = { 16 | CONTENT: TODO[]; 17 | mediaType: MediaType; 18 | section: Section; 19 | pages: Pages; 20 | maxPages: number; 21 | currentPage: number; 22 | tileCountPerPage: number; 23 | firstPageLength: number; 24 | lastPageLength: number; 25 | slideAmount: number; 26 | hasPaginated: boolean; 27 | isAnimating: boolean; 28 | isMounted: boolean; 29 | }; 30 | 31 | type SliderActions = { 32 | setCurrentPage: (currentPage: number) => void; 33 | setPages: (params: SetPagesParams) => void; 34 | setSlideAmount: (slideAmount: number) => void; 35 | setIsAnimating: (isAnimating: boolean) => void; 36 | markAsPaginated: () => void; 37 | }; 38 | 39 | export type SliderStore = SliderState & SliderActions; 40 | 41 | /* ─────────────────────────────────────────────────────────────────────── 42 | * Overview of Pages Map State Layout 43 | * ─────────────────────────────────────────────────────────────────────── 44 | * This documentation explains the organization of page states within a list, 45 | * particularly how tiles are distributed across pages, including the use of placeholders. 46 | * 47 | * Configuration Example: 48 | * - Total tiles: 6 49 | * - Tiles per page: 3 50 | * 51 | * Pages Layout: 52 | * - Page 0 (placeholder): [4, 5, 6] 53 | * - Page 1: [1, 2, 3] 54 | * - Page 2: [4, 5, 6] 55 | * - Page 3 (placeholder): [1, 2, 3] 56 | * 57 | * Purpose of placeholders: 58 | * - Placeholders (Page 0 and Page 3) prevent empty spaces, 59 | * when transitioning out of the central pages (Page 1 and Page 2). 60 | * - They aid in maintaining alignment due to the use of 'justify-center' in CSS. 61 | * 62 | * Key Management: 63 | * - Unique keys are necessary for each page to ensure proper rendering by list media-modal. 64 | * - To prevent key duplication between placeholder pages (Page 0 and Page 3), 65 | * UUIDs are updated before adding tiles to the pages map. 66 | */ 67 | 68 | export const createSliderStore = (CONTENT: TODO[], mediaType: MediaType, section: Section) => 69 | create(set => ({ 70 | CONTENT, 71 | mediaType, 72 | section, 73 | pages: new Map().set(1, CONTENT.slice(0, 7)), 74 | maxPages: 0, 75 | currentPage: 1, 76 | tileCountPerPage: 0, 77 | firstPageLength: 0, 78 | lastPageLength: 0, 79 | slideAmount: 0, 80 | hasPaginated: false, 81 | isAnimating: false, 82 | isMounted: false, 83 | 84 | setPages: (params: SetPagesParams) => set(() => params), 85 | setCurrentPage: (currentPage: number) => set(() => ({ currentPage })), 86 | setSlideAmount: (slideAmount: number) => set(() => ({ slideAmount })), 87 | setIsAnimating: (isAnimating: boolean) => set(() => ({ isAnimating })), 88 | markAsPaginated: () => set(() => ({ hasPaginated: true })), 89 | })); 90 | -------------------------------------------------------------------------------- /src/providers/slider/ref-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createContext, ReactNode, RefObject, useContext, useRef } from 'react'; 4 | 5 | type RefContextProps = { 6 | tileContainerRef: RefObject; 7 | tileItemRef: RefObject; 8 | } | null; 9 | 10 | type RefProviderProps = { 11 | children: ReactNode; 12 | }; 13 | 14 | const RefContext = createContext(null); 15 | 16 | export const RefProvider = ({ children }: RefProviderProps) => { 17 | const tileContainerRef = useRef(null); 18 | const tileItemRef = useRef(null); 19 | return ( 20 | 26 | {children} 27 | 28 | ); 29 | }; 30 | 31 | export const useRefContext = () => { 32 | const context = useContext(RefContext); 33 | if (!context) throw new Error('useRefContext must be used within a RefProvider'); 34 | return context; 35 | }; 36 | -------------------------------------------------------------------------------- /src/providers/slider/slider-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createContext, ReactNode, useContext, useRef } from 'react'; 4 | import { 5 | createSliderStore, 6 | SliderStore, 7 | } from '@/providers/slider/create-slider-store'; 8 | import { RefProvider } from '@/providers/slider/ref-provider'; 9 | import { StoreApi, useStore } from 'zustand'; 10 | 11 | import { MediaType, Section, TODO } from '@/types/global-types'; 12 | 13 | type SliderContextProps = StoreApi | null; 14 | 15 | type SliderProviderProps = { 16 | children: ReactNode; 17 | content: TODO[]; 18 | mediaType: MediaType; 19 | section: Section; 20 | }; 21 | 22 | const SliderContext = createContext(null); 23 | 24 | export const SliderProvider = ({ 25 | children, 26 | content, 27 | mediaType, 28 | section, 29 | }: SliderProviderProps) => { 30 | const storeRef = useRef>(); 31 | if (!storeRef.current) { 32 | storeRef.current = createSliderStore(content, mediaType, section); 33 | } 34 | 35 | return ( 36 | 37 | 38 |
{children}
39 |
40 |
41 | ); 42 | }; 43 | 44 | export const useSliderStore = (selector: (store: SliderStore) => T): T => { 45 | const store = useContext(SliderContext); 46 | if (!store) { 47 | throw new Error(`useSliderStore must be use within SliderProvider`); 48 | } 49 | return useStore(store, selector); 50 | }; 51 | -------------------------------------------------------------------------------- /src/routes/README.md: -------------------------------------------------------------------------------- 1 | This application supports typesafe routing for NextJS using the `declarative-routing` system. 2 | 3 | # What is `declarative-routing`? 4 | 5 | Declarative Routes is a system for typesafe routing in React. It uses a combination of TypeScript and a custom routing system to ensure that your routes are always in sync with your code. You'll never have to worry about broken links or missing routes again. 6 | 7 | In NextJS applications, Declarative Routes also handles API routes, so you'll have typesafe input and output from all of your APIs. In addition to `fetch` functions that are written for you automatically. 8 | 9 | # Route List 10 | 11 | Here are the routes of the application: 12 | 13 | | Route | Verb | Route Name | Using It | 14 | | ----- | ---- | ---- | ---- | 15 | | `/browse/[mediaType]/[id]` | - | `BrowseMediaTypeId` | `` | 16 | | `/browse` | - | `Browse` | `` | 17 | | `/movies` | - | `Movies` | `` | 18 | | `/tv` | - | `Tv` | `` | 19 | 20 | To use the routes, you can import them from `@/routes` and use them in your code. 21 | 22 | # Using the routes in your application 23 | 24 | For pages, use the `Link` component (built on top of `next/link`) to link to other pages. For example: 25 | 26 | ```tsx 27 | import { ProductDetail } from "@/routes"; 28 | 29 | return ( 30 | Product abc123 31 | ); 32 | ``` 33 | 34 | This is the equivalent of doing `Product abc123` but with typesafety. And you never have to remember the URL. If the route moves, the typesafe route will be updated automatically. 35 | 36 | For APIs, use the exported `fetch` wrapping functions. For example: 37 | 38 | ```tsx 39 | import { useEffect } from "react"; 40 | import { getProductInfo } from "@/routes"; 41 | 42 | useEffect(() => { 43 | // Parameters are typed to the input of the API 44 | getProductInfo({ productId: "abc123" }).then((data) => { 45 | // Data is typed to the result of the API 46 | console.log(data); 47 | }); 48 | }, []); 49 | ``` 50 | 51 | This is the equivalent of doing `fetch('/api/product/abc123')` but with typesafety, and you never have to remember the URL. If the API moves, the typesafe route will be updated automatically. 52 | 53 | ## Using typed hooks 54 | 55 | The system provides three typed hooks to use in your application `usePush`, `useParams`, and `useSearchParams`. 56 | 57 | * `usePush` wraps the NextJS `useRouter` hook and returns a typed version of the `push` function. 58 | * `useParams` wraps `useNextParams` and returns the typed parameters for the route. 59 | * `useSearchParams` wraps `useNextSearchParams` and returns the typed search parameters for the route. 60 | 61 | For each hook you give the route to get the appropriate data back. 62 | 63 | ```ts 64 | import { SearchInput } from "@/routes"; 65 | import { useSearchParams } from "@/routes/hooks"; 66 | 67 | export default MyClientComponent() { 68 | const searchParams = useSearchParams(SearchInput); 69 | return
{searchParams.query}
; 70 | } 71 | ``` 72 | 73 | We had to extract the hooks into a seperate module because NextJS would not allow the routes to include hooks directly if 74 | they were used by React Server Components (RSCs). 75 | 76 | # Configure declarative-routing 77 | 78 | After running `npx declarative-routing init`, you don't need to configure anything to use it. 79 | However, you may want to customize some options to change the behavior of route generation. 80 | 81 | You can edit `declarative-routing.config.json` in the root of your project. The following options are available: 82 | 83 | - `mode`: choose between `react-router`, `nextjs` or `qwikcity`. It is automatically picked on init based on the project type. 84 | - `routes`: the directory where the routes are defined. It is picked from the initial wizard (and defaults to `./src/components/declarativeRoutes`). 85 | - `importPathPrefix`: the path prefix to add to the import path of the self-generated route objects, in order to be able to resolve them. It defaults to `@/app`. 86 | 87 | # When your routes change 88 | 89 | You'll need to run `pnpm dr:build` to update the generated files. This will update the types and the `@/routes` module to reflect the changes. 90 | 91 | The way the system works the `.info.ts` files are link to the `@/routes/index.ts` file. So changing the Zod schemas for the routes does **NOT** require a rebuild. You need to run the build command when: 92 | 93 | - You change the name of the route in the `.info.ts` file 94 | - You change the location of the route (e.g. `/product` to `/products`) 95 | - You change the parameters of the route (e.g. `/product/[id]` to `/product/[productId]`) 96 | - You add or remove routes 97 | - You add or remove verbs from API routes (e.g. adding `POST` to an existing route) 98 | 99 | You can also run the build command in watch mode using `pnpm dr:build:watch` but we don't recommend using that unless you are changing routes a lot. It's a neat party trick to change a route directory name and to watch the links automagically change with hot module reloading, but routes really don't change that much. 100 | 101 | # Finishing your setup 102 | 103 | Post setup there are some additional tasks that you need to complete to completely typesafe your routes. We've compiled a handy check list so you can keep track of your progress. 104 | 105 | - [ ] `/browse/[mediaType]/[id]/page.info.ts`: Add search typing to if the page supports search paramaters 106 | - [ ] Convert `Link` components for `/browse/[mediaType]/[id]` to `` 107 | - [ ] Convert `params` typing in `/browse/[mediaType]/[id]/page.ts` to `z.infer<>` 108 | - [ ] `/browse/page.info.ts`: Add search typing to if the page supports search paramaters 109 | - [ ] Convert `Link` components for `/browse` to `` 110 | - [ ] `/movies/page.info.ts`: Add search typing to if the page supports search paramaters 111 | - [ ] Convert `Link` components for `/movies` to `` 112 | - [ ] `/tv/page.info.ts`: Add search typing to if the page supports search paramaters 113 | - [ ] Convert `Link` components for `/tv` to `` 114 | Once you've got that done you can remove this section. 115 | 116 | # Why is `makeRoute` copied into the `@/routes` module? 117 | 118 | You **own** this routing system once you install it. And we anticipate as part of that ownership you'll want to customize the routing system. That's why we create a `makeRoute.tsx` file in the `@/routes` module. This file is a copy of the `makeRoute.tsx` file from the `declarative-routing` package. You can modify this file to change the behavior of the routing system. 119 | 120 | For example, you might want to change the way `GET`, `POST`, `PUT`, and `DELETE` are handled. Or you might want to change the way the `Link` component works. You can do all of that by modifying the `makeRoute.tsx` file. 121 | 122 | We do **NOT** recommend changing the parameters of `makeRoute`, `makeGetRoute`, `makePostRoute`, `makePutRoute`, or `makeDeleteRoute` functions because that would cause incompatibility with the `build` command of `declarative-routing`. 123 | 124 | # Credit where credit is due 125 | 126 | This system is based on the work in [Fix Next.JS Routing To Have Full Type-Safety](https://www.flightcontrol.dev/blog/fix-nextjs-routing-to-have-full-type-safety). However the original article had a significantly different interface and didn't cover API routes at all. 127 | -------------------------------------------------------------------------------- /src/routes/hooks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useParams as useNextParams, 3 | useSearchParams as useNextSearchParams, 4 | useRouter, 5 | } from 'next/navigation'; 6 | import { z } from 'zod'; 7 | 8 | import { RouteBuilder } from './makeRoute'; 9 | 10 | const emptySchema = z.object({}); 11 | 12 | type PushOptions = Parameters['push']>[1]; 13 | 14 | export function usePush( 15 | builder: RouteBuilder 16 | ) { 17 | const { push } = useRouter(); 18 | return (p: z.input, search?: z.input, options?: PushOptions) => { 19 | push(builder(p, search), options); 20 | }; 21 | } 22 | 23 | export function useParams( 24 | builder: RouteBuilder 25 | ): z.output { 26 | const res = builder.paramsSchema.safeParse(useNextParams()); 27 | if (!res.success) { 28 | throw new Error(`Invalid route params for route ${builder.routeName}: ${res.error.message}`); 29 | } 30 | return res.data; 31 | } 32 | 33 | export function useSearchParams( 34 | builder: RouteBuilder 35 | ): z.output { 36 | const res = builder.searchSchema!.safeParse(convertURLSearchParamsToObject(useNextSearchParams())); 37 | if (!res.success) { 38 | throw new Error(`Invalid search params for route ${builder.routeName}: ${res.error.message}`); 39 | } 40 | return res.data; 41 | } 42 | 43 | function convertURLSearchParamsToObject( 44 | params: Readonly | null 45 | ): Record { 46 | if (!params) { 47 | return {}; 48 | } 49 | 50 | const obj: Record = {}; 51 | 52 | for (const [key, value] of params.entries()) { 53 | if (params.getAll(key).length > 1) { 54 | obj[key] = params.getAll(key); 55 | } else { 56 | obj[key] = value; 57 | } 58 | } 59 | return obj; 60 | } 61 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | // Automatically generated by declarative-routing, do NOT edit 2 | import { z } from 'zod'; 3 | 4 | import * as ErrorPageRoute from '@/app/(pages)/error/page.info'; 5 | import * as HomeRoute from '@/app/(pages)/home/page.info'; 6 | import * as MoviesRoute from '@/app/(pages)/movies/page.info'; 7 | import * as SearchRoute from '@/app/(pages)/search/page.info'; 8 | import * as TvRoute from '@/app/(pages)/tv/page.info'; 9 | import * as MediaModalRoute from '@/app/@modal/[...slug]/page.info'; 10 | import * as ApiSearchRoute from '@/app/api/search/route.info'; 11 | 12 | import { makeGetRoute, makeRoute } from './makeRoute'; 13 | 14 | const defaultInfo = { 15 | search: z.object({}), 16 | }; 17 | 18 | export const ErrorPage = makeRoute('/error', { 19 | ...defaultInfo, 20 | ...ErrorPageRoute.Route, 21 | }); 22 | export const Home = makeRoute('/home', { 23 | ...defaultInfo, 24 | ...HomeRoute.Route, 25 | }); 26 | export const Movies = makeRoute('/movies', { 27 | ...defaultInfo, 28 | ...MoviesRoute.Route, 29 | }); 30 | export const Search = makeRoute('/search', { 31 | ...defaultInfo, 32 | ...SearchRoute.Route, 33 | }); 34 | export const Tv = makeRoute('/tv', { 35 | ...defaultInfo, 36 | ...TvRoute.Route, 37 | }); 38 | export const MediaModal = makeRoute('/[...slug]', { 39 | ...defaultInfo, 40 | ...MediaModalRoute.Route, 41 | }); 42 | 43 | export const getApiSearch = makeGetRoute( 44 | '/api/search', 45 | { 46 | ...defaultInfo, 47 | ...ApiSearchRoute.Route, 48 | }, 49 | ApiSearchRoute.GET 50 | ); 51 | -------------------------------------------------------------------------------- /src/routes/makeRoute.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Derived from: https://www.flightcontrol.dev/blog/fix-nextjs-routing-to-have-full-type-safety 3 | */ 4 | import { z } from "zod"; 5 | import queryString from "query-string"; 6 | import Link from "next/link"; 7 | 8 | type LinkProps = Parameters[0]; 9 | 10 | export type RouteInfo< 11 | Params extends z.ZodSchema, 12 | Search extends z.ZodSchema 13 | > = { 14 | name: string; 15 | params: Params; 16 | search: Search; 17 | description?: string; 18 | }; 19 | 20 | export type GetInfo = { 21 | result: Result; 22 | }; 23 | 24 | export type PostInfo = { 25 | body: Body; 26 | result: Result; 27 | description?: string; 28 | }; 29 | 30 | export type PutInfo = { 31 | body: Body; 32 | result: Result; 33 | description?: string; 34 | }; 35 | 36 | type FetchOptions = Parameters[1]; 37 | 38 | type CoreRouteElements< 39 | Params extends z.ZodSchema, 40 | Search extends z.ZodSchema = typeof emptySchema 41 | > = { 42 | params: z.output; 43 | paramsSchema: Params; 44 | search: z.output; 45 | searchSchema: Search; 46 | }; 47 | 48 | type PutRouteBuilder< 49 | Params extends z.ZodSchema, 50 | Search extends z.ZodSchema, 51 | Body extends z.ZodSchema, 52 | Result extends z.ZodSchema 53 | > = CoreRouteElements & { 54 | ( 55 | body: z.input, 56 | p?: z.input, 57 | search?: z.input, 58 | options?: FetchOptions 59 | ): Promise>; 60 | 61 | body: z.output; 62 | bodySchema: Body; 63 | result: z.output; 64 | resultSchema: Result; 65 | }; 66 | 67 | type PostRouteBuilder< 68 | Params extends z.ZodSchema, 69 | Search extends z.ZodSchema, 70 | Body extends z.ZodSchema, 71 | Result extends z.ZodSchema 72 | > = CoreRouteElements & { 73 | ( 74 | body: z.input, 75 | p?: z.input, 76 | search?: z.input, 77 | options?: FetchOptions 78 | ): Promise>; 79 | 80 | body: z.output; 81 | bodySchema: Body; 82 | result: z.output; 83 | resultSchema: Result; 84 | }; 85 | 86 | type GetRouteBuilder< 87 | Params extends z.ZodSchema, 88 | Search extends z.ZodSchema, 89 | Result extends z.ZodSchema 90 | > = CoreRouteElements & { 91 | ( 92 | p?: z.input, 93 | search?: z.input, 94 | options?: FetchOptions 95 | ): Promise>; 96 | 97 | result: z.output; 98 | resultSchema: Result; 99 | }; 100 | 101 | type DeleteRouteBuilder = CoreRouteElements< 102 | Params, 103 | z.ZodSchema 104 | > & { 105 | (p?: z.input, options?: FetchOptions): Promise; 106 | }; 107 | 108 | export type RouteBuilder< 109 | Params extends z.ZodSchema, 110 | Search extends z.ZodSchema 111 | > = CoreRouteElements & { 112 | (p?: z.input, search?: z.input): string; 113 | 114 | routeName: string; 115 | 116 | Link: React.FC< 117 | Omit & 118 | z.input & { 119 | search?: z.input; 120 | } & { children?: React.ReactNode } 121 | >; 122 | ParamsLink: React.FC< 123 | Omit & { 124 | params?: z.input; 125 | search?: z.input; 126 | } & { children?: React.ReactNode } 127 | >; 128 | }; 129 | 130 | function createPathBuilder>( 131 | route: string 132 | ): (params: T) => string { 133 | const pathArr = route.split("/"); 134 | 135 | let catchAllSegment: ((params: T) => string) | null = null; 136 | if (pathArr.at(-1)?.startsWith("[[...")) { 137 | const catchKey = pathArr.pop()!.replace("[[...", "").replace("]]", ""); 138 | catchAllSegment = (params: T) => { 139 | const catchAll = params[catchKey] as unknown as string[]; 140 | return catchAll ? `/${catchAll.join("/")}` : ""; 141 | }; 142 | } 143 | 144 | const elems: ((params: T) => string)[] = []; 145 | for (const elem of pathArr) { 146 | const catchAll = elem.match(/\[\.\.\.(.*)\]/); 147 | const param = elem.match(/\[(.*)\]/); 148 | if (catchAll?.[1]) { 149 | const key = catchAll[1]; 150 | elems.push((params: T) => 151 | (params[key as unknown as string] as string[]).join("/") 152 | ); 153 | } else if (param?.[1]) { 154 | const key = param[1]; 155 | elems.push((params: T) => params[key as unknown as string] as string); 156 | } else if (!(elem.startsWith("(") && elem.endsWith(")"))) { 157 | elems.push(() => elem); 158 | } 159 | } 160 | 161 | return (params: T): string => { 162 | const p = elems.map((e) => e(params)).join("/"); 163 | if (catchAllSegment) { 164 | return p + catchAllSegment(params); 165 | } else { 166 | return p; 167 | } 168 | }; 169 | } 170 | 171 | function createRouteBuilder< 172 | Params extends z.ZodSchema, 173 | Search extends z.ZodSchema 174 | >(route: string, info: RouteInfo) { 175 | const fn = createPathBuilder>(route); 176 | 177 | return (params?: z.input, search?: z.input) => { 178 | let checkedParams = params || {}; 179 | if (info.params) { 180 | const safeParams = info.params.safeParse(checkedParams); 181 | if (!safeParams?.success) { 182 | throw new Error( 183 | `Invalid params for route ${info.name}: ${safeParams.error.message}` 184 | ); 185 | } else { 186 | checkedParams = safeParams.data; 187 | } 188 | } 189 | const safeSearch = info.search 190 | ? info.search?.safeParse(search || {}) 191 | : null; 192 | if (info.search && !safeSearch?.success) { 193 | throw new Error( 194 | `Invalid search params for route ${info.name}: ${safeSearch?.error.message}` 195 | ); 196 | } 197 | 198 | const baseUrl = fn(checkedParams); 199 | const searchString = search && queryString.stringify(search); 200 | return [baseUrl, searchString ? `?${searchString}` : ""].join(""); 201 | }; 202 | } 203 | 204 | const emptySchema = z.object({}); 205 | 206 | export function makePostRoute< 207 | Params extends z.ZodSchema, 208 | Search extends z.ZodSchema, 209 | Body extends z.ZodSchema, 210 | Result extends z.ZodSchema 211 | >( 212 | route: string, 213 | info: RouteInfo, 214 | postInfo: PostInfo 215 | ): PostRouteBuilder { 216 | const urlBuilder = createRouteBuilder(route, info); 217 | 218 | const routeBuilder: PostRouteBuilder = ( 219 | body: z.input, 220 | p?: z.input, 221 | search?: z.input, 222 | options?: FetchOptions 223 | ): Promise> => { 224 | const safeBody = postInfo.body.safeParse(body); 225 | if (!safeBody.success) { 226 | throw new Error( 227 | `Invalid body for route ${info.name}: ${safeBody.error.message}` 228 | ); 229 | } 230 | 231 | return fetch(urlBuilder(p, search), { 232 | ...options, 233 | method: "POST", 234 | body: JSON.stringify(safeBody.data), 235 | headers: { 236 | ...(options?.headers || {}), 237 | "Content-Type": "application/json" 238 | } 239 | }) 240 | .then((res) => { 241 | if (!res.ok) { 242 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`); 243 | } 244 | return res.json() as Promise>; 245 | }) 246 | .then((data) => { 247 | const result = postInfo.result.safeParse(data); 248 | if (!result.success) { 249 | throw new Error( 250 | `Invalid response for route ${info.name}: ${result.error.message}` 251 | ); 252 | } 253 | return result.data; 254 | }); 255 | }; 256 | 257 | routeBuilder.params = undefined as z.output; 258 | routeBuilder.paramsSchema = info.params; 259 | routeBuilder.search = undefined as z.output; 260 | routeBuilder.searchSchema = info.search; 261 | routeBuilder.body = undefined as z.output; 262 | routeBuilder.bodySchema = postInfo.body; 263 | routeBuilder.result = undefined as z.output; 264 | routeBuilder.resultSchema = postInfo.result; 265 | 266 | return routeBuilder; 267 | } 268 | 269 | export function makePutRoute< 270 | Params extends z.ZodSchema, 271 | Search extends z.ZodSchema, 272 | Body extends z.ZodSchema, 273 | Result extends z.ZodSchema 274 | >( 275 | route: string, 276 | info: RouteInfo, 277 | putInfo: PutInfo 278 | ): PutRouteBuilder { 279 | const urlBuilder = createRouteBuilder(route, info); 280 | 281 | const routeBuilder: PutRouteBuilder = ( 282 | body: z.input, 283 | p?: z.input, 284 | search?: z.input, 285 | options?: FetchOptions 286 | ): Promise> => { 287 | const safeBody = putInfo.body.safeParse(body); 288 | if (!safeBody.success) { 289 | throw new Error( 290 | `Invalid body for route ${info.name}: ${safeBody.error.message}` 291 | ); 292 | } 293 | 294 | return fetch(urlBuilder(p, search), { 295 | ...options, 296 | method: "PUT", 297 | body: JSON.stringify(safeBody.data), 298 | headers: { 299 | ...(options?.headers || {}), 300 | "Content-Type": "application/json" 301 | } 302 | }) 303 | .then((res) => { 304 | if (!res.ok) { 305 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`); 306 | } 307 | return res.json() as Promise>; 308 | }) 309 | .then((data) => { 310 | const result = putInfo.result.safeParse(data); 311 | if (!result.success) { 312 | throw new Error( 313 | `Invalid response for route ${info.name}: ${result.error.message}` 314 | ); 315 | } 316 | return result.data; 317 | }); 318 | }; 319 | 320 | routeBuilder.params = undefined as z.output; 321 | routeBuilder.paramsSchema = info.params; 322 | routeBuilder.search = undefined as z.output; 323 | routeBuilder.searchSchema = info.search; 324 | routeBuilder.body = undefined as z.output; 325 | routeBuilder.bodySchema = putInfo.body; 326 | routeBuilder.result = undefined as z.output; 327 | routeBuilder.resultSchema = putInfo.result; 328 | 329 | return routeBuilder; 330 | } 331 | 332 | export function makeGetRoute< 333 | Params extends z.ZodSchema, 334 | Search extends z.ZodSchema, 335 | Result extends z.ZodSchema 336 | >( 337 | route: string, 338 | info: RouteInfo, 339 | getInfo: GetInfo 340 | ): GetRouteBuilder { 341 | const urlBuilder = createRouteBuilder(route, info); 342 | 343 | const routeBuilder: GetRouteBuilder = ( 344 | p?: z.input, 345 | search?: z.input, 346 | options?: FetchOptions 347 | ): Promise> => { 348 | return fetch(urlBuilder(p, search), options) 349 | .then((res) => { 350 | if (!res.ok) { 351 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`); 352 | } 353 | return res.json() as Promise>; 354 | }) 355 | .then((data) => { 356 | const result = getInfo.result.safeParse(data); 357 | if (!result.success) { 358 | throw new Error( 359 | `Invalid response for route ${info.name}: ${result.error.message}` 360 | ); 361 | } 362 | return result.data; 363 | }); 364 | }; 365 | 366 | routeBuilder.params = undefined as z.output; 367 | routeBuilder.paramsSchema = info.params; 368 | routeBuilder.search = undefined as z.output; 369 | routeBuilder.searchSchema = info.search; 370 | routeBuilder.result = undefined as z.output; 371 | routeBuilder.resultSchema = getInfo.result; 372 | 373 | return routeBuilder; 374 | } 375 | 376 | export function makeDeleteRoute< 377 | Params extends z.ZodSchema, 378 | Search extends z.ZodSchema 379 | >(route: string, info: RouteInfo): DeleteRouteBuilder { 380 | const urlBuilder = createRouteBuilder(route, info); 381 | 382 | const routeBuilder: DeleteRouteBuilder = ( 383 | p?: z.input, 384 | search?: z.input, 385 | options?: FetchOptions 386 | ): Promise => { 387 | return fetch(urlBuilder(p, search), options).then((res) => { 388 | if (!res.ok) { 389 | throw new Error(`Failed to fetch ${info.name}: ${res.statusText}`); 390 | } 391 | }); 392 | }; 393 | 394 | routeBuilder.params = undefined as z.output; 395 | routeBuilder.paramsSchema = info.params; 396 | routeBuilder.search = undefined as z.output; 397 | routeBuilder.searchSchema = info.search; 398 | 399 | return routeBuilder; 400 | } 401 | 402 | export function makeRoute< 403 | Params extends z.ZodSchema, 404 | Search extends z.ZodSchema = typeof emptySchema 405 | >( 406 | route: string, 407 | info: RouteInfo 408 | ): RouteBuilder { 409 | const urlBuilder: RouteBuilder = createRouteBuilder( 410 | route, 411 | info 412 | ) as RouteBuilder; 413 | 414 | urlBuilder.routeName = info.name; 415 | 416 | urlBuilder.ParamsLink = function RouteLink({ 417 | params: linkParams, 418 | search: linkSearch, 419 | children, 420 | ...props 421 | }: Omit & { 422 | params?: z.input; 423 | search?: z.input; 424 | } & { children?: React.ReactNode }) { 425 | return ( 426 | 427 | {children} 428 | 429 | ); 430 | }; 431 | 432 | urlBuilder.Link = function RouteLink({ 433 | search: linkSearch, 434 | children, 435 | ...props 436 | }: Omit & 437 | z.input & { 438 | search?: z.input; 439 | } & { children?: React.ReactNode }) { 440 | const params = info.params.parse(props); 441 | const extraProps = { ...props }; 442 | for (const key of Object.keys(params)) { 443 | delete extraProps[key]; 444 | } 445 | return ( 446 | 450 | {children} 451 | 452 | ); 453 | }; 454 | 455 | urlBuilder.params = undefined as z.output; 456 | urlBuilder.paramsSchema = info.params; 457 | urlBuilder.search = undefined as z.output; 458 | urlBuilder.searchSchema = info.search; 459 | 460 | return urlBuilder; 461 | } 462 | -------------------------------------------------------------------------------- /src/types/global-types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { z } from 'zod'; 4 | 5 | import { KeyOf, Prettify, ValueOf } from '@/lib/utils'; 6 | 7 | export type TODO = any; 8 | 9 | export const NAV_ROUTES = { 10 | home: '/home', 11 | movies: '/movies', 12 | tv: '/tv', 13 | search: '/search', 14 | } as const; 15 | export type NavRoute = ValueOf; 16 | 17 | export const MediaType = z.enum(['movie', 'tv'] as const); 18 | 19 | export const GenreSlug = z.string().refine( 20 | slug => { 21 | const suffixes: Record = { '-movies': 7, '-tv': 3 }; 22 | 23 | const foundSuffix = Object.keys(suffixes).find(suffix => 24 | slug.endsWith(suffix) 25 | ); 26 | 27 | if (!foundSuffix) return false; 28 | 29 | const baseGenre = slug.slice(0, -suffixes[foundSuffix]); 30 | return Genre.safeParse(baseGenre).success; 31 | }, 32 | { message: "Must be a valid genre with '-movies' or '-tv' suffix." } 33 | ); 34 | 35 | export const PersonSlug = z.literal('person'); 36 | 37 | export const MediaModalSlug = z.union([ 38 | z.tuple([MediaType, z.string()]), 39 | z.tuple([GenreSlug]), 40 | z.tuple([PersonSlug, z.string()]), 41 | ]); 42 | 43 | const Section = z.enum([ 44 | MediaType.enum.movie, 45 | MediaType.enum.tv, 46 | 'trailer', 47 | 'bonus', 48 | 'cast', 49 | 'genre', 50 | 'spotlight', 51 | ] as const); 52 | 53 | const Category = z.enum([ 54 | 'credits', 55 | 'details', 56 | 'keywords', 57 | 'recommendations', 58 | 'similar', 59 | 'videos', 60 | 'images', 61 | 'popular', 62 | 'trending', 63 | 'discover', 64 | 'search', 65 | 'external_ids', 66 | 'combined_credits', 67 | ] as const); 68 | 69 | export const Genre = z.enum([ 70 | 'action', 71 | 'adventure', 72 | 'animation', 73 | 'comedy', 74 | 'crime', 75 | 'documentary', 76 | 'drama', 77 | 'family', 78 | 'fantasy', 79 | 'kids', 80 | 'history', 81 | 'horror', 82 | 'music', 83 | 'mystery', 84 | 'news', 85 | 'reality', 86 | 'romance', 87 | 'science-fiction', 88 | 'soap', 89 | 'talk', 90 | 'tv-movie', 91 | 'thriller', 92 | 'war', 93 | 'western', 94 | ] as const); 95 | 96 | export const MOVIE_GENRES = { 97 | 28: Genre.enum.action, 98 | 12: Genre.enum.adventure, 99 | 16: Genre.enum.animation, 100 | 35: Genre.enum.comedy, 101 | 80: Genre.enum.crime, 102 | 99: Genre.enum.documentary, 103 | 18: Genre.enum.drama, 104 | 10751: Genre.enum.family, 105 | 14: Genre.enum.fantasy, 106 | 36: Genre.enum.history, 107 | 27: Genre.enum.horror, 108 | 10402: Genre.enum.music, 109 | 9648: Genre.enum.mystery, 110 | 10749: Genre.enum.romance, 111 | 878: Genre.enum['science-fiction'], 112 | 10770: Genre.enum['tv-movie'], 113 | 53: Genre.enum.thriller, 114 | 10752: Genre.enum.war, 115 | 37: Genre.enum.western, 116 | } as const; 117 | 118 | export const TV_GENRES = { 119 | 10759: Genre.enum.action, 120 | 16: Genre.enum.animation, 121 | 35: Genre.enum.comedy, 122 | 80: Genre.enum.crime, 123 | 99: Genre.enum.documentary, 124 | 18: Genre.enum.drama, 125 | 10751: Genre.enum.family, 126 | 10762: Genre.enum.kids, 127 | 9648: Genre.enum.mystery, 128 | 10763: Genre.enum.news, 129 | 10764: Genre.enum.reality, 130 | 10765: Genre.enum['science-fiction'], 131 | 10766: Genre.enum.soap, 132 | 10767: Genre.enum.talk, 133 | 10768: Genre.enum.war, 134 | 37: Genre.enum.western, 135 | } as const; 136 | 137 | export type ContentRouteParams = { 138 | mediaType: MediaType; 139 | id: string; 140 | }; 141 | 142 | export type MediaModalSlug = z.infer; 143 | 144 | export type MediaType = z.infer; 145 | export type Section = z.infer; 146 | export type Pages = Map; 147 | 148 | export type Genre = MovieGenre | TvGenre; 149 | export type GenreId = MovieGenreId | TvGenreId; 150 | export type GenreSlug = z.infer; 151 | 152 | export type MovieGenreId = KeyOf; 153 | export type MovieGenre = ValueOf; 154 | 155 | export type TvGenreId = KeyOf; 156 | export type TvGenre = ValueOf; 157 | 158 | export type PersonSlug = z.infer; 159 | 160 | type CategoryWithIdProps = { 161 | mediaType: MediaType; 162 | id: string; 163 | category: 164 | | typeof Category.enum.credits 165 | | typeof Category.enum.details 166 | | typeof Category.enum.keywords 167 | | typeof Category.enum.recommendations 168 | | typeof Category.enum.similar 169 | | typeof Category.enum.videos 170 | | typeof Category.enum.images 171 | | typeof Category.enum.external_ids; 172 | }; 173 | 174 | type CategoryWithoutIdProps = { 175 | mediaType: MediaType; 176 | category: typeof Category.enum.popular | typeof Category.enum.trending; 177 | }; 178 | 179 | export type DiscoverMovieProps = { 180 | mediaType: typeof MediaType.enum.movie; 181 | genreId: MovieGenreId; 182 | primary_release_date_gte?: string; 183 | primary_release_date_lte?: string; 184 | }; 185 | 186 | export type DiscoverTvProps = { 187 | mediaType: typeof MediaType.enum.tv; 188 | genreId: TvGenreId; 189 | first_air_date_gte?: string; 190 | first_air_date_lte?: string; 191 | }; 192 | 193 | export type DiscoverProps = { 194 | category: typeof Category.enum.discover; 195 | page?: number; 196 | language?: string; 197 | vote_average_gte?: number; 198 | vote_average_lte?: number; 199 | } & (DiscoverMovieProps | DiscoverTvProps); 200 | 201 | type SearchProps = { 202 | mediaType: MediaType; 203 | category: typeof Category.enum.search; 204 | q: string; 205 | }; 206 | 207 | type PersonProps = { 208 | mediaType: PersonSlug; 209 | personId: string; 210 | category: 211 | | typeof Category.enum.details 212 | | typeof Category.enum.external_ids 213 | | typeof Category.enum.combined_credits; 214 | }; 215 | 216 | export type FetchTMDBParams = Prettify< 217 | | CategoryWithIdProps 218 | | CategoryWithoutIdProps 219 | | DiscoverProps 220 | | SearchProps 221 | | PersonProps 222 | >; 223 | -------------------------------------------------------------------------------- /src/types/tmdb-types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const BaseMedia = z.object({ 4 | id: z.number(), 5 | backdrop_path: z.string().nullable(), 6 | poster_path: z.string().nullable(), 7 | overview: z.string().nullable(), 8 | genre_ids: z.array(z.number()).nullable(), 9 | original_language: z.string().nullable(), 10 | vote_count: z.number().nullable(), 11 | }); 12 | 13 | const Movie = BaseMedia.merge( 14 | z.object({ 15 | original_title: z.string().nullable(), 16 | title: z.string().nullable(), 17 | release_date: z.string().nullable(), 18 | }) 19 | ); 20 | 21 | const Tv = BaseMedia.merge( 22 | z.object({ 23 | original_name: z.string().nullable(), 24 | name: z.string().nullable(), 25 | first_air_date: z.string().nullable(), 26 | }) 27 | ); 28 | 29 | const MediaResponse = z.object({ 30 | page: z.number(), 31 | total_pages: z.number(), 32 | total_results: z.number(), 33 | }); 34 | 35 | export const MovieResponse = MediaResponse.extend({ results: z.array(Movie) }); 36 | export const TvResponse = MediaResponse.extend({ results: z.array(Tv) }); 37 | 38 | export const Cast = z.object({ 39 | id: z.number(), 40 | cast_id: z.number().optional().nullable(), 41 | credit_id: z.string().nullable(), 42 | name: z.string().nullable(), 43 | original_name: z.string().nullable(), 44 | profile_path: z.string().nullable(), 45 | character: z.string().nullable(), 46 | adult: z.boolean().nullable(), 47 | gender: z.number().nullable(), 48 | known_for_department: z.string().nullable(), 49 | }); 50 | 51 | export const CreditsResponse = z.object({ 52 | id: z.number(), 53 | cast: z.array(Cast), 54 | }); 55 | 56 | const Video = z.object({ 57 | id: z.string(), 58 | name: z.string().nullable(), 59 | key: z.string().nullable(), 60 | site: z.string().nullable(), 61 | type: z.string().nullable(), 62 | official: z.boolean().nullable(), 63 | published_at: z.string().nullable(), 64 | }); 65 | 66 | export const VideoResponse = z.object({ 67 | id: z.number(), 68 | results: z.array(Video), 69 | }); 70 | 71 | const BaseDetails = z.object({ 72 | id: z.number(), 73 | backdrop_path: z.string().nullable(), 74 | poster_path: z.string().nullable(), 75 | genres: z 76 | .array( 77 | z.object({ 78 | id: z.number(), 79 | name: z.string(), 80 | }) 81 | ) 82 | .nullable(), 83 | homepage: z.string().nullable(), 84 | origin_country: z.array(z.string()).nullable(), 85 | original_language: z.string().nullable(), 86 | overview: z.string().nullable(), 87 | spoken_languages: z 88 | .array( 89 | z.object({ 90 | english_name: z.string(), 91 | iso_639_1: z.string(), 92 | name: z.string(), 93 | }) 94 | ) 95 | .nullable(), 96 | tagline: z.string().nullable(), 97 | vote_average: z.number().nullable(), 98 | vote_count: z.number().nullable(), 99 | }); 100 | 101 | export const DetailsMovieResponse = BaseDetails.extend({ 102 | title: z.string().nullable(), 103 | original_title: z.string().nullable(), 104 | belongs_to_collection: z 105 | .object({ 106 | id: z.number().nullable(), 107 | name: z.string().nullable(), 108 | poster_path: z.string().nullable(), 109 | backdrop_path: z.string().nullable(), 110 | }) 111 | .nullable(), 112 | imdb_id: z.string().nullable(), 113 | release_date: z.string().nullable(), 114 | runtime: z.number().nullable(), 115 | budget: z.number().nullable(), 116 | revenue: z.number().nullable(), 117 | }); 118 | 119 | export const DetailsTvResponse = BaseDetails.extend({ 120 | name: z.string().nullable(), 121 | original_name: z.string().nullable(), 122 | first_air_date: z.string().nullable(), 123 | languages: z.array(z.string()).nullable(), 124 | number_of_episodes: z.number().nullable(), 125 | number_of_seasons: z.number().nullable(), 126 | seasons: z.array( 127 | z 128 | .object({ 129 | air_date: z.string().nullable(), 130 | episode_count: z.number().nullable(), 131 | id: z.number(), 132 | name: z.string().nullable(), 133 | overview: z.string().nullable(), 134 | poster_path: z.string().nullable(), 135 | season_number: z.number(), 136 | vote_average: z.number().nullable(), 137 | }) 138 | .nullable() 139 | ), 140 | }); 141 | 142 | const Keyword = z.object({ 143 | id: z.number(), 144 | name: z.string(), 145 | }); 146 | 147 | export const KeywordsMovieResponse = z.object({ 148 | id: z.number(), 149 | keywords: z.array(Keyword), 150 | }); 151 | 152 | export const KeywordsTvResponse = z.object({ 153 | id: z.number(), 154 | results: z.array(Keyword), 155 | }); 156 | 157 | export const SearchResultsResponse = z.object({ 158 | movieData: MovieResponse, 159 | tvData: TvResponse, 160 | }); 161 | 162 | export const ExternalIds = z.object({ 163 | id: z.number(), 164 | imdb_id: z.string().nullable(), 165 | wikidata_id: z.string().nullable(), 166 | facebook_id: z.string().nullable(), 167 | instagram_id: z.string().nullable(), 168 | twitter_id: z.string().nullable(), 169 | }); 170 | 171 | export const DetailsPersonResponse = z.object({ 172 | id: z.number(), 173 | biography: z.string().nullable(), 174 | name: z.string().nullable(), 175 | profile_path: z.string().nullable(), 176 | }); 177 | 178 | export const MoviePersonCredits = z.object({ 179 | id: z.number(), 180 | adult: z.boolean().nullable(), 181 | backdrop_path: z.string().nullable(), 182 | genre_ids: z.array(z.number()).nullable(), 183 | original_language: z.string().nullable(), 184 | original_title: z.string().nullable(), 185 | overview: z.string().nullable(), 186 | popularity: z.number().nullable(), 187 | poster_path: z.string().nullable(), 188 | release_date: z.string().nullable(), 189 | title: z.string().nullable(), 190 | video: z.boolean().nullable(), 191 | vote_average: z.number().nullable(), 192 | vote_count: z.number().nullable(), 193 | character: z.string().nullable(), 194 | credit_id: z.string().nullable(), 195 | order: z.number().nullable(), 196 | media_type: z.literal('movie'), 197 | }); 198 | 199 | export const TvPersonCredits = z.object({ 200 | adult: z.boolean(), 201 | backdrop_path: z.string().nullable(), 202 | genre_ids: z.array(z.number()), 203 | id: z.number(), 204 | origin_country: z.array(z.string()), 205 | original_language: z.string(), 206 | original_name: z.string(), 207 | overview: z.string(), 208 | popularity: z.number(), 209 | poster_path: z.string().nullable(), 210 | first_air_date: z.string(), 211 | name: z.string(), 212 | vote_average: z.number(), 213 | vote_count: z.number(), 214 | character: z.string(), 215 | credit_id: z.string(), 216 | episode_count: z.number(), 217 | media_type: z.literal('tv'), 218 | }); 219 | 220 | export const CombinedCreditsResponse = z.object({ 221 | id: z.number(), 222 | cast: z.array(z.union([MoviePersonCredits, TvPersonCredits])), 223 | }); 224 | 225 | export type MovieResponse = z.infer; 226 | export type TvResponse = z.infer; 227 | export type MediaResponse = MovieResponse | TvResponse; 228 | export type Movie = z.infer; 229 | export type Tv = z.infer; 230 | 231 | export type Cast = z.infer; 232 | export type CreditsResponse = z.infer; 233 | export type Video = z.infer; 234 | export type VideoResponse = z.infer; 235 | export type DetailsMovieResponse = z.infer; 236 | export type DetailsTvResponse = z.infer; 237 | export type KeywordsMovieResponse = z.infer; 238 | export type KeywordsTvResponse = z.infer; 239 | 240 | export type SearchResultsResponse = z.infer; 241 | 242 | export type DetailsPersonResponse = z.infer; 243 | export type CombinedCreditsResponse = z.infer; 244 | 245 | export type MoviePersonCredits = z.infer; 246 | export type TvPersonCredits = z.infer; 247 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ['class'], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './media-modal/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | screens: { 14 | '2xl': '1700px', 15 | }, 16 | }, 17 | extend: { 18 | screens: { 19 | 'custom-xs': '640px', 20 | 'custom-sm': '800px', 21 | 'custom-md': '1000px', 22 | 'custom-lg': '1300px', 23 | }, 24 | spacing: { custom: 'min(4%, 60px)' }, 25 | boxShadow: { tileShadow: '0 4px 7px rgba(0, 0, 0, 0.25)' }, 26 | aspectRatio: { poster: '2 / 3' }, 27 | colors: { 28 | border: 'hsl(var(--border))', 29 | input: 'hsl(var(--input))', 30 | ring: 'hsl(var(--ring))', 31 | background: 'hsl(var(--background))', 32 | foreground: 'hsl(var(--foreground))', 33 | primary: { 34 | DEFAULT: 'hsl(var(--primary))', 35 | foreground: 'hsl(var(--primary-foreground))', 36 | }, 37 | secondary: { 38 | DEFAULT: 'hsl(var(--secondary))', 39 | foreground: 'hsl(var(--secondary-foreground))', 40 | }, 41 | destructive: { 42 | DEFAULT: 'hsl(var(--destructive))', 43 | foreground: 'hsl(var(--destructive-foreground))', 44 | }, 45 | muted: { 46 | DEFAULT: 'hsl(var(--muted))', 47 | foreground: 'hsl(var(--muted-foreground))', 48 | }, 49 | accent: { 50 | DEFAULT: 'hsl(var(--accent))', 51 | foreground: 'hsl(var(--accent-foreground))', 52 | }, 53 | popover: { 54 | DEFAULT: 'hsl(var(--popover))', 55 | foreground: 'hsl(var(--popover-foreground))', 56 | }, 57 | card: { 58 | DEFAULT: 'hsl(var(--card))', 59 | foreground: 'hsl(var(--card-foreground))', 60 | }, 61 | white: 'hsl(0, 0%, 100%)', 62 | red: 'hsl(0, 97%, 60%)', 63 | appBackground: 'hsl(0, 0%, 15%)', 64 | darkBlue: 'hsl(223, 23%, 46%)', 65 | darkerBlue: 'hsl(223, 36%, 14%)', 66 | darkestBlue: 'hsl(223, 30%, 9%)', 67 | }, 68 | borderRadius: { 69 | lg: 'var(--radius)', 70 | md: 'calc(var(--radius) - 2px)', 71 | sm: 'calc(var(--radius) - 4px)', 72 | }, 73 | keyframes: { 74 | 'accordion-down': { 75 | from: { height: 0 }, 76 | to: { height: 'var(--radix-accordion-content-height)' }, 77 | }, 78 | 'accordion-up': { 79 | from: { height: 'var(--radix-accordion-content-height)' }, 80 | to: { height: 0 }, 81 | }, 82 | 'animate-netflix-pulse': { 83 | '0%, 100%': { 84 | opacity: 1, 85 | }, 86 | '50%': { 87 | opacity: 0.5, 88 | }, 89 | }, 90 | }, 91 | animation: { 92 | 'accordion-down': 'accordion-down 0.2s ease-out', 93 | 'accordion-up': 'accordion-up 0.2s ease-out', 94 | 'netflix-pulse': 'animate-netflix-pulse 3s infinite', 95 | }, 96 | }, 97 | }, 98 | plugins: [require('tailwindcss-animate')], 99 | }; 100 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "strictNullChecks": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "baseUrl": "src/", 23 | "paths": { 24 | "@/*": ["*"], 25 | "@/browse/components/*": ["app/browse/[mediaType]/[id]/components/*"] 26 | } 27 | }, 28 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 29 | "exclude": ["node_modules"] 30 | } 31 | --------------------------------------------------------------------------------