diff --git a/src/App.tsx b/src/App.tsx index 9894621..5cae8b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { Settings } from './pages/mypage/Settings'; import { News } from './pages/News'; import PeoplePage from './pages/PeoplePage'; import { Rating } from './pages/Rating'; +import { Search } from './pages/Search'; import { Signup } from './pages/Signup'; export const App = () => { @@ -33,6 +34,7 @@ export const App = () => { } /> } /> } /> + } /> diff --git a/src/assets/no_result.svg b/src/assets/no_result.svg new file mode 100644 index 0000000..def093a --- /dev/null +++ b/src/assets/no_result.svg @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/src/components/SearchResultBlock.tsx b/src/components/SearchResultBlock.tsx new file mode 100644 index 0000000..15f79a1 --- /dev/null +++ b/src/components/SearchResultBlock.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import noProfile from '../assets/no_profile.svg'; +import NoResultSvg from '../assets/no_result.svg'; +import { + fetchCollection, + fetchMovie, + fetchPeople, + fetchUser, +} from '../utils/Functions'; +import type { + Collection, + Movie, + People, + SearchResult, + UserProfile, +} from '../utils/Types'; + +type Category = 'movie' | 'person' | 'collection' | 'user'; + +interface SearchResultBlockProps { + searchResults: SearchResult; + selectedCategory: Category; + setSelectedCategory: (category: Category) => void; +} + +export const SearchResultBlock = ({ + searchResults, + selectedCategory, + setSelectedCategory, +}: SearchResultBlockProps) => { + const [, setSearchParams] = useSearchParams(); + const [movieDetails, setMovieDetails] = useState([]); + const [peopleDetails, setPeopleDetails] = useState([]); + const [collectionDetails, setCollectionDetails] = useState([]); + const [userDetails, setUserDetails] = useState([]); + const [error, setError] = useState(null); + + const categories: Category[] = ['movie', 'person', 'collection', 'user']; + useEffect(() => { + const fetchSearchResults = async () => { + try { + const _movieDetails = await Promise.all( + searchResults.movie_list.map((id) => fetchMovie(id)), + ); + setMovieDetails( + _movieDetails.filter((detail): detail is Movie => detail !== null), + ); + const _peopleDetails = await Promise.all( + searchResults.participant_list.map((id) => fetchPeople(id)), + ); + setPeopleDetails( + _peopleDetails.filter((detail): detail is People => detail !== null), + ); + const _collectionDetails = await Promise.all( + searchResults.collection_list.map((id) => fetchCollection(id)), + ); + setCollectionDetails( + _collectionDetails.filter( + (detail): detail is Collection => detail !== null, + ), + ); + const _userDetails = await Promise.all( + searchResults.user_list.map((id) => fetchUser(id)), + ); + setUserDetails( + _userDetails.filter( + (detail): detail is UserProfile => detail !== null, + ), + ); + } catch (err) { + setError((err as Error).message); + } + }; + + void fetchSearchResults(); + }, [searchResults]); + + const handleCategoryClick = (category: Category) => { + setSelectedCategory(category); + setSearchParams((params) => { + params.set('category', category); + return params; + }); + }; + + const getCategoryContent = () => { + if (error != null) { + return Error: {error}; + } + + if (selectedCategory === 'movie' && movieDetails.length > 0) { + return ( + + {movieDetails.map((movie) => ( + + + + + {movie.title} + {movie.year} + + + + ))} + + ); + } + if (selectedCategory === 'person' && peopleDetails.length > 0) { + return ( + + {peopleDetails.map((people) => ( + + + + + {people.name} + + {people.roles.join(', ')} + + + + + ))} + + ); + } + if (selectedCategory === 'collection' && collectionDetails.length > 0) { + return ( + + {collectionDetails.map((collection) => ( + + + + {collection.title} + + + + ))} + + ); + } + if (selectedCategory === 'user' && userDetails.length > 0) { + return ( + + {userDetails.map((user, index) => ( + + + + + {user.username} + + 평가 {user.review_count} + + + + + ))} + + ); + } + return ( + + + + 검색 결과가 없어요. 다른 검색어를 입력해보세요. + + + ); + }; + + const getCategoryLabel = (category: Category): string => { + const labels: Record = { + movie: '영화', + person: '인물', + collection: '컬렉션', + user: '유저', + }; + return labels[category]; + }; + + return ( + <> + + {categories.map((category) => ( + { + handleCategoryClick(category); + }} + className={`py-3 px-4 text-center whitespace-nowrap ${ + selectedCategory === category + ? 'border-b-2 border-black text-black' + : 'text-gray-500 hover:text-gray-700' + }`} + > + {getCategoryLabel(category)} + + ))} + + {getCategoryContent()} + > + ); +}; diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx new file mode 100644 index 0000000..3db4914 --- /dev/null +++ b/src/pages/Search.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import search from '../assets/search.svg'; +import { Footerbar } from '../components/Footerbar'; +import { SearchResultBlock } from '../components/SearchResultBlock'; +import type { SearchCategory, SearchResult } from '../utils/Types'; + +export const Search = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [searchText, setSearchText] = useState(''); + const [searchResults, setSearchResults] = useState(null); + const [error, setError] = useState(null); + const [selectedCategory, setSelectedCategory] = + useState('movie'); + + useEffect(() => { + const query = searchParams.get('query'); + const category = searchParams.get('category') as SearchCategory; + console.debug(category); + + if ( + category.length > 0 && + ['movie', 'person', 'collection', 'user'].includes(category) + ) { + setSelectedCategory(category); + } + + if (query != null) { + setSearchText(query); + void performSearch(query); + } + }, [searchParams]); + + const handleClear = () => { + setSearchText(''); + setSearchResults(null); + }; + + const performSearch = async (query: string) => { + setError(null); + try { + const response = await fetch( + `/api/search?search_q=${encodeURIComponent(query)}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error('Failed to fetch search results'); + } + + const data = (await response.json()) as SearchResult; + setSearchResults(data); + } catch (err) { + setError((err as Error).message); + console.error('Search error:', err); + } + }; + + const handleSearch = () => { + if (searchText.trim().length > 0) { + const searchQuery = `query=${encodeURIComponent(searchText.trim())}`; + const categoryQuery = + selectedCategory !== 'movie' ? `&category=${selectedCategory}` : ''; + void navigate(`/search?${searchQuery}${categoryQuery}`); + } + }; + + return ( + + + + { + setSearchText(e.target.value); + }} + placeholder="콘텐츠, 인물, 컬렉션, 유저를 검색하세요" + className="w-full px-4 py-2 pl-12 pr-10 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === 'Enter') handleSearch(); + }} + /> + { + handleSearch(); + }} + /> + {searchText !== '' && ( + + ✕ + + )} + + {error !== null && ( + {error} + )} + + + {searchResults != null && ( + + )} + + + + + + + ); +}; diff --git a/src/utils/Functions.ts b/src/utils/Functions.ts new file mode 100644 index 0000000..8aabb7a --- /dev/null +++ b/src/utils/Functions.ts @@ -0,0 +1,80 @@ +import type { Collection, Movie, People, UserProfile } from '../utils/Types'; + +export const fetchMovie = async (movieId: number) => { + try { + const response = await fetch(`/api/movies/${movieId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch movie details'); + } + + return (await response.json()) as Movie; + } catch (err) { + console.error('Movie fetch error:', err); + return null; + } +}; + +export const fetchPeople = async (peopleId: number) => { + try { + const response = await fetch(`/api/participants/${peopleId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch people details'); + } + + return (await response.json()) as People; + } catch (err) { + console.error('People fetch error:', err); + return null; + } +}; + +export const fetchCollection = async (collectionId: number) => { + try { + const response = await fetch(`/api/collections/${collectionId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch collection details'); + } + + return (await response.json()) as Collection; + } catch (err) { + console.error('Collection fetch error:', err); + return null; + } +}; + +export const fetchUser = async (userId: number) => { + try { + const response = await fetch(`/api/users/profile/${userId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch user details'); + } + return (await response.json()) as UserProfile; + } catch (err) { + console.error('User fetch error:', err); + return null; + } +}; diff --git a/src/utils/Types.ts b/src/utils/Types.ts index ecf1f61..f0330aa 100644 --- a/src/utils/Types.ts +++ b/src/utils/Types.ts @@ -64,3 +64,35 @@ export type PeopleMovieCreditResponse = { role: string; movies: PeopleMovieCredit[]; }; + +export type SearchResult = { + movie_list: Array; + user_list: Array; + participant_list: Array; + collection_list: Array; +}; + +export type Collection = { + id: number; + user_id: number; + title: string; + overview: string | null; + likes_count: number; + comments_count: number; + created_at: string; + movies: Movie[]; +}; + +export type UserProfile = { + username: string; + login_id: string; + profile_url: string | null; + status_message: string | null; + following_count: number; + follower_count: number; + review_count: number; + comment_count: number; + collection_count: number; +}; + +export type SearchCategory = 'movie' | 'person' | 'collection' | 'user';
Error: {error}
{movie.year}
+ {people.roles.join(', ')} +
+ 평가 {user.review_count} +
+ 검색 결과가 없어요. 다른 검색어를 입력해보세요. +