Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[사전 미션 - CSR을 SSR로 재구성하기] - 초코(강다빈) 미션 제출합니다. #26

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions csr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,26 @@
VITE_TMDB_TOKEN=
```
- npm run dev

<br/>

# CSR 프로젝트 이해

- TanStack Router를 사용하여 SPA(Single Page Application) 구조를 구현
- Jotai를 사용하여 전역 상태를 관리
- TMDB API를 사용하여 영화 정보를 가져옴

- 컴포넌트 구조
- Header: 현재 선택된 영화 목록의 첫 번째 영화를 배너로 표시
- Container: 영화 목록을 표시하고, 탭을 통해 다른 카테고리로 전환
- Footer: 저작권 정보를 표시
- Modal: 영화 상세 정보를 표시
- 커스텀 훅:

- useModal: 모달 상태 및 영화 상세 정보 관리
- useMovies: 영화 목록 및 선택된 카테고리 관리

- 주요 기능
- 영화 목록 표시 (인기순, 상영 중, 평점순, 상영 예정)
- 영화 상세 정보 모달
- 탭을 통한 카테고리 전환
84 changes: 84 additions & 0 deletions ssr/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
"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": {
"express": "^4.18.2",
"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"
}
}
14 changes: 13 additions & 1 deletion ssr/public/scripts/index.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
17 changes: 17 additions & 0 deletions ssr/server/apis/movies.js
Original file line number Diff line number Diff line change
@@ -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;
};
20 changes: 20 additions & 0 deletions ssr/server/constants.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
148 changes: 144 additions & 4 deletions ssr/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => `
<li>
<a href="/detail/${movie.id}">
<div class="item">
<img
class="thumbnail"
src="https://media.themoviedb.org/t/p/w440_and_h660_face/${movie.poster_path}"
alt="${movie.title}"
/>
<div class="item-desc">
<p class="rate"><img src="../assets/images/star_empty.png" class="star" /><span>${movie.vote_average.toFixed(
1
)}</span></p>
<strong>${movie.title}</strong>
</div>
</div>
</a>
</li>
`
)
.join("");
};

const renderPage = async ({ category, id }) => {
const templatePath = path.join(__dirname, "../../views", "index.html");
const moviesHTML = "<p>들어갈 본문 작성</p>";
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("<!--${MOVIE_ITEMS_PLACEHOLDER}-->", 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("<!--${TAB_ITEMS_PLACEHOLDER}-->", renderTabItems(category));
if (id) {
template = template.replace("<!--${MODAL_AREA}-->", 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 `
<div class="modal-background active" id="modalBackground">
<div class="modal">
<button class="close-modal" id="closeModal"><img src="../assets/images/modal_button_close.png" /></button>
<div class="modal-container">
<div class="modal-image">
<img src="https://image.tmdb.org/t/p/w440_and_h660_face/${movie.poster_path}" />
</div>
<div class="modal-description">
<h2>${movie.title}</h2>
<p class="category">
${movie.release_date} · ${genres}
</p>
<p class="rate">
<img src="../assets/images/star_filled.png" class="star" />
<span>${movie.vote_average}</span>
</p>
<hr />
<p class="detail">${movie.overview}</p>
</div>
</div>
</div>
</div>
<!-- 모달 창 닫기 스크립트 -->
<script>
const modalBackground = document.getElementById("modalBackground");
const closeModal = document.getElementById("closeModal");
const previousPath = new URL(document.referrer).pathname;

document.addEventListener("DOMContentLoaded", () => {
closeModal.addEventListener("click", () => {
modalBackground.classList.remove("active");
history.replaceState({}, '', previousPath);
});
});
</script>
`;
};

const renderTabItems = (currentCategory) => {
return Object.entries(CATEGORIES)
.map(
([key, value]) => `
<li>
<a href="/${key}" class="${
currentCategory.toLowerCase().replace("_", "-") === key ? "selected" : ""
}">
<div class="tab-item">
<h3>${value}</h3>
</div>
</a>
</li>
`
)
.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("<!--${MOVIE_ITEMS_PLACEHOLDER}-->", 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);
});

Expand Down
Loading