diff --git a/ssr/server/routes/index.js b/ssr/server/routes/index.js index 84d32f2..4823530 100644 --- a/ssr/server/routes/index.js +++ b/ssr/server/routes/index.js @@ -1,19 +1,78 @@ import { Router } from "express"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import { + fetchNowPlayingMovies, + fetchPopularMovies, + fetchTopRatedMovies, + fetchUpcomingMovies, + generateRenderedMovieItemsHTML, +} from "../../src/movies.js"; +import { + fetchMovieItemDetail, + generateRenderedModalHTML, +} from "../../src/modal.js"; const router = Router(); -router.get("/", (_, res) => { - const templatePath = path.join(__dirname, "../../views", "index.html"); - const moviesHTML = "

들어갈 본문 작성

"; +router.get("/", async (_, res) => { + const movies = await fetchNowPlayingMovies(); + const renderedHTML = generateRenderedMovieItemsHTML( + movies.results, + "nowPlaying" + ); - const template = fs.readFileSync(templatePath, "utf-8"); - const renderedHTML = template.replace("", moviesHTML); + res.send(renderedHTML); +}); + +router.get("/now-playing", async (_, res) => { + const movies = await fetchNowPlayingMovies(); + const renderedHTML = generateRenderedMovieItemsHTML( + movies.results, + "nowPlaying" + ); + + res.send(renderedHTML); +}); + +router.get("/popular", async (_, res) => { + const movies = await fetchPopularMovies(); + const renderedHTML = generateRenderedMovieItemsHTML( + movies.results, + "popular" + ); + + res.send(renderedHTML); +}); + +router.get("/top-rated", async (_, res) => { + const movies = await fetchTopRatedMovies(); + const renderedHTML = generateRenderedMovieItemsHTML( + movies.results, + "topRated" + ); + + res.send(renderedHTML); +}); + +router.get("/upcoming", async (_, res) => { + const movies = await fetchUpcomingMovies(); + const renderedHTML = generateRenderedMovieItemsHTML( + movies.results, + "upcoming" + ); + + res.send(renderedHTML); +}); + +router.get("/detail/:id", async (req, res) => { + const id = req.params.id; + const movieItems = await fetchNowPlayingMovies(); + const movieDetail = await fetchMovieItemDetail(id); + + const renderedHTML = generateRenderedModalHTML( + movieItems.results, + movieDetail + ); res.send(renderedHTML); }); diff --git a/ssr/src/assets/images/logo.png b/ssr/src/assets/images/logo.png new file mode 100644 index 0000000..d8476f9 Binary files /dev/null and b/ssr/src/assets/images/logo.png differ diff --git a/ssr/src/assets/images/modal_button_close.png b/ssr/src/assets/images/modal_button_close.png new file mode 100644 index 0000000..b352a22 Binary files /dev/null and b/ssr/src/assets/images/modal_button_close.png differ diff --git a/ssr/src/assets/images/star_empty.png b/ssr/src/assets/images/star_empty.png new file mode 100644 index 0000000..bf32725 Binary files /dev/null and b/ssr/src/assets/images/star_empty.png differ diff --git a/ssr/src/assets/images/star_filled.png b/ssr/src/assets/images/star_filled.png new file mode 100644 index 0000000..5b6f299 Binary files /dev/null and b/ssr/src/assets/images/star_filled.png differ diff --git a/ssr/src/assets/images/woowacourse_logo.png b/ssr/src/assets/images/woowacourse_logo.png new file mode 100644 index 0000000..e64c002 Binary files /dev/null and b/ssr/src/assets/images/woowacourse_logo.png differ diff --git a/ssr/src/assets/styles/colors.css b/ssr/src/assets/styles/colors.css new file mode 100644 index 0000000..05e7a87 --- /dev/null +++ b/ssr/src/assets/styles/colors.css @@ -0,0 +1,13 @@ +:root { + --color-white: #ffffff; + --color-lightblue-90: #0da9fd; + --color-bluegray-100: #242a32; + --color-bluegray-80: #39414d; + --color-bluegray-10: #e0e6ed; + --color-bluegray-30: #a6b1c3; + --color-bluegray-90: #2f3844; + --color-gray-300: #d1d3d4; + --color-yellow: #ffc700; + --color-ffffff: #ffffff; + --color-242a32: #242a32; +} diff --git a/ssr/src/assets/styles/main.css b/ssr/src/assets/styles/main.css new file mode 100644 index 0000000..1cb07d9 --- /dev/null +++ b/ssr/src/assets/styles/main.css @@ -0,0 +1,134 @@ +@import "./colors.css"; + +* { + box-sizing: border-box; +} + +a { + color: inherit; + text-decoration: none; +} + +body { + font-size: 16px; + color: var(--color-white); + background-color: var(--color-bluegray-80); +} + +#wrap, +section { + display: flex; + flex-direction: column; + justify-content: center; +} + +main { + margin-bottom: 64px; +} + +.star { + width: 24px; +} + +button { + border: 0; + padding: 8px 16px; + cursor: pointer; +} + +button.primary { + color: var(--color-white); + font-weight: bold; + background-color: var(--color-lightblue-90); + border-radius: 4px; +} + +#wrap { + min-width: 1440px; + background-color: var(--color-bluegray-100); +} + +#wrap h2 { + font-size: 1.4rem; + font-weight: bold; + margin-bottom: 32px; +} + +.container { + max-width: 1280px; + margin: 0 auto; +} + +.background-container { + position: relative; + background-position: center center; + background-size: cover; + height: 500px; + padding: 48px; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.5); + width: 100%; + height: 100%; + z-index: 1; +} + +.top-rated-container { + user-select: none; + position: relative; + z-index: 2; + max-width: 1280px; + margin: 0 auto; +} + +.top-rated-movie { + margin-top: 64px; +} + +.top-rated-movie > *:not(:last-child) { + margin-bottom: 8px; +} + +h1.logo { + font-size: 2rem; +} + +.rate { + display: flex; + align-items: baseline; + color: var(--color-yellow); +} + +.rate > img { + position: relative; + top: 2px; +} + +span.rate-value { + margin-left: 8px; + font-weight: bold; + font-size: 1.66rem; +} + +.title { + font-size: 3rem; + font-weight: bold; +} + +footer.footer { + min-height: 180px; + background-color: var(--color-bluegray-80); + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + font-size: 1.1rem; +} + +footer.footer p:not(:last-child) { + margin-bottom: 8px; +} diff --git a/ssr/src/assets/styles/modal.css b/ssr/src/assets/styles/modal.css new file mode 100644 index 0000000..240a7a3 --- /dev/null +++ b/ssr/src/assets/styles/modal.css @@ -0,0 +1,88 @@ +@import "./colors.css"; + +/* modal.css */ +body.modal-open { + overflow: hidden; +} + +.modal-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); /* 반투명 배경을 위해 설정 */ + backdrop-filter: blur(10px); /* 블러 효과 적용 */ + display: flex; + justify-content: center; + align-items: center; + z-index: 10; + visibility: hidden; /* 모달이 기본적으로 보이지 않도록 설정 */ + opacity: 0; + transition: + opacity 0.3s ease, + visibility 0.3s ease; +} + +.modal-background.active { + visibility: visible; + opacity: 1; +} + +.modal { + background-color: var(--color-bluegray-90); + padding: 20px; + border-radius: 16px; + color: white; + z-index: 2; + position: relative; + width: 1000px; +} + +.close-modal { + position: absolute; + margin: 0; + padding: 0; + top: 24px; + right: 24px; + background: none; + border: none; + color: white; + font-size: 20px; + cursor: pointer; +} + +.modal-container { + display: flex; +} + +.modal-image img { + width: 380px; + border-radius: 16px; +} + +.modal-description { + width: 100%; + padding: 8px; + margin-left: 16px; + line-height: 1.6rem; +} + +.modal-description .rate > img { + position: relative; + top: 5px; +} + +.modal-description > *:not(:last-child) { + margin-bottom: 8px; +} + +.modal-description h2 { + font-size: 2rem; + margin: 0 0 8px; +} + +.detail { + max-height: 430px; + overflow-y: auto; +} diff --git a/ssr/src/assets/styles/reset.css b/ssr/src/assets/styles/reset.css new file mode 100644 index 0000000..3675e64 --- /dev/null +++ b/ssr/src/assets/styles/reset.css @@ -0,0 +1,129 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} +body { + line-height: 1; +} +ol, +ul { + list-style: none; +} +blockquote, +q { + quotes: none; +} +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ""; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/ssr/src/assets/styles/tab.css b/ssr/src/assets/styles/tab.css new file mode 100644 index 0000000..ac357b5 --- /dev/null +++ b/ssr/src/assets/styles/tab.css @@ -0,0 +1,32 @@ +@import "./colors.css"; + +.tab { + display: flex; + margin: 32px 0; +} + +.tab-item { + cursor: pointer; + user-select: none; + + display: flex; + align-items: center; + justify-content: center; + width: 120px; + height: 40px; + + border-radius: 40px; + background: linear-gradient(90deg, #14191f 0%, #0a0d12 100%); +} + +.tab-item.selected { + background: linear-gradient(90deg, #4b8bf4 0%, #1c2a3a 100%); +} + +.tab-item:hover { + background: linear-gradient(90deg, #2f3e54 0%, #4b8bf4 100%); +} + +.tab li:not(:last-child) { + margin-right: 8px; +} diff --git a/ssr/src/assets/styles/thumbnail.css b/ssr/src/assets/styles/thumbnail.css new file mode 100644 index 0000000..378a494 --- /dev/null +++ b/ssr/src/assets/styles/thumbnail.css @@ -0,0 +1,40 @@ +@import "./colors.css"; + +.thumbnail-list { + margin: 0 auto 56px; + display: grid; + grid-template-columns: repeat(5, 200px); + gap: 70px; +} + +.thumbnail { + width: 200px; + height: 300px; + border-radius: 8px; +} + +.item { + user-select: none; + cursor: pointer; +} + +.item-desc > *:not(:last-child) { + position: relative; + margin-bottom: 4px; + line-height: 1.2rem; +} + +p.rate { + display: flex; + align-items: baseline; + color: var(--color-yellow); +} + +p.rate > span { + margin-left: 4px; +} + +.item .star { + width: 16px; + top: 1px; +} diff --git a/ssr/src/constant.js b/ssr/src/constant.js new file mode 100644 index 0000000..b26b9a7 --- /dev/null +++ b/ssr/src/constant.js @@ -0,0 +1,22 @@ +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/src/modal.js b/ssr/src/modal.js new file mode 100644 index 0000000..d322024 --- /dev/null +++ b/ssr/src/modal.js @@ -0,0 +1,62 @@ +import { FETCH_OPTIONS, TMDB_MOVIE_DETAIL_URL } from "./constant.js"; +import { generateRenderedMovieItemsHTML } from "./movies.js"; + +export const fetchMovieItemDetail = async (id) => { + const url = TMDB_MOVIE_DETAIL_URL + id; + const params = new URLSearchParams({ + language: "ko-KR", + }); + const response = await fetch(url + "?" + params, FETCH_OPTIONS); + + return await response.json(); +}; + +export const generateRenderedModalHTML = (movieItems, movieDetail) => { + const moviesPageTemplate = generateRenderedMovieItemsHTML( + movieItems, + "nowPlaying" + ); + + return moviesPageTemplate.replace( + "", + /*html*/ ` + + + + ` + ); +}; diff --git a/ssr/src/movies.js b/ssr/src/movies.js new file mode 100644 index 0000000..3b165db --- /dev/null +++ b/ssr/src/movies.js @@ -0,0 +1,96 @@ +import { FETCH_OPTIONS, TMDB_MOVIE_LISTS } from "./constant.js"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const fetchNowPlayingMovies = async () => { + const response = await fetch(TMDB_MOVIE_LISTS.NOW_PLAYING, FETCH_OPTIONS); + + return await response.json(); +}; + +export const fetchPopularMovies = async () => { + const response = await fetch(TMDB_MOVIE_LISTS.POPULAR, FETCH_OPTIONS); + + return await response.json(); +}; + +export const fetchTopRatedMovies = async () => { + const response = await fetch(TMDB_MOVIE_LISTS.TOP_RATED, FETCH_OPTIONS); + + return await response.json(); +}; + +export const fetchUpcomingMovies = async () => { + const response = await fetch(TMDB_MOVIE_LISTS.UPCOMING, FETCH_OPTIONS); + + return await response.json(); +}; + +export const generateMovies = (movieItems = []) => + movieItems + .map( + ({ id, title, poster_path, vote_average }) => /*html*/ ` +
  • + +
    + ${title} +
    +

    ${vote_average.toFixed( + 1 + )}

    + ${title} +
    +
    +
    +
  • + ` + ) + .join("\n"); + +export const generateRenderedMovieItemsHTML = (movieItems, tabItem) => { + const bestMovieItem = movieItems[0]; + const moviesHTML = generateMovies(movieItems); + + const templatePath = path.join(__dirname, "../views", "index.html"); + let template = fs.readFileSync(templatePath, "utf-8"); + + template = template.replace("", moviesHTML); + template = template.replace( + "${background-container}", + "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/" + + bestMovieItem.poster_path + ); + template = template.replace( + "${bestMovie.rate}", + bestMovieItem.vote_average.toFixed(1) + ); + template = template.replace("${bestMovie.title}", bestMovieItem.title); + template = template.replace("${id}", bestMovieItem.id); + + template = template.replace( + "${nowPlayingTabItem}", + tabItem === "nowPlaying" ? "selected" : "" + ); + template = template.replace( + "${popularTabItem}", + tabItem === "popular" ? "selected" : "" + ); + template = template.replace( + "${topRatedTabItem}", + tabItem === "topRated" ? "selected" : "" + ); + template = template.replace( + "${upcomingTabItem}", + tabItem === "upcoming" ? "selected" : "" + ); + + return template; +}; diff --git a/ssr/views/index.html b/ssr/views/index.html index a052396..afeb44b 100644 --- a/ssr/views/index.html +++ b/ssr/views/index.html @@ -14,17 +14,24 @@
    -
    +
    -

    MovieList

    +

    + MovieList +

    ${bestMovie.rate}
    ${bestMovie.title}
    - +
    @@ -33,28 +40,28 @@

    MovieList