Commit a51d31e8 authored by dungtnguyen's avatar dungtnguyen

initial setup

parent cc2c08ae
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
File added
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https', // Hoặc 'http' nếu cần
hostname: 'image.tmdb.org', // Địa chỉ của TMDB
port: '', // Để trống nếu không sử dụng cổng tùy chỉnh
pathname: '/**', // Sử dụng dấu hoa thị để cho phép bất kỳ đường dẫn nào
},
],
},
};
export default nextConfig;
This diff is collapsed.
{
"name": "meu_movies",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"@types/react-query": "^1.2.9",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"next": "14.2.14",
"next-themes": "^0.3.0",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.3.0",
"sharp": "^0.33.5",
"swiper": "^11.1.14"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.20",
"eslint": "^8",
"eslint-config-next": "14.2.14",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5"
}
}
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
export {}; // Đánh dấu file này là một module
export interface Movie {
adult?: boolean;
backdrop_path?: string;
first_air_date?: Date;
genre_ids?: number[];
original_title?: string;
id: number;
name?: string;
media_type?: string;
origin_country?: string[];
original_language?:string;
original_name?: string | null | undefined;
overview?: string;
popularity?: number;
poster_path: string | null | undefined;
vote_average?:number;
vote_count?: number;
}
// FilmDetails.ts
export interface Genre {
id: number;
name: string;
}
export interface ProductionCompany {
id: number;
logo_path: string | null;
name: string;
origin_country: string;
}
export interface Language {
english_name: string;
iso_639_1: string;
name: string;
}
export interface FilmDetails {
adult: boolean;
backdrop_path: string | null;
budget: number;
genres: Genre[];
homepage: string;
id: number;
imdb_id: string;
origin_country: string[];
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string | null;
production_companies: ProductionCompany[];
production_countries: { iso_3166_1: string; name: string }[];
release_date: string;
revenue: number;
runtime: number;
spoken_languages: Language[];
status: string;
tagline: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
}
export interface Video {
id: string;
iso_639_1: string;
iso_3166_1: string;
key: string;
name: string;
official: boolean;
published_at: string;
site: string;
size: number;
type: string;
}
export interface SimilarFilm {
adult: boolean;
backdrop_path: string | null;
genre_ids: number[];
id: number;
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string | null;
release_date: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
name: string;
original_name: string;
}
export interface Cast {
adult: boolean;
gender: number;
id: number;
known_for_department: string;
name: string;
original_name: string;
popularity: number;
profile_path: string | null;
cast_id: number;
character: string;
credit_id: string;
order: number;
}
export interface Crew {
adult: boolean;
gender: number;
id: number;
known_for_department: string;
name: string;
original_name: string;
popularity: number;
profile_path: string | null;
credit_id: string;
department: string;
job: string;
}
export interface Credits {
id: number;
cast: Cast[];
crew: Crew[];
}
export interface FilmSectionProps {
title: string;
viewMoreLink: string;
mediaType: string;
data: Movie[];
isLoading: boolean;
}
export interface SwiperData {
title: string;
data: Movie[];
viewMoreLink: string;
media_type: string;
isLoading: boolean;
}
export interface HeaderSwiperProps {
swipersData: SwiperData[];
onWatchNow: (id: number) => void;
onWatchTrailer: (id: number) => void;
}
import footerImg from './footer_bg.jpg';
import default_image from './default_image.png';
import meu_logo from './logo_meu.png'
export const Images: { [key: string]: string } = {
logo: meu_logo.src,
footerImg: footerImg.src,
default_image: default_image.src,
};
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
\ No newline at end of file
This diff is collapsed.
import type { Metadata } from "next";
// import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
// const geistSans = localFont({
// src: "./fonts/GeistVF.woff",
// variable: "--font-geist-sans",
// weight: "100 900",
// });
// const geistMono = localFont({
// src: "./fonts/GeistMonoVF.woff",
// variable: "--font-geist-mono",
// weight: "100 900",
// });
export const metadata: Metadata = {
title: "MeU-Streaming",
description: "Stream high-quality movies with vibrant sound and dual-language subtitles on MeU-Streaming.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
// Core
import React, { useCallback } from "react";
import clsx from "clsx";
import Config from "../../configuration";
import { Images } from "@/app/assets/images";
import { useRouter } from "next/router"; // Import useRouter
// Types
import { FilmItemProps } from "./lib/types";
// Component: FilmItem
const FilmItem: React.FC<FilmItemProps> = ({ id, original_title, name, original_name, poster_path, media_type, className }) => {
const router = useRouter(); // Khởi tạo router
// Method: handleNavigate
const handleNavigate = useCallback(() => {
// Chuyển đến trang chi tiết của phim
router.push(`/${media_type}/${id}`);
}, [router, media_type, id]); // Chỉ định các dependency cần thiết
// Core: Xử lý ảnh nền
const backgroundImage = poster_path ? `${Config.imgPath}${poster_path}` : Images.default_image;
// Core: Xử lý tiêu đề
const title = original_title ?? original_name ?? name;
return (
<div className={clsx("px-2 w-full mb-8", className)}> {/* Component: Wrapper */}
<div
className="hover:cursor-pointer group/container z-10"
onClick={handleNavigate}
>
<div
className='relative w-full h-72 2xl:h-80 rounded-3xl bg-center bg-no-repeat bg-cover group/poster after:content-[""] after:absolute after:top-0 after:right-0 after:bottom-0 after:left-0 after:rounded-3xl hover:after:bg-black/60 after:transition after:ease-in-out after:duration-300'
style={{
backgroundImage: `url(${backgroundImage})`,
}}
>
<button className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 py-4 px-8 bg-red-main rounded-full shadow-btn z-10 global-text text-xl scale-50 opacity-0 transition ease-in-out duration-300 group-hover/poster:opacity-100 group-hover/poster:scale-100 hover:shadow-btn-hover">
<svg
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 16 16"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"></path>
</svg>
</button>
</div>
<h3 className="font-boldtext-left global-text text-sm md:text-lg mt-4 transition duration-300 ease-in-out group-hover/container:text-red-main">
{title}
</h3>
</div>
</div>
);
};
export default FilmItem;
import { Movie } from "../../../Types/Types";
export interface FilmItemProps extends Movie {
className?: string;
}
\ No newline at end of file
// components/FilmSlide.tsx
import React from 'react';
import Image from 'next/image';
interface FilmSlideProps {
id?: number;
title: string | undefined;
description: string | undefined;
backgroundImage?: string;
posterImage?: string | undefined;
onWatchNow: () => void;
onWatchTrailer?: () => void;
}
const FilmSlide: React.FC<FilmSlideProps> = ({
title,
description,
backgroundImage,
posterImage,
onWatchNow,
onWatchTrailer,
}) => {
return (
<div className="swiper-slide" style={{ width: '100%' }}>
<div
className="relative h-auto md:h-[20rem] lg:h-[30rem] px-4 md:px-12 object-cover py-12 md:py-32 flex justify-center bg-center bg-no-repeat before:content-[''] before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:bg-black/60 after:content-[''] after:absolute after:bottom-0 after:left-0 after:right-0 after:h-28 after:bg-gradient-to-t after:from-black-main after:to-transparent"
style={{
backgroundImage: `url(${backgroundImage})`,
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
imageRendering: 'crisp-edges',
}}
>
<div className="w-full z-10 h-full flex items-center justify-between">
<div className="w-full lg:w-2/3 px-4 flex flex-col items-start justify-between">
<h2 className="font-bold text-2xl md:text-4xl lg:text-6xl text-white animate-fallDown">
{title}
</h2>
<p className="font-medium text-white text-xs md:text-xl my-4 text-left animate-fallDown">
{description}
</p>
<div className="flex text-white animate-fallDown mt-4">
<button className="btn-lg btn-primary mr-4" onClick={onWatchNow}>
Watch now
</button>
<button className="btn-lg btn-default" onClick={onWatchTrailer}>
Watch trailer
</button>
</div>
</div>
<div className="hidden px-4 lg:block lg:w-1/3 animate-scaleUp">
{posterImage && (
<Image
className="rounded-3xl animate-scale h-full object-cover"
src={posterImage} // Use the Next.js Image component
alt="Poster"
width={200} // width of 96 rem (in pixels)
height={576} // appropriate height for your image
quality={75} // Set image quality for optimization
priority // This can optimize loading for important images
/>
)}
</div>
</div>
</div>
</div>
);
};
export default FilmSlide;
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Images } from '@/app/assets/images';
const Footer: React.FC = () => {
return (
<div
className="h-100 w-[100%] lg:h-120 px-8 py-12 md:p-16 bg-cover bg-no-repeat"
style={{ backgroundImage: `url(${Images.footerImg})` }} // Đảm bảo Images.footerImg là đường dẫn đúng
>
<div className="max-w-4xl h-full mx-auto flex flex-col justify-around">
<Link href="/" className="flex items-center justify-center hover:cursor-pointer group mb-10">
<Image
src={Images.logo}
alt="Logo"
className="mr-2 md:mr-4 w-8 md:w-12"
width={48} // Cung cấp chiều rộng cố định
height={48} // Cung cấp chiều cao cố định
/>
<h1 className="text-white font-semibold text-2xl md:text-4xl group-hover:text-red-500 group-hover:transition duration-300">
MeU Movies
</h1>
</Link>
<div className="flex text-white font-semibold text-base md:text-2xl items-start justify-between flex-wrap -mx-2 mt-8 mb-10">
<Link href="/" className="footer-item">Home</Link>
<Link href="/" className="footer-item">Live</Link>
<Link href="/" className="footer-item">You must watch</Link>
<Link href="/" className="footer-item">Contact us</Link>
<Link href="/" className="footer-item">FAQ</Link>
<Link href="/" className="footer-item">Recent releases</Link>
<Link href="/" className="footer-item">Terms of services</Link>
<Link href="/" className="footer-item">Premium</Link>
<Link href="/" className="footer-item">Top IMDB</Link>
<Link href="/" className="footer-item">About us</Link>
<Link href="/" className="footer-item">Privacy policy</Link>
</div>
</div>
</div>
);
};
export default Footer;
import React, { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; // Để quản lý active link
import Image from "next/image";
import { Images } from "@/app/assets/images";
const Header: React.FC = () => {
const [isScrolled, setIsScrolled] = useState(false);
const router = useRouter(); // Dùng useRouter để lấy pathname
// Sự kiện theo dõi khi người dùng cuộn
const handleScroll = useCallback(() => {
const scrollY = window.scrollY;
setIsScrolled(scrollY > 90);
}, []);
useEffect(() => {
const onScroll = () => {
// Sử dụng requestAnimationFrame để tối ưu hóa hiệu suất
requestAnimationFrame(handleScroll);
};
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [handleScroll]);
return (
<header
className={`px-8 flex justify-center fixed top-0 w-full z-50 transition-all duration-200 ease-in-out ${
isScrolled ? "py-4 bg-black" : "py-0 md:py-8 bg-transparent"
}`}
>
<div className="max-w-screen-2xl flex justify-between items-center w-full">
<Link
href="/"
className="hidden md:flex items-center hover:cursor-pointer group"
>
<Image
src={Images.logo}
alt="Logo"
className="mr-4 w-8 md:w-12"
width={48}
height={48}
/>
<h1 className="text-white font-semibold text-2xl md:text-4xl group-hover:text-red-main group-hover:transition-custom">
MeU-Streaming
</h1>
</Link>
<nav className="fixed md:relative left-0 md:left-auto right-0 md:right-auto bottom-0 md:bottom-auto flex items-center justify-evenly bg-black md:bg-transparent py-2 md:py-4 -mx-4">
<Link
href="/"
className={`nav-item ${router.pathname === "/" ? "active" : ""}`}
>
<span className="px-4 text-white hover:text-red-500">Home</span>
</Link>
<Link
href="/movies"
className={`nav-item ${
router.pathname === "/movies" ? "active" : ""
}`}
>
<span className="px-4 text-white hover:text-red-500">Movies</span>
</Link>
<Link
href="/tvseries"
className={`nav-item ${
router.pathname === "/tvseries" ? "active" : ""
}`}
>
<span className="px-4 text-white hover:text-red-500">
TV Series
</span>
</Link>
</nav>
</div>
</header>
);
};
export default Header;
import React from 'react';
import Spinner from '../Spinner/Spinner';
interface LoadMoreButtonProps {
onClick: () => void;
isFetching: boolean;
hasNextPage: boolean;
}
const LoadMoreButton: React.FC<LoadMoreButtonProps> = ({ onClick, isFetching, hasNextPage }) => {
return (
<div className="text-center mt-8">
{isFetching ? (
<div className="h-[20vh] flex justify-center items-center flex-row gap-5">
<Spinner />
</div>
) : hasNextPage ? (
<button onClick={onClick} className="btn-sm btn-default">
Xem thêm
</button>
) : (
<div>
{/* Không còn kết quả nữa */}
</div>
)}
</div>
);
};
export default LoadMoreButton;
// components/MovieCard.tsx
import React from "react";
import Image from "next/image";
import { Movie } from "@/Types/Types"; // Ensure this type has the properties you need
interface MovieCardProps {
movie: Movie; // Accept a movie object
}
const MovieCard: React.FC<MovieCardProps> = ({ movie }) => {
const { id, original_title, name, original_name, genre_ids, vote_average, poster_path} = movie; // Destructure properties from the movie object
const title = original_title ?? name ?? original_name;
console.log(id);
return (
<div className="flex w-full justify-start items-center text-left gap-4 group mb-4">
<Image src={'https://image.tmdb.org/t/p/w500/' + poster_path} alt="Logo" width={70} height={60} className="rounded-lg"/>
<div className="flex flex-col justify-evenly items-start h-full">
<span className="global-text font-bold text-xl group-hover:text-mainRed">
{title}
</span>
<span className="global-text group-hover:text-mainRed">
{genre_ids}
</span>
{vote_average !== undefined && ( // Check explicitly if rating is not undefined
<span className="global-text font-bold text-xl">
{vote_average}
</span>
)}
</div>
</div>
);
};
export default MovieCard;
import React from 'react';
import FilmItem from "../../components/FilmItem";
import { Movie } from '@/Types/Types';
interface MoviesGridProps {
movies: Movie[]; // Replace `any` with the proper movie type
}
const MoviesGrid: React.FC<MoviesGridProps> = ({ movies }) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-16">
{movies.map((movie) => (
<FilmItem
key={movie.id}
id={movie.id}
original_title={movie.original_title}
original_name={movie.original_name}
name={movie.name}
media_type="movie"
poster_path={movie.poster_path}
className="w-full"
/>
))}
</div>
);
export default MoviesGrid;
const MovieQualityComparison = () => {
return (
<div className="w-full flex flex-col text-white p-6 rounded-lg shadow-md mt-6">
<div className="flex flex-col mb-4">
<span className="text-lg md:text-xl text-white">
Phim chất lượng cao online của MeU-Streaming khác gì so với các trang phim khác?
</span>
</div>
<div className="flex flex-col space-y-4">
<ul className="list-disc pl-6 space-y-2">
<li className="text-base md:text-lg lg:text-xl text-gray-400">
Là phim bluray (reencoded), có độ phân giải thấp nhất là Full HD (1080p), trong khi hầu hết các trang phim khác chỉ có tới độ phân giải HD (720p) là cao nhất.
</li>
<li className="text-base md:text-lg lg:text-xl text-gray-400">
Chất lượng cao, lượng dữ liệu trên giây (bitrate) gấp từ 5 - 10 lần phim online thông thường - đây là yếu tố quyết định độ nét của phim (thậm chí còn quan trọng hơn độ phân giải).
</li>
<li className="text-base md:text-lg lg:text-xl text-gray-400">
Âm thanh 5.1 (6 channel) thay vì stereo (2 channel) như các trang phim khác (kể cả Youtube).
</li>
<li className="text-base md:text-lg lg:text-xl text-gray-400">
Phù hợp để xem trên màn hình TV, máy tính, laptop có độ phân giải cao.
</li>
<li className="text-base md:text-lg lg:text-xl text-gray-400">
Nếu không hài lòng với phụ đề có sẵn, bạn có thể tự upload phụ đề của riêng mình để xem online.
</li>
<li className="text-base md:text-lg lg:text-xl text-gray-400">
Có lựa chọn hiện phụ đề song ngữ (thực hiện đồng thời cả tiếng Anh & tiếng Việt), phù hợp với những người muốn học tiếng Anh qua phụ đề phim.
</li>
</ul>
</div>
</div>
);
};
export default MovieQualityComparison;
\ No newline at end of file
import React from 'react';
const NoResults: React.FC = () => {
return (
<div className="flex items-center justify-center text-center h-[50vh]">
<p className="text-xl md:text-2xl text-white text-opacity-50">Không có kết quả</p>
</div>
);
};
export default NoResults;
import React from 'react';
interface SearchFormProps {
keyword: string;
onKeywordChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
const SearchForm: React.FC<SearchFormProps> = ({ keyword, onKeywordChange, onSubmit }) => (
<form className="flex items-center relative rounded-full bg-black w-full md:w-fit lg:w-fit" onSubmit={onSubmit}>
<input
type="text"
placeholder="Enter keyword"
name="keyword"
value={keyword}
onChange={onKeywordChange}
className="outline-none border-none rounded-full px-6 py-2 bg-black placeholder-gray-500 text-white flex-1 md:flex-auto md:w-96"
/>
<button type="submit" className="btn-primary py-2 px-8 text-white rounded-full">
Search
</button>
</form>
);
export default SearchForm;
// Spinner.tsx
import React from "react";
const Spinner: React.FC = () => {
return (
<div className="flex items-center justify-center h-full">
<div className="w-6 h-6 md:w-10 md:h-10 border-2 border-t-transparent border-white border-opacity-50 rounded-full animate-spin"></div>
</div>
);
};
export default Spinner;
// src/components/ThemeToggle.tsx
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Đảm bảo rằng theme chỉ được gắn sau khi component đã mounted (khắc phục sự không đồng bộ giữa server và client)
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<button
className="p-2 border rounded"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
{theme === "light" ? "Dark Mode" : "Light Mode"}
</button>
);
}
// src/components/theme-provider.tsx
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ReactNode } from "react";
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="light" enableSystem>
{children}
</NextThemesProvider>
);
}
interface EnvConfig {
devEndPoint: string;
prodEndPoint? : string
}
const envConfig: EnvConfig = {
devEndPoint: 'https://api.themoviedb.org/3/',
prodEndPoint: 'https://api.themoviedb.org/3/',
};
const imgPath = 'https://image.tmdb.org/t/p/w500'
const Config = {
envConfig,
imgPath
};
export default Config;
\ No newline at end of file
interface MovieEmbed {
id: number;
linkEmbed: string;
}
export const moviesEmbed : MovieEmbed[] = [
{
id: 533535,
linkEmbed: 'https://vip.opstream11.com/share/201c0f76a64e14fdfe74bdff9eb099f0'
},
{
id: 1125510,
linkEmbed: 'https://vip.opstream11.com/share/0bca32740868fedd063b49c7d0c54166'
},
{
id: 917496,
linkEmbed: 'https://vip.opstream14.com/share/e1166024037608746496d53e2d03f97f'
},
{
id: 889737,
linkEmbed: 'https://vip.opstream10.com/share/b95720ee4419a4f70ef06157c549dbcb'
},
{
id: 748230,
linkEmbed: 'https://vip.opstream11.com/share/95c67d929b9fee3507244cbb024c0e73'
},
{
id: 194764,
linkEmbed: 'https://vip.opstream11.com/share/6c780ce7b9fdccbacff91dde48fe45cf'
},
{
id: 1186532,
linkEmbed: 'https://vip.opstream14.com/share/f04c67050a9d82baa7f3cdbe5a084f2a'
},
{
id: 1052280,
linkEmbed: 'https://vip.opstream12.com/share/1c80289707dba7161733a9a248855ce8'
},
{
id: 12477,
linkEmbed: 'https://vip.opstream14.com/share/c44799b04a1c72e3c8593a53e8000c78'
},
{
id: 278,
linkEmbed: 'https://vip.opstream12.com/share/13fe9d84310e77f13a6d184dbf1232f3'
},
{
id: 238,
linkEmbed: 'https://vip.opstream12.com/share/131799f66a96ee034181e8a54b4c0b49'
},
{
id: 240,
linkEmbed: 'https://vip.opstream15.com/share/ab7314887865c4265e896c6e209d1cd6'
},
{
id: 424,
linkEmbed: 'https://vip.opstream14.com/share/be18f4dac22b7a34ce750a5e3e2eed21'
},
{
id: 389,
linkEmbed: 'https://vip.opstream14.com/share/671792587502028b6cd4be7c4d662d08'
},
{
id: 129,
linkEmbed: 'https://vip.opstream12.com/share/539fd53b59e3bb12d203f45a912eeaf2'
},
{
id: 19404,
linkEmbed: 'https://vip.opstream14.com/share/588d1888c2f6920233743f25c56898a3'
},
{
id: 155,
linkEmbed: 'https://vip.opstream12.com/share/d3989fd6bcf000eeba6633fa4c003b6b'
},
{
id: 497,
linkEmbed: 'https://vip.opstream16.com/share/13f3cf8c531952d72e5847c4183e6910'
},
{
id: 496243,
linkEmbed: 'https://vip.opstream12.com/share/1534b76d325a8f591b52d302e7181331'
},
{
id: 680,
linkEmbed: 'https://vip.opstream12.com/share/ef05e93f3eb69985c3dcc58b11aac369'
},
{
id: 372058,
linkEmbed: 'https://vip.opstream11.com/share/4d6b3e38b952600251ee92fe603170ff'
},
{
id: 122,
linkEmbed: 'https://vip.opstream14.com/share/1319c26b37ea5c6413750b06f30f6b6a'
},
{
id: 13,
linkEmbed: 'https://vip.opstream15.com/share/bcbe3365e6ac95ea2c0343a2395834dd'
},
{
id: 769,
linkEmbed: 'https://vip.opstream10.com/share/43cca4b3de2097b9558efefd0ecc3588'
},
{
id: 429,
linkEmbed: 'https://vip.opstream12.com/share/242693e0e518a473d4374f84de433521'
},
{
id: 346,
linkEmbed: 'https://vip.opstream14.com/share/b0c36c9e967a20ad29b5d8e26cb2bc91'
},
{
id: 11216,
linkEmbed: 'https://vip.opstream14.com/share/09b24f6bc75811639ed94b8c719d7c7b'
},
{
id: 637,
linkEmbed: 'https://vip.opstream12.com/share/0de5d1a081a3095d62b416e44e055e7a'
},
]
\ No newline at end of file
import { useEffect, useState } from "react";
const useDarkMode = () => {
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
setTheme(savedTheme);
document.documentElement.classList.add(savedTheme === "dark" ? "dark" : "light");
}
}, []);
const toggleDarkMode = () => {
const newTheme = theme === "light" ? "dark" : "light";
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
};
return {
theme,
toggleDarkMode,
};
};
export default useDarkMode;
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/router";
import Image from "next/image";
import useDarkMode from "@/hooks/useDarkMode";
import { Images } from "@/app/assets/images";
import LeftSidebar from "./components/LeftSideBar/LeftSidebar";
import { FaTh, FaBell } from "react-icons/fa";
import HeaderSwiper from "./components/Banner/Banner";
import FilmSection from "./components/FilmSection/FilmSection";
import Modal from "./components/Modal/Modal";
import { Movie } from "@/Types/Types";
import MovieCard from "@/components/MovieCard/MovieCard";
// Internal (Client)
import apiClient from "../../services/apiServices/apiServices";
// Component: Types
import { SwiperData } from "@/Types/Types";
const HomeMainView: React.FC = () => {
const router = useRouter();
const { toggleDarkMode } = useDarkMode();
const [videoId, setVideoId] = useState<string | null>(null);
const isSidebarOpen : boolean = true
// const handleLogoClick = () => {
// setIsSidebarOpen((prev) => !prev);
// };
// Custom Hook: Fetch videos for a specific movie
const useFetchVideos = (movieId: string) => {
return useQuery({
queryKey: ["videos", movieId],
queryFn: async () => {
if (!movieId) return [];
const response = await apiClient.get(
`/movie/${movieId}/videos?language=en-US`
);
return response.data.results;
},
enabled: !!movieId, // Chỉ thực hiện query nếu movieId có giá trị
});
};
const trendingMoviesQuery = useQuery({
queryKey: ["trendingMovies"],
queryFn: async () => {
console.time("Fetching Trending Movies");
const response = await apiClient.get("/trending/all/day?language=en-US");
console.timeEnd("Fetching Trending Movies");
return response.data.results;
},
});
const topRatedMoviesQuery = useQuery({
queryKey: ["topRatedMovies"],
queryFn: async () => {
console.time("Fetching Top Rated Movies");
const response = await apiClient.get(
"/movie/top_rated?language=en-US&page=1"
);
console.timeEnd("Fetching Top Rated Movies");
return response.data.results;
},
});
const trendingTVQuery = useQuery({
queryKey: ["trendingTV"],
queryFn: async () => {
console.time("Fetching Trending TV");
const response = await apiClient.get("/trending/tv/day?language=en-US");
console.timeEnd("Fetching Trending TV");
return response.data.results;
},
});
const topRatedTVQuery = useQuery({
queryKey: ["topRatedTV"],
queryFn: async () => {
console.time("Fetching Top Rated TV");
const response = await apiClient.get(
"/tv/top_rated?language=en-US&page=1"
);
console.timeEnd("Fetching Top Rated TV");
return response.data.results;
},
});
const swipersData: SwiperData[] = [
{
title: "Trending Movies",
data: trendingMoviesQuery.data || [],
viewMoreLink: "/movie",
media_type: "movie",
isLoading: trendingMoviesQuery.isLoading,
},
{
title: "Top Rated Movies",
data: topRatedMoviesQuery.data || [],
viewMoreLink: "/movie",
media_type: "movie",
isLoading: topRatedMoviesQuery.isLoading,
},
{
title: "Top Trending TV",
data: trendingTVQuery.data || [],
viewMoreLink: "/tvseries",
media_type: "tv",
isLoading: trendingTVQuery.isLoading,
},
{
title: "Top Rated TV",
data: topRatedTVQuery.data || [],
viewMoreLink: "/tvseries",
media_type: "tv",
isLoading: topRatedTVQuery.isLoading,
},
];
// Queries: Fetch videos when videoId is set using the custom hook
const {
data: videos = [],
// isLoading: isVideosLoading,
// error: videosError,
} = useFetchVideos(videoId!);
// Methods: Toggle modal
const toggleModal = () => {
setVideoId(null);
};
const handleWatchTrailer = (id: number) => {
setVideoId(id.toString());
};
const handleWatchNow = (id: number) => {
router.push(`/movie/${id}`);
};
return (
<main className="w-full h-screen flex flex-col">
<div className="w-full h-screen flex relative">
{/* Left Sidebar */}
<LeftSidebar />
{/* Main Content */}
<section
className={`flex-1 overflow-y-auto bg-mainLight dark:bg-mainDark transition-all duration-300 ${
isSidebarOpen ? "ml-1/6" : "ml-0"
}`}
>
{/* Header */}
<header className="fixed top-0 z-10 h-[8%] bg-mainLight dark:bg-mainDark w-4/6 flex items-center justify-center p-4 pb-0 ">
<div className="flex flex-1 flex-row justify-start w-4/5 px-[5%] gap-10">
<span className="text-textLight dark:text-textDark text-2xl font-bold hover:text-mainRed dark:hover:text-mainRed transition-colors duration-200 ease-in-out">
Home
</span>
<span className="left-sidebar-text font-bold hover:text-mainRed dark:hover:text-mainRed transition-colors duration-200 ease-in-out">
Movies
</span>
<span className="left-sidebar-text font-bold hover:text-mainRed dark:hover:text-mainRed transition-colors duration-200 ease-in-out">
TV Series
</span>
</div>
<div className="h-[2px] bg-gray-400 w-[90%] mt-2 absolute bottom-0 left-1/2 transform -translate-x-1/2" />
</header>
{/* Main content */}
<div className="w-5/6 px-[5%] flex flex-col pt-[5%]">
<span className="global-text text-3xl font-bold my-10">
Trending
</span>
<HeaderSwiper
swipersData={swipersData}
onWatchNow={handleWatchNow}
onWatchTrailer={handleWatchTrailer}
/>
<div className="h-10"></div>
{/* Render swipers for movies */}
<FilmSection
title="Trending Movies"
viewMoreLink="/movies"
mediaType="movie"
data={trendingMoviesQuery.data || []}
isLoading={trendingMoviesQuery.isLoading}
/>
{/* Top Rated Movies Section */}
<FilmSection
title="Top Rated Movies"
viewMoreLink="/movies"
mediaType="movie"
data={topRatedMoviesQuery.data || []}
isLoading={topRatedMoviesQuery.isLoading}
/>
{/* Trending TV Section */}
<FilmSection
title="Top Trending TV"
viewMoreLink="/tvseries"
mediaType="tv"
data={trendingTVQuery.data || []}
isLoading={trendingTVQuery.isLoading}
/>
{/* Top Rated TV Section */}
<FilmSection
title="Top Rated TV"
viewMoreLink="/tvseries"
mediaType="tv"
data={topRatedTVQuery.data || []}
isLoading={topRatedTVQuery.isLoading}
/>
</div>
</section>
{/* Sidebar phải - ẩn trên thiết bị nhỏ */}
<aside className="hidden lg:block w-1/6 bg-sidebarLight dark:bg-sidebarDark h-screen fixed right-0">
<div className="flex flex-col h-full">
<header className="h-[8%] w-full flex items-center justify-end px-10">
<div className="flex items-center gap-4">
<FaTh className="text-3xl text-textLight dark:text-textDark cursor-pointer hover:text-mainRed transition-colors duration-200 ease-in-out" />
<FaBell className="text-3xl text-textLight dark:text-textDark cursor-pointer hover:text-mainRed transition-colors duration-200 ease-in-out" />
<button
className="ml-auto px-4 py-2 rounded bg-gray-700 dark:bg-gray-200"
onClick={toggleDarkMode}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
/>
</svg>
</button>
<div className="h-10 w-10 ">
<Image src={Images.logo} alt="Logo" width={50} height={50} />
</div>
</div>
</header>
<div className="flex flex-1 pt-10 flex-col px-6">
<span className="text-2xl font-bold mb-5 global-text">
Top This Week
</span>
{
topRatedMoviesQuery.data?.slice(0, 3).map((Movie:Movie) => (
<MovieCard key={Movie.id} movie={Movie} />
))
}
</div>
</div>
</aside>
{/* Component: Modal section for video */}
<Modal videoKey={videos[0]?.key} onClose={toggleModal} />
</div>
</main>
);
};
export default HomeMainView;
// Core: Import necessary libraries and types
import React from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import FilmSlide from "../../../../components/FilmSlide";
import Config from "../../../../configuration";
import { HeaderSwiperProps } from "@/Types/Types";
// Component Props
const HeaderSwiper: React.FC<HeaderSwiperProps> = ({ swipersData, onWatchNow, onWatchTrailer }) => {
if (!swipersData || !swipersData[0]?.data) {
return <div>Loading...</div>;
}
return (
<Swiper
style={{borderRadius:20, overflow:"hidden"}}
loop
autoplay={{ delay: 3000, disableOnInteraction: true }}
className="w-full mb-0 p-0"
onSlideChange={() => {
// Effects: Reset animation on slide change
document
.querySelectorAll(".animate-fallDown, .animate-scaleUp")
.forEach((element) => {
element.classList.remove("animate-fallDown", "animate-scaleUp");
setTimeout(() => {
element.classList.add("animate-fallDown", "animate-scaleUp");
}, 0);
});
}}
>
{swipersData[0].data.slice(0, 4).map((movie) => (
<SwiperSlide key={movie.id}>
<FilmSlide
id={movie.id}
title={movie.original_title}
description={movie.overview}
backgroundImage={
"https://image.tmdb.org/t/p/original/" + movie.backdrop_path
}
posterImage={Config.imgPath + movie.poster_path}
onWatchNow={() => onWatchNow(movie.id)} // Pass the navigation function
onWatchTrailer={() => onWatchTrailer(movie.id)}
/>
</SwiperSlide>
))}
</Swiper>
);
};
export default HeaderSwiper;
// FilmSection.tsx
// Core
import React from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Pagination, Navigation } from 'swiper/modules';
import FilmItem from '../../../../components/FilmItem';
import Spinner from '../../../../components/Spinner/Spinner';
import { FilmSectionProps } from '@/Types/Types';
// Component: FilmSection
const FilmSection: React.FC<FilmSectionProps> = ({
title,
viewMoreLink,
mediaType,
data = [], // Khởi tạo data với mảng rỗng nếu không có dữ liệu
isLoading,
}) => {
return (
<div className=" w-full py-6 md:py-0 border-0"> {/* Component: Wrapper */}
<div className="max-w-screen-2xl mx-auto mb-8"> {/* Component: Container */}
<div className="flex items-center justify-between"> {/* Component: Header */}
<span className=" global-text font-bold text-lg md:text-2xl">
{title} {/* Hiển thị tiêu đề ngay cả khi đang tải */}
</span>
{viewMoreLink && ( // Kiểm tra viewMoreLink trước khi render
<a className="btn-sm btn-default btn-more" href={viewMoreLink}> {/* Component: Link */}
<span className='global-text'>Xem thêm</span>
</a>
)}
</div>
<div className="max-w-screen-2xl mx-auto mt-8"> {/* Component: Swiper Container */}
{isLoading ? ( // Kiểm tra nếu đang tải
<div className='py-20'>
<Spinner /> {/* Hiển thị Spinner khi đang tải */}
</div>
) : data.length === 0 ? (
<p className=" global-text">Không có dữ liệu</p> // Hiển thị khi không có dữ liệu
) : (
<Swiper
modules={[Autoplay, Pagination, Navigation]}
spaceBetween={20}
loop={true}
autoplay={{ delay: 2500, disableOnInteraction: false }}
slidesPerView={2}
breakpoints={{
640: { slidesPerView: 2 },
768: { slidesPerView: 4 },
1024: { slidesPerView: 6 },
}}
className="w-full"
>
{data.map((movie) => (
<SwiperSlide key={movie.id}> {/* Component: Slide */}
<FilmItem
id={movie.id} // Core
original_title={movie.original_title}
original_name={movie.original_name}
name={movie.name}
poster_path={movie.poster_path}
media_type={mediaType}
/>
</SwiperSlide>
))}
</Swiper>
)}
</div>
</div>
</div>
);
};
export default FilmSection;
import React, { useState } from "react";
import Image from "next/image";
import {
FaHome,
FaSearch,
FaGamepad,
FaClock,
FaList,
FaDownload,
FaCog,
FaQuestionCircle,
} from "react-icons/fa";
import { Images } from "@/app/assets/images";
const LeftSidebar: React.FC = () => {
const [isSidebarOpen, setIsSidebarOpen] = useState(true); // Start with the sidebar open
const handleLogoClick = () => {
setIsSidebarOpen((prev) => !prev); // Toggle sidebar visibility
};
return (
<aside
className={`transition-transform duration-300 fixed left-0 top-0 bg-sidebarLight dark:bg-sidebarDark block ${
isSidebarOpen ? "w-1/6" : "w-20"
} ${isSidebarOpen ? "z-10" : "z-0"} ${
isSidebarOpen ? "transform-none" : "-translate-x-full"
} lg:relative lg:translate-x-0`}
>
<div className="flex flex-col ">
{/* Logo */}
<div className="flex flex-row items-center h-[8%] w-full p-6 gap-4">
<Image
onClick={handleLogoClick} // Toggle open/close on logo click
src={Images.logo}
alt="Logo"
width={50}
height={50}
/>
{isSidebarOpen && (
<span className="text-sm md:text-md lg:text-3xl text-textLight dark:text-textDark font-semibold tracking-wide">
Meu Movies
</span>
)}
</div>
{/* Menu and Library only visible when sidebar is open */}
<div className={`flex flex-1 flex-col ${isSidebarOpen ? "px-10 py-6 mt-5" : "px-4 py-6 items-center gap-5 mt-5"}`}>
{/* Menu Items */}
<ul className="space-y-6">
<li className="flex items-center space-x-4 group hover:text-red-500">
<FaHome className="left-sidebar-icon" />
{isSidebarOpen && <span className="left-sidebar-text">Home</span>}
</li>
<li className="flex items-center space-x-4 group hover:text-red-500">
<FaSearch className="left-sidebar-icon" />
{isSidebarOpen && <span className="left-sidebar-text">Discovery</span>}
</li>
<li className="flex items-center space-x-4 group hover:text-red-500">
<FaGamepad className="left-sidebar-icon" />
{isSidebarOpen && <span className="left-sidebar-text">Community</span>}
</li>
</ul>
<div className="h-[2px] bg-gray-400 w-full my-6" />
{/* Library Section */}
<ul className="space-y-6">
<li className="flex items-center space-x-4 group hover:text-red-500">
<FaClock className="left-sidebar-icon" />
{isSidebarOpen && <span className="left-sidebar-text">Recent</span>}
</li>
<li className="flex items-center space-x-4 group hover:text-red-500">
<FaList className="left-sidebar-icon" />
{isSidebarOpen && <span className="left-sidebar-text">My List</span>}
</li>
<li className="flex items-center space-x-4 group hover:text-red-500">
<FaDownload className="left-sidebar-icon" />
{isSidebarOpen && <span className="left-sidebar-text">Download</span>}
</li>
</ul>
<div className="h-[2px] bg-gray-400 w-full my-6" />
{/* Settings & Help */}
<ul className="space-y-6">
<li className="flex items-center space-x-4 group hover:text-red-500">
<FaCog className="left-sidebar-icon" />
{isSidebarOpen && <span className="left-sidebar-text">Settings</span>}
</li>
<li className="flex items-center space-x-4 group hover:text-red-500">
<FaQuestionCircle className="left-sidebar-icon" />
{isSidebarOpen && <span className="left-sidebar-text">Help</span>}
</li>
</ul>
</div>
</div>
</aside>
);
};
export default LeftSidebar;
import React from "react";
interface ModalProps {
videoKey: string | null;
onClose: () => void;
}
const Modal: React.FC<ModalProps> = ({ videoKey, onClose }) => {
if (!videoKey) return null;
return (
<div className="fixed top-0 bottom-0 left-0 right-0 bg-black/40 py-16 md:py-64 lg:py-16 z-[60]">
<div className="relative max-w-screen-md bg-black-main h-full z-50 mx-auto p-8">
<iframe
allowFullScreen={true}
src={`https://www.youtube.com/embed/${videoKey}`}
className="w-full h-full"
></iframe>
<svg
onClick={onClose}
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 512 512"
className="absolute top-2 right-2 text-xl text-white cursor-pointer hover:text-red-main"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M405 136.798L375.202 107 256 226.202 136.798 107 107 136.798 226.202 256 107 375.202 136.798 405 256 285.798 375.202 405 405 375.202 285.798 256z"></path>
</svg>
</div>
</div>
);
};
export default Modal;
This diff is collapsed.
// Core: Contains the core components like libraries, routing, and common configuration.
import React, { useState, useEffect } from "react";
import apiClient from '../../services/apiServices/apiServices';
import { useInfiniteQuery } from '@tanstack/react-query';
import Spinner from "../../components/Spinner/Spinner";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
// Component: UI components to display data.
import FilmItem from "../../components/FilmItem";
import { useRouter } from 'next/router'; // Sử dụng useRouter từ next/router
export const MoviesMainView: React.FC = () => {
// States: Manage internal component states like keywords for searching.
const [keyword, setKeyword] = useState<string>('');
const router = useRouter(); // Khởi tạo useRouter
// Get the search term from the URL query parameters
const searchTerm = router.query.keyword as string || ''; // Lấy từ query parameters
// Sync the search term with the input keyword.
useEffect(() => {
setKeyword(searchTerm);
}, [searchTerm]);
// Client: Function to fetch popular films.
const fetchPopularFilm = async ({ pageParam = 1 }) => {
const response = await apiClient.get(`movie/popular?language=en-US&page=${pageParam}`);
return {
results: response.data.results,
nextPage: response.data.page < response.data.total_pages ? response.data.page + 1 : undefined,
};
};
// Queries: Fetch popular movies data using `useInfiniteQuery` for infinite scrolling.
const {
data: popularMoviesData,
fetchNextPage,
hasNextPage: hasNextPagePopular,
isFetching,
isFetchingNextPage,
isLoading, // Indicates if the initial loading is happening
} = useInfiniteQuery({
queryKey: ['popularMovies'],
queryFn: fetchPopularFilm,
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 1,
enabled: searchTerm === '', // Only fetch when search term is empty
});
const movies = popularMoviesData?.pages.flatMap(page => page.results) || [];
// Client: Function to fetch movies based on the search keyword.
const fetchMoviesByKeyword = async ({ pageParam = 1 }) => {
const response = await apiClient.get(`search/movie?query=${searchTerm}&include_adult=false&language=en-US&page=${pageParam}`);
return {
results: response.data.results,
nextPage: response.data.page < response.data.total_pages ? response.data.page + 1 : undefined,
};
};
// Queries: Fetch search results using `useInfiniteQuery` when a search term is provided.
const {
data: searchResultsData,
fetchNextPage: fetchNextPageSearch,
hasNextPage: hasNextPageSearch,
refetch: refetchSearch,
isLoading: isLoadingSearch,
} = useInfiniteQuery({
queryKey: ['searchMovies', searchTerm],
queryFn: fetchMoviesByKeyword,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled: searchTerm.trim() !== '', // Only fetch when search term is not empty
initialPageParam: 1,
});
const searchResults = searchResultsData?.pages.flatMap(page => page.results) || [];
// Methods: Determine what movies to display based on search term.
const moviesToDisplay = searchTerm.trim() ? searchResults : movies;
// Methods: Load more movies depending on whether searching or showing popular movies.
const handleLoadMore = () => {
if (searchTerm.trim()) {
fetchNextPageSearch(); // Load more search results
} else {
fetchNextPage(); // Load more popular movies
}
};
// Methods: Handle the search form submission.
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (keyword.trim()) {
router.push(`/movies/?keyword=${keyword}`); // Cập nhật URL với keyword
refetchSearch(); // Fetch search results
}
};
// Methods: Handle keyword input changes.
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value); // Update local keyword state
// Cập nhật URL nếu input không rỗng
if (e.target.value.trim() === "") {
router.push('/?keyword='); // Nếu không có keyword, xóa keyword khỏi URL
}
};
return (
<main className="w-full flex flex-col items-center justify-start">
<Header/>
{/* App: Layout structure for the main view */}
<div className="relative w-full h-48 bg-gradient-to-b from-white to-black">
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 md:translate-y-0 text-white text-4xl font-bold z-10">
Movies
</span>
</div>
<div className="bg-black-main w-full px-4 md:px-8 py-8 xl:p-16">
<div className="max-w-screen-2xl mx-auto">
{/* Component: Search form */}
<form
className="flex items-center relative rounded-full bg-black w-full md:w-fit lg:w-fit"
onSubmit={handleSearch}
>
<input
type="text"
placeholder="Enter keyword"
name="keyword"
value={keyword}
onChange={handleKeywordChange}
className="outline-none border-none rounded-full px-6 py-2 bg-black placeholder-gray-500 text-white flex-1 md:flex-auto md:w-96"
/>
<button
type="submit"
className="btn-primary py-2 px-8 text-white rounded-full"
>
Search
</button>
</form>
{/* Component: Conditional content display */}
{moviesToDisplay.length === 0 && (isLoading || isFetching || isLoadingSearch) ? (
<div className="flex flex-row items-center justify-center text-center text-white h-[50vh] gap-10">
<Spinner />
<p className="text-xl md:text-2xl text-opacity-50">Loading...</p>
</div>
) : moviesToDisplay.length === 0 && !isFetching ? (
<div className="flex items-center justify-center text-center h-[50vh]">
<p className="text-xl md:text-2xl text-white text-opacity-50">No results</p>
</div>
) : (
<>
{/* Component: Movie list grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-16">
{moviesToDisplay.map((movie) => (
<FilmItem
key={movie.id}
id={movie.id}
original_title={movie.original_title}
original_name={movie.original_name}
name={movie.name}
media_type="movie"
poster_path={movie.poster_path}
className="w-full"
/>
))}
</div>
{/* Component: Load more button */}
<div className="text-center mt-8">
{(isFetching || isFetchingNextPage) ? (
<div className="h-[20vh] flex justify-center items-center flex-row gap-5">
<Spinner />
</div>
) : (
(searchTerm.trim() ? hasNextPageSearch : hasNextPagePopular) ? (
<button
onClick={handleLoadMore}
className="btn-sm btn-default"
>
Watch more
</button>
) : (
<div>
{/* No more results */}
<p className="text-xl text-white">No more results</p>
</div>
)
)}
</div>
</>
)}
</div>
</div>
<Footer/>
</main>
);
};
export default MoviesMainView;
import React, { useEffect, useState } from 'react';
import FilmItem from '../../components/FilmItem';
import apiClient from '../../services/apiServices/apiServices';
import { useInfiniteQuery } from '@tanstack/react-query';
import Spinner from "../../components/Spinner/Spinner";
import { useRouter } from 'next/router';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
const TVMainView: React.FC = () => {
const router = useRouter();
const initialSearchTerm = typeof router.query.query === 'string' ? router.query.query : '';
const [keyword, setKeyword] = useState<string>(initialSearchTerm);
const [searchTerm, setSearchTerm] = useState<string>(initialSearchTerm);
const fetchPopularTV = async ({ pageParam = 1 }) => {
const response = await apiClient.get(`tv/popular?language=en-US&page=${pageParam}`);
return {
results: response.data.results,
nextPage: response.data.page < response.data.total_pages ? response.data.page + 1 : undefined,
};
};
const {
data: popularTVData,
fetchNextPage,
hasNextPage: hasNextPagePopular,
isFetching,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['popularTV'],
queryFn: fetchPopularTV,
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 1,
enabled: searchTerm === '',
});
const tvSeries = popularTVData?.pages.flatMap(page => page.results) || [];
const fetchTVByKeyword = async ({ pageParam = 1 }) => {
const response = await apiClient.get(`search/tv?query=${searchTerm}&include_adult=false&language=en-US&page=${pageParam}`);
return {
results: response.data.results,
nextPage: response.data.page < response.data.total_pages ? response.data.page + 1 : undefined,
};
};
const {
data: searchResultsData,
fetchNextPage: fetchNextPageSearch,
hasNextPage: hasNextPageSearch,
refetch: refetchSearch,
isLoading: isLoadingSearch,
} = useInfiniteQuery({
queryKey: ['searchTV', searchTerm],
queryFn: fetchTVByKeyword,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled: searchTerm.trim() !== '',
initialPageParam: 1,
});
const searchResults = searchResultsData?.pages.flatMap(page => page.results) || [];
const tvSeriesToDisplay = searchTerm.trim() ? searchResults : tvSeries;
const handleLoadMore = () => {
if (searchTerm.trim()) {
fetchNextPageSearch();
} else {
fetchNextPage();
}
};
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
router.push({ query: { query: keyword } });
setSearchTerm(keyword);
if (keyword.trim()) {
refetchSearch();
}
};
const resetSearch = () => {
setSearchTerm('');
router.push({ query: {} });
};
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value);
if (e.target.value.trim() === "") {
resetSearch();
}
};
// Effect: Update the search term from URL and keyword state
useEffect(() => {
setKeyword(initialSearchTerm); // Cập nhật từ khóa mỗi khi URL thay đổi
setSearchTerm(initialSearchTerm);
if (initialSearchTerm) {
refetchSearch();
}
}, [initialSearchTerm, refetchSearch]);
return (
<main className='w-full flex flex-col items-center justify-start'>
<Header/>
<div className="relative w-full h-48 bg-gradient-to-b from-white to-black">
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 md:translate-y-0 text-white text-4xl font-bold z-10">TV Series</span>
</div>
<div className="bg-black-main w-full px-4 md:px-8 py-8 xl:p-16">
<div className='max-w-screen-2xl mx-auto'>
<form className="flex items-center relative rounded-full bg-black w-full md:w-fit lg:w-fit" onSubmit={handleSearch}>
<input
type="text"
placeholder="Enter keyword"
value={keyword}
onChange={handleKeywordChange}
className="outline-none border-none rounded-full px-6 py-2 bg-black placeholder-gray-500 text-white flex-1 md:w-96"
/>
<button type="submit" className="btn-primary py-2 px-8 text-white rounded-full">Search</button>
</form>
{tvSeriesToDisplay.length === 0 && (isLoading || isFetching || isLoadingSearch) ? (
<div className="flex flex-row items-center justify-center text-center text-white h-[50vh] gap-10">
<Spinner />
<p className="text-xl md:text-2xl text-opacity-50">Loading TV series, please wait...</p>
</div>
) : tvSeriesToDisplay.length === 0 && !isFetching ? (
<div className="flex items-center justify-center text-center h-[50vh]">
<p className="text-xl md:text-2xl text-white text-opacity-50">No TV series found matching your criteria.</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-16">
{tvSeriesToDisplay.map((tv) => (
<FilmItem
key={tv.id}
id={tv.id}
original_title={tv.original_title}
original_name={tv.original_name}
name={tv.name}
media_type="tv"
poster_path={tv.poster_path}
className="w-full"
/>
))}
</div>
<div className="text-center mt-8">
{(isFetching || isFetchingNextPage) ? (
<div className="h-[20vh] flex justify-center items-center">
<Spinner />
</div>
) : (
(searchTerm.trim() ? hasNextPageSearch : hasNextPagePopular) ? (
<button onClick={handleLoadMore} className="btn-sm btn-default">
Load More
</button>
) : (
<div>
{/* No more results to load */}
</div>
)
)}
</div>
</>
)}
</div>
</div>
<Footer/>
</main>
);
};
export default TVMainView;
// pages/[media_type]/[id].tsx
import { useRouter } from 'next/router';
import MovieDetailMainView from '../MovieDetail/MovieDetailMainView';
const MovieDetailPage = () => {
const router = useRouter();
if (router.isFallback || !router.isReady) {
return <p>Loading...</p>;
}
return (
<MovieDetailMainView />
);
};
export default MovieDetailPage;
// pages/_app.tsx
import "../app/globals.css";
import type { AppProps } from "next/app";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function MyApp({ Component, pageProps }: AppProps) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<div className="h-screen w-full p-0 m-0 ">
<Component {...pageProps} />
</div>
</QueryClientProvider>
);
}
export default MyApp;
import HomeMainView from "./Home/HomeMainView";
const Home = () => (
<HomeMainView/>
)
export default Home;
\ No newline at end of file
import MoviesMainView from "./Movies/MoviesMainView";
const movies = () => (
<MoviesMainView/>
)
export default movies;
\ No newline at end of file
import TVMainView from "./TVSeries/TVSeriesMainView";
const tvseries = () => (
<TVMainView/>
)
export default tvseries;
\ No newline at end of file
This diff is collapsed.
import axios from 'axios';
// const API_KEY = import.meta.env.VITE_API_KEY;
const API_KEY = "2f30a285847bcb6a4f1befca239cfc20";
// const API_TOKEN = import.meta.env.VITE_API_TOKEN;
const API_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIyZjMwYTI4NTg0N2JjYjZhNGYxYmVmY2EyMzljZmMyMCIsIm5iZiI6MTcyNzE3MDMwOC4wNjgzMDEsInN1YiI6IjY2ZjIyYTc2ZGUyZDUyZGZiZDhkNjJmNSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.LsbQe7RIJpuLjBGl5huNheOodieA0REwmOXzgLus7JM";
const apiClient = axios.create({
baseURL: "https://api.themoviedb.org/3",
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_TOKEN}`,
},
params: {
api_key: API_KEY,
},
timeout: 10000,
});
// Thêm interceptor để xử lý lỗi và logging
apiClient.interceptors.response.use(
(response) => {
// Xử lý response
return response;
},
(error) => {
// Xử lý lỗi (ví dụ: refresh token hoặc logging)
if (error.response) {
console.error('API Error:', error.response.status, error.response.data);
} else {
console.error('Network Error:', error.message);
}
return Promise.reject(error);
}
);
export default apiClient;
/* styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
font-family: 'Open Sans', sans-serif;
background-color: #000; /* Màu nền đen */
color: #fff; /* Màu chữ trắng */
}
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/pages/Home/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/FilmSlide/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
mainLight: '#FFFFFF', // Màu cho main lúc light mode
sidebarLight: '#F4F2F2', // Màu cho sidebar lúc light mode
textLight: '#000000',
mainDark: '#000000', // Màu cho main lúc dark mode
sidebarDark: '#1E1E1E', // Màu cho sidebar lúc dark mode
textDark: '#F4F2F2',
mainRed:'#A81A4B'
},
},
},
plugins: [],
};
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
};
export default config;
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment