Home.tsx Home 화면
// pages/Home.tsx
import React, { useState, useEffect } from "react";
import SearchBar from "../components/SearchBar";
import MovieCard from "../components/MovieCard";
import UserSearchBar from "../components/UserSearchBar";
import { useNavigate } from 'react-router-dom';
import styles from './Home.module.css'
const API_URL = import.meta.env.VITE_API_URL;
interface Movie {
movieId: number;
title: string;
rating: number;
genre: string;
}
// 인기 영화 목록 (고정)
const popularMovies: Movie[] = [
{movieId: 1, title: "The Matrix", rating: 4.8, genre: "Action"},
{movieId: 2, title: "Inception", rating: 4.7, genre: "Sci-Fi"},
{movieId: 3, title: "Godfather", rating: 4.6, genre: "Crime"},
];
// 추천 영화 목록 (고정)
const recommendedMovies: Movie[] = [
{movieId: 1, title: "The Matrix", rating: 4.8, genre: "Action"},
{movieId: 2, title: "Inception", rating: 4.7, genre: "Sci-Fi"},
{movieId: 3, title: "Godfather", rating: 4.6, genre: "Crime"},
];
const Home: React.FC = () => {
const [search, setSearch] = useState<string>("");
const [userId, setUserId] = useState<number | null>(null);
const [createUserId, setCreateUserId] = useState<number | null>(null);
const [getUserId, setGetUserId] = useState<number | null>(null);
const [userRecommendedMovies, setUserRecommendedMovies] = useState<Movie[]>([]);
const navigate = useNavigate();
useEffect(() => {
const fetchRecommendedMovies = async () => {
const userId = 1;
try {
const response = await fetch(`${API_URL}/api/recommended?userId=${encodeURIComponent(userId)}`);
if (!response.ok) {
throw new Error(`추천 영화 불러오기 실패: ${response.status}`);
}
const recommendations = await response.json();
setUserRecommendedMovies(recommendations);
} catch (error) {
console.error('Error:', error);
alert('추천 영화 불러오기 중 오류가 발생했습니다: ' + error);
}
};
fetchRecommendedMovies();
}, []); // Empty dependency array means this effect runs once when the component mounts
const handleSearch = async () => {
if (!search.trim()) {
alert('검색어를 입력해주세요');
return;
}
console.log(API_URL);
try {
const response = await fetch(`${API_URL}/api/search?query=${encodeURIComponent(search)}`);
if (!response.ok) {
throw new Error(`검색 실패: ${response.status}`);
}
const searchResults = await response.json();
navigate('/search-results', {
state: {
results: searchResults,
query: search
}
});
} catch (error) {
console.error('Error:', error);
alert('검색 중 오류가 발생했습니다: ' + error);
}
};
const handleUserCreateRecommendSearch = async () => {
if (!createUserId) {
alert('유저 ID를 입력해주세요');
return;
}
try {
{/* 추천 영화 등록, POST part*/}
const json_body = {userId: createUserId};
console.log(json_body);
const response = await fetch(`${API_URL}/api/recommend`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(json_body)
});
if (!response.ok) {
throw new Error(`추천 영화 등록 실패: ${response.status}`);
};
}
catch (error) {
console.error('Error:', error);
alert('추천 영화를 등록하는 중 오류가 발생했습니다: ' + error);
}
};
const handleUserGetRecommendSearch = async () => {
if (!getUserId) {
alert('유저 ID를 입력해주세요');
return;
}
try {
{/* 추천 영화 불러오기, GET part*/}
const json_body = {userId: getUserId};
console.log(json_body);
const response = await fetch(`${API_URL}/api/recommended?userId=${encodeURIComponent(getUserId)}`);
if (!response.ok) {
throw new Error(`추천 영화 불러오기 실패: ${response.status}`);
}
const Recommendations = await response.json();
navigate(`/user/${getUserId}`, {
state: {
results: Recommendations,
query: getUserId
}
});
} catch (error) {
console.error('Error:', error);
alert('추천 영화 불러오기 중 오류가 발생했습니다: ' + error);
}
};
return (
<div className="container mx-auto px-4 py-8">
{/* 검색 섹션 */}
<div className="mb-12">
<h1 className="text-3xl font-bold text-center mb-8">영화 검색</h1>
<div className={styles.searchContainer}>
<div>
<SearchBar
search={search}
setSearch={setSearch}
onSearch={handleSearch}
/>
</div>
<div>
<button
onClick={handleSearch}
className={styles.searchButton}
>
검색
</button>
</div>
</div>
</div>
{/* 인기 영화 섹션 */}
<div className="mt-8">
{/* text-2xl이란 1.5rem 24px */}
<h2 className="text-3xl font-bold mb-6">인기 영화</h2>
{/* 그리드 방식 <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> */}
{/*recommendedMovies.map((movie) => (
<MovieCard key={movie.movieId} movie={movie} />
))*/}
<div className={styles.popularContainer}>
{popularMovies.map((movie) => (
<div key={movie.movieId} className="flex-shrink-0">
<MovieCard movie={movie} />
</div>
))}
</div>
</div>
{/* 추천 영화 섹션 */}
<div className="mt-8">
{/* text-2xl이란 1.5rem 24px */}
<h2 className="text-3xl font-bold mb-6">추천 영화</h2>
{/* 그리드 방식 <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> */}
{/*recommendedMovies.map((movie) => (
<MovieCard key={movie.movieId} movie={movie} />
))*/}
<div className={styles.recommendContainer}>
{/* 5개만 보여주는 경우 */}
{userRecommendedMovies.slice(0, 5).map((movie) => (
<div key={movie.movieId} className="flex-shrink-0">
<MovieCard movie={movie} />
</div>
))}
</div>
</div>
{/* 추천 영화 생성 확인 섹션 */}
<div className="mt-8">
<h2 className="text-3xl font-bold mb-6">유저 기반 추천 영화 등록</h2>
<div className={styles.searchContainer}>
<div>
<UserSearchBar
search={createUserId}
setSearch={setCreateUserId}
onSearch={handleUserCreateRecommendSearch}
/>
</div>
<div>
<button
onClick={handleUserCreateRecommendSearch}
className={styles.searchButton}
>
생성
</button>
</div>
</div>
</div>
{/* 추천 영화 불러오기 확인 섹션 */}
<div className="mt-8">
<h2 className="text-3xl font-bold mb-6">유저 기반 추천 영화 불러오기</h2>
<div className={styles.searchContainer}>
<div>
<UserSearchBar
search={getUserId}
setSearch={setGetUserId}
onSearch={handleUserGetRecommendSearch}
/>
</div>
<div>
<button
onClick={handleUserGetRecommendSearch}
className={styles.searchButton}
>
불러오기
</button>
</div>
</div>
</div>
</div>
);
};
export default Home;
인기 영화 목록은 고정하고,
유저별 추천 영화는 fetchRecommendedMovies로 가져와서 표시한다.
const [userId, setUserId] = useState<number | null>(null);
userId는 number 이거나 null인데 null로 초기화 한다.
search는 데이터 타입이 string이며 setSearch로 값을 변경한다.
fetch(`${API_URL}/api/search?query=${encodeURIComponent(search)}`)
위 코드를 보면 알겠지만 RESTFul API에 따른 쿼리 형태를 Typescript에서 표현하는 방식임을 알 수 있다.
search의 결과는 router를 이용해서 SearchResults.tsx 페이지로 이동한다.
이때 useNavigate를 사용한다. 검색 결과를 받아온 다음에 navigate('/search-results') 코드로 연결된 페이지로 이동한다.
router와 관련된 사항은 아래의 App.tsx에서 정의한다.
App.tsx
import React from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import SearchBar from './components/SearchBar';
import MovieList from './components/MovieList';
import SearchResults from './pages/SearchResults';
import UserRecommendations from './pages/UserRecommendations';
import Home from './pages/Home';
import './App.css'
/*
React Router를 사용
<div> 아래, <Routes> 위에 있던
<SearchBar /> 를 삭제해본다
*/
const App: React.FC = () => {
return (
<Router>
<div className="container">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/search-results" element={<SearchResults />} />
<Route path="/user/:userId" element={<UserRecommendations />} /> {/* 새로운 라우트 추가 */}
</Routes>
</div>
</Router>
);
};
export default App;
SearchResults.tsx
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
// 같은 폴더에 있는 경우 ./tsxfile.tsx로 import 가능
import MovieCard from '../components/MovieCard';
interface Movie {
id: number;
title: string;
rating: number;
genre: string;
}
const SearchResults: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const searchResults = location.state?.results as Movie[];
const searchQuery = location.state?.query as string;
const handleBackToHome = () => {
navigate('/');
};
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">"{searchQuery}" 검색 결과</h1>
<button
onClick={handleBackToHome}
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
홈으로 돌아가기
</button>
</div>
{searchResults?.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">검색 결과가 없습니다.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{searchResults?.map((movie) => (
<MovieCard key={movie.id} movie={movie} />
))}
</div>
)}
</div>
);
};
export default SearchResults;
SearchResults라는 페이지인데 MovieCard를 불러와서 보여준다.
MovieCard.tsx는 다음과 같다.
MovieCard.tsx
// MovieCards
import React, { useState } from 'react';
import styles from './MovieCard.module.css'
const API_URL = import.meta.env.VITE_API_URL;
interface Movie {
movieId: number;
title: string;
rating: number;
genre: string;
}
interface MovieCardProps {
movie: Movie;
userId: number;
}
const MovieCard: React.FC<MovieCardProps> = ({ movie, userId = 230213 }) => {
const [showRating, setShowRating] = useState(false);
const [userRating, setUserRating] = useState<number | null>(null);
/* Mouse and corresponding rating card Control */
const handleMouseEnter = () => {
setShowRating(true);
};
const handleMouseLeave = () => {
setShowRating(false);
};
const handleRatingChange = (rating: number) => {
setUserRating(rating);
};
const handleRatingSubmit = async () => {
if (userRating !== null) {
try {
const timestamp = new Date();
const isoTimestampString = timestamp.toISOString();
const unixTimeInSeconds = Math.floor(timestamp.getTime() / 1000);
const userFloatRating = userRating + 0.0; // Convert to float
console.log(timestamp);
console.log(isoTimestampString);
console.log(unixTimeInSeconds);
console.log(userId);
console.log(movie.movieId);
console.log(userRating);
console.log(userFloatRating);
const jsonBody = {
userId: userId,
movieId: movie.movieId,
rating: userFloatRating,
timestamp: unixTimeInSeconds,
};
const response = await fetch(`${API_URL}/api/ratings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(jsonBody)
/*body: JSON.stringify({ userId: userId,
movieId: movie.movieId,
rating: userRating }),*/
});
if (response.ok) {
alert('Rating added successfully');
setShowRating(false); // 제출 후 별점 창 숨김
} else {
alert('Failed to add rating');
}
} catch (error) {
console.error('Error submitting rating:', error);
alert('An error occurred while submitting rating');
}
}
};
return (
<div
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="p-6">
<h3 className="text-xl font-semibold mb-2">{movie.title}</h3>
<div className="flex flex-col">
<span>평점: {movie.rating}</span>
<span>장르: {movie.genre}</span>
</div>
</div>
{showRating && (
<div className="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white p-6 rounded-log">
<h4 className="text-lg font-semibold mb-4">별점 매기기</h4>
<div className="flex items-center space-x-2">
{[1, 2, 3, 4, 5].map((rating) => (
<button
key={rating}
className={`text-2xl ${userRating && userRating >= rating ? 'text-yellow-500' : 'text-gray-300'}`}
onClick={() => handleRatingChange(rating)}
>
★
</button>
))}
</div>
{userRating && <p className="mt-4">선택한 별점: {userRating}</p>}
<button className="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" onClick={handleRatingSubmit}>
Submit Rating
</button>
</div>
</div>
)}
</div>
);
};
export default MovieCard;
아래의 코드 부분에서 영화의 제목, 평점, 장르를 표기한다.
<div className="p-6">
<h3 className="text-xl font-semibold mb-2">{movie.title}</h3>
<div className="flex flex-col">
<span>평점: {movie.rating}</span>
<span>장르: {movie.genre}</span>
</div>
</div>
위 Movie Card를 리스트의 형태로 보여주는 코드는 아래 MovieList.tsx를 사용한다.
MovieList.tsx
import React from 'react';
import styles from './MovieItem.module.css';
interface MovieListProps{
movies: {
movieId: number;
title: string;
genre: string;
poster: string;
}[];
}
/*
이미지 포함 버젼
<div className={styles.movieGrid}>
{movies.map(movie => (
<div key={movie.movieId} className={styles.movieCard}>
<img src={movie.poster} alt={movie.title} />
<h3>{movie.title}</h3>
</div>
))}
</div>
*/
const MovieList: React.FC<MovieListProps> = ({ movies }) => {
return (
<div className={styles.movieListContainer}>
<h2 className={styles.title}>추천 영화</h2>
<div className={styles.movieGrid}>
{movies.map(movie => (
<div key={movie.movieId} className={styles.movieCard}>
<h3>{movie.title}</h3>
</div>
))}
</div>
</div>
);
};
export default MovieList;
MovieList의 경우 그리드나 카드의 크기, 타이틀의 사이즈 등을 아래에 있는 별도의 css 파일로 컨트롤한다.
MovieList.module.css
.movieListContainer {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.title {
text-align: center;
margin-bottom: 30px;
}
.movieGrid {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
}
.movieCard {
width: 200px;
background-color: #1a1a1a;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s;
}
.movieCard:hover {
transform: translateY{-5px};
}
.movieCard:img{
width: 100%;
height: 300px;
object-fit: cover;
}
.movieCard h3 {
padding: 10px;
margin: 0;
text-align: center;
}
여담
개인적으로는 프론트엔드와 Typescript 자체를 처음 다루어봐서 어려웠다.
ChatGPT, Gemini를 기반으로 코드를 만들어 달라고 하고 하나하나 console.log로 찍어보거나 고치면서 익숙해져 나갔다.
특히 프론트엔드에서 백엔드로 보낼 때 데이터 타입을 매칭시키는게 어려웠다.
rating을 DB에서 float로 설정해서 userFloatRating을 만들기전까지 왜 안되지 하면서 시간을 많이 썼다.
다음부터는 이를 신경 써서 구현해야겠다.
'개발 > Web and Frontend' 카테고리의 다른 글
Typescript Function (0) | 2025.03.31 |
---|---|
Typescript Interface (0) | 2025.03.31 |
Typescript Data Types (0) | 2025.03.31 |
Typescript 기초 (0) | 2025.03.31 |