diff --git a/src/apis/category.ts b/src/apis/category.ts index be348b5..62479aa 100644 --- a/src/apis/category.ts +++ b/src/apis/category.ts @@ -8,6 +8,12 @@ export const getCategories = async () => { return response.data; }; +// 카테고리 별 태그 가져오는 API +export const getCategoryTags = async (categoryId: string) => { + const response = await axiosInstance.get(`/category/${categoryId}/`); + return response.data; +}; + // 카테고리 이동1 API export const putSubToOtherTop = async ( categoryId: number, diff --git a/src/apis/videos.ts b/src/apis/videos.ts index e6e81d7..83393b9 100644 --- a/src/apis/videos.ts +++ b/src/apis/videos.ts @@ -29,9 +29,9 @@ export const getRecentVideos = async (): Promise< }; export const getVideoById = async ( - videoId: number, + videoId: string, ): Promise<APIResponse<Record<'videos', IVideoProps[]>>> => { - const response = await axiosInstance.get(`/videos/${videoId}`); + const response = await axiosInstance.get(`/videos/${videoId}/get`); return response.data; }; diff --git a/src/components/Home/RecentVideos.tsx b/src/components/Home/RecentVideos.tsx index 0e910b3..52a3967 100644 --- a/src/components/Home/RecentVideos.tsx +++ b/src/components/Home/RecentVideos.tsx @@ -19,31 +19,29 @@ const RecentVideos = ({ videos }: IRecentVideosProp) => { return ( <RecentVideosContainer> <div className="container"> - <div className='title-container'> - <VideosTitle>최근 읽은 영상</VideosTitle> - {videos.length >= 4 && ( - <Link to='/videos/recent'> - <div className='icon-wrapper'> - <MoveIcon width={28} height={28}/> - </div> - </Link> - )} + <div className="title-container"> + <VideosTitle>최근 읽은 영상</VideosTitle> + {videos.length >= 4 && ( + <Link to="/videos/recent"> + <div className="icon-wrapper"> + <MoveIcon width={28} height={28} /> + </div> + </Link> + )} </div> - + {videos.length === 0 && ( - <> + <div className="empty-container"> <div className="empty-video"> <img src={CardImage} alt="비어있는 비디오 이미지" /> </div> - <div className='empty-text'> - <VideosSubtitle> - 처음 방문하셨나요? <br /> 아직 정리해본 영상이 없어요! - </VideosSubtitle> - <VideoButton> - <h2 className="button-text">영상 정리해보기</h2> - </VideoButton> - </div> - </> + <VideosSubtitle> + 처음 방문하셨나요? <br /> 아직 정리해본 영상이 없어요! + </VideosSubtitle> + <VideoButton> + <h2 className="button-text">영상 정리해보기</h2> + </VideoButton> + </div> )} {videos.length > 0 && ( <CardContainer> diff --git a/src/components/category/Card.tsx b/src/components/category/Card.tsx index f4302f0..4b38c5a 100644 --- a/src/components/category/Card.tsx +++ b/src/components/category/Card.tsx @@ -7,6 +7,7 @@ import { CategorySelectBox } from '@/components/SummaryPage/SummaryDetailBox/Cat import { categoryState } from '@/stores/category'; import * as CardStyles from '@/styles/category/Card.style'; +import Chip from '../common/chip/Chip'; interface ICardProps { mode: 'default' | 'category' | 'recommend'; @@ -65,7 +66,7 @@ const Card: React.FC<ICardProps> = ({ <CardStyles.Summary>{video.description}</CardStyles.Summary> <CardStyles.ChipWrap> {video.tag.map((tag) => ( - <CardStyles.Chip key={tag.name}>{`# ${tag.name}`}</CardStyles.Chip> + <Chip key={tag.name} name={tag.name} /> ))} </CardStyles.ChipWrap> </CardStyles.Content> diff --git a/src/components/category/DefaultMenu.tsx b/src/components/category/DefaultMenu.tsx new file mode 100644 index 0000000..eb355d2 --- /dev/null +++ b/src/components/category/DefaultMenu.tsx @@ -0,0 +1,68 @@ +import * as CategoryPageStyles from '@/styles/category/index.style'; +import { ISubFolderProps, ITagProps } from 'types/category'; +import Chip from '../common/chip/Chip'; +import ChangeBottomSvg from '@/assets/icons/change-bottom.svg?react'; +import ChangeTopSvg from '@/assets/icons/change-top.svg?react'; + +interface IDefaultMenuProps { + menus: ISubFolderProps[] | ITagProps[]; + recentRegisterMode: boolean; + selectedTags: string[]; + setSelectedTags: React.Dispatch<React.SetStateAction<string[]>>; + toggleRecentRegisterMode: () => void; +} + +const DefaultMenu = ({ + menus, + recentRegisterMode, + selectedTags, + setSelectedTags, + toggleRecentRegisterMode, +}: IDefaultMenuProps) => { + const onSelectTag = (name: string) => { + if (selectedTags.includes(name)) { + setSelectedTags(selectedTags.filter((tag) => tag !== name)); + } else { + setSelectedTags([...selectedTags, name]); + } + }; + return ( + <> + <div style={{ display: 'flex' }}> + {menus.map((menu: ISubFolderProps | ITagProps) => ( + <div key={menu.name}> + {'tag_id' in menu && ( + <Chip + key={menu.tag_id} + name={menu.name} + light + selected={selectedTags.includes(menu.name)} + onSelectTag={onSelectTag} + /> + )} + {!('tag_id' in menu) && ( + <CategoryPageStyles.Menu + to={`/category/${menu.topCategoryId}/${menu.categoryId}`} + key={`${menu.name}-${menu.categoryId}`} + > + {menu.name} + </CategoryPageStyles.Menu> + )} + </div> + ))} + </div> + <CategoryPageStyles.ModeWrap onClick={toggleRecentRegisterMode}> + <CategoryPageStyles.Mode> + {recentRegisterMode ? '최근등록순' : '최근영상순'} + </CategoryPageStyles.Mode> + {recentRegisterMode ? ( + <ChangeBottomSvg width={24} height={24} /> + ) : ( + <ChangeTopSvg width={24} height={24} /> + )} + </CategoryPageStyles.ModeWrap> + </> + ); +}; + +export default DefaultMenu; diff --git a/src/components/category/VideoSelectMenu.tsx b/src/components/category/VideoSelectMenu.tsx new file mode 100644 index 0000000..3685bb9 --- /dev/null +++ b/src/components/category/VideoSelectMenu.tsx @@ -0,0 +1,72 @@ +import * as CategoryPageStyles from '@/styles/category/index.style'; +import GarbageSvg from '@/assets/icons/garbage.svg?react'; +import CloseSvg from '@/assets/icons/close.svg?react'; +import { useState } from 'react'; +import { CategorySelectBox } from '../SummaryPage/SummaryDetailBox/CategorySelectBox'; +import { IFolderProps } from 'types/category'; + +interface IVideoSelectMenuProps { + categories: IFolderProps[]; + totalVideoCount: number; + checkedVideos: number[]; + setCheckedVideos: React.Dispatch<React.SetStateAction<number[]>>; + handleDeleteVideos: () => void; + allCheckBtnHandler: () => void; +} + +const VideoSelectMenu = ({ + categories, + totalVideoCount, + checkedVideos, + setCheckedVideos, + handleDeleteVideos, + allCheckBtnHandler, +}: IVideoSelectMenuProps) => { + const [selectedCategoryId, setSelectedCategoryId] = useState( + categories.length ? categories[0].categoryId : -1, + ); + + const handleSelectCategory = (categoryId: number) => { + setSelectedCategoryId(categoryId); + }; + + const onFileClick = (e: React.MouseEvent) => { + e.stopPropagation(); + // 비디오 이동 API 호출 후 모든 비디오 받아오는 API 재호출로 최신화하기 + }; + return ( + <CategoryPageStyles.SelectModeWrap> + <div> + <CategoryPageStyles.AllSelectBtn onClick={allCheckBtnHandler}> + {checkedVideos.length === totalVideoCount ? '모두 삭제' : '모두 선택'} + </CategoryPageStyles.AllSelectBtn> + <CategoryPageStyles.SelectedCount> + {checkedVideos.length}개 선택 + </CategoryPageStyles.SelectedCount> + </div> + <CategoryPageStyles.CardManagement> + <CategoryPageStyles.DropdownWrap> + <CategorySelectBox + selectedCategoryId={selectedCategoryId} + onSelect={handleSelectCategory} + onFileClick={onFileClick} + /> + </CategoryPageStyles.DropdownWrap> + <CategoryPageStyles.ManagementBoxGray onClick={handleDeleteVideos}> + <GarbageSvg width={28} height={28} /> + </CategoryPageStyles.ManagementBoxGray> + <CategoryPageStyles.ManagementBox> + <CloseSvg + width={28} + height={28} + onClick={() => { + setCheckedVideos([]); + }} + /> + </CategoryPageStyles.ManagementBox> + </CategoryPageStyles.CardManagement> + </CategoryPageStyles.SelectModeWrap> + ); +}; + +export default VideoSelectMenu; diff --git a/src/components/common/chip/Chip.style.ts b/src/components/common/chip/Chip.style.ts new file mode 100644 index 0000000..891e912 --- /dev/null +++ b/src/components/common/chip/Chip.style.ts @@ -0,0 +1,23 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; + +export const ChipContainer = styled.div` + cursor: pointer; + margin-right: 18px; + margin-bottom: 18px; + padding: 3px 9.5px; + background-color: ${theme.color.gray100}; + border-radius: 8px; + color: ${theme.color.gray400}; + ${theme.typography.Caption1}; + + &.light { + border: 1px solid ${theme.color.gray200}; + background-color: ${theme.color.white}; + } + + &.selected { + border-color: ${theme.color.gray300}; + background-color: ${theme.color.gray100}; + } +`; diff --git a/src/components/common/chip/Chip.tsx b/src/components/common/chip/Chip.tsx new file mode 100644 index 0000000..02a224c --- /dev/null +++ b/src/components/common/chip/Chip.tsx @@ -0,0 +1,19 @@ +import { ChipContainer } from './Chip.style'; + +interface IChipProps { + name: string; + light?: boolean; + selected?: boolean; + onSelectTag?: (name: string) => void; +} + +const Chip = ({ name, light, selected, onSelectTag }: IChipProps) => { + return ( + <ChipContainer + className={`${light && 'light'} ${selected && 'selected'}`} + onClick={() => onSelectTag && onSelectTag(name)} + >{`# ${name}`}</ChipContainer> + ); +}; + +export default Chip; diff --git a/src/hooks/useMoveCategory.ts b/src/hooks/useMoveCategory.ts index 3be900b..e1d61f2 100644 --- a/src/hooks/useMoveCategory.ts +++ b/src/hooks/useMoveCategory.ts @@ -25,7 +25,6 @@ const useMoveCategory = () => { grabedCategory.current!.categoryId, topId, ); - console.log(res); if (res.isSuccess) { await updateCategories(); navigate(`/category/${grabedCategory.current?.topCategoryId}`); diff --git a/src/pages/CategoryPage.tsx b/src/pages/CategoryPage.tsx index bbbfcc9..ac9eb6b 100644 --- a/src/pages/CategoryPage.tsx +++ b/src/pages/CategoryPage.tsx @@ -1,29 +1,28 @@ import CategoryTitle from '@/components/category/CategoryTitle'; import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import ChangeBottomSvg from '@/assets/icons/change-bottom.svg?react'; -import ChangeTopSvg from '@/assets/icons/change-top.svg?react'; -import GarbageSvg from '@/assets/icons/garbage.svg?react'; -import FolderSvg from '@/assets/icons/open-file.svg?react'; -import CloseSvg from '@/assets/icons/close.svg?react'; import * as CategoryPageStyles from '@/styles/category/index.style'; import Card from '@/components/category/Card'; import { useRecoilValue } from 'recoil'; import { categoryState } from '@/stores/category'; -import { ISubFolderProps } from 'types/category'; +import { ISubFolderProps, ITagProps } from 'types/category'; import EmptyCard from '@/components/category/EmptyCard'; -import { deleteVideos, getRecentVideos, getVideoById } from '@/apis/videos'; +import { deleteVideos, getRecentVideos } from '@/apis/videos'; import { IVideoProps } from 'types/videos'; import { sortVideos } from '@/utils/sortVideos'; import { CardContainer } from '@/styles/category/Card.style'; +import handleVideo from '@/utils/handleVideo'; +import VideoSelectMenu from '@/components/category/VideoSelectMenu'; +import DefaultMenu from '@/components/category/DefaultMenu'; const CategoryPage = () => { const params = useParams(); const [name, setName] = useState(''); - const [menus, setMenus] = useState<ISubFolderProps[]>([]); + const [menus, setMenus] = useState<ISubFolderProps[] | ITagProps[]>([]); const [videos, setVideos] = useState<IVideoProps[]>([]); const [recentRegisterMode, setRecentRegisterMode] = useState(false); const [checkedVideos, setCheckedVideos] = useState<number[]>([]); + const [selectedTags, setSelectedTags] = useState<string[]>([]); const categories = useRecoilValue(categoryState); const toggleRecentRegisterMode = () => @@ -37,19 +36,21 @@ const CategoryPage = () => { .then((res) => { setVideos(res.result.videos); setName('최근 읽은 영상'); + setMenus([]); }) .catch((err) => console.log(err)); } else { - getVideoById(Number(params.top_folder)).then((res) => { - const index = categories.findIndex( - (category) => category.categoryId === Number(params.top_folder), - ); - setVideos(res.result.videos); - setName(categories[index].name); - setMenus(categories[index].subFolders); - }); + handleVideo( + categories, + params.top_folder, + params.sub_folder!, + setMenus, + setName, + setVideos, + ); } - }, [categories, params.top_folder]); + setCheckedVideos([]); + }, [categories, params.sub_folder, params.top_folder]); const handleDeleteVideos = async () => { const res = await deleteVideos(checkedVideos); @@ -68,83 +69,31 @@ const CategoryPage = () => { if (checkedVideos.length === videos.length) { handleDeleteVideos(); } else { - console.log('모두 선택'); setCheckedVideos(videos.map((video) => video.video_id)); } }; - const dirMoveHanlder = () => { - console.log(checkedVideos); - }; - return ( <CategoryPageStyles.Container> <CategoryTitle name={name} totalVideos={sortedVideos.length} /> <CategoryPageStyles.MenuWrap> {checkedVideos.length > 0 ? ( - <> - <div> - <CategoryPageStyles.AllSelectBtn onClick={allCheckBtnHandler}> - {checkedVideos.length === sortedVideos.length - ? '모두 삭제' - : '모두 선택'} - </CategoryPageStyles.AllSelectBtn> - <CategoryPageStyles.SelectedCount> - {checkedVideos.length}개 선택 - </CategoryPageStyles.SelectedCount> - </div> - <CategoryPageStyles.CardManagement> - <CategoryPageStyles.SelectManagement> - {menus.map((menu) => ( - <option key={menu.name}>{menu.name}</option> - ))} - </CategoryPageStyles.SelectManagement> - <CategoryPageStyles.ManagementBoxGray onClick={dirMoveHanlder}> - <FolderSvg width={28} height={28} /> - </CategoryPageStyles.ManagementBoxGray> - <CategoryPageStyles.ManagementBoxGray - onClick={handleDeleteVideos} - > - <GarbageSvg width={28} height={28} /> - </CategoryPageStyles.ManagementBoxGray> - <CategoryPageStyles.ManagementBox> - <CloseSvg - width={28} - height={28} - onClick={() => { - setCheckedVideos([]); - }} - /> - </CategoryPageStyles.ManagementBox> - </CategoryPageStyles.CardManagement> - </> + <VideoSelectMenu + categories={categories} + totalVideoCount={sortedVideos.length} + checkedVideos={checkedVideos} + setCheckedVideos={setCheckedVideos} + handleDeleteVideos={handleDeleteVideos} + allCheckBtnHandler={allCheckBtnHandler} + /> ) : ( - <> - <div> - {menus.map((menu) => ( - <CategoryPageStyles.Menu - to={`/category/${menu.topCategoryId}/${menu.categoryId}`} - className={`${ - params.sub_folder === menu.categoryId.toString() && - 'activated' - }`} - key={`${menu.name}-${menu.categoryId}`} - > - {menu.name} - </CategoryPageStyles.Menu> - ))} - </div> - <CategoryPageStyles.ModeWrap onClick={toggleRecentRegisterMode}> - <CategoryPageStyles.Mode> - {recentRegisterMode ? '최근등록순' : '최근영상순'} - </CategoryPageStyles.Mode> - {recentRegisterMode ? ( - <ChangeBottomSvg width={24} height={24} /> - ) : ( - <ChangeTopSvg width={24} height={24} /> - )} - </CategoryPageStyles.ModeWrap> - </> + <DefaultMenu + menus={menus} + recentRegisterMode={recentRegisterMode} + selectedTags={selectedTags} + setSelectedTags={setSelectedTags} + toggleRecentRegisterMode={toggleRecentRegisterMode} + /> )} </CategoryPageStyles.MenuWrap> @@ -153,15 +102,29 @@ const CategoryPage = () => { )} {sortedVideos.length > 0 && ( <CardContainer> - {sortedVideos.map((video) => ( - <Card - mode="category" - video={video} - checkedVideos={checkedVideos} - setCheckedVideos={setCheckedVideos} - key={video.category_id} - /> - ))} + {sortedVideos.map((video) => { + // 하위 카테고리에 있을 때 태그 선택된 것에 따라 비디오 보여지게하는 로직 + const matchedTagCount = video.tag.reduce((acc, cur) => { + if (selectedTags.includes(cur.name)) return (acc += 1); + return acc; + }, 0); + if ( + params.sub_folder && + selectedTags.length && + matchedTagCount !== selectedTags.length + ) + return; + + return ( + <Card + mode="category" + video={video} + checkedVideos={checkedVideos} + setCheckedVideos={setCheckedVideos} + key={video.video_id} + /> + ); + })} </CardContainer> )} </CategoryPageStyles.Container> diff --git a/src/styles/HomepageStyle.ts b/src/styles/HomepageStyle.ts index f3bd6fc..a152ce2 100644 --- a/src/styles/HomepageStyle.ts +++ b/src/styles/HomepageStyle.ts @@ -142,10 +142,11 @@ export const RecentVideosContainer = styled.div` width: 910px; } - .empty-video{ + .empty-container { display: flex; - justify-content: center; - align-content: center; + flex-direction: column; + align-items: center; + text-align: center; } .empty-video img { @@ -192,7 +193,6 @@ export const VideosTitle = styled.h2` width: 910px; height: 45px; font-weight: bold; - `; export const VideosSubtitle = styled.h4` diff --git a/src/styles/category/Card.style.ts b/src/styles/category/Card.style.ts index 33760df..32a6263 100644 --- a/src/styles/category/Card.style.ts +++ b/src/styles/category/Card.style.ts @@ -143,13 +143,3 @@ export const DropdownWrap = styled.div` } } `; - -export const Chip = styled.div` - margin-right: 18px; - margin-bottom: 18px; - padding: 3px 9.5px; - background-color: ${theme.color.gray100}; - border-radius: 8px; - color: ${theme.color.gray400}; - ${theme.typography.Caption1}; -`; diff --git a/src/styles/category/index.style.ts b/src/styles/category/index.style.ts index f03b5b4..399d20f 100644 --- a/src/styles/category/index.style.ts +++ b/src/styles/category/index.style.ts @@ -2,6 +2,17 @@ import styled from 'styled-components'; import theme from '../theme'; import { Link } from 'react-router-dom'; +const CommonIconBackground = styled.div` + cursor: pointer; + width: 40px; + height: 40px; + border-radius: 8px; + + display: flex; + align-items: center; + justify-content: center; +`; + export const Container = styled.div` padding: 60px 60px 0px 120px; width: 100%; @@ -9,6 +20,7 @@ export const Container = styled.div` export const MenuWrap = styled.div` display: flex; + min-width: 'fit-content'; justify-content: space-between; align-items: center; margin-bottom: 40px; @@ -20,7 +32,7 @@ export const Menu = styled(Link)` color: ${theme.color.gray300}; margin-right: 20px; - &.activated { + &:hover { color: ${theme.color.gray500}; ${theme.typography.Subheader1}; } @@ -40,6 +52,13 @@ export const Mode = styled.span` color: ${theme.color.gray400}; `; +export const SelectModeWrap = styled.div` + display: flex; + justify-content: space-between; + width: 100%; + align-items: center; +`; + export const CardManagement = styled.div` display: flex; flex-direction: row; @@ -55,29 +74,16 @@ export const SelectManagement = styled.select` color: ${theme.color.gray400}; `; -export const ManagementBoxGray = styled.div` - width: 36px; - height: 34px; - border-radius: 8px; - - display: flex; - align-items: center; - justify-content: center; +export const ManagementBoxGray = styled(CommonIconBackground)` background: ${theme.color.gray100}; `; -export const ManagementBox = styled.div` - width: 36px; - height: 34px; - border-radius: 8px; - - display: flex; - align-items: center; - justify-content: center; +export const ManagementBox = styled(CommonIconBackground)` background: ${theme.color.white}; `; export const AllSelectBtn = styled.button` + cursor: pointer; width: 90px; height: 28px; background: ${theme.color.gray500}; @@ -94,3 +100,31 @@ export const SelectedCount = styled.span` padding: 0px 10px; color: ${theme.color.gray400}; `; + +export const DropdownWrap = styled.div` + margin: 0; + display: flex; + flex-direction: column; + z-index: 10; + & div.select-box { + padding: 8px 16px; + width: 202px; + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + border-radius: 8px; + border: solid 1px ${theme.color.gray200}; + color: ${theme.color.gray400}; + ${theme.typography.Body3}; + cursor: pointer; + } + & span.icon-button { + padding: 5px 6px; + width: 40px; + height: 40px; + border-radius: 8px; + cursor: pointer; + background-color: ${theme.color.gray100}; + } +`; diff --git a/src/utils/handleVideo.ts b/src/utils/handleVideo.ts new file mode 100644 index 0000000..f6301a3 --- /dev/null +++ b/src/utils/handleVideo.ts @@ -0,0 +1,36 @@ +import { getCategoryTags } from '@/apis/category'; +import { getVideoById } from '@/apis/videos'; +import { IFolderProps, ISubFolderProps, ITagProps } from 'types/category'; +import { IVideoProps } from 'types/videos'; + +const handleVideo = async ( + categories: IFolderProps[], + topCategoryId: string, + subCategoryId: string, + setMenus: React.Dispatch< + React.SetStateAction<ISubFolderProps[] | ITagProps[]> + >, + setName: React.Dispatch<React.SetStateAction<string>>, + setVideos: React.Dispatch<React.SetStateAction<IVideoProps[]>>, +) => { + await getVideoById(topCategoryId).then(async (res) => { + const topCategory = categories.find( + (category) => category.categoryId === Number(topCategoryId), + ); + if (subCategoryId) { + const subName = topCategory?.subFolders.find( + (subFolder) => subFolder.categoryId === Number(subCategoryId), + ); + setName(subName!.name); + await getCategoryTags(subCategoryId!).then((res) => { + if (res.isSuccess) setMenus(res.result.tags); + else setMenus([]); + }); + } else { + setName(topCategory!.name); + setMenus(topCategory!.subFolders); + } + setVideos(res.isSuccess ? res.result.videos : []); + }); +}; +export default handleVideo; diff --git a/src/utils/sortVideos.ts b/src/utils/sortVideos.ts index c711277..62af05b 100644 --- a/src/utils/sortVideos.ts +++ b/src/utils/sortVideos.ts @@ -9,13 +9,13 @@ export const sortVideos = ( prevVideo[isRecentRegisterMode ? 'created_at' : 'youtube_created_at'] > nextVideo[isRecentRegisterMode ? 'created_at' : 'youtube_created_at'] ) - return 1; + return -1; if ( prevVideo[isRecentRegisterMode ? 'created_at' : 'youtube_created_at'] === nextVideo[isRecentRegisterMode ? 'created_at' : 'youtube_created_at'] ) return 0; - return -1; + return 1; }); return sortedVideos; }; diff --git a/types/category.ts b/types/category.ts index 760296a..ac6a12b 100644 --- a/types/category.ts +++ b/types/category.ts @@ -15,3 +15,8 @@ export interface ISelectedCategoryProps { name: string; categoryId: number; } + +export interface ITagProps { + tag_id: number; + name: string; +}