diff --git a/next-netflix/.gitignore b/next-netflix/.gitignore
new file mode 100644
index 0000000..64e4035
--- /dev/null
+++ b/next-netflix/.gitignore
@@ -0,0 +1,42 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+.env
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/next-netflix/.prettierignore b/next-netflix/.prettierignore
new file mode 100644
index 0000000..b1f23eb
--- /dev/null
+++ b/next-netflix/.prettierignore
@@ -0,0 +1,12 @@
+.next
+node_modules
+out
+build
+dist
+public
+*.config.js
+*.config.mjs
+next-env.d.ts
+package-lock.json
+yarn.lock
+pnpm-lock.yaml
diff --git a/next-netflix/.prettierrc b/next-netflix/.prettierrc
new file mode 100644
index 0000000..5414ccd
--- /dev/null
+++ b/next-netflix/.prettierrc
@@ -0,0 +1,8 @@
+{
+ "semi": true,
+ "singleQuote": false,
+ "tabWidth": 2,
+ "trailingComma": "es5",
+ "printWidth": 100,
+ "arrowParens": "always"
+}
diff --git a/next-netflix/.vscode/settings.json b/next-netflix/.vscode/settings.json
new file mode 100644
index 0000000..3ec35d4
--- /dev/null
+++ b/next-netflix/.vscode/settings.json
@@ -0,0 +1,22 @@
+{
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": "explicit"
+ },
+ "[javascript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[javascriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[typescript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[json]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ }
+}
diff --git a/next-netflix/README.md b/next-netflix/README.md
new file mode 100644
index 0000000..e215bc4
--- /dev/null
+++ b/next-netflix/README.md
@@ -0,0 +1,36 @@
+This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
diff --git a/next-netflix/app/api/tmdb/[...path]/route.ts b/next-netflix/app/api/tmdb/[...path]/route.ts
new file mode 100644
index 0000000..fd276cb
--- /dev/null
+++ b/next-netflix/app/api/tmdb/[...path]/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from "next/server";
+
+const API_BASE = "https://api.themoviedb.org/3";
+
+export async function GET(req: Request, { params }: { params: Promise<{ path: string[] }> }) {
+ const token = process.env.TMDB_API_KEY;
+ if (!token) {
+ return NextResponse.json({ message: "TMDB_API_KEY missing" }, { status: 500 });
+ }
+
+ const { searchParams } = new URL(req.url);
+ const { path } = await params;
+ const joined = (path ?? []).join("/");
+ const target = `${API_BASE}/${joined}?${searchParams.toString()}`;
+
+ const res = await fetch(target, {
+ headers: { accept: "application/json", Authorization: `Bearer ${token}` },
+ cache: "no-store",
+ });
+
+ return new NextResponse(await res.text(), {
+ status: res.status,
+ headers: {
+ "content-type": res.headers.get("content-type") ?? "application/json",
+ },
+ });
+}
diff --git a/next-netflix/app/detail/[id]/page.tsx b/next-netflix/app/detail/[id]/page.tsx
new file mode 100644
index 0000000..e2ceb2b
--- /dev/null
+++ b/next-netflix/app/detail/[id]/page.tsx
@@ -0,0 +1,5 @@
+import Detail from "@/components/features/Detail";
+
+export default function Page() {
+ return ;
+}
diff --git a/next-netflix/app/favicon.ico b/next-netflix/app/favicon.ico
new file mode 100644
index 0000000..718d6fe
Binary files /dev/null and b/next-netflix/app/favicon.ico differ
diff --git a/next-netflix/app/globals.css b/next-netflix/app/globals.css
new file mode 100644
index 0000000..a2dc41e
--- /dev/null
+++ b/next-netflix/app/globals.css
@@ -0,0 +1,26 @@
+@import "tailwindcss";
+
+:root {
+ --background: #ffffff;
+ --foreground: #171717;
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --background: #0a0a0a;
+ --foreground: #ededed;
+ }
+}
+
+body {
+ background: var(--background);
+ color: var(--foreground);
+ font-family: Arial, Helvetica, sans-serif;
+}
diff --git a/next-netflix/app/home/page.tsx b/next-netflix/app/home/page.tsx
new file mode 100644
index 0000000..434f65b
--- /dev/null
+++ b/next-netflix/app/home/page.tsx
@@ -0,0 +1,25 @@
+import Previews from "../../components/features/Home/Previews";
+import Thumb from "../../components/features/Home/Thumb";
+import Continue from "../../components/features/Home/Continue";
+import { getTrendingMoviesDay, getPopularMovies, getTopRatedMovies } from "@/lib/tmdbServer";
+import Popular from "@/components/features/Home/Popular";
+
+export default async function Home() {
+ // SSR: 서버에서 데이터 페칭
+ const [trendingData, popularData, topRatedData] = await Promise.all([
+ getTrendingMoviesDay("en-US"),
+ getPopularMovies("en-US"),
+ getTopRatedMovies("en-US"),
+ ]);
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/next-netflix/app/layout.tsx b/next-netflix/app/layout.tsx
new file mode 100644
index 0000000..f3ae6b3
--- /dev/null
+++ b/next-netflix/app/layout.tsx
@@ -0,0 +1,39 @@
+import "./globals.css";
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import { TabBar } from "@/components/features/TabBar";
+import { NavBar } from "@/components/features/NavBar";
+import Providers from "./providers";
+
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+export const metadata: Metadata = {
+ title: "Create Next App",
+ description: "Generated by create next app",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/next-netflix/app/page.tsx b/next-netflix/app/page.tsx
new file mode 100644
index 0000000..96cadf7
--- /dev/null
+++ b/next-netflix/app/page.tsx
@@ -0,0 +1,5 @@
+import LandingPageComponent from "@/components/features/landingPage";
+
+export default function LandingPage() {
+ return ;
+}
diff --git a/next-netflix/app/providers.tsx b/next-netflix/app/providers.tsx
new file mode 100644
index 0000000..293a077
--- /dev/null
+++ b/next-netflix/app/providers.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useState, type ReactNode } from "react";
+
+export default function Providers({ children }: { children: ReactNode }) {
+ const [client] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 60_000, // 1분 fresh
+ refetchOnWindowFocus: false, // 포커스될 때 재요청 방지
+ },
+ },
+ })
+ );
+ return {children};
+}
diff --git a/next-netflix/app/search/page.tsx b/next-netflix/app/search/page.tsx
new file mode 100644
index 0000000..2ae7f15
--- /dev/null
+++ b/next-netflix/app/search/page.tsx
@@ -0,0 +1,216 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import Image from "next/image";
+import { useSearchMovies } from "@/hooks/useSearchMovies";
+import { useTopRatedMovies } from "@/hooks/useTopRatedMovies";
+import { tmdbImage } from "@/lib/tmdbImage";
+import SearchIcon from "@/images/searchPage/search.svg";
+import DeleteIcon from "@/images/searchPage/delete.svg";
+import PlayIcon from "@/images/searchPage/play.svg";
+
+export default function SearchPage() {
+ const [query, setQuery] = useState("");
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useSearchMovies(query);
+ const {
+ data: topRatedData,
+ isLoading: topRatedLoading,
+ fetchNextPage: fetchNextTopRated,
+ hasNextPage: hasNextTopRated,
+ isFetchingNextPage: isFetchingNextTopRated
+ } = useTopRatedMovies();
+ const results = data?.pages.flatMap((page) => page.results) ?? [];
+ const topSearches = topRatedData?.pages.flatMap((page) => page.results) ?? [];
+
+ const observerTarget = useRef(null);
+ const topSearchObserverTarget = useRef(null);
+
+ // 검색 결과 무한 스크롤
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ },
+ {
+ threshold: 0,
+ rootMargin: "200px", // 화면 하단 200px 전에 미리 로드
+ }
+ );
+
+ if (observerTarget.current) {
+ observer.observe(observerTarget.current);
+ }
+
+ return () => observer.disconnect();
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ // Top Searches 무한 스크롤
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && hasNextTopRated && !isFetchingNextTopRated) {
+ fetchNextTopRated();
+ }
+ },
+ {
+ threshold: 0,
+ rootMargin: "200px",
+ }
+ );
+
+ if (topSearchObserverTarget.current) {
+ observer.observe(topSearchObserverTarget.current);
+ }
+
+ return () => observer.disconnect();
+ }, [hasNextTopRated, isFetchingNextTopRated, fetchNextTopRated]);
+
+ return (
+
+ {/* 검색창 */}
+
+
+ setQuery(e.target.value)}
+ autoFocus
+ />
+
+
+
+ {/* 검색 결과 또는 Top Searches */}
+
+ {!query ? (
+ // Top Searches
+
+
Top Searches
+
+ {topRatedLoading
+ ? Array.from({ length: 5 }).map((_, i) => (
+
+ ))
+ : topSearches.map((movie) => {
+ const src = tmdbImage(movie.backdrop_path || movie.poster_path, "w780");
+ return (
+
+
+ {src && (
+
+ )}
+
+
+
+ );
+ })}
+
+ {/* Top Searches Infinite scroll trigger */}
+
+
+ {/* Loading more skeleton */}
+ {isFetchingNextTopRated && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+
+ ) : (
+ // 검색 결과
+
+ {isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : results.length > 0 ? (
+
+ {results.map((movie) => {
+ const src = tmdbImage(movie.backdrop_path || movie.poster_path, "w780");
+ return (
+
+
+ {src ? (
+
+ ) : (
+
+ )}
+
+
+
+ {movie.title}
+
+
+
+
+ );
+ })}
+
+ {/* Infinite scroll trigger */}
+
+
+ {/* Loading more skeleton */}
+ {isFetchingNextPage && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ ) : null}
+
+ )}
+
+
+ );
+}
diff --git a/next-netflix/components/features/Detail.tsx b/next-netflix/components/features/Detail.tsx
new file mode 100644
index 0000000..b6c2d0a
--- /dev/null
+++ b/next-netflix/components/features/Detail.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import Image from "next/image";
+import { tmdbImage } from "@/lib/tmdbImage";
+
+export default function Detail() {
+ const sp = useSearchParams();
+
+ const poster_path = sp.get("poster_path") ?? "";
+ const backdrop_path = sp.get("backdrop_path") ?? "";
+ const overview = sp.get("overview") ?? "";
+
+ const poster = poster_path ? tmdbImage(poster_path, "w500") : "";
+
+ const src = tmdbImage(backdrop_path, "w1280") || tmdbImage(poster_path, "w780");
+
+ return (
+
+
+ {poster && (
+
+ )}
+
+
+
+
+ Previews
+
+ {overview && (
+
+ {overview}
+
+ )}
+
+ );
+}
diff --git a/next-netflix/components/features/Home/Continue.tsx b/next-netflix/components/features/Home/Continue.tsx
new file mode 100644
index 0000000..f5f7121
--- /dev/null
+++ b/next-netflix/components/features/Home/Continue.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import Image from "next/image";
+import { tmdbImage } from "@/lib/tmdbImage";
+import fallbackPoster from "@/public/fallback.svg";
+import type { TMDBMovie } from "@/types/tmdb";
+import Link from "next/link";
+
+interface ContinueProps {
+ movies: TMDBMovie[];
+}
+
+export default function Continue({ movies }: ContinueProps) {
+ const items = movies;
+
+ return (
+
+ Continue Watching for Hammoo
+
+
+ {items.map((m) => {
+ const src =
+ tmdbImage(m.poster_path, "w342") ||
+ tmdbImage(m.backdrop_path, "w342") ||
+ fallbackPoster.src;
+
+ const href = {
+ pathname: `/detail/${m.id}`,
+ query: {
+ title: m.title ?? "",
+ poster_path: m.poster_path ?? "",
+ backdrop_path: m.backdrop_path ?? "",
+ overview: m.overview ?? "",
+ release_date: m.release_date ?? "",
+ original_language: m.original_language ?? "",
+ },
+ } as const;
+
+ return (
+
+ {
+ const img = e.currentTarget as HTMLImageElement;
+ if (img.src !== fallbackPoster.src) img.src = fallbackPoster.src;
+ }}
+ />
+
+ );
+ })}
+
+
+ );
+}
diff --git a/next-netflix/components/features/Home/Home.tsx b/next-netflix/components/features/Home/Home.tsx
new file mode 100644
index 0000000..c8d79eb
--- /dev/null
+++ b/next-netflix/components/features/Home/Home.tsx
@@ -0,0 +1,9 @@
+import { TabBar } from "../TabBar";
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/next-netflix/components/features/Home/Popular.tsx b/next-netflix/components/features/Home/Popular.tsx
new file mode 100644
index 0000000..2a56224
--- /dev/null
+++ b/next-netflix/components/features/Home/Popular.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import Image from "next/image";
+import { tmdbImage } from "@/lib/tmdbImage";
+import fallbackPoster from "@/public/fallback.svg";
+import type { TMDBMovie } from "@/types/tmdb";
+import Link from "next/link";
+
+interface PopularProps {
+ movies: TMDBMovie[];
+}
+
+export default function Popular({ movies }: PopularProps) {
+ const items = movies;
+
+ return (
+
+ Popular on Netflix
+
+
+ {items.map((m) => {
+ const src =
+ tmdbImage(m.poster_path, "w342") ||
+ tmdbImage(m.backdrop_path, "w342") ||
+ fallbackPoster.src;
+
+ const href = {
+ pathname: `/detail/${m.id}`,
+ query: {
+ title: m.title ?? "",
+ poster_path: m.poster_path ?? "",
+ backdrop_path: m.backdrop_path ?? "",
+ overview: m.overview ?? "",
+ release_date: m.release_date ?? "",
+ original_language: m.original_language ?? "",
+ },
+ } as const;
+
+ return (
+
+ {
+ const img = e.currentTarget as HTMLImageElement;
+ if (img.src !== fallbackPoster.src) img.src = fallbackPoster.src;
+ }}
+ />
+
+ );
+ })}
+
+
+ );
+}
diff --git a/next-netflix/components/features/Home/Previews.tsx b/next-netflix/components/features/Home/Previews.tsx
new file mode 100644
index 0000000..fce27f9
--- /dev/null
+++ b/next-netflix/components/features/Home/Previews.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { tmdbImage } from "@/lib/tmdbImage";
+import type { TMDBMovie } from "@/types/tmdb";
+
+interface PreviewsProps {
+ movies: TMDBMovie[];
+}
+
+export default function Previews({ movies }: PreviewsProps) {
+ const previews = movies ?? [];
+
+ return (
+
+ Previews
+
+
+ {previews.map((m) => {
+ const src = tmdbImage(m.poster_path, "w342") || tmdbImage(m.backdrop_path, "w342");
+
+ const href = {
+ pathname: `/detail/${m.id}`,
+ query: {
+ title: m.title ?? "",
+ poster_path: m.poster_path ?? "",
+ backdrop_path: m.backdrop_path ?? "",
+ overview: m.overview ?? "",
+ release_date: m.release_date ?? "",
+ original_language: m.original_language ?? "",
+ },
+ } as const;
+
+ return (
+
+
+ {src && (
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/next-netflix/components/features/Home/Thumb.tsx b/next-netflix/components/features/Home/Thumb.tsx
new file mode 100644
index 0000000..7a33076
--- /dev/null
+++ b/next-netflix/components/features/Home/Thumb.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import Image from "next/image";
+import { tmdbImage } from "@/lib/tmdbImage";
+import InfoIcon from "@/images/info.svg";
+import PlusIcon from "@/images/plus.svg";
+import Top10Icon from "@/images/top10.svg";
+import type { TMDBMovie } from "@/types/tmdb";
+
+interface ThumbProps {
+ movies: TMDBMovie[];
+}
+
+export default function Thumb({ movies }: ThumbProps) {
+ const m = movies?.[0];
+ const src = tmdbImage(m?.backdrop_path, "w1280") || tmdbImage(m?.poster_path, "w780");
+
+ return (
+
+
+ {src ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ #2 in Korea Today
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/next-netflix/components/features/NavBar.tsx b/next-netflix/components/features/NavBar.tsx
new file mode 100644
index 0000000..e217bf9
--- /dev/null
+++ b/next-netflix/components/features/NavBar.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import NetflixIcon from "@/images/navBar/netflixIcon.svg";
+
+type MenuItem = {
+ href: string;
+ label: string;
+};
+
+const menuItems: ReadonlyArray