diff --git a/csr/README.md b/csr/README.md index 6f2e7c1..84d472c 100644 --- a/csr/README.md +++ b/csr/README.md @@ -5,3 +5,26 @@ VITE_TMDB_TOKEN= ``` - npm run dev + +
+ +# CSR 프로젝트 이해 + +- TanStack Router를 사용하여 SPA(Single Page Application) 구조를 구현 +- Jotai를 사용하여 전역 상태를 관리 +- TMDB API를 사용하여 영화 정보를 가져옴 + +- 컴포넌트 구조 + - Header: 현재 선택된 영화 목록의 첫 번째 영화를 배너로 표시 + - Container: 영화 목록을 표시하고, 탭을 통해 다른 카테고리로 전환 + - Footer: 저작권 정보를 표시 + - Modal: 영화 상세 정보를 표시 +- 커스텀 훅: + + - useModal: 모달 상태 및 영화 상세 정보 관리 + - useMovies: 영화 목록 및 선택된 카테고리 관리 + +- 주요 기능 + - 영화 목록 표시 (인기순, 상영 중, 평점순, 상영 예정) + - 영화 상세 정보 모달 + - 탭을 통한 카테고리 전환 diff --git a/ssr/package-lock.json b/ssr/package-lock.json index a73fb42..e3e83dd 100644 --- a/ssr/package-lock.json +++ b/ssr/package-lock.json @@ -12,6 +12,7 @@ "node-fetch": "^3.3.2" }, "devDependencies": { + "cross-env": "^7.0.3", "dotenv": "^16.0.0", "nodemon": "^3.1.6" } @@ -197,6 +198,38 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -625,6 +658,12 @@ "node": ">=0.12.0" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -829,6 +868,15 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.10", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", @@ -1019,6 +1067,27 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -1146,6 +1215,21 @@ "engines": { "node": ">= 8" } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } } } } diff --git a/ssr/package.json b/ssr/package.json index db1a534..ff8de99 100644 --- a/ssr/package.json +++ b/ssr/package.json @@ -4,8 +4,8 @@ "description": "SSR 렌더링으로 영화 목록 불러오기", "main": "server/index.js", "scripts": { - "start": "NODE_TLS_REJECT_UNAUTHORIZED=0 node server/index.js", - "dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 nodemon server/index.js --watch" + "start": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 node server/index.js", + "dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 nodemon server/index.js --watch" }, "type": "module", "dependencies": { @@ -13,7 +13,8 @@ "node-fetch": "^3.3.2" }, "devDependencies": { - "nodemon": "^3.1.6", - "dotenv": "^16.0.0" + "cross-env": "^7.0.3", + "dotenv": "^16.0.0", + "nodemon": "^3.1.6" } } diff --git a/ssr/public/scripts/index.js b/ssr/public/scripts/index.js index 8337712..c897c6b 100644 --- a/ssr/public/scripts/index.js +++ b/ssr/public/scripts/index.js @@ -1 +1,13 @@ -// +document.addEventListener("DOMContentLoaded", () => { + const tabItems = document.querySelectorAll(".tab-item"); + + tabItems.forEach((item) => { + item.addEventListener("mouseover", () => { + item.classList.add("hover"); + }); + + item.addEventListener("mouseout", () => { + item.classList.remove("hover"); + }); + }); +}); diff --git a/ssr/server/apis/movies.js b/ssr/server/apis/movies.js new file mode 100644 index 0000000..4971aa5 --- /dev/null +++ b/ssr/server/apis/movies.js @@ -0,0 +1,17 @@ +import { TMDB_MOVIE_LISTS, TMDB_MOVIE_DETAIL_URL, FETCH_OPTIONS } from "../constants.js"; + +export const fetchMoviesByCategory = async (category) => { + const dd = category === "" ? "NOW_PLAYING" : category; + const response = await fetch(TMDB_MOVIE_LISTS[dd], FETCH_OPTIONS); + const data = await response.json(); + + return data; +}; + +export const fetchDetailMovie = async (id) => { + const url = TMDB_MOVIE_DETAIL_URL + id; + const response = await fetch(url, FETCH_OPTIONS); + const data = await response.json(); + + return data; +}; diff --git a/ssr/server/constants.js b/ssr/server/constants.js new file mode 100644 index 0000000..3082683 --- /dev/null +++ b/ssr/server/constants.js @@ -0,0 +1,20 @@ +export const BASE_URL = "https://api.themoviedb.org/3/movie"; + +export const TMDB_THUMBNAIL_URL = "https://media.themoviedb.org/t/p/w440_and_h660_face/"; +export const TMDB_ORIGINAL_URL = "https://image.tmdb.org/t/p/original/"; +export const TMDB_BANNER_URL = "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/"; +export const TMDB_MOVIE_LISTS = { + POPULAR: BASE_URL + "/popular?language=ko-KR&page=1", + NOW_PLAYING: BASE_URL + "/now_playing?language=ko-KR&page=1", + TOP_RATED: BASE_URL + "/top_rated?language=ko-KR&page=1", + UPCOMING: BASE_URL + "/upcoming?language=ko-KR&page=1", +}; +export const TMDB_MOVIE_DETAIL_URL = "https://api.themoviedb.org/3/movie/"; + +export const FETCH_OPTIONS = { + method: "GET", + headers: { + accept: "application/json", + Authorization: "Bearer " + process.env.TMDB_TOKEN, + }, +}; diff --git a/ssr/server/routes/index.js b/ssr/server/routes/index.js index 84d32f2..873a98e 100644 --- a/ssr/server/routes/index.js +++ b/ssr/server/routes/index.js @@ -2,19 +2,159 @@ import { Router } from "express"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; +import { fetchMoviesByCategory, fetchDetailMovie } from "../apis/movies.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const router = Router(); -router.get("/", (_, res) => { +const CATEGORIES = { + "now-playing": "상영 중", + popular: "인기순", + "top-rated": "평점순", + upcoming: "상영 예정", +}; + +const renderMovieItems = (movies) => { + return movies + .map( + (movie) => ` +
  • + +
    + ${movie.title} +
    +

    ${movie.vote_average.toFixed( + 1 + )}

    + ${movie.title} +
    +
    +
    +
  • + ` + ) + .join(""); +}; + +const renderPage = async ({ category, id }) => { const templatePath = path.join(__dirname, "../../views", "index.html"); - const moviesHTML = "

    들어갈 본문 작성

    "; + let template = fs.readFileSync(templatePath, "utf-8"); + + const moviesData = await fetchMoviesByCategory(category); + const movieItems = moviesData.results; + const moviesHTML = renderMovieItems(movieItems); + + const bestMovie = movieItems[0]; + + template = template.replace("", moviesHTML); + + template = template.replace( + "${background-container}", + `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${bestMovie.backdrop_path}` + ); + template = template.replace("${bestMovie.rate}", bestMovie.vote_average.toFixed(1)); + template = template.replace("${bestMovie.title}", bestMovie.title); + template = template.replace("", renderTabItems(category)); + if (id) { + template = template.replace("", await renderMovieItemModal(id)); + } + + return template; +}; + +const renderMovieItemModal = async (id) => { + const movie = await fetchDetailMovie(id); + const genres = movie.genres && movie.genres.map((genre) => genre.name).join(", "); + + return ` + + + + `; +}; + +const renderTabItems = (currentCategory) => { + return Object.entries(CATEGORIES) + .map( + ([key, value]) => ` +
  • + +
    +

    ${value}

    +
    +
    +
  • + ` + ) + .join(""); +}; + +router.get(["/", "/now-playing"], async (_, res) => { + const renderedHTML = await renderPage({ category: "NOW_PLAYING", id: null }); + res.send(renderedHTML); +}); + +router.get("/popular", async (_, res) => { + const renderedHTML = await renderPage({ category: "POPULAR", id: null }); + res.send(renderedHTML); +}); + +router.get("/top-rated", async (_, res) => { + const renderedHTML = await renderPage({ category: "TOP_RATED", id: null }); + res.send(renderedHTML); +}); + +router.get("/upcoming", async (_, res) => { + const renderedHTML = await renderPage({ category: "UPCOMING", id: null }); + res.send(renderedHTML); +}); - const template = fs.readFileSync(templatePath, "utf-8"); - const renderedHTML = template.replace("", moviesHTML); +router.get("/detail/:id", async (req, res) => { + const previousPage = req.get("Referrer") || req.header("Referrer"); + const category = previousPage.split("/").at(-1).toUpperCase().replace("-", "_"); + const movieId = req.params.id; + const renderedHTML = await renderPage({ category, id: movieId }); res.send(renderedHTML); }); diff --git a/ssr/views/index.html b/ssr/views/index.html index a052396..d4dd4b7 100644 --- a/ssr/views/index.html +++ b/ssr/views/index.html @@ -31,34 +31,7 @@

    MovieList