├── .npmrc
├── .vscode
└── settings.json
├── .eslintignore
├── app
├── @modal
│ ├── default.tsx
│ └── (.)disclaimer
│ │ ├── default.tsx
│ │ └── page.tsx
├── twitter-image.png
├── opengraph-image.png
├── (landing)
│ ├── loading.tsx
│ └── page.tsx
├── tv-shows
│ ├── (tv-list)
│ │ ├── loading.tsx
│ │ └── page.tsx
│ └── [id]
│ │ └── (tv-details)
│ │ ├── loading.tsx
│ │ └── page.tsx
├── movies
│ ├── (movies-list)
│ │ ├── loading.tsx
│ │ └── page.tsx
│ └── [id]
│ │ └── (movie-details)
│ │ ├── loading.tsx
│ │ └── page.tsx
├── watch-history
│ └── (history)
│ │ ├── loading.tsx
│ │ └── page.tsx
├── disclaimer
│ └── page.tsx
├── server-sitemap-index.xml
│ └── route.ts
├── sitemap-static.xml
│ └── route.ts
├── sitemap-trending.xml
│ └── route.ts
├── sitemap-tv-shows.xml
│ └── route.ts
├── sitemap-movies.xml
│ └── route.ts
├── robots.ts
└── sitemap.ts
├── commitlint.config.js
├── postcss.config.js
├── public
├── favicon.ico
├── mstile-70x70.png
├── favicon-16x16.png
├── favicon-32x32.png
├── mstile-144x144.png
├── mstile-150x150.png
├── personal-logo.png
├── apple-touch-icon.png
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon-57x57.png
├── apple-touch-icon-60x60.png
├── apple-touch-icon-72x72.png
├── apple-touch-icon-76x76.png
├── apple-touch-icon-114x114.png
├── apple-touch-icon-120x120.png
├── apple-touch-icon-144x144.png
├── apple-touch-icon-152x152.png
├── apple-touch-icon-180x180.png
├── apple-touch-icon-precomposed.png
├── apple-touch-icon-57x57-precomposed.png
├── apple-touch-icon-60x60-precomposed.png
├── apple-touch-icon-72x72-precomposed.png
├── apple-touch-icon-76x76-precomposed.png
├── apple-touch-icon-114x114-precomposed.png
├── apple-touch-icon-120x120-precomposed.png
├── apple-touch-icon-144x144-precomposed.png
├── apple-touch-icon-152x152-precomposed.png
├── apple-touch-icon-180x180-precomposed.png
├── robots.txt
├── sitemap.xml
├── browserconfig.xml
├── site.webmanifest
├── vercel.svg
├── next.svg
├── sitemap-0.xml
└── safari-pinned-tab.svg
├── types
├── movie-genre.ts
├── navbar.ts
├── page-details.ts
├── search.ts
├── media.ts
├── production.ts
├── guest.ts
├── season-details.ts
├── series-result.ts
├── credit.ts
├── movie-result.ts
├── episode.ts
├── movie-details.ts
├── series-details.ts
└── filter.ts
├── renovate.json
├── lib
├── queryKeys.ts
├── fonts.ts
├── constants.ts
├── tmdbConfig.ts
├── fetch-client.ts
├── genres.ts
└── utils.ts
├── .prettierignore
├── providers
├── toast-provider.tsx
├── query-provider.tsx
└── posthog-provider.tsx
├── .editorconfig
├── hooks
├── use-mounted.ts
├── use-scroll-to-top.ts
├── use-search-params.ts
├── use-scroll-overlay.ts
├── use-cmdk-listener.ts
├── use-infinite-scroll.ts
├── use-episode-handler.ts
├── use-local-storage.ts
└── use-watched-media.ts
├── next-sitemap.config.js
├── .env.sample
├── components.json
├── actions
├── season-details.ts
├── search.ts
├── imdb-rating.ts
└── filter.ts
├── open-next.config.ts
├── components
├── loaders
│ ├── intro-pages-loader.tsx
│ ├── movie-tv-list-loader.tsx
│ ├── slider-horizontal-list-loader.tsx
│ └── media-grid-skeleton.tsx
├── watch-history
│ ├── watch-history-skeleton.tsx
│ ├── delete-alert.tsx
│ ├── watch-history.tsx
│ └── watch-history-card.tsx
├── ui
│ ├── input.tsx
│ ├── label.tsx
│ ├── separator.tsx
│ ├── skeleton.tsx
│ ├── badge.tsx
│ ├── hover-card.tsx
│ ├── slider.tsx
│ ├── popover.tsx
│ ├── avatar.tsx
│ ├── scroll-area.tsx
│ ├── card.tsx
│ ├── button.tsx
│ ├── dialog.tsx
│ └── select.tsx
├── media
│ ├── details-hero.tsx
│ ├── filter-debug.tsx
│ ├── details-credits.tsx
│ ├── details-extra-info.tsx
│ ├── extra-info.ts
│ ├── details-content.tsx
│ ├── media-content.tsx
│ ├── filter-sheet.tsx
│ └── filter-dialog.tsx
├── play-button.tsx
├── blurred-image.tsx
├── series
│ ├── season-navigator.tsx
│ ├── details-hero.tsx
│ ├── selector.tsx
│ ├── details-extra-info.tsx
│ ├── details-content.tsx
│ └── episodes.tsx
├── header
│ ├── hero-slider.tsx
│ ├── hero-image.tsx
│ ├── animated-watch-button.tsx
│ ├── hero-info.tsx
│ └── hero-rates-info.tsx
├── disclaimer
│ └── disclaimer-modal.tsx
├── layouts
│ ├── main-nav.tsx
│ ├── footer.tsx
│ └── site-header.tsx
├── main-page
│ └── intro-section.tsx
├── list.tsx
├── details-hero.tsx
└── card.tsx
├── next.config.mjs
├── .gitignore
├── dtos
├── series.ts
└── search.ts
├── README.md
├── .eslintrc.json
├── wrangler.jsonc
├── prettier.config.js
├── tsconfig.json
├── LICENSE.md
├── package.json
├── services
└── series.ts
└── config
└── site.ts
/.npmrc:
--------------------------------------------------------------------------------
1 | enable-pre-post-scripts=true
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "peacock.color": "#007fff"
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/*
2 | .cache
3 | public
4 | node_modules
5 | *.esm.js
6 |
--------------------------------------------------------------------------------
/app/@modal/default.tsx:
--------------------------------------------------------------------------------
1 | export default function Default() {
2 | return null
3 | }
4 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = { extends: ['@commitlint/config-conventional'] }
2 |
--------------------------------------------------------------------------------
/app/@modal/(.)disclaimer/default.tsx:
--------------------------------------------------------------------------------
1 | export default function Default() {
2 | return null
3 | }
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | },
5 | }
6 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/app/twitter-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/app/twitter-image.png
--------------------------------------------------------------------------------
/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/app/opengraph-image.png
--------------------------------------------------------------------------------
/public/mstile-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/mstile-70x70.png
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/mstile-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/mstile-144x144.png
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/personal-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/personal-logo.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/types/movie-genre.ts:
--------------------------------------------------------------------------------
1 | interface MovieGenre {
2 | id: number
3 | name: string
4 | }
5 |
6 | export { type MovieGenre }
7 |
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-57x57.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-60x60.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-72x72.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-76x76.png
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:recommended"]
4 | }
5 |
--------------------------------------------------------------------------------
/lib/queryKeys.ts:
--------------------------------------------------------------------------------
1 | export const QUERY_KEYS = {
2 | MOVIES_KEY: 'movies_react-query_key',
3 | SERIES_KEY: 'series_react-query_key',
4 | }
5 |
--------------------------------------------------------------------------------
/public/apple-touch-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-114x114.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-120x120.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-144x144.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-152x152.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-180x180.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-57x57-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-57x57-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-60x60-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-60x60-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-72x72-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-72x72-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-76x76-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-76x76-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-114x114-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-114x114-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-120x120-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-120x120-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-144x144-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-144x144-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-152x152-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-152x152-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-180x180-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vette1123/movies-streaming-platform/HEAD/public/apple-touch-icon-180x180-precomposed.png
--------------------------------------------------------------------------------
/types/navbar.ts:
--------------------------------------------------------------------------------
1 | export interface NavItem {
2 | title: string
3 | href?: string
4 | disabled?: boolean
5 | external?: boolean
6 | scroll?: boolean
7 | }
8 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | cache
2 | .cache
3 | package.json
4 | package-lock.json
5 | public
6 | CHANGELOG.md
7 | .yarn
8 | dist
9 | node_modules
10 | .next
11 | build
12 | .contentlayer
--------------------------------------------------------------------------------
/types/page-details.ts:
--------------------------------------------------------------------------------
1 | type PageDetailsProps = {
2 | params: { id: string }
3 | searchParams: { [key: string]: string | string[] | undefined }
4 | }
5 |
6 | export type { PageDetailsProps }
7 |
--------------------------------------------------------------------------------
/providers/toast-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { Toaster as RadToaster } from 'sonner'
5 |
6 | export const ToastProvider = () => {
7 | return
8 | }
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | end_of_line = lf
7 | indent_size = 2
8 | indent_style = space
9 | insert_final_newline = true
10 | trim_trailing_whitespace = true
11 |
12 |
13 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # *
2 | User-agent: *
3 | Allow: /
4 |
5 | # Host
6 | Host: https://www.reely.space
7 |
8 | # Sitemaps
9 | Sitemap: https://www.reely.space/sitemap.xml
10 | Sitemap: https://www.reely.space/server-sitemap-index.xml
11 |
--------------------------------------------------------------------------------
/app/(landing)/loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { FullScreenLoader } from '@/components/loaders/intro-pages-loader'
4 |
5 | const loading = () => {
6 | return
7 | }
8 |
9 | export default loading
10 |
--------------------------------------------------------------------------------
/types/search.ts:
--------------------------------------------------------------------------------
1 | import { Movie } from '@/types/movie-result'
2 | import { Series } from '@/types/series-result'
3 |
4 | type SearchResponse = {
5 | page: number
6 | results: (Series & Movie)[]
7 | }
8 |
9 | export type { SearchResponse }
10 |
--------------------------------------------------------------------------------
/app/tv-shows/(tv-list)/loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { MovieTVListLoader } from '@/components/loaders/movie-tv-list-loader'
4 |
5 | const loading = () => {
6 | return
7 | }
8 |
9 | export default loading
10 |
--------------------------------------------------------------------------------
/app/movies/(movies-list)/loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { MovieTVListLoader } from '@/components/loaders/movie-tv-list-loader'
4 |
5 | const loading = () => {
6 | return
7 | }
8 |
9 | export default loading
10 |
--------------------------------------------------------------------------------
/app/movies/[id]/(movie-details)/loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { FullScreenLoader } from '@/components/loaders/intro-pages-loader'
4 |
5 | const loading = () => {
6 | return
7 | }
8 |
9 | export default loading
10 |
--------------------------------------------------------------------------------
/app/tv-shows/[id]/(tv-details)/loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { FullScreenLoader } from '@/components/loaders/intro-pages-loader'
4 |
5 | const loading = () => {
6 | return
7 | }
8 |
9 | export default loading
10 |
--------------------------------------------------------------------------------
/app/watch-history/(history)/loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { MovieTVListLoader } from '@/components/loaders/movie-tv-list-loader'
4 |
5 | const loading = () => {
6 | return
7 | }
8 |
9 | export default loading
10 |
--------------------------------------------------------------------------------
/hooks/use-mounted.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export function useMounted() {
4 | const [mounted, setMounted] = React.useState(false)
5 |
6 | React.useEffect(() => {
7 | setMounted(true)
8 | }, [])
9 |
10 | return mounted
11 | }
12 |
--------------------------------------------------------------------------------
/hooks/use-scroll-to-top.ts:
--------------------------------------------------------------------------------
1 | export function useScrollToTop() {
2 | const scrollToTop = () => {
3 | window.scrollTo({
4 | top: 0,
5 | behavior: 'smooth', // You can change this to 'auto' for instant scrolling
6 | })
7 | }
8 |
9 | return { scrollToTop }
10 | }
11 |
--------------------------------------------------------------------------------
/public/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | https://www.reely.space/sitemap-0.xml
4 | https://www.reely.space/server-sitemap-index.xml
5 |
--------------------------------------------------------------------------------
/types/media.ts:
--------------------------------------------------------------------------------
1 | import { Movie } from '@/types/movie-result'
2 |
3 | type MediaType = Movie
4 |
5 | interface MediaResponse {
6 | page: number
7 | results: MediaType[]
8 | total_pages?: number
9 | total_results?: number
10 | }
11 |
12 | export type { MediaType, MediaResponse }
13 |
--------------------------------------------------------------------------------
/types/production.ts:
--------------------------------------------------------------------------------
1 | interface ProductionCompany {
2 | id: number
3 | logo_path: string
4 | name: string
5 | origin_country: string
6 | }
7 |
8 | interface ProductionCountry {
9 | iso_3166_1: string
10 | name: string
11 | }
12 |
13 | export type { ProductionCompany, ProductionCountry }
14 |
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #da532c
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import { JetBrains_Mono as FontMono, Inter as FontSans } from 'next/font/google'
2 |
3 | export const fontSans = FontSans({
4 | subsets: ['latin'],
5 | variable: '--font-sans',
6 | })
7 |
8 | export const fontMono = FontMono({
9 | subsets: ['latin'],
10 | variable: '--font-mono',
11 | })
12 |
--------------------------------------------------------------------------------
/next-sitemap.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next-sitemap').IConfig} */
2 | module.exports = {
3 | siteUrl: process.env.SITE_URL || 'https://www.reely.space',
4 | generateRobotsTxt: true,
5 | robotsTxtOptions: {
6 | additionalSitemaps: [
7 | 'https://www.reely.space/server-sitemap-index.xml',
8 | ],
9 | },
10 | }
--------------------------------------------------------------------------------
/app/disclaimer/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { DisclaimerContent } from '@/components/disclaimer/disclaimer-content'
4 |
5 | function Disclaimer() {
6 | return (
7 |
8 |
9 |
10 | )
11 | }
12 |
13 | export default Disclaimer
14 |
--------------------------------------------------------------------------------
/types/guest.ts:
--------------------------------------------------------------------------------
1 | interface GuestStar {
2 | character: string
3 | credit_id: string
4 | order: number
5 | adult: boolean
6 | gender: number
7 | id: number
8 | known_for_department: string
9 | name: string
10 | original_name: string
11 | popularity: number
12 | profile_path: string
13 | }
14 |
15 | export type { GuestStar }
16 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | TMDB_API_KEY=''
2 | NEXT_PUBLIC_TMDB_BASEURL=""
3 | TMDB_HEADER_KEY=''
4 | NEXT_PUBLIC_BASE_URL=''
5 | NEXT_PUBLIC_STREAMING_MOVIES_API_URL=''
6 | NEXT_PUBLIC_SEARCH_ACTOR_GOOGLE=''
7 | NEXT_PUBLIC_IMAGE_CACHE_HOST_URL=''
8 | GOOGLE_GTM_ID=''
9 | GOOGLE_MEASUREMENT_ID=''
10 | NEXT_PUBLIC_POSTHOG_KEY=''
11 | NEXT_PUBLIC_POSTHOG_HOST=''
12 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "css": "styles/globals.css",
8 | "baseColor": "zinc",
9 | "cssVariables": true
10 | },
11 | "aliases": {
12 | "components": "@/components",
13 | "utils": "@/lib/utils"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/types/season-details.ts:
--------------------------------------------------------------------------------
1 | import { EpisodeDetails } from '@/types/episode'
2 |
3 | interface SeasonDetails {
4 | _id: string
5 | air_date: string
6 | episodes: EpisodeDetails[]
7 | name: string
8 | overview: string
9 | id: number
10 | poster_path: string
11 | season_number: number
12 | vote_average: number
13 | }
14 |
15 | export type { SeasonDetails }
16 |
--------------------------------------------------------------------------------
/app/@modal/(.)disclaimer/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { DisclaimerContent } from '@/components/disclaimer/disclaimer-content'
4 | import DisclaimerModal from '@/components/disclaimer/disclaimer-modal'
5 |
6 | function Disclaimer() {
7 | return (
8 |
9 |
10 |
11 | )
12 | }
13 |
14 | export default Disclaimer
15 |
--------------------------------------------------------------------------------
/app/server-sitemap-index.xml/route.ts:
--------------------------------------------------------------------------------
1 | import { getServerSideSitemapIndex } from 'next-sitemap'
2 | import { siteConfig } from '@/config/site'
3 |
4 | export async function GET(request: Request) {
5 | const baseUrl = siteConfig.websiteURL
6 |
7 | return getServerSideSitemapIndex([
8 | `${baseUrl}/sitemap-static.xml`,
9 | `${baseUrl}/sitemap-movies.xml`,
10 | `${baseUrl}/sitemap-tv-shows.xml`,
11 | `${baseUrl}/sitemap-trending.xml`,
12 | ])
13 | }
--------------------------------------------------------------------------------
/actions/season-details.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { Param } from '@/types/movie-result'
4 | import { SeasonDetails } from '@/types/season-details'
5 | import { fetchClient } from '@/lib/fetch-client'
6 |
7 | export const getSeasonEpisodesAction = async (
8 | seasonId: number,
9 | seasonNumber: string,
10 | params?: Param
11 | ) => {
12 | const url = `tv/${seasonId}/season/${seasonNumber}?language=en-US`
13 | return fetchClient.get(url, params, true)
14 | }
15 |
--------------------------------------------------------------------------------
/actions/search.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { searchDTO } from '@/dtos/search'
4 |
5 | import { Param } from '@/types/movie-result'
6 | import { SearchResponse } from '@/types/search'
7 | import { fetchClient } from '@/lib/fetch-client'
8 |
9 | export const searchMovieAction = async (params: Param = {}) => {
10 | const url = `search/multi?include_adult=false&language=en-US&page=1`
11 | const rawData = await fetchClient.get(url, params, true)
12 | return searchDTO(rawData)
13 | }
14 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/open-next.config.ts:
--------------------------------------------------------------------------------
1 | import { defineCloudflareConfig } from '@opennextjs/cloudflare'
2 |
3 | // Enhanced caching configuration for optimal Cloudflare Workers performance
4 | // This configuration leverages KV incremental cache and cache interception for better performance
5 | // Based on @opennextjs/cloudflare documentation: https://opennext.js.org/cloudflare
6 | export default defineCloudflareConfig({
7 | // Use KV incremental cache for ISR/SSG pages - this is the core caching mechanism
8 | // incrementalCache: kvIncrementalCache,
9 | })
10 |
--------------------------------------------------------------------------------
/components/loaders/intro-pages-loader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { SkeletonContainer } from '@/components/ui/skeleton'
4 |
5 | export const FullScreenLoader = () => {
6 | return (
7 |
8 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/hooks/use-search-params.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useSearchParams } from 'next/navigation'
3 |
4 | export const useSearchQueryParams = () => {
5 | const searchParams = useSearchParams()
6 | const seasonQuerySTR = searchParams.get('season')
7 | const episodeQuerySTR = searchParams.get('episode')
8 | const seasonQueryINT = Number(seasonQuerySTR)
9 | const episodeQueryINT = Number(episodeQuerySTR)
10 | return {
11 | seasonQueryINT,
12 | episodeQueryINT,
13 | seasonQuerySTR,
14 | episodeQuerySTR,
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare'
2 |
3 | initOpenNextCloudflareForDev()
4 |
5 | /** @type {import('next').NextConfig} */
6 | const nextConfig = {
7 | images: {
8 | unoptimized: true,
9 | remotePatterns: [
10 | {
11 | protocol: 'https',
12 | hostname: 'images.unsplash.com',
13 | },
14 | {
15 | protocol: 'https',
16 | hostname: 'image.tmdb.org',
17 | },
18 | ],
19 | },
20 | typescript: { ignoreBuildErrors: true },
21 | }
22 |
23 | export default nextConfig
24 |
--------------------------------------------------------------------------------
/.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 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | .contentlayer
38 | .env
39 | .open-next
40 |
--------------------------------------------------------------------------------
/providers/query-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5 |
6 | export const QueryProvider = ({ children }: { children: React.ReactNode }) => {
7 | const [queryClient] = useState(
8 | () =>
9 | new QueryClient({
10 | defaultOptions: {
11 | queries: {
12 | refetchOnWindowFocus: false,
13 | },
14 | },
15 | })
16 | )
17 | return (
18 | {children}
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/constants.ts:
--------------------------------------------------------------------------------
1 | const TOP_OFFSET = 60
2 | const STREAMING_MOVIES_API_URL =
3 | process.env.NEXT_PUBLIC_STREAMING_MOVIES_API_URL
4 | const SEARCH_ACTOR_GOOGLE = process.env.NEXT_PUBLIC_SEARCH_ACTOR_GOOGLE
5 | const IMAGE_CACHE_HOST_URL = process.env.NEXT_PUBLIC_IMAGE_CACHE_HOST_URL
6 | const GOOGLE_GTM_ID = process.env.GOOGLE_GTM_ID
7 | const GOOGLE_MEASUREMENT_ID = process.env.GOOGLE_MEASUREMENT_ID
8 | const SEARCH_DEBOUNCE = 400
9 |
10 | export {
11 | TOP_OFFSET,
12 | STREAMING_MOVIES_API_URL,
13 | SEARCH_ACTOR_GOOGLE,
14 | SEARCH_DEBOUNCE,
15 | IMAGE_CACHE_HOST_URL,
16 | GOOGLE_GTM_ID,
17 | GOOGLE_MEASUREMENT_ID,
18 | }
19 |
--------------------------------------------------------------------------------
/dtos/series.ts:
--------------------------------------------------------------------------------
1 | import { MediaResponse } from '@/types/media'
2 | import { SeriesResponse } from '@/types/series-result'
3 |
4 | export const seriesDTO = (seriesResponse: SeriesResponse): MediaResponse => ({
5 | page: seriesResponse.page,
6 | results: seriesResponse.results.map((series) => {
7 | const { name, original_name, first_air_date, ...rest } = series
8 | return {
9 | ...rest,
10 | title: name,
11 | original_title: original_name,
12 | release_date: first_air_date,
13 | video: false,
14 | }
15 | }),
16 | total_pages: seriesResponse?.total_pages,
17 | total_results: seriesResponse?.total_results,
18 | })
19 |
--------------------------------------------------------------------------------
/providers/posthog-provider.tsx:
--------------------------------------------------------------------------------
1 | // app/providers.js
2 | 'use client'
3 |
4 | import { PropsWithChildren } from 'react'
5 | import posthog from 'posthog-js'
6 | import { PostHogProvider } from 'posthog-js/react'
7 |
8 | if (typeof window !== 'undefined') {
9 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY as string, {
10 | api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST as string,
11 | person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
12 | })
13 | }
14 | export function CSPostHogProvider({ children }: PropsWithChildren) {
15 | return {children}
16 | }
17 |
--------------------------------------------------------------------------------
/types/series-result.ts:
--------------------------------------------------------------------------------
1 | import { ItemType } from '@/types/movie-result'
2 |
3 | type Series = {
4 | adult: boolean
5 | backdrop_path: string
6 | id: number
7 | name: string
8 | original_language: string
9 | original_name: string
10 | overview: string
11 | poster_path: string
12 | media_type: ItemType
13 | genre_ids: number[]
14 | popularity: number
15 | first_air_date: string
16 | vote_average: number
17 | vote_count: number
18 | origin_country: string[]
19 | }
20 |
21 | interface SeriesResponse {
22 | page: number
23 | results: Series[]
24 | total_pages?: number
25 | total_results?: number
26 | }
27 |
28 | export type { Series, SeriesResponse }
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Reely!
2 |
3 | ## Introduction
4 |
5 | Welcome to [Reely Space!](https://www.reely.space)! This is your go-to platform for discovering and streaming the latest movies and exclusive content, designed to enhance your viewing experience.
6 |
7 | ## Features
8 |
9 | - Personalized recommendations
10 | - Stream the latest movies
11 | - High-quality video playback
12 | - User reviews and ratings
13 | - Seamless integration with various devices
14 | - Tailwind CSS
15 | - Community discussions and forums
16 |
17 | ## Support
18 |
19 | Would appreciate your support and feedback.
20 |
21 | [](https://buymeacoffee.com/vetteotp)
22 |
--------------------------------------------------------------------------------
/components/watch-history/watch-history-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from '@/components/ui/card'
2 | import { Skeleton } from '@/components/ui/skeleton'
3 |
4 | export function WatchedItemCardSkeleton() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/types/credit.ts:
--------------------------------------------------------------------------------
1 | interface Credit {
2 | id: number
3 | cast: {
4 | adult: boolean
5 | gender: number
6 | id: number
7 | known_for_department: string
8 | name: string
9 | original_name: string
10 | popularity: number
11 | profile_path: string
12 | cast_id: number
13 | character: string
14 | credit_id: string
15 | order: number
16 | }[]
17 | crew: {
18 | adult: boolean
19 | gender: number
20 | id: number
21 | known_for_department: string
22 | name: string
23 | original_name: string
24 | popularity: number
25 | profile_path: string
26 | credit_id: string
27 | department: string
28 | job: string
29 | }[]
30 | }
31 |
32 | export type { Credit }
33 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/eslintrc",
3 | // "root": true,
4 | "extends": [
5 | "next/core-web-vitals",
6 | "prettier",
7 | "plugin:tailwindcss/recommended"
8 | ],
9 | "plugins": ["tailwindcss"],
10 | "rules": {
11 | "@next/next/no-html-link-for-pages": "off",
12 | "react/jsx-key": "off",
13 | "tailwindcss/no-custom-classname": "off"
14 | },
15 | "settings": {
16 | "tailwindcss": {
17 | "callees": ["cn"],
18 | "config": "tailwind.config.js"
19 | },
20 | "next": {
21 | "rootDir": ["./"]
22 | }
23 | },
24 | "overrides": [
25 | {
26 | "files": ["*.ts", "*.tsx"],
27 | "parser": "@typescript-eslint/parser"
28 | }
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/app/watch-history/(history)/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Metadata } from 'next'
3 |
4 | import { siteConfig } from '@/config/site'
5 | import { WatchHistoryContainer } from '@/components/watch-history/watch-history'
6 |
7 | export const metadata: Metadata = {
8 | title: 'Watch History',
9 | description: 'Watch History',
10 | metadataBase: new URL('/watch-history', process.env.NEXT_PUBLIC_BASE_URL),
11 | openGraph: {
12 | images: [siteConfig.personalLogo, siteConfig.links.twitter],
13 | },
14 | }
15 |
16 | function WatchHistory() {
17 | return (
18 |
21 | )
22 | }
23 |
24 | export default WatchHistory
25 |
--------------------------------------------------------------------------------
/hooks/use-scroll-overlay.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { TOP_OFFSET } from '@/lib/constants'
4 |
5 | export const useNavbarScrollOverlay = () => {
6 | const [isShowNavBackground, setIsShowNavBackground] = React.useState(false)
7 | const isBrowser = typeof window !== 'undefined'
8 |
9 | React.useEffect(() => {
10 | const handleScroll = () => {
11 | if (isBrowser && window.scrollY >= TOP_OFFSET) {
12 | setIsShowNavBackground(true)
13 | } else {
14 | setIsShowNavBackground(false)
15 | }
16 | }
17 |
18 | window.addEventListener('scroll', handleScroll)
19 |
20 | return () => {
21 | window.removeEventListener('scroll', handleScroll)
22 | }
23 | }, [isBrowser])
24 |
25 | return { isShowNavBackground }
26 | }
27 |
--------------------------------------------------------------------------------
/hooks/use-cmdk-listener.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export const useCMDKListener = () => {
4 | const [open, setOpen] = React.useState(false)
5 | const [isLoading, setIsLoading] = React.useState(false)
6 |
7 | React.useEffect(() => {
8 | const down = (e: KeyboardEvent) => {
9 | if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
10 | e.preventDefault()
11 | setOpen((open) => !open)
12 | }
13 | }
14 |
15 | document.addEventListener('keydown', down)
16 | return () => document.removeEventListener('keydown', down)
17 | }, [])
18 |
19 | const runCommand = React.useCallback((command: () => unknown) => {
20 | setOpen(false)
21 | command()
22 | }, [])
23 |
24 | return { open, setOpen, runCommand, isLoading, setIsLoading }
25 | }
26 |
--------------------------------------------------------------------------------
/components/loaders/movie-tv-list-loader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { SkeletonContainer } from '@/components/ui/skeleton'
4 |
5 | export const MovieTVListLoader = () => {
6 | return (
7 |
8 |
9 | {Array.from({ length: 20 }).map((_, i) => (
10 |
11 |
16 |
17 | ))}
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = 'Input'
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/components/loaders/slider-horizontal-list-loader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { SkeletonContainer } from '../ui/skeleton'
4 |
5 | export const SliderHorizontalListLoader = () => {
6 | return (
7 |
8 |
9 | {Array.from({ length: 5 }).map((_, i) => (
10 |
11 |
16 |
17 | ))}
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/dtos/search.ts:
--------------------------------------------------------------------------------
1 | import { MediaResponse } from '@/types/media'
2 | import { SearchResponse } from '@/types/search'
3 |
4 | export const searchDTO = (searchResponse: SearchResponse): MediaResponse => ({
5 | page: searchResponse.page,
6 | results: searchResponse.results.map((search) => {
7 | const {
8 | name,
9 | original_name,
10 | first_air_date,
11 | title,
12 | original_title,
13 | release_date,
14 | ...rest
15 | } = search
16 |
17 | if (search.media_type === 'tv') {
18 | return {
19 | ...rest,
20 | title: name,
21 | original_title: original_name,
22 | release_date: first_air_date,
23 | }
24 | }
25 | return {
26 | ...rest,
27 | title,
28 | original_title,
29 | release_date,
30 | }
31 | }),
32 | })
33 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as LabelPrimitive from '@radix-ui/react-label'
5 | import { cva, type VariantProps } from 'class-variance-authority'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const labelVariants = cva(
10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/media/details-hero.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 |
5 | import { MovieDetails } from '@/types/movie-details'
6 | import { STREAMING_MOVIES_API_URL } from '@/lib/constants'
7 | import { DetailsHero } from '@/components/details-hero'
8 |
9 | export const MovieDetailsHero = ({ movie }: { movie: MovieDetails }) => {
10 | const [isIframeShown, setIsIframeShown] = React.useState(false)
11 | const iframeRef = React.useRef(null)
12 |
13 | const playVideo = () => {
14 | if (iframeRef.current) {
15 | setIsIframeShown(true)
16 | iframeRef.current.src = `${STREAMING_MOVIES_API_URL}/movie/${movie?.id}`
17 | }
18 | }
19 | return (
20 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/components/loaders/media-grid-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { SkeletonContainer } from '@/components/ui/skeleton'
4 |
5 | interface MediaGridSkeletonProps {
6 | count?: number
7 | }
8 |
9 | export const MediaGridSkeleton = ({ count = 20 }: MediaGridSkeletonProps) => {
10 | return (
11 |
12 | {Array.from({ length: count }).map((_, i) => (
13 |
14 |
19 |
20 | ))}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SeparatorPrimitive from '@radix-ui/react-separator'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = 'horizontal', decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/wrangler.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "node_modules/wrangler/config-schema.json",
3 | "main": ".open-next/worker.js",
4 | "name": "reely-cloudflare",
5 | "compatibility_date": "2024-12-30",
6 | "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
7 | "assets": {
8 | "directory": ".open-next/assets",
9 | "binding": "ASSETS",
10 | },
11 | "observability": {
12 | "enabled": true,
13 | },
14 | "preview_urls": false,
15 | // "placement": {
16 | // "mode": "smart",
17 | // },
18 | "kv_namespaces": [
19 | {
20 | "binding": "NEXT_INC_CACHE_KV",
21 | "id": "eb6499ab89774a56b9c5665dbd5d03bc",
22 | },
23 | ],
24 | "services": [
25 | {
26 | "binding": "WORKER_SELF_REFERENCE",
27 | "service": "reely-cloudflare",
28 | },
29 | ],
30 | "vars": {
31 | "ENVIRONMENT": "production",
32 | "NODE_ENV": "production",
33 | },
34 | }
35 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | function Skeleton({
6 | className,
7 | ...props
8 | }: React.HTMLAttributes) {
9 | return (
10 |
14 | )
15 | }
16 |
17 | function SkeletonContainer({
18 | className,
19 | ...props
20 | }: React.HTMLAttributes) {
21 | return (
22 |
29 | {props.children}
30 |
31 | )
32 | }
33 |
34 | export { Skeleton, SkeletonContainer }
35 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config} */
2 | module.exports = {
3 | endOfLine: 'lf',
4 | semi: false,
5 | singleQuote: true,
6 | tabWidth: 2,
7 | trailingComma: 'es5',
8 | importOrder: [
9 | '^(react/(.*)$)|^(react$)',
10 | '^(next/(.*)$)|^(next$)',
11 | '',
12 | '',
13 | '^types$',
14 | '^@/types/(.*)$',
15 | '^@/config/(.*)$',
16 | '^@/lib/(.*)$',
17 | '^@/hooks/(.*)$',
18 | '^@/components/ui/(.*)$',
19 | '^@/components/(.*)$',
20 | '^@/styles/(.*)$',
21 | '^@/app/(.*)$',
22 | '',
23 | '^[./]',
24 | ],
25 | importOrderSeparation: false,
26 | importOrderSortSpecifiers: true,
27 | importOrderBuiltinModulesToTop: true,
28 | importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'],
29 | importOrderMergeDuplicateImports: true,
30 | importOrderCombineTypeAndValueImports: true,
31 | plugins: ['@ianvs/prettier-plugin-sort-imports'],
32 | }
33 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | // it was working as node, but not bundler
17 | // "moduleResolution": "node",
18 | "moduleResolution": "bundler",
19 | "resolveJsonModule": true,
20 | "isolatedModules": true,
21 | "jsx": "react-jsx",
22 | "incremental": true,
23 | "plugins": [
24 | {
25 | "name": "next"
26 | }
27 | ],
28 | "paths": {
29 | "@/*": [
30 | "./*"
31 | ]
32 | }
33 | },
34 | "include": [
35 | "next-env.d.ts",
36 | "**/*.ts",
37 | "**/*.tsx",
38 | ".next/types/**/*.ts",
39 | ".next/dev/types/**/*.ts"
40 | ],
41 | "exclude": [
42 | "node_modules"
43 | ]
44 | }
45 |
--------------------------------------------------------------------------------
/components/play-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 |
5 | import { MovieDetails } from '@/types/movie-details'
6 | import { SeriesDetails } from '@/types/series-details'
7 | import { cn } from '@/lib/utils'
8 | import { useWatchedMedia } from '@/hooks/use-watched-media'
9 | import { Icons } from '@/components/icons'
10 |
11 | interface PlayButtonProps {
12 | onClick: () => void
13 | media: MovieDetails & SeriesDetails
14 | }
15 |
16 | export function PlayButton({ onClick, media }: PlayButtonProps) {
17 | const { handleWatchMedia } = useWatchedMedia()
18 |
19 | const handleClick = () => {
20 | handleWatchMedia(media)
21 | onClick()
22 | }
23 |
24 | return (
25 |
26 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/hooks/use-infinite-scroll.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteQuery } from '@tanstack/react-query'
2 |
3 | import { MediaResponse } from '@/types/media'
4 | import { MovieResponse, PopularMediaAction } from '@/types/movie-result'
5 | import { QUERY_KEYS } from '@/lib/queryKeys'
6 |
7 | interface Props {
8 | popularMediaAction: PopularMediaAction
9 | media: MovieResponse
10 | queryKey: typeof QUERY_KEYS.SERIES_KEY | typeof QUERY_KEYS.MOVIES_KEY
11 | }
12 |
13 | export const useInfiniteScroll = ({
14 | media,
15 | popularMediaAction,
16 | queryKey,
17 | }: Props) => {
18 | const { data, fetchNextPage } = useInfiniteQuery({
19 | queryKey: [queryKey],
20 | initialPageParam: 0,
21 | queryFn: async ({ pageParam = 1 }) => {
22 | const data = await popularMediaAction({ page: pageParam })
23 | return data
24 | },
25 | getNextPageParam: (_, pages) => pages.length + 1,
26 | initialData: {
27 | pages: [media],
28 | pageParams: [1],
29 | },
30 | })
31 |
32 | return {
33 | data,
34 | fetchNextPage,
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/lib/tmdbConfig.ts:
--------------------------------------------------------------------------------
1 | import { IMAGE_CACHE_HOST_URL } from './constants'
2 |
3 | const apiConfig = {
4 | baseUrl: process.env.NEXT_PUBLIC_TMDB_BASEURL,
5 | apiKey: process.env.TMDB_API_KEY,
6 | headerKey: process.env.TMDB_HEADER_KEY,
7 | originalImage: (imgPath: string) =>
8 | `${IMAGE_CACHE_HOST_URL}/original${imgPath}`,
9 | w500Image: (imgPath: string) => `${IMAGE_CACHE_HOST_URL}/w500${imgPath}`,
10 | }
11 |
12 | // old
13 | // originalImage: (imgPath: string) =>
14 | // `https://image.tmdb.org/t/p/original${imgPath}`,
15 | // w500Image: (imgPath: string) => `https://image.tmdb.org/t/p/w500${imgPath}`,
16 |
17 | const category = {
18 | movie: 'movie',
19 | tv: 'tv',
20 | }
21 |
22 | const movieType = {
23 | upcoming: 'upcoming',
24 | popular: 'popular',
25 | top_rated: 'top_rated',
26 | now_playing: 'now_playing',
27 | trending: 'trending',
28 | }
29 |
30 | const tvType = {
31 | popular: 'popular',
32 | top_rated: 'top_rated',
33 | on_the_air: 'on_the_air',
34 | trending: 'trending',
35 | }
36 |
37 | export { apiConfig, category, movieType, tvType }
38 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Vette1123 (Mohamed Gado)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/hooks/use-episode-handler.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { getSeasonEpisodesAction } from '@/actions/season-details'
3 | import { useQuery } from '@tanstack/react-query'
4 |
5 | import { useSearchQueryParams } from '@/hooks/use-search-params'
6 |
7 | export const useEpisodeHandler = (seriesID: number) => {
8 | const { seasonQuerySTR } = useSearchQueryParams()
9 | const [selectedSeason, setSelectedSeason] = React.useState(
10 | seasonQuerySTR || '1'
11 | )
12 |
13 | const getSeasonEpisodes = React.useCallback(
14 | async (seriesId: number, seasonNumber: string) => {
15 | const seasonDetails = await getSeasonEpisodesAction(
16 | seriesId,
17 | seasonNumber
18 | )
19 | return seasonDetails?.episodes
20 | },
21 | []
22 | )
23 | const { data: episodes, isLoading: isEpisodesLoading } = useQuery({
24 | queryKey: [selectedSeason, seriesID],
25 | queryFn: () => getSeasonEpisodes(seriesID, selectedSeason),
26 | enabled: Boolean(seriesID),
27 | })
28 |
29 | return {
30 | selectedSeason,
31 | setSelectedSeason,
32 | getSeasonEpisodes,
33 | episodes,
34 | isEpisodesLoading,
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/hooks/use-local-storage.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export interface WatchedItem {
4 | id: number
5 | type: 'movie' | 'series'
6 | title: string
7 | overview: string
8 | backdrop_path: string
9 | poster_path: string
10 | season?: number
11 | episode?: number
12 | added_at: string
13 | modified_at: string
14 | }
15 |
16 | export function useLocalStorage(key: string, initialValue: WatchedItem[] = []) {
17 | const [storedValue, setStoredValue] = useState(() => {
18 | if (typeof window === 'undefined') {
19 | return initialValue
20 | }
21 | try {
22 | const item = window.localStorage.getItem(key)
23 | return item ? JSON.parse(item) : initialValue
24 | } catch (error) {
25 | console.error('Error reading from localStorage:', error)
26 | return initialValue
27 | }
28 | })
29 |
30 | useEffect(() => {
31 | try {
32 | window.localStorage.setItem(key, JSON.stringify(storedValue))
33 | } catch (error) {
34 | console.error('Error writing to localStorage:', error)
35 | }
36 | }, [key, storedValue])
37 |
38 | return [storedValue, setStoredValue] as const
39 | }
40 |
--------------------------------------------------------------------------------
/components/blurred-image.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import Image, { ImageProps } from 'next/image'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | interface BlurImageProps extends ImageProps {
9 | className: string
10 | intro?: boolean
11 | }
12 |
13 | export function BlurredImage({
14 | src,
15 | alt,
16 | className,
17 | intro = false,
18 | ...props
19 | }: BlurImageProps) {
20 | const [isLoading, setLoading] = React.useState(true)
21 |
22 | return intro ? (
23 | setLoading(false)}
32 | />
33 | ) : (
34 |
35 | setLoading(false)}
44 | />
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/components/series/season-navigator.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 |
5 | import { SeriesDetails } from '@/types/series-details'
6 | import { useEpisodeHandler } from '@/hooks/use-episode-handler'
7 | import { ScrollArea } from '@/components/ui/scroll-area'
8 | import { Episodes } from '@/components/series/episodes'
9 | import { SeasonsSelector } from '@/components/series/selector'
10 |
11 | export const SeasonNavigator = ({ series }: { series: SeriesDetails }) => {
12 | const { setSelectedSeason, episodes, selectedSeason, isEpisodesLoading } =
13 | useEpisodeHandler(series?.id)
14 |
15 | return (
16 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/components/header/hero-slider.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { Movie } from '@/types/movie-result'
4 | import { Carousel } from '@/components/carousel'
5 | import { HeroImage, HeroImageMedia } from '@/components/header/hero-image'
6 | import { HeroSectionInfo } from '@/components/header/hero-info'
7 |
8 | export const HeroSlider = async ({ movies }: { movies: Movie[] }) => {
9 | return (
10 |
11 |
12 | {movies?.map((movie) => (
13 |
21 | ))}
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/app/sitemap-static.xml/route.ts:
--------------------------------------------------------------------------------
1 | import { getServerSideSitemap } from 'next-sitemap'
2 | import { siteConfig } from '@/config/site'
3 |
4 | export async function GET(request: Request) {
5 | const baseUrl = siteConfig.websiteURL
6 |
7 | // Static routes in your application
8 | const staticRoutes = [
9 | {
10 | loc: baseUrl,
11 | lastmod: new Date().toISOString(),
12 | changefreq: 'daily' as const,
13 | priority: 1.0,
14 | },
15 | {
16 | loc: `${baseUrl}/movies`,
17 | lastmod: new Date().toISOString(),
18 | changefreq: 'daily' as const,
19 | priority: 0.9,
20 | },
21 | {
22 | loc: `${baseUrl}/tv-shows`,
23 | lastmod: new Date().toISOString(),
24 | changefreq: 'daily' as const,
25 | priority: 0.9,
26 | },
27 | {
28 | loc: `${baseUrl}/watch-history`,
29 | lastmod: new Date().toISOString(),
30 | changefreq: 'weekly' as const,
31 | priority: 0.7,
32 | },
33 | {
34 | loc: `${baseUrl}/disclaimer`,
35 | lastmod: new Date().toISOString(),
36 | changefreq: 'monthly' as const,
37 | priority: 0.3,
38 | }
39 | ]
40 |
41 | return getServerSideSitemap(staticRoutes)
42 | }
43 |
--------------------------------------------------------------------------------
/types/movie-result.ts:
--------------------------------------------------------------------------------
1 | import { MediaType } from '@/types/media'
2 |
3 | type Movie = {
4 | adult: boolean
5 | backdrop_path: string
6 | genre_ids: number[]
7 | id: number
8 | original_language: string
9 | original_title: string
10 | overview: string
11 | popularity: number
12 | poster_path: string
13 | release_date: string
14 | title: string
15 | video: boolean
16 | vote_average: number
17 | vote_count: number
18 | media_type?: ItemType
19 | name?: string
20 | first_air_date?: string
21 | }
22 |
23 | interface MovieResponse {
24 | page: number
25 | results: Movie[]
26 | total_pages?: number
27 | total_results?: number
28 | }
29 |
30 | type Param = Record
31 |
32 | type ItemType = 'movie' | 'tv'
33 |
34 | interface MultiRequestProps {
35 | trendingMediaForHero: Movie[]
36 | latestTrendingMovies: Movie[]
37 | popularMovies: Movie[]
38 | allTimeTopRatedMovies: Movie[]
39 | latestTrendingSeries: MediaType[]
40 | popularSeries: MediaType[]
41 | allTimeTopRatedSeries: MediaType[]
42 | }
43 |
44 | type PopularMediaAction = (params?: Param) => Promise
45 |
46 | export type {
47 | Movie,
48 | MovieResponse,
49 | Param,
50 | MultiRequestProps,
51 | ItemType,
52 | PopularMediaAction,
53 | }
54 |
--------------------------------------------------------------------------------
/types/episode.ts:
--------------------------------------------------------------------------------
1 | import { GuestStar } from '@/types/guest'
2 |
3 | interface EpisodeToAir {
4 | id: number
5 | name: string
6 | overview: string
7 | vote_average: number
8 | vote_count: number
9 | air_date: string
10 | episode_number: number
11 | episode_type: string
12 | production_code: string
13 | runtime: number
14 | season_number: number
15 | show_id: number
16 | still_path: string
17 | }
18 |
19 | interface Network {
20 | id: number
21 | logo_path: string
22 | name: string
23 | origin_country: string
24 | }
25 |
26 | type Crew = {
27 | job: string
28 | department: string
29 | credit_id: string
30 | adult: boolean
31 | gender: number
32 | id: number
33 | known_for_department: string
34 | name: string
35 | original_name: string
36 | popularity: number
37 | profile_path: string | null
38 | }
39 |
40 | interface EpisodeDetails {
41 | air_date: string
42 | crew: Crew[]
43 | episode_number: number
44 | guest_stars: GuestStar[]
45 | name: string
46 | overview: string
47 | show_id: number
48 | id: number
49 | production_code: string
50 | runtime: number
51 | season_number: number
52 | still_path: string
53 | vote_average: number
54 | vote_count: number
55 | }
56 |
57 | export type { EpisodeToAir, Network, EpisodeDetails }
58 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-full border px-1 py-.25 lg:px-2.5 lg:py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | 'border-transparent bg-primary text-secondary-foreground hover:bg-primary/80',
13 | secondary:
14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
15 | destructive:
16 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
17 | outline: 'text-foreground',
18 | },
19 | },
20 | defaultVariants: {
21 | variant: 'default',
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const HoverCard = HoverCardPrimitive.Root
9 |
10 | const HoverCardTrigger = HoverCardPrimitive.Trigger
11 |
12 | const HoverCardContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
16 |
26 | ))
27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
28 |
29 | export { HoverCard, HoverCardTrigger, HoverCardContent }
30 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SliderPrimitive from '@radix-ui/react-slider'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Slider = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
21 |
22 |
23 | {Array.from({ length: props.value?.length || 1 }).map((_, index) => (
24 |
28 | ))}
29 |
30 | ))
31 | Slider.displayName = SliderPrimitive.Root.displayName
32 |
33 | export { Slider }
34 |
--------------------------------------------------------------------------------
/components/media/filter-debug.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 |
5 | import { FilterParams, MediaFilter } from '@/types/filter'
6 |
7 | interface FilterDebugProps {
8 | filter: MediaFilter
9 | filterParams: FilterParams
10 | enabled?: boolean
11 | }
12 |
13 | export const FilterDebug = ({
14 | filter,
15 | filterParams,
16 | enabled = false,
17 | }: FilterDebugProps) => {
18 | if (!enabled || process.env.NODE_ENV !== 'development') {
19 | return null
20 | }
21 |
22 | return (
23 |
24 |
Filter Debug
25 |
26 |
27 | Selected Genres:{' '}
28 | {filter.selectedGenres.join(', ') || 'None'}
29 |
30 |
31 | Excluded Genres:{' '}
32 | {filter.excludedGenres.join(', ') || 'None'}
33 |
34 |
35 | Sort By: {filter.sortBy}
36 |
37 |
38 |
API Params:
39 |
40 | {JSON.stringify(filterParams, null, 2)}
41 |
42 |
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/types/movie-details.ts:
--------------------------------------------------------------------------------
1 | import { Credit } from '@/types/credit'
2 | import { MovieGenre } from '@/types/movie-genre'
3 | import { Movie } from '@/types/movie-result'
4 |
5 | interface MovieDetails {
6 | adult: boolean
7 | backdrop_path: string
8 | belongs_to_collection: {
9 | id: number
10 | name: string
11 | poster_path: string
12 | backdrop_path: string
13 | }
14 | budget: number
15 | genres: MovieGenre[]
16 | homepage: string
17 | id: number
18 | imdb_id: string
19 | original_language: string
20 | original_title: string
21 | overview: string
22 | popularity: number
23 | poster_path: string
24 | production_companies: {
25 | id: number
26 | logo_path: string
27 | name: string
28 | origin_country: string
29 | }[]
30 | production_countries: {
31 | iso_3166_1: string
32 | name: string
33 | }[]
34 | release_date: string
35 | revenue: number
36 | runtime: number
37 | spoken_languages: {
38 | english_name: string
39 | iso_639_1: string
40 | name: string
41 | }[]
42 | status: string
43 | tagline: string
44 | title: string
45 | video: boolean
46 | vote_average: number
47 | vote_count: number
48 | imdbRating?: string | null
49 | }
50 |
51 | interface MultiMovieDetailsRequestProps {
52 | movieDetails: MovieDetails
53 | movieCredits: Credit
54 | similarMovies: Movie[]
55 | recommendedMovies: Movie[]
56 | }
57 |
58 | export type { MovieDetails, MultiMovieDetailsRequestProps }
59 |
--------------------------------------------------------------------------------
/components/header/hero-image.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { MovieDetails } from '@/types/movie-details'
4 | import { Movie } from '@/types/movie-result'
5 | import { SeriesDetails } from '@/types/series-details'
6 | import { getImageURL, getPosterImageURL } from '@/lib/utils'
7 | import { BlurredImage } from '@/components/blurred-image'
8 |
9 | export type HeroImageMedia = (Movie | MovieDetails) & SeriesDetails
10 | interface HeroImageProps {
11 | movie?: HeroImageMedia
12 | }
13 |
14 | export const HeroImage = ({ movie }: HeroImageProps) => {
15 | const media = movie
16 | const alt = media?.title || media?.name || 'ALT TEXT'
17 | return (
18 | <>
19 | {media?.backdrop_path && (
20 |
29 | )}
30 | {media?.poster_path && (
31 |
40 | )}
41 | >
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/lib/fetch-client.ts:
--------------------------------------------------------------------------------
1 | import queryString from 'query-string'
2 |
3 | import { apiConfig } from '@/lib/tmdbConfig'
4 |
5 | export const fetchClient = {
6 | get: async (
7 | url: string,
8 | params?: Record,
9 | isHeaderAuth = false
10 | ): Promise => {
11 | const query = {
12 | ...params,
13 | ...(!isHeaderAuth && { api_key: apiConfig.apiKey! }),
14 | }
15 |
16 | try {
17 | const res = await fetch(
18 | `${apiConfig.baseUrl}${queryString.stringifyUrl({ url, query })}`,
19 | {
20 | method: 'GET',
21 | headers: {
22 | 'Content-Type': 'application/json',
23 | ...(isHeaderAuth && {
24 | Authorization: `Bearer ${apiConfig.headerKey}`,
25 | }),
26 | },
27 | // data will revalidate every 8 hours
28 | next: { revalidate: 28800 },
29 | }
30 | )
31 |
32 | return await res.json()
33 | } catch (error: any) {
34 | console.error(error)
35 | throw error
36 | }
37 | },
38 | post: async (url: string, body = {}): Promise => {
39 | try {
40 | const res = await fetch(`${apiConfig.baseUrl}${url}`, {
41 | method: 'POST',
42 | headers: {
43 | 'Content-Type': 'application/json',
44 | Authorization: `Bearer ${apiConfig.apiKey}`,
45 | },
46 | body: JSON.stringify(body),
47 | })
48 | return await res.json()
49 | } catch (error: any) {
50 | console.error(error)
51 | throw error
52 | }
53 | },
54 | }
55 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as AvatarPrimitive from '@radix-ui/react-avatar'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/app/(landing)/page.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react'
2 | import { Metadata } from 'next'
3 | import { populateHomePageData } from '@/services/movies'
4 |
5 | import { siteConfig } from '@/config/site'
6 | import { HeroSlider } from '@/components/header/hero-slider'
7 | import { FullScreenLoader } from '@/components/loaders/intro-pages-loader'
8 | import { MoviesIntroSection } from '@/components/main-page/intro-section'
9 |
10 | export const metadata: Metadata = {
11 | title: {
12 | default: siteConfig.name,
13 | template: `%s - ${siteConfig.name}`,
14 | },
15 | description: siteConfig.description,
16 | // Open Graph and Twitter images will be automatically handled by
17 | // opengraph-image.png and twitter-image.png files in the app directory
18 | }
19 |
20 | async function IndexPage() {
21 | const {
22 | trendingMediaForHero,
23 | latestTrendingMovies,
24 | allTimeTopRatedMovies,
25 | popularMovies,
26 | latestTrendingSeries,
27 | popularSeries,
28 | allTimeTopRatedSeries,
29 | } = await populateHomePageData()
30 | return (
31 |
32 | }>
33 |
34 |
35 |
43 |
44 | )
45 | }
46 | export default IndexPage
47 |
--------------------------------------------------------------------------------
/components/disclaimer/disclaimer-modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { FC, ReactNode } from 'react'
4 | import { useRouter } from 'next/navigation'
5 |
6 | import { useMounted } from '@/hooks/use-mounted'
7 | import { Button } from '@/components/ui/button'
8 | import {
9 | Dialog,
10 | DialogContent,
11 | DialogDescription,
12 | DialogFooter,
13 | DialogHeader,
14 | DialogTitle,
15 | } from '@/components/ui/dialog'
16 |
17 | interface ModalProps {
18 | children: ReactNode
19 | }
20 |
21 | const Modal: FC = ({ children }) => {
22 | const router = useRouter()
23 | const isMounted = useMounted()
24 |
25 | const handleOnOpenChange = (open: boolean) => {
26 | if (!open) {
27 | router.back()
28 | }
29 | }
30 |
31 | if (!isMounted) {
32 | return null
33 | }
34 |
35 | return (
36 |
57 | )
58 | }
59 |
60 | export default Modal
61 |
--------------------------------------------------------------------------------
/components/header/animated-watch-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useEffect, useState } from 'react'
4 | import Link from 'next/link'
5 | import { motion } from 'framer-motion'
6 |
7 | import { ItemType } from '@/types/movie-result'
8 | import { cn } from '@/lib/utils'
9 | import { buttonVariants } from '@/components/ui/button'
10 | import { Icons } from '@/components/icons'
11 |
12 | interface AnimatedWatchButtonProps {
13 | movieId: number
14 | mediaType?: ItemType
15 | }
16 |
17 | // Global variable to track if animation has played
18 | let hasAnimated = false
19 |
20 | export const AnimatedWatchButton = ({
21 | movieId,
22 | mediaType,
23 | }: AnimatedWatchButtonProps) => {
24 | const [shouldAnimate, setShouldAnimate] = useState(!hasAnimated)
25 |
26 | useEffect(() => {
27 | if (!hasAnimated) {
28 | hasAnimated = true
29 | setShouldAnimate(true)
30 | }
31 | }, [])
32 |
33 | const href =
34 | mediaType === 'tv' ? `/tv-shows/${movieId}` : `/movies/${movieId}`
35 |
36 | return (
37 |
44 |
55 |
56 | Watch Now
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/components/series/details-hero.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 |
5 | import { SeriesDetails } from '@/types/series-details'
6 | import { STREAMING_MOVIES_API_URL } from '@/lib/constants'
7 | import { useMounted } from '@/hooks/use-mounted'
8 | import { useSearchQueryParams } from '@/hooks/use-search-params'
9 | import { DetailsHero } from '@/components/details-hero'
10 |
11 | export const SeriesDetailsHero = ({ series }: { series: SeriesDetails }) => {
12 | const { episodeQueryINT, seasonQueryINT } = useSearchQueryParams()
13 | const [isIframeShown, setIsIframeShown] = React.useState(false)
14 | const isMounted = useMounted()
15 | const iframeRef = React.useRef(null)
16 |
17 | const autoplaySessionURL = `${STREAMING_MOVIES_API_URL}/tv/${series?.id}/${seasonQueryINT}/${episodeQueryINT}`
18 |
19 | const playDefaultSeries = () => {
20 | if (iframeRef.current && !episodeQueryINT && !seasonQueryINT) {
21 | setIsIframeShown(true)
22 | iframeRef.current.src = `${STREAMING_MOVIES_API_URL}/tv/${series?.id}`
23 | }
24 | if (iframeRef.current && episodeQueryINT && seasonQueryINT) {
25 | setIsIframeShown(true)
26 | iframeRef.current.src = autoplaySessionURL
27 | }
28 | }
29 |
30 | React.useEffect(() => {
31 | if (iframeRef.current && episodeQueryINT && seasonQueryINT && isMounted) {
32 | setIsIframeShown(true)
33 | iframeRef.current.src = autoplaySessionURL
34 | }
35 | // eslint-disable-next-line react-hooks/exhaustive-deps
36 | }, [episodeQueryINT, seasonQueryINT, series?.id])
37 |
38 | return (
39 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/components/header/hero-info.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { Movie } from '@/types/movie-result'
4 | import { getPosterImageURL } from '@/lib/utils'
5 | import { BlurredImage } from '@/components/blurred-image'
6 | import { AnimatedWatchButton } from '@/components/header/animated-watch-button'
7 | import { HeroRatesInfos } from '@/components/header/hero-rates-info'
8 |
9 | export const HeroSectionInfo = ({ movie }: { movie: Movie }) => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | {movie.title || movie.name}
17 |
18 |
19 |
20 | {movie.overview}
21 |
22 |
23 |
27 |
28 |
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/components/watch-history/delete-alert.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Trash } from 'lucide-react'
3 |
4 | import {
5 | AlertDialog,
6 | AlertDialogAction,
7 | AlertDialogCancel,
8 | AlertDialogContent,
9 | AlertDialogDescription,
10 | AlertDialogFooter,
11 | AlertDialogHeader,
12 | AlertDialogTitle,
13 | AlertDialogTrigger,
14 | } from '@/components/ui/alert-dialog'
15 | import { Button } from '@/components/ui/button'
16 |
17 | interface DeleteHistoryAlertProps {
18 | onDelete: () => void
19 | }
20 |
21 | export function DeleteHistoryAlert({ onDelete }: DeleteHistoryAlertProps) {
22 | const [isOpen, setIsOpen] = useState(false)
23 |
24 | const deleteWatchedItems = () => {
25 | onDelete()
26 | setIsOpen(false)
27 | }
28 |
29 | return (
30 |
31 |
32 |
36 |
37 |
38 |
39 | Are you absolutely sure?
40 |
41 | This action cannot be undone. This will permanently delete your
42 | entire watch history.
43 |
44 |
45 |
46 | Cancel
47 |
51 | Yes, clear history
52 |
53 |
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/components/media/details-credits.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 |
4 | import { Credit } from '@/types/credit'
5 | import { SEARCH_ACTOR_GOOGLE } from '@/lib/constants'
6 | import { getPosterImageURL } from '@/lib/utils'
7 | import { BlurredImage } from '@/components/blurred-image'
8 |
9 | export const DetailsCredits = ({ movieCredits }: { movieCredits: Credit }) => {
10 | return (
11 | <>
12 | Cast
13 |
14 | {movieCredits?.cast?.slice(0, 6)?.map((cast) => (
15 |
22 | {cast.profile_path ? (
23 |
24 |
32 |
33 | ) : (
34 |
35 | )}
36 |
{cast.name}
37 |
38 | ))}
39 |
40 | >
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = 'vertical', ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/components/watch-history/watch-history.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 |
5 | import { useMounted } from '@/hooks/use-mounted'
6 | import { useWatchedMedia } from '@/hooks/use-watched-media'
7 |
8 | import { DeleteHistoryAlert } from './delete-alert'
9 | import { WatchedItemCard } from './watch-history-card'
10 | import { WatchedItemCardSkeleton } from './watch-history-skeleton'
11 |
12 | export const WatchHistoryContainer = () => {
13 | const { watchedItems, deleteWatchedItems } = useWatchedMedia()
14 | const isMounted = useMounted()
15 |
16 | if (!isMounted) {
17 | return (
18 |
19 | {Array.from({ length: 10 }).map((_, index) => (
20 |
21 | ))}
22 |
23 | )
24 | }
25 |
26 | if (!watchedItems.length) {
27 | return (
28 |
29 |
No watched items yet
30 |
31 | Start watching your favorite movies and TV shows to see them here
32 |
33 |
34 | )
35 | }
36 |
37 | return (
38 |
39 |
40 | {/* clear history */}
41 |
42 |
43 |
44 | {watchedItems
45 | ?.sort(
46 | (a, b) =>
47 | new Date(b.modified_at).getTime() -
48 | new Date(a.modified_at).getTime()
49 | )
50 | ?.map((item) => )}
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/components/layouts/main-nav.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { usePathname } from 'next/navigation'
3 | import { motion } from 'framer-motion'
4 |
5 | import { NavItem } from '@/types/navbar'
6 | import { siteConfig } from '@/config/site'
7 | import { cn } from '@/lib/utils'
8 | import { buttonVariants } from '@/components/ui/button'
9 | import { Icons } from '@/components/icons'
10 |
11 | interface MainNavProps {
12 | items?: NavItem[]
13 | }
14 |
15 | export function MainNav({ items }: MainNavProps) {
16 | const pathname = usePathname()
17 | return (
18 |
19 |
20 |
21 |
22 | {siteConfig.name}
23 |
24 |
25 | {items?.length ? (
26 |
49 | ) : null}
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/public/sitemap-0.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | https://www.reely.space2025-12-21T08:21:42.610Zdaily0.7
4 | https://www.reely.space/(.)disclaimer2025-12-21T08:21:42.610Zdaily0.7
5 | https://www.reely.space/disclaimer2025-12-21T08:21:42.610Zdaily0.7
6 | https://www.reely.space/movies2025-12-21T08:21:42.610Zdaily0.7
7 | https://www.reely.space/opengraph-image.png2025-12-21T08:21:42.610Zdaily0.7
8 | https://www.reely.space/robots.txt2025-12-21T08:21:42.610Zdaily0.7
9 | https://www.reely.space/sitemap.xml2025-12-21T08:21:42.610Zdaily0.7
10 | https://www.reely.space/tv-shows2025-12-21T08:21:42.610Zdaily0.7
11 | https://www.reely.space/twitter-image.png2025-12-21T08:21:42.610Zdaily0.7
12 | https://www.reely.space/watch-history2025-12-21T08:21:42.610Zdaily0.7
13 |
--------------------------------------------------------------------------------
/app/sitemap-trending.xml/route.ts:
--------------------------------------------------------------------------------
1 | import { getServerSideSitemap } from 'next-sitemap'
2 | import { getTrendingMediaForHeroSlider, getLatestTrendingMovies } from '@/services/movies'
3 | import { getLatestTrendingSeries } from '@/services/series'
4 | import { siteConfig } from '@/config/site'
5 |
6 | export async function GET(request: Request) {
7 | const baseUrl = siteConfig.websiteURL
8 |
9 | try {
10 | // Fetch trending content from different sources
11 | const [trendingMedia, trendingMovies, trendingSeries] = await Promise.all([
12 | getTrendingMediaForHeroSlider(),
13 | getLatestTrendingMovies({ page: 1 }),
14 | getLatestTrendingSeries({ page: 1 })
15 | ])
16 |
17 | // Combine all trending results
18 | const allTrending = [
19 | ...(trendingMedia || []), // trendingMedia is already Movie[]
20 | ...(trendingMovies?.results || []),
21 | ...(trendingSeries?.results || [])
22 | ]
23 |
24 | // Remove duplicates based on ID and media type
25 | const uniqueTrending = allTrending.filter((item, index, self) =>
26 | index === self.findIndex(t => t.id === item.id && (t.media_type === item.media_type ||
27 | (t.title && item.title) || (t.name && item.name)))
28 | )
29 |
30 | // Generate sitemap fields based on media type
31 | const fields = uniqueTrending.map((item) => {
32 | const isMovie = item.media_type === 'movie' || item.title
33 | const route = isMovie ? 'movies' : 'tv-shows'
34 |
35 | return {
36 | loc: `${baseUrl}/${route}/${item.id}`,
37 | lastmod: new Date().toISOString(),
38 | changefreq: 'daily' as const,
39 | priority: 0.9, // Trending content gets high priority
40 | }
41 | })
42 |
43 | return getServerSideSitemap(fields)
44 | } catch (error) {
45 | console.error('Error generating trending sitemap:', error)
46 |
47 | // Return empty sitemap if API fails
48 | return getServerSideSitemap([])
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/components/series/selector.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { SeriesDetails } from '@/types/series-details'
4 | import { seasonsFormatter } from '@/lib/utils'
5 | import { useSearchQueryParams } from '@/hooks/use-search-params'
6 | import {
7 | Select,
8 | SelectContent,
9 | SelectGroup,
10 | SelectItem,
11 | SelectLabel,
12 | SelectSeparator,
13 | SelectTrigger,
14 | SelectValue,
15 | } from '@/components/ui/select'
16 |
17 | interface SeasonsSelectorProps {
18 | series: SeriesDetails
19 | setSelectedSeason: React.Dispatch>
20 | }
21 |
22 | export function SeasonsSelector({
23 | series,
24 | setSelectedSeason,
25 | }: SeasonsSelectorProps) {
26 | const { seasonQuerySTR } = useSearchQueryParams()
27 | const formattedSeasons = seasonsFormatter(series?.seasons)
28 |
29 | return (
30 |
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/types/series-details.ts:
--------------------------------------------------------------------------------
1 | import { Credit } from '@/types/credit'
2 | import { EpisodeToAir, Network } from '@/types/episode'
3 | import { MediaType } from '@/types/media'
4 | import { MovieGenre } from '@/types/movie-genre'
5 | import { ProductionCompany, ProductionCountry } from '@/types/production'
6 |
7 | type Season = {
8 | air_date: string
9 | episode_count: number
10 | id: number
11 | name: string
12 | overview: string
13 | poster_path: string | null
14 | season_number: number
15 | vote_average: number
16 | }
17 |
18 | type SpokenLanguage = {
19 | english_name: string
20 | iso_639_1: string
21 | name: string
22 | }
23 |
24 | interface SeriesDetails {
25 | adult: boolean
26 | backdrop_path: string
27 | created_by: {
28 | id: number
29 | credit_id: string
30 | name: string
31 | gender: number
32 | profile_path: string
33 | }[]
34 | episode_run_time: number[]
35 | first_air_date: string
36 | genres: MovieGenre[]
37 | homepage: string
38 | id: number
39 | imdb_id?: string
40 | in_production: boolean
41 | languages: string[]
42 | last_air_date: string
43 | last_episode_to_air: EpisodeToAir | null
44 | name: string
45 | next_episode_to_air: EpisodeToAir | null
46 | networks: Network[]
47 | number_of_episodes: number
48 | number_of_seasons: number
49 | origin_country: string[]
50 | original_language: string
51 | original_name: string
52 | overview: string
53 | poster_path: string
54 | production_companies: ProductionCompany[]
55 | production_countries: ProductionCountry[]
56 | seasons: Season[]
57 | spoken_languages: SpokenLanguage[]
58 | status: string
59 | tagline: string
60 | type: string
61 | vote_average: number
62 | vote_count: number
63 | imdbRating?: string | null
64 | }
65 |
66 | interface MultiSeriesDetailsRequestProps {
67 | seriesDetails: SeriesDetails
68 | seriesCredits: Credit
69 | similarSeries: MediaType[]
70 | recommendedSeries: MediaType[]
71 | }
72 |
73 | export type { SeriesDetails, MultiSeriesDetailsRequestProps, Season }
74 |
--------------------------------------------------------------------------------
/app/movies/(movies-list)/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Metadata } from 'next'
3 | import { getPopularMovies } from '@/services/movies'
4 |
5 | import { siteConfig } from '@/config/site'
6 | import { QUERY_KEYS } from '@/lib/queryKeys'
7 | import { MediaContent } from '@/components/media/media-content'
8 |
9 | const generateOgImageUrl = (title: string, description: string) =>
10 | `${siteConfig.websiteURL}/api/og?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`
11 |
12 | export const metadata: Metadata = {
13 | title: 'Movies',
14 | description: 'Discover and explore popular movies, trending releases, and all-time favorites.',
15 | metadataBase: new URL('/movies', process.env.NEXT_PUBLIC_BASE_URL),
16 | openGraph: {
17 | title: 'Movies - ' + siteConfig.name,
18 | description: 'Discover and explore popular movies, trending releases, and all-time favorites.',
19 | url: '/movies',
20 | images: [
21 | {
22 | url: generateOgImageUrl('Movies', 'Discover and explore popular movies'),
23 | width: siteConfig.openGraph.images.default.width,
24 | height: siteConfig.openGraph.images.default.height,
25 | alt: 'Movies - ' + siteConfig.name,
26 | },
27 | ],
28 | },
29 | twitter: {
30 | card: 'summary_large_image',
31 | title: 'Movies - ' + siteConfig.name,
32 | description: 'Discover and explore popular movies, trending releases, and all-time favorites.',
33 | images: [generateOgImageUrl('Movies', 'Discover and explore popular movies')],
34 | },
35 | }
36 |
37 | async function Movies() {
38 | const movies = await getPopularMovies()
39 | return (
40 |
50 | )
51 | }
52 |
53 | export default Movies
54 |
--------------------------------------------------------------------------------
/app/tv-shows/(tv-list)/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Metadata } from 'next'
3 | import { getPopularSeries } from '@/services/series'
4 |
5 | import { siteConfig } from '@/config/site'
6 | import { QUERY_KEYS } from '@/lib/queryKeys'
7 | import { MediaContent } from '@/components/media/media-content'
8 |
9 | const generateOgImageUrl = (title: string, description: string) =>
10 | `${siteConfig.websiteURL}/api/og?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`
11 |
12 | export const metadata: Metadata = {
13 | title: 'TV Shows',
14 | description: 'Discover and explore popular TV shows, trending series, and all-time favorites.',
15 | metadataBase: new URL('/tv-shows', process.env.NEXT_PUBLIC_BASE_URL),
16 | openGraph: {
17 | title: 'TV Shows - ' + siteConfig.name,
18 | description: 'Discover and explore popular TV shows, trending series, and all-time favorites.',
19 | url: '/tv-shows',
20 | images: [
21 | {
22 | url: generateOgImageUrl('TV Shows', 'Discover and explore popular TV shows'),
23 | width: siteConfig.openGraph.images.default.width,
24 | height: siteConfig.openGraph.images.default.height,
25 | alt: 'TV Shows - ' + siteConfig.name,
26 | },
27 | ],
28 | },
29 | twitter: {
30 | card: 'summary_large_image',
31 | title: 'TV Shows - ' + siteConfig.name,
32 | description: 'Discover and explore popular TV shows, trending series, and all-time favorites.',
33 | images: [generateOgImageUrl('TV Shows', 'Discover and explore popular TV shows')],
34 | },
35 | }
36 |
37 | async function TvShows() {
38 | const series = await getPopularSeries()
39 | return (
40 |
50 | )
51 | }
52 |
53 | export default TvShows
54 |
--------------------------------------------------------------------------------
/components/media/details-extra-info.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 |
4 | import { MovieDetails } from '@/types/movie-details'
5 | import { SEARCH_ACTOR_GOOGLE } from '@/lib/constants'
6 | import { cn } from '@/lib/utils'
7 | import { HeroRatesInfos } from '@/components/header/hero-rates-info'
8 | import { Icons } from '@/components/icons'
9 | import { movieExtraInfoFormatter } from '@/components/media/extra-info'
10 |
11 | export const DetailsExtraInfo = ({
12 | movie,
13 | director,
14 | }: {
15 | movie: MovieDetails
16 | director: string | undefined
17 | }) => {
18 | const extraInfo = movieExtraInfoFormatter(movie, director)
19 | return (
20 |
21 | {movie.title}
22 |
23 |
24 | {movie.overview}
25 |
26 |
27 | {extraInfo.map((info) => (
28 |
32 |
{info.name}
33 | {info.isLink ? (
34 |
40 |
41 |
42 | {info.value}
43 |
44 |
45 |
46 |
47 | ) : (
48 |
{info.value}
49 | )}
50 |
51 | ))}
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/components/media/extra-info.ts:
--------------------------------------------------------------------------------
1 | import { MovieDetails } from '@/types/movie-details'
2 | import { SeriesDetails } from '@/types/series-details'
3 | import { convertMinutesToHours, moneyFormatter } from '@/lib/utils'
4 |
5 | export const movieExtraInfoFormatter = (
6 | movie: MovieDetails,
7 | director: string | undefined
8 | ) => [
9 | {
10 | name: 'Runtime:',
11 | value: convertMinutesToHours(movie?.runtime),
12 | },
13 | {
14 | name: 'Status:',
15 | value: movie?.status,
16 | },
17 | {
18 | name: 'Original Language:',
19 | value: movie?.original_language,
20 | className: 'uppercase',
21 | },
22 | {
23 | name: 'Budget:',
24 | value: moneyFormatter(movie?.budget),
25 | },
26 | {
27 | name: 'Revenue:',
28 | value: moneyFormatter(movie?.revenue),
29 | },
30 | {
31 | name: 'Director:',
32 | value: director,
33 | isLink: true,
34 | },
35 | ]
36 |
37 | export const seriesExtraInfoFormatter = (
38 | series: SeriesDetails,
39 | director: string | undefined
40 | ) => [
41 | {
42 | name: 'First Air Date:',
43 | value: series?.first_air_date,
44 | },
45 | {
46 | name: 'Last Air Date:',
47 | value: series?.last_air_date,
48 | },
49 | {
50 | name: 'Status:',
51 | value: series?.status,
52 | },
53 | {
54 | name: 'Original Country:',
55 | value: series?.origin_country?.join(', '),
56 | },
57 | {
58 | name: 'Original Language:',
59 | value: series?.original_language,
60 | className: 'uppercase',
61 | },
62 | {
63 | name: 'Number of Seasons:',
64 | value: series?.number_of_seasons,
65 | },
66 | {
67 | name: 'Number of Episodes:',
68 | value: series?.number_of_episodes,
69 | },
70 | ...(director
71 | ? [
72 | {
73 | name: 'Director:',
74 | value: director,
75 | isLink: true,
76 | },
77 | ]
78 | : []),
79 | ...(series?.created_by?.length
80 | ? [
81 | {
82 | name: 'Created By:',
83 | value: series?.created_by?.map((creator) => creator.name).at(0),
84 | isLink: true,
85 | },
86 | ]
87 | : []),
88 | ]
89 |
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
35 |
--------------------------------------------------------------------------------
/components/main-page/intro-section.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react'
2 |
3 | import { MediaType } from '@/types/media'
4 | import { Movie } from '@/types/movie-result'
5 | import { List } from '@/components/list'
6 | import { SliderHorizontalListLoader } from '@/components/loaders/slider-horizontal-list-loader'
7 |
8 | interface MoviesIntroSectionProps {
9 | latestTrendingMovies: Movie[]
10 | allTimeTopRatedMovies: Movie[]
11 | popularMovies: Movie[]
12 | latestTrendingSeries: MediaType[]
13 | popularSeries: MediaType[]
14 | allTimeTopRatedSeries: MediaType[]
15 | }
16 |
17 | export const MoviesIntroSection = ({
18 | latestTrendingMovies,
19 | allTimeTopRatedMovies,
20 | popularMovies,
21 | latestTrendingSeries,
22 | popularSeries,
23 | allTimeTopRatedSeries,
24 | }: MoviesIntroSectionProps) => {
25 | return (
26 |
27 | }>
28 |
33 |
34 | }>
35 |
36 |
37 | }>
38 |
43 |
44 | }>
45 |
50 |
51 | }>
52 |
53 |
54 | }>
55 |
60 |
61 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/components/media/details-content.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react'
2 |
3 | import { Credit } from '@/types/credit'
4 | import { MovieDetails } from '@/types/movie-details'
5 | import { Movie } from '@/types/movie-result'
6 | import { getPosterImageURL } from '@/lib/utils'
7 | import { BlurredImage } from '@/components/blurred-image'
8 | import { List } from '@/components/list'
9 | import { SliderHorizontalListLoader } from '@/components/loaders/slider-horizontal-list-loader'
10 | import { DetailsCredits } from '@/components/media/details-credits'
11 | import { DetailsExtraInfo } from '@/components/media/details-extra-info'
12 |
13 | export const MoviesDetailsContent = ({
14 | movie,
15 | movieCredits,
16 | similarMovies,
17 | recommendedMovies,
18 | }: {
19 | movie: MovieDetails
20 | movieCredits: Credit
21 | similarMovies: Movie[]
22 | recommendedMovies: Movie[]
23 | }) => {
24 | const director = movieCredits?.crew?.find(
25 | (crew) => crew.job === 'Director'
26 | )?.name
27 | return (
28 |
29 |
47 | }>
48 |
49 |
50 | }>
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/components/series/details-extra-info.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 |
4 | import { SeriesDetails } from '@/types/series-details'
5 | import { SEARCH_ACTOR_GOOGLE } from '@/lib/constants'
6 | import { cn } from '@/lib/utils'
7 | import { HeroRatesInfos } from '@/components/header/hero-rates-info'
8 | import { Icons } from '@/components/icons'
9 | import { seriesExtraInfoFormatter } from '@/components/media/extra-info'
10 |
11 | interface SeriesDetailsExtraInfoProps {
12 | series: SeriesDetails
13 | director: string | undefined
14 | }
15 |
16 | export const SeriesDetailsExtraInfo = ({
17 | series,
18 | director,
19 | }: SeriesDetailsExtraInfoProps) => {
20 | const extraInfo = seriesExtraInfoFormatter(series, director)
21 | return (
22 |
23 | {series.name}
24 |
25 |
26 | {series.overview}
27 |
28 |
29 | {extraInfo.map((info) => (
30 |
34 |
{info.name}
35 | {info.isLink ? (
36 |
42 |
43 |
44 | {info.value}
45 |
46 |
47 |
48 |
49 | ) : (
50 |
{info.value}
51 | )}
52 |
53 | ))}
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/types/filter.ts:
--------------------------------------------------------------------------------
1 | interface FilterParams {
2 | // Genres
3 | with_genres?: string
4 | without_genres?: string
5 |
6 | // Release/Air Date
7 | 'release_date.gte'?: string
8 | 'release_date.lte'?: string
9 | 'first_air_date.gte'?: string
10 | 'first_air_date.lte'?: string
11 |
12 | // Ratings
13 | 'vote_average.gte'?: number
14 | 'vote_average.lte'?: number
15 | 'vote_count.gte'?: number
16 |
17 | // Sort
18 | sort_by?: SortOption
19 |
20 | // Other filters
21 | with_runtime_gte?: number
22 | with_runtime_lte?: number
23 | include_adult?: boolean
24 | include_video?: boolean
25 | with_original_language?: string
26 |
27 | // Common params
28 | page?: number
29 | language?: string
30 | }
31 |
32 | interface MediaFilter {
33 | // Genre filters
34 | selectedGenres: number[]
35 | excludedGenres: number[]
36 |
37 | // Date filters
38 | fromDate?: string
39 | toDate?: string
40 |
41 | // Rating filters
42 | minRating?: number
43 | maxRating?: number
44 | minVotes?: number
45 |
46 | // Runtime filters (for movies)
47 | minRuntime?: number
48 | maxRuntime?: number
49 |
50 | // Sort option
51 | sortBy: SortOption
52 |
53 | // Content filters
54 | includeAdult: boolean
55 | originalLanguage?: string
56 | }
57 |
58 | type SortOption =
59 | | 'popularity.desc'
60 | | 'popularity.asc'
61 | | 'release_date.desc'
62 | | 'release_date.asc'
63 | | 'revenue.desc'
64 | | 'revenue.asc'
65 | | 'primary_release_date.desc'
66 | | 'primary_release_date.asc'
67 | | 'original_title.asc'
68 | | 'original_title.desc'
69 | | 'vote_average.desc'
70 | | 'vote_average.asc'
71 | | 'vote_count.desc'
72 | | 'vote_count.asc'
73 | | 'first_air_date.desc'
74 | | 'first_air_date.asc'
75 | | 'name.asc'
76 | | 'name.desc'
77 |
78 | interface FilterOption {
79 | label: string
80 | value: string | number
81 | }
82 |
83 | interface FilterSection {
84 | title: string
85 | type: 'multiselect' | 'range' | 'select' | 'checkbox' | 'date'
86 | options?: FilterOption[]
87 | min?: number
88 | max?: number
89 | step?: number
90 | }
91 |
92 | export type {
93 | FilterParams,
94 | MediaFilter,
95 | SortOption,
96 | FilterOption,
97 | FilterSection,
98 | }
99 |
--------------------------------------------------------------------------------
/components/header/hero-rates-info.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { MovieDetails } from '@/types/movie-details'
4 | import { Movie } from '@/types/movie-result'
5 | import { SeriesDetails } from '@/types/series-details'
6 | import { dateFormatter, getGenres, numberRounder } from '@/lib/utils'
7 | import { Badge } from '@/components/ui/badge'
8 | import { Icons } from '@/components/icons'
9 |
10 | interface HeroRatesInfosProps {
11 | movie?: Movie
12 | movieDetails?: MovieDetails
13 | seriesDetails?: SeriesDetails
14 | }
15 |
16 | export const HeroRatesInfos = ({
17 | movie,
18 | movieDetails,
19 | seriesDetails,
20 | }: HeroRatesInfosProps) => {
21 | const item = (movieDetails || movie || seriesDetails) as (
22 | | MovieDetails
23 | | Movie
24 | ) &
25 | SeriesDetails
26 | const movieGenres = getGenres(
27 | movie?.genre_ids,
28 | movieDetails?.genres || seriesDetails?.genres
29 | )
30 |
31 | const displayRating = () => {
32 | // Show IMDB rating if available for movieDetails or seriesDetails
33 | if (movieDetails?.imdbRating) {
34 | return {movieDetails.imdbRating}
35 | }
36 |
37 | if (seriesDetails?.imdbRating) {
38 | return {seriesDetails.imdbRating}
39 | }
40 |
41 | // Fallback to TMDB rating
42 | return (
43 | {numberRounder(item?.vote_average)}
44 | )
45 | }
46 |
47 | return (
48 |
49 |
{item?.original_language}
50 |
{item?.adult ? 'NC-17' : 'PG-13'}
51 |
52 |
53 | {displayRating()}
54 |
55 |
56 | {dateFormatter(item?.release_date || item?.first_air_date)}
57 |
58 | {movieGenres.map((genre) => (
59 |
60 | {genre.name}
61 |
62 | ))}
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/app/movies/[id]/(movie-details)/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Metadata, ResolvingMetadata } from 'next'
3 | import {
4 | getMovieDetailsById,
5 | populateMovieDetailsPage,
6 | } from '@/services/movies'
7 |
8 | import { PageDetailsProps } from '@/types/page-details'
9 | import { getPosterImageURL } from '@/lib/utils'
10 | import { MoviesDetailsContent } from '@/components/media/details-content'
11 | import { MovieDetailsHero } from '@/components/media/details-hero'
12 |
13 | export async function generateMetadata(
14 | props: PageDetailsProps,
15 | parent: ResolvingMetadata
16 | ): Promise {
17 | const params = await props.params
18 | // read route params
19 | const id = params.id
20 |
21 | const movieDetails = await getMovieDetailsById(id)
22 |
23 | return {
24 | title: movieDetails.title,
25 | description: movieDetails.overview,
26 | metadataBase: new URL(`/movies/${id}`, process.env.NEXT_PUBLIC_BASE_URL),
27 | openGraph: {
28 | title: movieDetails.title,
29 | description: movieDetails.overview,
30 | url: `/movies/${id}`,
31 | images: [
32 | {
33 | url: getPosterImageURL(movieDetails.backdrop_path),
34 | width: 1280,
35 | height: 720,
36 | alt: movieDetails.title,
37 | },
38 | {
39 | url: getPosterImageURL(movieDetails.poster_path),
40 | width: 500,
41 | height: 750,
42 | alt: movieDetails.title,
43 | },
44 | ],
45 | },
46 | twitter: {
47 | card: 'summary_large_image',
48 | title: movieDetails.title,
49 | description: movieDetails.overview,
50 | images: [getPosterImageURL(movieDetails.backdrop_path)],
51 | },
52 | }
53 | }
54 |
55 | const MoviePage = async (props: PageDetailsProps) => {
56 | const params = await props.params
57 | const { movieCredits, movieDetails, similarMovies, recommendedMovies } =
58 | await populateMovieDetailsPage(params?.id)
59 |
60 | return (
61 |
70 | )
71 | }
72 |
73 | export default MoviePage
74 |
--------------------------------------------------------------------------------
/app/tv-shows/[id]/(tv-details)/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Metadata, ResolvingMetadata } from 'next'
3 | import {
4 | getSeriesDetailsById,
5 | populateSeriesDetailsPageData,
6 | } from '@/services/series'
7 |
8 | import { PageDetailsProps } from '@/types/page-details'
9 | import { getPosterImageURL } from '@/lib/utils'
10 | import { SeriesDetailsContent } from '@/components/series/details-content'
11 | import { SeriesDetailsHero } from '@/components/series/details-hero'
12 |
13 | export async function generateMetadata(
14 | props: PageDetailsProps,
15 | parent: ResolvingMetadata
16 | ): Promise {
17 | const params = await props.params
18 | // read route params
19 | const id = params.id
20 |
21 | const seriesDetails = await getSeriesDetailsById(id)
22 |
23 | return {
24 | title: seriesDetails.name,
25 | description: seriesDetails.overview,
26 | metadataBase: new URL(`/tv-shows/${id}`, process.env.NEXT_PUBLIC_BASE_URL),
27 | openGraph: {
28 | title: seriesDetails.name,
29 | description: seriesDetails.overview,
30 | url: `/tv-shows/${id}`,
31 | images: [
32 | {
33 | url: getPosterImageURL(seriesDetails?.backdrop_path),
34 | width: 1280,
35 | height: 720,
36 | alt: seriesDetails.name,
37 | },
38 | {
39 | url: getPosterImageURL(seriesDetails?.poster_path),
40 | width: 500,
41 | height: 750,
42 | alt: seriesDetails.name,
43 | },
44 | ],
45 | },
46 | twitter: {
47 | card: 'summary_large_image',
48 | title: seriesDetails.name,
49 | description: seriesDetails.overview,
50 | images: [getPosterImageURL(seriesDetails?.backdrop_path)],
51 | },
52 | }
53 | }
54 |
55 | const TVSeries = async (props: PageDetailsProps) => {
56 | const params = await props.params
57 | const { seriesDetails, seriesCredits, similarSeries, recommendedSeries } =
58 | await populateSeriesDetailsPageData(params?.id)
59 |
60 | return (
61 |
70 | )
71 | }
72 |
73 | export default TVSeries
74 |
--------------------------------------------------------------------------------
/lib/genres.ts:
--------------------------------------------------------------------------------
1 | const MOVIES_GENRE = [
2 | {
3 | id: 28,
4 | name: 'Action',
5 | },
6 | {
7 | id: 12,
8 | name: 'Adventure',
9 | },
10 | {
11 | id: 16,
12 | name: 'Animation',
13 | },
14 | {
15 | id: 35,
16 | name: 'Comedy',
17 | },
18 | {
19 | id: 80,
20 | name: 'Crime',
21 | },
22 | {
23 | id: 99,
24 | name: 'Documentary',
25 | },
26 | {
27 | id: 18,
28 | name: 'Drama',
29 | },
30 | {
31 | id: 10751,
32 | name: 'Family',
33 | },
34 | {
35 | id: 14,
36 | name: 'Fantasy',
37 | },
38 | {
39 | id: 36,
40 | name: 'History',
41 | },
42 | {
43 | id: 27,
44 | name: 'Horror',
45 | },
46 | {
47 | id: 10402,
48 | name: 'Music',
49 | },
50 | {
51 | id: 9648,
52 | name: 'Mystery',
53 | },
54 | {
55 | id: 10749,
56 | name: 'Romance',
57 | },
58 | {
59 | id: 878,
60 | name: 'Science Fiction',
61 | },
62 | {
63 | id: 10770,
64 | name: 'TV Movie',
65 | },
66 | {
67 | id: 53,
68 | name: 'Thriller',
69 | },
70 | {
71 | id: 10752,
72 | name: 'War',
73 | },
74 | {
75 | id: 37,
76 | name: 'Western',
77 | },
78 | ]
79 |
80 | const TV_GENRE = [
81 | {
82 | id: 10759,
83 | name: 'Action & Adventure',
84 | },
85 | {
86 | id: 16,
87 | name: 'Animation',
88 | },
89 | {
90 | id: 35,
91 | name: 'Comedy',
92 | },
93 | {
94 | id: 80,
95 | name: 'Crime',
96 | },
97 | {
98 | id: 99,
99 | name: 'Documentary',
100 | },
101 | {
102 | id: 18,
103 | name: 'Drama',
104 | },
105 | {
106 | id: 10751,
107 | name: 'Family',
108 | },
109 | {
110 | id: 10762,
111 | name: 'Kids',
112 | },
113 | {
114 | id: 9648,
115 | name: 'Mystery',
116 | },
117 | {
118 | id: 10763,
119 | name: 'News',
120 | },
121 | {
122 | id: 10764,
123 | name: 'Reality',
124 | },
125 | {
126 | id: 10765,
127 | name: 'Sci-Fi & Fantasy',
128 | },
129 | {
130 | id: 10766,
131 | name: 'Soap',
132 | },
133 | {
134 | id: 10767,
135 | name: 'Talk',
136 | },
137 | {
138 | id: 10768,
139 | name: 'War & Politics',
140 | },
141 | {
142 | id: 37,
143 | name: 'Western',
144 | },
145 | ]
146 |
147 | export { MOVIES_GENRE, TV_GENRE }
148 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Slot } from '@radix-ui/react-slot'
3 | import { cva, type VariantProps } from 'class-variance-authority'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13 | destructive:
14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
15 | outline:
16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
17 | secondary:
18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
19 | ghost: 'hover:bg-accent hover:text-accent-foreground',
20 | link: 'text-primary underline-offset-4 hover:underline',
21 | 'animated-watch-now':
22 | 'bg-primary text-secondary-foreground text-2xl font-medium font-sans hover:scale-105 transition-transform animate-border bg-linear-to-r from-red-500 via-purple-500 to-blue-500 bg-size-[400%_400%]',
23 | watchNow:
24 | 'bg-primary text-secondary-foreground text-xl font-medium font-sans hover:scale-105 transition-transform',
25 | },
26 | size: {
27 | default: 'h-10 px-4 py-2',
28 | sm: 'h-9 rounded-md px-3',
29 | text: 'h-auto px-2',
30 | lg: 'h-11 rounded-md px-8',
31 | icon: 'h-10 w-10',
32 | '2xl': 'h-11 rounded-md px-12',
33 | },
34 | },
35 | defaultVariants: {
36 | variant: 'default',
37 | size: 'default',
38 | },
39 | }
40 | )
41 |
42 | export interface ButtonProps
43 | extends React.ButtonHTMLAttributes,
44 | VariantProps {
45 | asChild?: boolean
46 | }
47 |
48 | const Button = React.forwardRef(
49 | ({ className, variant, size, asChild = false, ...props }, ref) => {
50 | const Comp = asChild ? Slot : 'button'
51 | return (
52 |
57 | )
58 | }
59 | )
60 | Button.displayName = 'Button'
61 |
62 | export { Button, buttonVariants }
63 |
--------------------------------------------------------------------------------
/components/series/details-content.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react'
2 |
3 | import { Credit } from '@/types/credit'
4 | import { MediaType } from '@/types/media'
5 | import { SeriesDetails } from '@/types/series-details'
6 | import { getPosterImageURL } from '@/lib/utils'
7 | import { BlurredImage } from '@/components/blurred-image'
8 | import { List } from '@/components/list'
9 | import { SliderHorizontalListLoader } from '@/components/loaders/slider-horizontal-list-loader'
10 | import { DetailsCredits } from '@/components/media/details-credits'
11 | import { SeriesDetailsExtraInfo } from '@/components/series/details-extra-info'
12 | import { SeasonNavigator } from '@/components/series/season-navigator'
13 |
14 | interface SeriesDetailsContentProps {
15 | series: SeriesDetails
16 | seriesCredits: Credit
17 | similarSeries: MediaType[]
18 | recommendedSeries: MediaType[]
19 | }
20 |
21 | export const SeriesDetailsContent = ({
22 | series,
23 | seriesCredits,
24 | similarSeries,
25 | recommendedSeries,
26 | }: SeriesDetailsContentProps) => {
27 | const director = seriesCredits?.crew?.find(
28 | (crew) => crew.job === 'Director'
29 | )?.name
30 | return (
31 |
32 |
51 | }>
52 |
57 |
58 | }>
59 |
60 |
61 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/components/list.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import '@splidejs/react-splide/css'
4 |
5 | import React from 'react'
6 | import Link from 'next/link'
7 | import { Splide, SplideSlide } from '@splidejs/react-splide'
8 | import { motion } from 'framer-motion'
9 |
10 | import { MediaType } from '@/types/media'
11 | import { ItemType } from '@/types/movie-result'
12 | import {
13 | CHANGE_COLOR_VARIANT,
14 | HIDDEN_TEXT_ARROW_VARIANT,
15 | HIDDEN_TEXT_VARIANT,
16 | } from '@/lib/motion-variants'
17 | import { itemRedirect } from '@/lib/utils'
18 | import { Card } from '@/components/card'
19 | import { Icons } from '@/components/icons'
20 |
21 | interface ListProps {
22 | title: string
23 | items: MediaType[]
24 | itemType?: ItemType
25 | }
26 |
27 | export const List = ({ title, items, itemType = 'movie' }: ListProps) => {
28 | return (
29 |
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/app/sitemap-tv-shows.xml/route.ts:
--------------------------------------------------------------------------------
1 | import { getServerSideSitemap } from 'next-sitemap'
2 | import {
3 | getPopularSeries,
4 | getAllTimeTopRatedSeries,
5 | getLatestTrendingSeries
6 | } from '@/services/series'
7 | import { siteConfig } from '@/config/site'
8 |
9 | export async function GET(request: Request) {
10 | const baseUrl = siteConfig.websiteURL
11 |
12 | try {
13 | // Fetch different types of TV series
14 | const [popularSeries, topRatedSeries, trendingSeries] = await Promise.all([
15 | getPopularSeries({ page: 1 }),
16 | getAllTimeTopRatedSeries({ page: 1 }),
17 | getLatestTrendingSeries({ page: 1 })
18 | ])
19 |
20 | // Get additional pages for more comprehensive coverage
21 | const [popularPage2, topRatedPage2, trendingPage2] = await Promise.all([
22 | getPopularSeries({ page: 2 }),
23 | getAllTimeTopRatedSeries({ page: 2 }),
24 | getLatestTrendingSeries({ page: 2 })
25 | ])
26 |
27 | // Combine all series results and remove duplicates
28 | const allSeries = [
29 | ...(popularSeries?.results || []),
30 | ...(topRatedSeries?.results || []),
31 | ...(trendingSeries?.results || []),
32 | ...(popularPage2?.results || []),
33 | ...(topRatedPage2?.results || []),
34 | ...(trendingPage2?.results || [])
35 | ]
36 |
37 | // Remove duplicates based on series ID
38 | const uniqueSeries = allSeries.filter((series, index, self) =>
39 | index === self.findIndex(s => s.id === series.id)
40 | )
41 |
42 | // Generate sitemap fields
43 | const fields = uniqueSeries.map((series) => ({
44 | loc: `${baseUrl}/tv-shows/${series.id}`,
45 | lastmod: new Date().toISOString(),
46 | changefreq: 'weekly' as const,
47 | priority: series.popularity > 50 ? 0.8 : 0.6,
48 | }))
49 |
50 | // Add static TV show routes
51 | const staticRoutes = [
52 | {
53 | loc: `${baseUrl}/tv-shows`,
54 | lastmod: new Date().toISOString(),
55 | changefreq: 'daily' as const,
56 | priority: 0.9,
57 | }
58 | ]
59 |
60 | return getServerSideSitemap([...staticRoutes, ...fields])
61 | } catch (error) {
62 | console.error('Error generating TV shows sitemap:', error)
63 |
64 | // Return at least the static routes if API fails
65 | const fallbackRoutes = [
66 | {
67 | loc: `${baseUrl}/tv-shows`,
68 | lastmod: new Date().toISOString(),
69 | changefreq: 'daily' as const,
70 | priority: 0.9,
71 | }
72 | ]
73 |
74 | return getServerSideSitemap(fallbackRoutes)
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/components/details-hero.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from 'react'
2 | import { AnimatePresence, motion } from 'framer-motion'
3 |
4 | import { MovieDetails } from '@/types/movie-details'
5 | import { SeriesDetails } from '@/types/series-details'
6 | import { cn } from '@/lib/utils'
7 | import { HeroImage } from '@/components/header/hero-image'
8 | import { PlayButton } from '@/components/play-button'
9 |
10 | export const DetailsHero = forwardRef<
11 | HTMLIFrameElement,
12 | {
13 | movie?: MovieDetails
14 | series?: SeriesDetails
15 | isIframeShown: boolean
16 | playVideo: () => void
17 | }
18 | >(({ movie, isIframeShown, playVideo, series }, ref) => {
19 | const media = (movie || series) as MovieDetails & SeriesDetails
20 | const title = media?.title || media?.name
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 | {!isIframeShown && (
29 |
38 |
39 |
40 | )}
41 |
42 |
58 |
59 |
60 |
61 |
62 | )
63 | })
64 |
65 | DetailsHero.displayName = 'DetailsHero'
66 |
--------------------------------------------------------------------------------
/components/media/media-content.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { useInView } from 'react-intersection-observer'
5 |
6 | import { MediaResponse, MediaType } from '@/types/media'
7 | import { PopularMediaAction } from '@/types/movie-result'
8 | import { QUERY_KEYS } from '@/lib/queryKeys'
9 | import { useInfiniteScroll } from '@/hooks/use-infinite-scroll'
10 | import { Card } from '@/components/card'
11 |
12 | import { FilteredMediaContent } from './filtered-media-content'
13 |
14 | interface MediaContentProps {
15 | media: MediaResponse
16 | getPopularMediaAction: PopularMediaAction
17 | queryKey: typeof QUERY_KEYS.SERIES_KEY | typeof QUERY_KEYS.MOVIES_KEY
18 | enableFilters?: boolean
19 | filterLayout?: 'sidebar' | 'dialog' | 'sheet'
20 | title?: string
21 | }
22 |
23 | export const MediaContent = ({
24 | media,
25 | getPopularMediaAction,
26 | queryKey,
27 | enableFilters = false,
28 | filterLayout = 'dialog',
29 | title,
30 | }: MediaContentProps) => {
31 | // If filters are enabled, use the FilteredMediaContent component
32 | if (enableFilters) {
33 | const mediaType = queryKey === QUERY_KEYS.MOVIES_KEY ? 'movie' : 'tv'
34 | return (
35 |
41 | )
42 | }
43 |
44 | // Original implementation for backward compatibility
45 | const [myRef, inView] = useInView({
46 | threshold: 0,
47 | rootMargin: '0px 0px 200px 0px',
48 | })
49 | const { data, fetchNextPage } = useInfiniteScroll({
50 | media,
51 | popularMediaAction: getPopularMediaAction,
52 | queryKey,
53 | })
54 |
55 | React.useEffect(() => {
56 | if (inView) {
57 | fetchNextPage()
58 | }
59 | }, [inView, fetchNextPage])
60 |
61 | if (!data) return No data found
62 | const { pages } = data
63 |
64 | return (
65 |
66 | {pages &&
67 | pages.map((page, index) => (
68 |
69 | {page &&
70 | page?.results?.map((movie) => (
71 |
77 | ))}
78 |
79 | ))}
80 |
81 |
82 | )
83 | }
84 |
--------------------------------------------------------------------------------
/app/sitemap-movies.xml/route.ts:
--------------------------------------------------------------------------------
1 | import { getServerSideSitemap } from 'next-sitemap'
2 | import {
3 | getPopularMovies,
4 | getAllTimeTopRatedMovies,
5 | getLatestTrendingMovies,
6 | getNowPlayingMovies
7 | } from '@/services/movies'
8 | import { siteConfig } from '@/config/site'
9 |
10 | export async function GET(request: Request) {
11 | const baseUrl = siteConfig.websiteURL
12 |
13 | try {
14 | // Fetch different types of movies
15 | const [popularMovies, topRatedMovies, trendingMovies, nowPlayingMovies] = await Promise.all([
16 | getPopularMovies({ page: 1 }),
17 | getAllTimeTopRatedMovies({ page: 1 }),
18 | getLatestTrendingMovies({ page: 1 }),
19 | getNowPlayingMovies({ page: 1 })
20 | ])
21 |
22 | // Get additional pages for more comprehensive coverage
23 | const [popularPage2, topRatedPage2, trendingPage2] = await Promise.all([
24 | getPopularMovies({ page: 2 }),
25 | getAllTimeTopRatedMovies({ page: 2 }),
26 | getLatestTrendingMovies({ page: 2 })
27 | ])
28 |
29 | // Combine all movie results and remove duplicates
30 | const allMovies = [
31 | ...(popularMovies?.results || []),
32 | ...(topRatedMovies?.results || []),
33 | ...(trendingMovies?.results || []),
34 | ...(nowPlayingMovies?.results || []),
35 | ...(popularPage2?.results || []),
36 | ...(topRatedPage2?.results || []),
37 | ...(trendingPage2?.results || [])
38 | ]
39 |
40 | // Remove duplicates based on movie ID
41 | const uniqueMovies = allMovies.filter((movie, index, self) =>
42 | index === self.findIndex(m => m.id === movie.id)
43 | )
44 |
45 | // Generate sitemap fields
46 | const fields = uniqueMovies.map((movie) => ({
47 | loc: `${baseUrl}/movies/${movie.id}`,
48 | lastmod: new Date().toISOString(),
49 | changefreq: 'weekly' as const,
50 | priority: movie.popularity > 50 ? 0.8 : 0.6,
51 | }))
52 |
53 | // Add static movie routes
54 | const staticRoutes = [
55 | {
56 | loc: `${baseUrl}/movies`,
57 | lastmod: new Date().toISOString(),
58 | changefreq: 'daily' as const,
59 | priority: 0.9,
60 | }
61 | ]
62 |
63 | return getServerSideSitemap([...staticRoutes, ...fields])
64 | } catch (error) {
65 | console.error('Error generating movies sitemap:', error)
66 |
67 | // Return at least the static routes if API fails
68 | const fallbackRoutes = [
69 | {
70 | loc: `${baseUrl}/movies`,
71 | lastmod: new Date().toISOString(),
72 | changefreq: 'daily' as const,
73 | priority: 0.9,
74 | }
75 | ]
76 |
77 | return getServerSideSitemap(fallbackRoutes)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/components/layouts/footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 |
4 | import { siteConfig } from '@/config/site'
5 | import { cn } from '@/lib/utils'
6 |
7 | import { Icons } from '../icons'
8 | import { buttonVariants } from '../ui/button'
9 |
10 | export function Footer() {
11 | return (
12 |
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/components/watch-history/watch-history-card.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 | import { motion } from 'framer-motion'
4 | import { CalendarDays, Film, Tv } from 'lucide-react'
5 |
6 | import { dateFormatter, getPosterImageURL } from '@/lib/utils'
7 | import { WatchedItem } from '@/hooks/use-local-storage'
8 | import { Badge } from '@/components/ui/badge'
9 | import { Card, CardContent } from '@/components/ui/card'
10 |
11 | import { BlurredImage } from '../blurred-image'
12 |
13 | interface WatchedItemCardProps {
14 | item: WatchedItem
15 | }
16 |
17 | const CARD_VARIANT = {
18 | rest: { scale: 1 },
19 | hover: { scale: 1.05 },
20 | }
21 |
22 | export function WatchedItemCard({ item }: WatchedItemCardProps) {
23 | const handleRedirect = () => {
24 | if (item.type === 'movie') {
25 | return `/movies/${item.id}`
26 | }
27 | return `/tv-shows/${item.id}?season=${item.season}&episode=${item.episode}`
28 | }
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 |
43 |
44 |
45 | {item.type === 'movie' ? (
46 |
47 | ) : (
48 |
49 | )}
50 |
51 |
52 |
53 |
54 |
55 |
{item.title}
56 | {item.type === 'series' && (
57 |
58 | S{item.season}, E{item.episode}
59 |
60 | )}
61 |
62 |
63 |
64 | {dateFormatter(item.added_at, true)}
65 |
66 |
67 |
68 |
69 |
70 |
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/app/robots.ts:
--------------------------------------------------------------------------------
1 | import { siteConfig } from '@/config/site'
2 | import { MetadataRoute } from 'next'
3 |
4 | const baseUrl = siteConfig.websiteURL
5 |
6 | export default function robots(): MetadataRoute.Robots {
7 | return {
8 | rules: [
9 | // Main crawlers - full access with optimizations
10 | {
11 | userAgent: ['Googlebot', 'Bingbot', 'DuckDuckBot', 'facebookexternalhit'],
12 | allow: [
13 | '/',
14 | '/movies',
15 | '/tv-shows',
16 | '/movies/*',
17 | '/tv-shows/*',
18 | '/disclaimer',
19 | '/_next/static/',
20 | '/api/og/*', // Allow OG image generation
21 | ],
22 | disallow: [
23 | '/api/*',
24 | '/_next/',
25 | '/admin/',
26 | '/private/',
27 | '*.json$',
28 | '/watch-history', // Private user data
29 | '/user/',
30 | '/auth/',
31 | '/login',
32 | '/register',
33 | ],
34 | crawlDelay: 1, // Be respectful - 1 second delay
35 | },
36 | // Social media crawlers - optimized for sharing
37 | {
38 | userAgent: ['LinkedInBot', 'WhatsApp', 'TwitterBot', 'TelegramBot'],
39 | allow: [
40 | '/',
41 | '/movies/*',
42 | '/tv-shows/*',
43 | '/api/og/*', // Allow OG image generation
44 | '/_next/static/',
45 | ],
46 | disallow: [
47 | '/api/*',
48 | '/watch-history',
49 | '/user/',
50 | '/admin/',
51 | ],
52 | },
53 | // General crawlers - standard access
54 | {
55 | userAgent: '*',
56 | allow: [
57 | '/',
58 | '/movies',
59 | '/tv-shows',
60 | '/movies/*',
61 | '/tv-shows/*',
62 | '/disclaimer',
63 | ],
64 | disallow: [
65 | '/api/',
66 | '/_next/',
67 | '/admin/',
68 | '/private/',
69 | '/watch-history',
70 | '/user/',
71 | '/auth/',
72 | '/login',
73 | '/register',
74 | '*.json$',
75 | '/*?*', // Disallow query parameters to avoid duplicate content
76 | ],
77 | crawlDelay: 2,
78 | },
79 | // Block malicious bots
80 | {
81 | userAgent: [
82 | 'AhrefsBot',
83 | 'SemrushBot',
84 | 'MJ12bot',
85 | 'DotBot',
86 | 'AspiegelBot',
87 | 'DataForSeoBot',
88 | 'BLEXBot',
89 | 'PetalBot',
90 | ],
91 | disallow: '/',
92 | },
93 | ],
94 | sitemap: [
95 | `${baseUrl}/sitemap.xml`,
96 | `${baseUrl}/sitemap-index.xml`,
97 | `${baseUrl}/sitemap-images.xml`,
98 | `${baseUrl}/sitemap-news.xml`,
99 | ],
100 | host: baseUrl,
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "streaming-movies",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "postbuild": "next-sitemap",
9 | "start": "next start",
10 | "lint": "next lint",
11 | "prettier:format": "prettier --write .",
12 | "prettier:check": "prettier --check .",
13 | "build:worker": "pnpm opennextjs-cloudflare build",
14 | "preview:worker": "pnpm opennextjs-cloudflare preview",
15 | "preview": "pnpm build:worker && pnpm preview:worker",
16 | "deploy": "pnpm build:worker && pnpm opennextjs-cloudflare deploy"
17 | },
18 | "dependencies": {
19 | "@next/third-parties": "15.5.4",
20 | "@radix-ui/react-alert-dialog": "^1.1.1",
21 | "@radix-ui/react-avatar": "^1.1.0",
22 | "@radix-ui/react-dialog": "^1.1.1",
23 | "@radix-ui/react-hover-card": "^1.1.1",
24 | "@radix-ui/react-label": "^2.1.7",
25 | "@radix-ui/react-popover": "^1.1.15",
26 | "@radix-ui/react-scroll-area": "^1.1.0",
27 | "@radix-ui/react-select": "^2.1.1",
28 | "@radix-ui/react-separator": "^1.1.0",
29 | "@radix-ui/react-slider": "^1.3.6",
30 | "@radix-ui/react-slot": "^1.1.0",
31 | "@radix-ui/react-visually-hidden": "^1.2.3",
32 | "@splidejs/react-splide": "^0.7.12",
33 | "@tanstack/react-query": "^5.8.4",
34 | "@vercel/og": "^0.8.5",
35 | "class-variance-authority": "^0.7.1",
36 | "clsx": "^2.1.1",
37 | "cmdk": "^0.2.1",
38 | "date-fns": "^4.1.0",
39 | "framer-motion": "^12.23.5",
40 | "lucide-react": "^0.436.0",
41 | "next": "16.0.10",
42 | "next-sitemap": "^4.2.3",
43 | "nuqs": "^2.6.0",
44 | "posthog-js": "^1.256.2",
45 | "query-string": "^9.1.0",
46 | "react": "19.2.3",
47 | "react-day-picker": "^9.9.0",
48 | "react-dom": "19.2.3",
49 | "react-intersection-observer": "^9.13.0",
50 | "sonner": "^2.0.6",
51 | "tailwind-merge": "^3.3.1",
52 | "tailwindcss-animate": "^1.0.7",
53 | "use-debounce": "^10.0.3"
54 | },
55 | "devDependencies": {
56 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
57 | "@opennextjs/aws": "^3.9.6",
58 | "@opennextjs/cloudflare": "1.14.6",
59 | "@tailwindcss/postcss": "^4.1.8",
60 | "@tailwindcss/typography": "^0.5.10",
61 | "@types/node": "^24.0.10",
62 | "@types/react": "19.2.2",
63 | "@types/react-dom": "19.2.2",
64 | "@typescript-eslint/parser": "^6.12.0",
65 | "eslint": "^8.54.0",
66 | "eslint-config-next": "15.5.4",
67 | "eslint-config-prettier": "^9.0.0",
68 | "eslint-plugin-react": "^7.33.2",
69 | "eslint-plugin-tailwindcss": "^3.13.0",
70 | "husky": "^8.0.3",
71 | "postcss": "^8.5.6",
72 | "prettier": "^3.3.3",
73 | "tailwindcss": "^4.1.14",
74 | "typescript": "^5.5.4",
75 | "wrangler": "^4.54.0"
76 | },
77 | "pnpm": {
78 | "overrides": {
79 | "@types/react": "19.2.2",
80 | "@types/react-dom": "19.2.2"
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | import { MovieGenre } from '@/types/movie-genre'
5 | import { ItemType } from '@/types/movie-result'
6 | import { Season } from '@/types/series-details'
7 | import { MOVIES_GENRE } from '@/lib/genres'
8 | import { apiConfig } from '@/lib/tmdbConfig'
9 |
10 | function cn(...inputs: ClassValue[]) {
11 | return twMerge(clsx(inputs))
12 | }
13 |
14 | function getImageURL(path: string) {
15 | return `${apiConfig.originalImage(path)}`
16 | }
17 |
18 | function getPosterImageURL(path: string) {
19 | return `${apiConfig.w500Image(path)}`
20 | }
21 |
22 | function dateFormatter(date: string, showDay: boolean = false) {
23 | if (!date) return 'N/A'
24 | return new Date(date).toLocaleDateString('en-US', {
25 | year: 'numeric',
26 | month: 'long',
27 | day: showDay ? 'numeric' : undefined,
28 | })
29 | }
30 |
31 | function numberRounder(number: number | undefined) {
32 | if (number) return Math.round(number * 10) / 10
33 | }
34 |
35 | function getGenres(genres: number[] = [], defaultGenres: MovieGenre[] = []) {
36 | if (defaultGenres.length) return defaultGenres
37 | return MOVIES_GENRE.filter((genre) => genres.includes(genre.id))
38 | }
39 |
40 | function itemRedirect(itemType: ItemType) {
41 | if (itemType === 'movie') {
42 | return '/movies'
43 | }
44 | return '/tv-shows'
45 | }
46 |
47 | function moneyFormatter(money: number) {
48 | if (!money) return 'N/A'
49 | return new Intl.NumberFormat('en-US', {
50 | style: 'currency',
51 | currency: 'USD',
52 | }).format(money)
53 | }
54 |
55 | function convertMinutesToHours(minutes: number): string {
56 | if (!minutes) return 'N/A'
57 |
58 | const hours = Math.floor(minutes / 60)
59 | const min = minutes % 60
60 |
61 | let hoursString = hours > 0 ? `${hours} hour${hours > 1 ? 's' : ''}` : ''
62 | let minString = min > 0 ? `${min} minute${min > 1 ? 's' : ''}` : ''
63 |
64 | return `${hoursString} ${minString}`
65 | }
66 |
67 | function seasonsFormatter(seasons: Season[]) {
68 | return seasons.map((season) => {
69 | if (season.season_number === 0) return null
70 | return {
71 | id: season.id,
72 | name: season.name,
73 | poster_path: season.poster_path,
74 | season_number: season.season_number,
75 | }
76 | })
77 | }
78 |
79 | function formatDate(
80 | date: Date,
81 | format: 'short' | 'long' | 'medium' = 'medium'
82 | ): string {
83 | const options: Intl.DateTimeFormatOptions = {
84 | year: 'numeric',
85 | month:
86 | format === 'short' ? '2-digit' : format === 'long' ? 'long' : 'short',
87 | day: '2-digit',
88 | }
89 |
90 | return date.toLocaleDateString('en-US', options)
91 | }
92 |
93 | export {
94 | cn,
95 | getImageURL,
96 | getPosterImageURL,
97 | dateFormatter,
98 | getGenres,
99 | numberRounder,
100 | itemRedirect,
101 | moneyFormatter,
102 | convertMinutesToHours,
103 | seasonsFormatter,
104 | formatDate,
105 | }
106 |
--------------------------------------------------------------------------------
/components/card.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 | import { motion } from 'framer-motion'
4 | import { CalendarDays } from 'lucide-react'
5 |
6 | import { MediaType } from '@/types/media'
7 | import { ItemType } from '@/types/movie-result'
8 | import { CARD_VARIANT } from '@/lib/motion-variants'
9 | import {
10 | dateFormatter,
11 | getPosterImageURL,
12 | itemRedirect,
13 | numberRounder,
14 | } from '@/lib/utils'
15 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
16 | import { Badge } from '@/components/ui/badge'
17 | import {
18 | HoverCard,
19 | HoverCardContent,
20 | HoverCardTrigger,
21 | } from '@/components/ui/hover-card'
22 | import { BlurredImage } from '@/components/blurred-image'
23 |
24 | interface CardProps {
25 | item: MediaType
26 | itemType?: ItemType
27 | isTruncateOverview?: boolean
28 | }
29 |
30 | export const Card = ({
31 | item,
32 | itemType = 'movie',
33 | isTruncateOverview = true,
34 | }: CardProps) => {
35 | return (
36 |
37 |
38 | {item?.poster_path && (
39 |
40 |
46 |
47 |
54 |
55 |
56 |
57 | )}
58 |
59 |
60 |
61 |
62 |
63 | VC
64 |
65 |
66 |
67 |
68 | {item?.title} ({item?.release_date?.slice(0, 4)})
69 |
70 | {numberRounder(item.vote_average)}
71 |
72 |
73 | {isTruncateOverview && item.overview.length > 100 ? (
74 | <>{item.overview.slice(0, 100)}...>
75 | ) : (
76 | item.overview.slice(0, 400)
77 | )}
78 |
79 |
80 | {' '}
81 |
82 | {dateFormatter(item?.release_date, true)}
83 |
84 |
85 |
86 |
87 |
88 |
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/services/series.ts:
--------------------------------------------------------------------------------
1 | import { seriesDTO } from '@/dtos/series'
2 |
3 | import { Credit } from '@/types/credit'
4 | import { Param } from '@/types/movie-result'
5 | import {
6 | MultiSeriesDetailsRequestProps,
7 | SeriesDetails,
8 | } from '@/types/series-details'
9 | import { SeriesResponse } from '@/types/series-result'
10 | import { fetchClient } from '@/lib/fetch-client'
11 | import { tvType } from '@/lib/tmdbConfig'
12 |
13 | const getLatestTrendingSeries = async (params: Param = {}) => {
14 | const url = `${tvType.trending}/tv/day?language=en-US`
15 | const rawData = await fetchClient.get(url, params, true)
16 | return seriesDTO(rawData)
17 | }
18 |
19 | const getPopularSeries = async (params: Param = {}) => {
20 | 'use server'
21 | const url = `tv/${tvType.popular}?language=en-US`
22 | const rawData = await fetchClient.get(url, params, true)
23 | return seriesDTO(rawData)
24 | }
25 |
26 | const getAllTimeTopRatedSeries = async (params: Param = {}) => {
27 | const url = `tv/${tvType.top_rated}?language=en-US`
28 | const rawData = await fetchClient.get(url, params, true)
29 | return seriesDTO(rawData)
30 | }
31 |
32 | const getSeriesDetailsById = async (id: string, params: Param = {}) => {
33 | const url = `tv/${id}?language=en-US`
34 | const seriesDetails = await fetchClient.get(url, params, true)
35 | // Fetch IMDB rating if imdb_id is available
36 | // if (seriesDetails.imdb_id) {
37 | // const imdbRating = await getIMDbRating(seriesDetails.imdb_id)
38 | // return {
39 | // ...seriesDetails,
40 | // imdbRating,
41 | // }
42 | // }
43 |
44 | return {
45 | ...seriesDetails,
46 | imdbRating: null,
47 | }
48 | }
49 |
50 | const getSeriesCreditsById = async (id: string, params: Param = {}) => {
51 | const url = `tv/${id}/credits?language=en-US`
52 | return fetchClient.get(url, params, true)
53 | }
54 |
55 | const getSimilarSeriesById = async (id: string, params: Param = {}) => {
56 | const url = `tv/${id}/similar?language=en-US`
57 | const rawData = await fetchClient.get(url, params, true)
58 | return seriesDTO(rawData)
59 | }
60 |
61 | const getRecommendedSeriesById = async (id: string, params: Param = {}) => {
62 | const url = `tv/${id}/recommendations?language=en-US`
63 | const rawData = await fetchClient.get(url, params, true)
64 | return seriesDTO(rawData)
65 | }
66 |
67 | const populateSeriesDetailsPageData = async (
68 | id: string
69 | ): Promise => {
70 | try {
71 | const [seriesDetails, seriesCredits, similarSeries, recommendedSeries] =
72 | await Promise.all([
73 | getSeriesDetailsById(id),
74 | getSeriesCreditsById(id),
75 | getSimilarSeriesById(id),
76 | getRecommendedSeriesById(id),
77 | ])
78 | return {
79 | seriesDetails,
80 | seriesCredits,
81 | similarSeries: similarSeries?.results || [],
82 | recommendedSeries: recommendedSeries?.results || [],
83 | }
84 | } catch (error: any) {
85 | console.error(error, 'error')
86 | throw new Error(error)
87 | }
88 | }
89 |
90 | export {
91 | getLatestTrendingSeries,
92 | getPopularSeries,
93 | getAllTimeTopRatedSeries,
94 | getSeriesDetailsById,
95 | getSeriesCreditsById,
96 | populateSeriesDetailsPageData,
97 | }
98 |
--------------------------------------------------------------------------------
/hooks/use-watched-media.ts:
--------------------------------------------------------------------------------
1 | import { MovieDetails } from '@/types/movie-details'
2 | import { SeriesDetails } from '@/types/series-details'
3 | import { useLocalStorage } from '@/hooks/use-local-storage'
4 | import { useSearchQueryParams } from '@/hooks/use-search-params'
5 |
6 | type MediaItem = MovieDetails | SeriesDetails
7 |
8 | interface WatchedMediaHookResult {
9 | handleWatchMedia: (media: MediaItem) => void
10 | watchedItems: ReturnType[0]
11 | deleteWatchedItems: () => void
12 | }
13 |
14 | export function useWatchedMedia(): WatchedMediaHookResult {
15 | const [watchedItems, setWatchedItems] = useLocalStorage('watchedItems', [])
16 | const { seasonQueryINT, episodeQueryINT } = useSearchQueryParams()
17 | const deleteWatchedItems = () => setWatchedItems([])
18 |
19 | const handleWatchMedia = (media: MediaItem) => {
20 | const isMovie = 'title' in media
21 | const existingItemIndex = watchedItems.findIndex(
22 | (item) => item.id === media.id
23 | )
24 |
25 | if (existingItemIndex === -1) {
26 | // Item not in localStorage, add it
27 | if (isMovie) {
28 | // Handle movie
29 | setWatchedItems([
30 | ...watchedItems,
31 | {
32 | id: media.id,
33 | type: 'movie',
34 | title: media.title,
35 | overview: media.overview,
36 | backdrop_path: media.backdrop_path,
37 | poster_path: media.poster_path,
38 | added_at: new Date().toISOString(),
39 | modified_at: new Date().toISOString(),
40 | },
41 | ])
42 | } else {
43 | // Handle series
44 | setWatchedItems([
45 | ...watchedItems,
46 | {
47 | id: media.id,
48 | type: 'series',
49 | title: media.name,
50 | overview: media.overview,
51 | backdrop_path: media.backdrop_path,
52 | poster_path: media.poster_path,
53 | added_at: new Date().toISOString(),
54 | modified_at: new Date().toISOString(),
55 | season: seasonQueryINT || 1,
56 | episode: episodeQueryINT || 1,
57 | },
58 | ])
59 | }
60 | } else {
61 | // Item already exists in localStorage
62 | const existingItem = watchedItems[existingItemIndex]
63 |
64 | if (!isMovie && existingItem.type === 'series') {
65 | // Only update series if season or episode changed
66 | if (
67 | existingItem.season !== seasonQueryINT ||
68 | existingItem.episode !== episodeQueryINT
69 | ) {
70 | const updatedItems = [...watchedItems]
71 | updatedItems[existingItemIndex] = {
72 | ...existingItem,
73 | season: seasonQueryINT || existingItem.season,
74 | episode: episodeQueryINT || existingItem.episode,
75 | modified_at: new Date().toISOString(),
76 | }
77 | setWatchedItems(updatedItems)
78 | }
79 | } else {
80 | // Just update the modified date for movies
81 | const updatedItems = [...watchedItems]
82 | updatedItems[existingItemIndex] = {
83 | ...existingItem,
84 | modified_at: new Date().toISOString(),
85 | }
86 | setWatchedItems(updatedItems)
87 | }
88 | }
89 | }
90 |
91 | return { handleWatchMedia, watchedItems, deleteWatchedItems }
92 | }
93 |
--------------------------------------------------------------------------------
/components/layouts/site-header.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Link from 'next/link'
4 |
5 | import { siteConfig } from '@/config/site'
6 | import { cn } from '@/lib/utils'
7 | import { useNavbarScrollOverlay } from '@/hooks/use-scroll-overlay'
8 | import { buttonVariants } from '@/components/ui/button'
9 | import { CommandMenu } from '@/components/command-menu'
10 | import { Icons } from '@/components/icons'
11 | import { MainNav } from '@/components/layouts/main-nav'
12 | import { MobileNav } from '@/components/layouts/mobile-nav'
13 |
14 | export function SiteHeader() {
15 | const { isShowNavBackground } = useNavbarScrollOverlay()
16 | return (
17 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
94 |
95 |
96 |
97 | )
98 | }
99 |
--------------------------------------------------------------------------------
/actions/imdb-rating.ts:
--------------------------------------------------------------------------------
1 | // IMDB rating service using reliable APIs
2 | export const getIMDbRating = async (imdbId: string): Promise => {
3 | try {
4 | if (!imdbId || !imdbId.startsWith('tt')) {
5 | return null
6 | }
7 |
8 | // Use suggestion API directly (more reliable)
9 | return await getIMDbRatingFromSuggestion(imdbId)
10 | } catch (error) {
11 | return null
12 | }
13 | }
14 |
15 | // Primary method using suggestion API with scraping fallback
16 | const getIMDbRatingFromSuggestion = async (
17 | imdbId: string
18 | ): Promise => {
19 | try {
20 | // Try the suggestion API first
21 | const suggestionUrl = `https://v2.sg.media-imdb.com/suggestion/${imdbId[2]}/${imdbId}.json`
22 |
23 | const suggestionResponse = await fetch(suggestionUrl, {
24 | headers: {
25 | 'User-Agent': 'Mozilla/5.0 (compatible; Movie-Platform/1.0)',
26 | Accept: 'application/json',
27 | },
28 | cache: 'force-cache',
29 | next: { revalidate: 86400 },
30 | })
31 |
32 | if (suggestionResponse.ok) {
33 | try {
34 | const suggestionData = await suggestionResponse.json()
35 |
36 | if (suggestionData.d && suggestionData.d.length > 0) {
37 | const movieData = suggestionData.d.find(
38 | (item: any) => item.id === imdbId
39 | )
40 | if (movieData && movieData.r) {
41 | const rating = parseFloat(movieData.r)
42 | if (rating >= 0 && rating <= 10) {
43 | return rating.toFixed(1)
44 | }
45 | }
46 | }
47 | } catch (jsonError) {
48 | console.warn('Failed to parse suggestion API JSON:', jsonError)
49 | }
50 | }
51 |
52 | // Last resort: minimal scraping
53 | const pageResponse = await fetch(`https://www.imdb.com/title/${imdbId}/`, {
54 | headers: {
55 | 'User-Agent': 'Mozilla/5.0 (compatible; Movie-Platform/1.0)',
56 | },
57 | cache: 'force-cache',
58 | next: { revalidate: 86400 },
59 | })
60 |
61 | if (pageResponse.ok) {
62 | const html = await pageResponse.text()
63 |
64 | // Look for JSON-LD structured data first
65 | const jsonLdMatch = html.match(
66 | /