(null);
+ const [logoState, setLogoState] = useState<"image" | "lottie" | "finished">(
+ "image",
+ );
+
+ const [isImageFading, setIsImageFading] = useState(false); // 1. 이미지 사라짐 상태
+ const [isLottieFading, setIsLottieFading] = useState(false); // 2. 로티 사라짐 상태
+
+ useEffect(() => {
+ const imageTimer = window.setTimeout(() => {
+ setIsImageFading(true);
+ }, 700);
+
+ const lottieTimer = window.setTimeout(() => {
+ setLogoState("lottie");
+ }, 1200);
+
+ const fallbackTimer = window.setTimeout(() => {
+ router.replace("/main");
+ }, 4200);
+
+ return () => {
+ window.clearTimeout(imageTimer);
+ window.clearTimeout(lottieTimer);
+ window.clearTimeout(fallbackTimer);
+ };
+ }, [router]);
+
+ // 로티 재생 및 종료 제어
+ useEffect(() => {
+ if (!dotLottie || logoState !== "lottie") return;
+
+ const handleComplete = () => {
+ setIsLottieFading(true);
+ setTimeout(() => {
+ setLogoState("finished");
+ router.replace("/main");
+ }, 1000); // 로티 페이드아웃 시간
+ };
+
+ dotLottie.addEventListener("complete", handleComplete);
+ dotLottie.play();
+
+ return () => {
+ dotLottie.removeEventListener("complete", handleComplete);
+ };
+ }, [dotLottie, logoState, router]);
+
+ const dotLottieRefCallback = (instance: DotLottie) => {
+ setDotLottie(instance);
+ };
+
+ if (logoState === "finished") return null;
+
+ return (
+
+ {/* 1. 이미지 섹션 */}
+ {logoState === "image" && (
+
+
+
+ )}
+
+ {/* 2. 로티 섹션 */}
+ {logoState === "lottie" && (
+
+ )}
+
+ );
+}
diff --git a/src/components/Search/SearchHeader.tsx b/src/components/Search/SearchHeader.tsx
new file mode 100644
index 0000000..fd8c31f
--- /dev/null
+++ b/src/components/Search/SearchHeader.tsx
@@ -0,0 +1,56 @@
+import Image from "next/image";
+
+type SearchHeaderProps = {
+ value: string;
+ onChange: (value: string) => void;
+ onClear: () => void;
+};
+
+export default function SearchHeader({
+ value,
+ onChange,
+ onClear,
+}: SearchHeaderProps) {
+ return (
+ <>
+
+
+
+
+
+ onChange(event.target.value)}
+ placeholder="Search for a show, movie, genre, e.t.c."
+ className="ml-5 flex h-[31px] min-w-0 flex-1 appearance-none items-center bg-transparent font-[Pretendard] text-[15px] leading-[135%] font-normal tracking-[-0.3px] text-white outline-none placeholder:font-normal placeholder:text-grey-600 [font-feature-settings:'liga'_off,'clig'_off]"
+ />
+
+
+ >
+ );
+}
diff --git a/src/components/Search/SearchPageContent.tsx b/src/components/Search/SearchPageContent.tsx
new file mode 100644
index 0000000..38f6259
--- /dev/null
+++ b/src/components/Search/SearchPageContent.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import type { TmdbTrendingItem } from "@/types/tmdb";
+import SearchHeader from "./SearchHeader";
+import SearchResults from "./SearchResults";
+
+type SearchPageContentProps = {
+ topSearches: TmdbTrendingItem[];
+};
+
+type SearchResponse = {
+ results?: TmdbTrendingItem[];
+};
+
+export default function SearchPageContent({
+ topSearches,
+}: SearchPageContentProps) {
+ const [query, setQuery] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const trimmedQuery = useMemo(() => query.trim(), [query]);
+ const isSearching = trimmedQuery.length > 0;
+ const items = isSearching ? searchResults : topSearches;
+
+ useEffect(() => {
+ if (!isSearching) {
+ return;
+ }
+
+ const controller = new AbortController();
+ const timer = window.setTimeout(async () => {
+ setIsLoading(true);
+
+ try {
+ const response = await fetch(
+ `/api/search?query=${encodeURIComponent(trimmedQuery)}`,
+ { signal: controller.signal },
+ );
+
+ if (!response.ok) {
+ setSearchResults([]);
+ return;
+ }
+
+ const data = (await response.json()) as SearchResponse;
+ setSearchResults(data.results ?? []);
+ } catch {
+ if (!controller.signal.aborted) {
+ setSearchResults([]);
+ }
+ } finally {
+ if (!controller.signal.aborted) {
+ setIsLoading(false);
+ }
+ }
+ }, 300);
+
+ return () => {
+ window.clearTimeout(timer);
+ controller.abort();
+ };
+ }, [isSearching, topSearches, trimmedQuery]);
+
+ return (
+
+ setQuery("")}
+ />
+
+
+
+ {isSearching ? "Search Results" : "Top Searches"}
+
+
+
+
+ );
+}
diff --git a/src/components/Search/SearchResultItem.tsx b/src/components/Search/SearchResultItem.tsx
new file mode 100644
index 0000000..8f82350
--- /dev/null
+++ b/src/components/Search/SearchResultItem.tsx
@@ -0,0 +1,59 @@
+import Image from "next/image";
+import DetailLink from "@/components/Detail/DetailLink";
+import type { TmdbTrendingItem } from "@/types/tmdb";
+import {
+ getTmdbImagePath,
+ getTmdbImageUrl,
+ getTmdbTitle,
+} from "@/utils/tmdb";
+
+type SearchResultItemProps = {
+ item: TmdbTrendingItem;
+};
+
+export default function SearchResultItem({ item }: SearchResultItemProps) {
+ const imagePath = getTmdbImagePath(item);
+ const title = getTmdbTitle(item);
+
+ return (
+
+
+
+ {imagePath ? (
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/src/components/Search/SearchResults.tsx b/src/components/Search/SearchResults.tsx
new file mode 100644
index 0000000..b6c6635
--- /dev/null
+++ b/src/components/Search/SearchResults.tsx
@@ -0,0 +1,30 @@
+import type { TmdbTrendingItem } from "@/types/tmdb";
+import SearchResultItem from "./SearchResultItem";
+
+type SearchResultsProps = {
+ items: TmdbTrendingItem[];
+ emptyMessage?: string;
+ isLoading?: boolean;
+};
+
+export default function SearchResults({
+ items,
+ emptyMessage = "검색 결과가 없습니다.",
+ isLoading = false,
+}: SearchResultsProps) {
+ if (isLoading) {
+ return 검색 중...
;
+ }
+
+ if (items.length === 0) {
+ return {emptyMessage}
;
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/common/.gitkeep b/src/components/common/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/components/common/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/constants/.gitkeep b/src/constants/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/constants/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/data/.gitkeep b/src/data/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/data/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/hooks/.gitkeep b/src/hooks/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/hooks/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/store/.gitkeep b/src/store/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/store/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/store/myList.ts b/src/store/myList.ts
new file mode 100644
index 0000000..4c8e760
--- /dev/null
+++ b/src/store/myList.ts
@@ -0,0 +1,43 @@
+"use client";
+
+import { Movie } from "@/types/movie";
+
+const KEY = "myList";
+const EMPTY_LIST: Movie[] = [];
+
+let cachedRaw: string | null = null;
+let cachedList: Movie[] = EMPTY_LIST;
+
+export function getMyList(): Movie[] {
+ if (typeof window === "undefined") return EMPTY_LIST;
+
+ const raw = localStorage.getItem(KEY) ?? "[]";
+
+ if (raw === cachedRaw) {
+ return cachedList;
+ }
+
+ try {
+ cachedRaw = raw;
+ cachedList = JSON.parse(raw);
+ return cachedList;
+ } catch {
+ cachedRaw = raw;
+ cachedList = EMPTY_LIST;
+ return cachedList;
+ }
+}
+
+export const MY_LIST_EVENT = "mylist-update";
+
+export function toggleMyList(movie: Movie): void {
+ const list = getMyList();
+ const exists = list.some((m) => m.id === movie.id);
+ const next = exists ? list.filter((m) => m.id !== movie.id) : [...list, movie];
+ localStorage.setItem(KEY, JSON.stringify(next));
+ window.dispatchEvent(new Event(MY_LIST_EVENT));
+}
+
+export function isInMyList(id: number): boolean {
+ return getMyList().some((m) => m.id === id);
+}
diff --git a/src/styles/colors.css b/src/styles/colors.css
new file mode 100644
index 0000000..9e34bfc
--- /dev/null
+++ b/src/styles/colors.css
@@ -0,0 +1,17 @@
+@theme {
+ --color-black: #000000;
+ --color-white: #ffffff;
+ --color-grey-900: #121212;
+ --color-grey-800: #424242;
+ --color-grey-700: #8c8787;
+ --color-grey-600: #c4c4c4;
+}
+
+:root {
+ --gradient-linear: linear-gradient(
+ 180deg,
+ rgb(0 0 0 / 45%) 0%,
+ rgb(0 0 0 / 0%) 87%,
+ rgb(0 0 0 / 100%) 100%
+ );
+}
diff --git a/src/styles/fonts.css b/src/styles/fonts.css
new file mode 100644
index 0000000..52c2180
--- /dev/null
+++ b/src/styles/fonts.css
@@ -0,0 +1,21 @@
+@theme {
+ --font-pretendard:
+ "Pretendard", -apple-system, blinkmacsystemfont, system-ui, "Segoe UI",
+ sans-serif;
+
+ --text-heading-1: 24px;
+ --text-heading-2: 20px;
+ --text-body-1: 18px;
+ --text-body-2: 16px;
+ --text-label-1: 20px;
+ --text-label-2: 15px;
+ --text-caption-1: 12px;
+ --text-caption-2: 10px;
+
+ --font-weight-regular: 400;
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+
+ --leading-design: 135%;
+ --tracking-design: -0.02em;
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
new file mode 100644
index 0000000..d9faf9b
--- /dev/null
+++ b/src/styles/globals.css
@@ -0,0 +1,158 @@
+@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css");
+@import "tailwindcss";
+@import "./colors.css";
+@import "./fonts.css";
+
+@layer base {
+ * {
+ box-sizing: border-box;
+ }
+
+ html,
+ body {
+ min-height: 100%;
+ margin: 0;
+ color: var(--color-white);
+ font-family: var(--font-pretendard);
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ html::-webkit-scrollbar,
+ body::-webkit-scrollbar {
+ display: none;
+ }
+
+ * {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ *::-webkit-scrollbar {
+ display: none;
+ }
+
+ body {
+ display: flex;
+ justify-content: center;
+ background: #f2f2f2;
+ }
+
+ button,
+ input,
+ textarea,
+ select {
+ font: inherit;
+ }
+
+ button {
+ border: 0;
+ cursor: pointer;
+ }
+
+ a {
+ color: inherit;
+ text-decoration: none;
+ }
+
+ img,
+ picture,
+ video,
+ canvas,
+ svg {
+ display: block;
+ max-width: 100%;
+ }
+
+ #app-frame {
+ position: relative;
+ width: 375px;
+ height: 812px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ background: var(--color-black);
+ }
+
+ #app-content {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-x: hidden;
+ }
+}
+
+@layer utilities {
+ .no-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+
+ .scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ .scrollbar-hide::-webkit-scrollbar {
+ display: none;
+ }
+
+ .text-heading-1 {
+ font-size: var(--text-heading-1);
+ font-weight: var(--font-weight-semibold);
+ line-height: var(--leading-design);
+ letter-spacing: var(--tracking-design);
+ }
+
+ .text-heading-2 {
+ font-size: var(--text-heading-2);
+ font-weight: var(--font-weight-semibold);
+ line-height: var(--leading-design);
+ letter-spacing: var(--tracking-design);
+ }
+
+ .text-body-1 {
+ font-size: var(--text-body-1);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--leading-design);
+ letter-spacing: var(--tracking-design);
+ }
+
+ .text-body-2 {
+ font-size: var(--text-body-2);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--leading-design);
+ letter-spacing: var(--tracking-design);
+ }
+
+ .text-label-1 {
+ font-size: var(--text-label-1);
+ font-weight: var(--font-weight-semibold);
+ line-height: var(--leading-design);
+ letter-spacing: var(--tracking-design);
+ }
+
+ .text-label-2 {
+ font-size: var(--text-label-2);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--leading-design);
+ letter-spacing: var(--tracking-design);
+ }
+
+ .text-caption-1 {
+ font-size: var(--text-caption-1);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--leading-design);
+ letter-spacing: var(--tracking-design);
+ }
+
+ .text-caption-2 {
+ font-size: var(--text-caption-2);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--leading-design);
+ letter-spacing: var(--tracking-design);
+ }
+}
diff --git a/src/types/movie.ts b/src/types/movie.ts
new file mode 100644
index 0000000..d9aacbd
--- /dev/null
+++ b/src/types/movie.ts
@@ -0,0 +1,13 @@
+export interface Movie {
+ id: number;
+ title: string;
+ poster_path: string;
+ vote_average: number;
+ overview: string;
+}
+
+export interface TMDBResponse {
+ results: Movie[];
+}
+
+export type RowType = "action" | "original" | "korea" | "mylist";
diff --git a/src/types/tmdb.ts b/src/types/tmdb.ts
new file mode 100644
index 0000000..3ffcc7d
--- /dev/null
+++ b/src/types/tmdb.ts
@@ -0,0 +1,27 @@
+export type TmdbMediaType = "movie" | "tv" | "person";
+
+export type TmdbTrendingItem = {
+ id: number;
+ title?: string;
+ name?: string;
+ original_title?: string;
+ original_name?: string;
+ backdrop_path: string | null;
+ poster_path: string | null;
+ media_type: TmdbMediaType;
+};
+
+export type TmdbTrendingResponse = {
+ results?: TmdbTrendingItem[];
+};
+
+export type TmdbDetail = {
+ id: number;
+ title?: string;
+ name?: string;
+ original_title?: string;
+ original_name?: string;
+ overview: string;
+ backdrop_path: string | null;
+ poster_path: string | null;
+};
diff --git a/src/utils/tmdb.ts b/src/utils/tmdb.ts
new file mode 100644
index 0000000..464916b
--- /dev/null
+++ b/src/utils/tmdb.ts
@@ -0,0 +1,26 @@
+import type { TmdbDetail, TmdbTrendingItem } from "@/types/tmdb";
+
+export const TMDB_IMAGE_BASE_URL =
+ process.env.NEXT_PUBLIC_TMDB_IMAGE_BASE_URL ?? "https://image.tmdb.org/t/p";
+
+export function getTmdbTitle(item: TmdbDetail | TmdbTrendingItem) {
+ return (
+ item.original_title ??
+ item.original_name ??
+ item.title ??
+ item.name ??
+ "Untitled"
+ );
+}
+
+export function getTmdbImagePath(item: TmdbDetail | TmdbTrendingItem) {
+ return item.backdrop_path ?? item.poster_path;
+}
+
+export function getTmdbMediaType(mediaType?: string) {
+ return mediaType === "tv" ? "tv" : "movie";
+}
+
+export function getTmdbImageUrl(path: string, size = "w300") {
+ return `${TMDB_IMAGE_BASE_URL}/${size}${path}`;
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..ea09593
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}