diff --git a/frontend/src/apis/fetcher.ts b/frontend/src/apis/fetcher.ts index ff0eac6cf..c3897e920 100644 --- a/frontend/src/apis/fetcher.ts +++ b/frontend/src/apis/fetcher.ts @@ -3,7 +3,8 @@ import * as Sentry from '@sentry/react'; interface RequestProps { url: string; method: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT'; - errorMessage: string; + errorMessage?: string; + //TODO: errorMessage 제거 body?: string; headers?: Record; } @@ -11,7 +12,7 @@ interface RequestProps { type FetchProps = Omit; const fetcher = { - async request({ url, method, errorMessage, body, headers }: RequestProps): Promise { + async request({ url, method, body, headers, errorMessage }: RequestProps): Promise { try { const response = await fetch(url, { method, @@ -20,10 +21,15 @@ const fetcher = { credentials: 'include', }); - if (!response.ok) throw new Error(errorMessage); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || errorMessage); + } + return response; } catch (error) { - if (!(error instanceof Error)) throw new Error(errorMessage); + if (!(error instanceof Error)) (error as { message: string }).message; + Sentry.captureException(error); throw error; } @@ -40,7 +46,7 @@ const fetcher = { }); }, delete(props: FetchProps) { - return this.request({ ...props, method: 'DELETE' }); + return this.request({ ...props, method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); }, patch(props: FetchProps) { return this.request({ diff --git a/frontend/src/apis/referenceLink/category.ts b/frontend/src/apis/referenceLink/category.ts new file mode 100644 index 000000000..dc0ae6066 --- /dev/null +++ b/frontend/src/apis/referenceLink/category.ts @@ -0,0 +1,61 @@ +import fetcher from '@/apis/fetcher'; + +import { ERROR_MESSAGES } from '@/constants/message'; + +const API_URL = process.env.REACT_APP_API_URL; + +interface GetCategoriesResponse { + value: string; + id: string; +} + +export const getCategories = async (accessCode: string): Promise => { + const response = await fetcher.get({ + url: `${API_URL}/${accessCode}/category`, + errorMessage: ERROR_MESSAGES.GET_CATEGORIES, + }); + + return await response.json(); +}; + +interface AddCategoryRequest { + accessCode: string; + category: string; +} +export const addCategory = async ({ category, accessCode }: AddCategoryRequest) => { + const response = await fetcher.post({ + url: `${API_URL}/${accessCode}/category`, + body: JSON.stringify({ value: category }), + errorMessage: ERROR_MESSAGES.ADD_CATEGORY, + }); + + return await response.json(); +}; + +interface DeleteCategoryRequest { + accessCode: string; + categoryName: string; +} + +export const deleteCategory = async ({ categoryName, accessCode }: DeleteCategoryRequest) => { + await fetcher.delete({ + url: `${API_URL}/${accessCode}/category/${categoryName}`, + }); +}; + +interface UpdateCategoryRequest { + accessCode: string; + previousCategoryName: string; + updatedCategoryName: string; +} + +export const updateCategory = async ({ + previousCategoryName, + updatedCategoryName, + accessCode, +}: UpdateCategoryRequest) => { + await fetcher.patch({ + url: `${API_URL}/${accessCode}/category`, + body: JSON.stringify({ previousCategoryName, updatedCategoryName }), + }); +}; diff --git a/frontend/src/apis/referenceLink.ts b/frontend/src/apis/referenceLink/referenceLink.ts similarity index 63% rename from frontend/src/apis/referenceLink.ts rename to frontend/src/apis/referenceLink/referenceLink.ts index 88619781a..1110e05e1 100644 --- a/frontend/src/apis/referenceLink.ts +++ b/frontend/src/apis/referenceLink/referenceLink.ts @@ -11,15 +11,20 @@ export interface Link { openGraphTitle: string; description: string; image: string; + categoryName: string; } interface GetReferenceLinksRequest { accessCode: string; + currentCategory: string; } -export const getReferenceLinks = async ({ accessCode }: GetReferenceLinksRequest): Promise => { +export const getReferenceLinks = async ({ accessCode, currentCategory }: GetReferenceLinksRequest): Promise => { + const categoryName = encodeURIComponent(currentCategory); + const categoryParamsUrl = currentCategory === '전체' ? `` : `?categoryName=${categoryName}`; + const response = await fetcher.get({ - url: `${API_URL}/${accessCode}/reference-link`, + url: `${API_URL}/${accessCode}/reference-link${categoryParamsUrl}`, errorMessage: ERROR_MESSAGES.GET_REFERENCE_LINKS, }); @@ -29,12 +34,13 @@ export const getReferenceLinks = async ({ accessCode }: GetReferenceLinksRequest interface AddReferenceLinkRequest { url: string; accessCode: string; + category: string | null; } -export const addReferenceLink = async ({ url, accessCode }: AddReferenceLinkRequest) => { +export const addReferenceLink = async ({ url, accessCode, category }: AddReferenceLinkRequest) => { await fetcher.post({ url: `${API_URL}/${accessCode}/reference-link`, - body: JSON.stringify({ url }), + body: JSON.stringify({ url, categoryName: category }), errorMessage: ERROR_MESSAGES.ADD_REFERENCE_LINKS, }); }; diff --git a/frontend/src/assets/images/check_box_checked.svg b/frontend/src/assets/images/check_box_checked.svg new file mode 100644 index 000000000..c98d8bc85 --- /dev/null +++ b/frontend/src/assets/images/check_box_checked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/images/check_box_unchecked.svg b/frontend/src/assets/images/check_box_unchecked.svg new file mode 100644 index 000000000..4efd76f9a --- /dev/null +++ b/frontend/src/assets/images/check_box_unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/index.ts b/frontend/src/assets/index.ts index c1736d711..6d5128deb 100644 --- a/frontend/src/assets/index.ts +++ b/frontend/src/assets/index.ts @@ -1,4 +1,6 @@ import AlarmSound from '@/assets/audio/alarm_sound.mp3'; +import CheckBoxChecked from '@/assets/images/check_box_checked.svg'; +import CheckBoxUnchecked from '@/assets/images/check_box_unchecked.svg'; import GithubLogoWhite from '@/assets/images/github-mark-white.png'; import GithubLogo from '@/assets/images/github-mark.png'; import LogoIcon from '@/assets/images/logo_icon.svg'; @@ -6,4 +8,14 @@ import LogoIconWithTitle from '@/assets/images/logo_icon_with_title.svg'; import LogoTitle from '@/assets/images/logo_title.svg'; import Wave from '@/assets/images/wave.svg'; -export { GithubLogo, GithubLogoWhite, LogoIcon, LogoIconWithTitle, LogoTitle, Wave, AlarmSound }; +export { + GithubLogo, + GithubLogoWhite, + CheckBoxUnchecked, + CheckBoxChecked, + LogoIcon, + LogoIconWithTitle, + LogoTitle, + Wave, + AlarmSound, +}; diff --git a/frontend/src/components/PairRoom/PairRoomCard/Header/Header.styles.ts b/frontend/src/components/PairRoom/PairRoomCard/Header/Header.styles.ts index f08cbd65a..92ee3f51c 100644 --- a/frontend/src/components/PairRoom/PairRoomCard/Header/Header.styles.ts +++ b/frontend/src/components/PairRoom/PairRoomCard/Header/Header.styles.ts @@ -10,6 +10,7 @@ export const Layout = styled.div<{ $isOpen: boolean }>` padding: 2rem; font-size: ${({ theme }) => theme.fontSize.lg}; + cursor: pointer; `; export const TitleContainer = styled.div` diff --git a/frontend/src/components/PairRoom/ReferenceCard/AddReferenceForm/AddReferenceForm.styles.ts b/frontend/src/components/PairRoom/ReferenceCard/AddReferenceForm/AddReferenceForm.styles.ts new file mode 100644 index 000000000..4a8255b1e --- /dev/null +++ b/frontend/src/components/PairRoom/ReferenceCard/AddReferenceForm/AddReferenceForm.styles.ts @@ -0,0 +1,46 @@ +import styled, { css } from 'styled-components'; + +export const Form = styled.form` + display: flex; + align-items: center; + gap: 2rem; + + width: 75%; + padding: 0 2rem; +`; + +export const ButtonContainer = styled.div` + display: flex; + gap: 0.6rem; +`; + +export const inputStyles = css` + height: 4rem; +`; +export const FooterButton = styled.button` + display: flex; + align-items: center; + gap: 1rem; + + width: 100%; + height: 6rem; + padding: 2rem; + border-radius: 0 0 1.5rem 1.5rem; + + color: ${({ theme }) => theme.color.black[70]}; + font-size: ${({ theme }) => theme.fontSize.base}; + + transition: all 0.2s ease 0s; + + &:hover { + background-color: ${({ theme }) => theme.color.black[20]}; + } +`; + +export const ReferenceFormContainer = styled.div` + display: flex; + width: 100%; + height: 6rem; + align-items: center; + padding-left: 1rem; +`; diff --git a/frontend/src/components/PairRoom/ReferenceCard/AddReferenceForm/AddReferenceForm.tsx b/frontend/src/components/PairRoom/ReferenceCard/AddReferenceForm/AddReferenceForm.tsx new file mode 100644 index 000000000..6e493bc1b --- /dev/null +++ b/frontend/src/components/PairRoom/ReferenceCard/AddReferenceForm/AddReferenceForm.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; + +import { LuPlus } from 'react-icons/lu'; + +import Button from '@/components/common/Button/Button'; +import Dropdown from '@/components/common/Dropdown/Dropdown/Dropdown'; +import Input from '@/components/common/Input/Input'; + +import useInput from '@/hooks/common/useInput'; + +import { BUTTON_TEXT } from '@/constants/button'; + +import * as S from './AddReferenceForm.styles'; + +interface ReferenceFormProps { + handleAddReferenceLink: (value: string, category: string | null) => void; + categories: string[]; +} +const AddReferenceForm = ({ categories, handleAddReferenceLink }: ReferenceFormProps) => { + const { value, status, message, handleChange, resetValue } = useInput(); + const [isFooterOpen, setIsFooterOpen] = useState(false); + const [currentCategory, setCurrentCategory] = useState(null); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const category = currentCategory === '카테고리 없음' ? null : currentCategory; + handleAddReferenceLink(value, category); + + resetValue(); + }; + const handleCategory = (option: string | null) => { + setCurrentCategory(option); + }; + + const newCategories = [...categories, '카테고리 없음']; + + return isFooterOpen ? ( + + handleCategory(option)} + direction="upper" + /> + + + + + + + + + ) : ( + setIsFooterOpen(true)}> + + 링크 추가하기 + + ); +}; + +export default AddReferenceForm; diff --git a/frontend/src/components/PairRoom/ReferenceCard/Bookmark/Bookmark.styles.ts b/frontend/src/components/PairRoom/ReferenceCard/Bookmark/Bookmark.styles.ts index db5749e92..9b319b5dd 100644 --- a/frontend/src/components/PairRoom/ReferenceCard/Bookmark/Bookmark.styles.ts +++ b/frontend/src/components/PairRoom/ReferenceCard/Bookmark/Bookmark.styles.ts @@ -82,18 +82,17 @@ export const Content = styled.p` export const DeleteButton = styled(MdClose)` position: absolute; - top: 1.2rem; - right: 1.2rem; - z-index: 3; - width: 2rem; height: 2rem; padding: 0.3rem; border-radius: 100%; - background-color: ${({ theme }) => theme.color.black[80]}; - opacity: 0.5; - color: ${({ theme }) => theme.color.black[50]}; + right: 1rem; + top: 1rem; + + background-color: ${({ theme }) => theme.color.black[90]}; + opacity: 0.6; + color: ${({ theme }) => theme.color.black[20]}; cursor: pointer; @@ -101,3 +100,18 @@ export const DeleteButton = styled(MdClose)` opacity: 1; } `; + +export const Header = styled.div` + padding: 0 1rem; + display: flex; + + justify-content: space-between; + position: absolute; + top: 1.2rem; + width: 100%; + + button { + width: fit-content; + padding: 0 1rem; + } +`; diff --git a/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryBox.tsx b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryBox.tsx new file mode 100644 index 000000000..f4260df06 --- /dev/null +++ b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryBox.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import Input from '@/components/common/Input/Input'; +import { Message } from '@/components/common/Input/Input.styles'; +import { InputStatus } from '@/components/common/Input/Input.type'; +import CategoryItem from '@/components/PairRoom/ReferenceCard/CategoryFilter/CategoryItem'; +import IconButton from '@/components/PairRoom/ReferenceCard/CategoryFilter/IconButton'; + +import useInput from '@/hooks/common/useInput'; + +import useDeleteCategory from '@/queries/PairRoom/category/useDeleteCategory'; +import useGetCategories from '@/queries/PairRoom/category/useGetCategories'; +import useUpdateCategory from '@/queries/PairRoom/category/useUpdateCategory'; + +import * as S from './CategoryFilter.styles'; + +interface CategoryBoxProps { + category: string; + isChecked: boolean; + handleSelectCategory: (category: string) => void; +} + +const CategoryBox = ({ category, isChecked, handleSelectCategory }: CategoryBoxProps) => { + const [isEditing, setIsEditing] = useState(false); + const { value, handleChange, resetValue, message, status } = useInput(category); + const { accessCode } = useParams() as { accessCode: string }; + const { deleteCategoryMutation } = useDeleteCategory(); + const { updateCategoryMutation } = useUpdateCategory(() => resetValue()); + const { isCategoryExist } = useGetCategories(accessCode); + + const updateCategory = async (event: React.FormEvent) => { + event.preventDefault(); + if (status === 'ERROR') return; + + await updateCategoryMutation({ updatedCategoryName: value, previousCategoryName: category, accessCode }); + + setIsEditing(false); + handleSelectCategory('전체'); + }; + + const handleCancel = () => { + setIsEditing(false); + resetValue(); + }; + + const validateCategoryName = (category: string): { status: InputStatus; message: string } => { + if (category.length >= 8) + return { + status: 'ERROR', + message: '8자 이하로 입력해주세요', + }; + if (isCategoryExist(category) || category === '전체') + return { + status: 'ERROR', + message: '중복된 카테고리 입니다.', + }; + return { + status: 'DEFAULT', + message: '', + }; + }; + + const handleEdit = () => setIsEditing(true); + + const deleteCategory = () => { + deleteCategoryMutation({ categoryName: category, accessCode }); + if (!isChecked) return; + handleSelectCategory('전체'); + }; + + return ( + <> + { + handleSelectCategory(category); + }} + //TODO: 방 정보 기능 구현 시 state 삭제 + > + {isEditing ? ( + updateCategory(event)}> + + { + handleChange(event, validateCategoryName(event.target.value)); + }} + status={status} + /> + + + + + + + {message && {message}} + + ) : ( + <> + + + {category !== '전체' && ( + + + + + )} + + )} + + + ); +}; + +export default CategoryBox; diff --git a/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryFilter.styles.ts b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryFilter.styles.ts new file mode 100644 index 000000000..12dd6bda3 --- /dev/null +++ b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryFilter.styles.ts @@ -0,0 +1,94 @@ +import styled from 'styled-components'; + +export const Categories = styled.ul` + display: flex; + flex-direction: column-reverse; + width: 100%; + gap: 1rem; +`; + +export const Category = styled.li<{ $isChecked: boolean }>` + font-size: ${({ theme }) => theme.fontSize.md}; + width: 100%; + border: 1px solid ${({ theme }) => theme.color.black[50]}; + border-radius: 0.5rem; + + height: 4.8rem; + display: flex; + align-items: center; + justify-content: space-between; + + color: ${({ theme, $isChecked }) => ($isChecked ? theme.color.black[10] : theme.color.black[70])}; + background-color: ${({ theme, $isChecked }) => ($isChecked ? theme.color.primary[700] : theme.color.black[10])}; + + padding: 0 1rem; + + transition: all 0.2s ease-out; + + &:hover { + color: ${({ theme }) => theme.color.black[10]}; + background-color: ${({ theme }) => theme.color.primary[700]}; + } + &:active { + color: ${({ theme }) => theme.color.black[10]}; + background-color: ${({ theme }) => theme.color.primary[800]}; + } +`; + +export const CategoryInput = styled.input` + width: 100%; + border: 1px solid; +`; + +export const CategoryBox = styled.div` + display: flex; + gap: 2.5rem; + cursor: pointer; + width: 100%; +`; + +export const CategoryIconsBox = styled.div` + display: flex; + gap: 0.3rem; + align-items: center; +`; + +export const IconsButton = styled.button` + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + color: ${({ theme }) => theme.color.primary[700]}; + transition: all 0.3s; + padding: 0.5rem; + border-radius: 0.3rem; + &:hover { + background-color: ${({ theme }) => theme.color.black[30]}; + color: ${({ theme }) => theme.color.primary[800]}; + } +`; + +export const EditFrom = styled.form` + display: flex; + flex-direction: column; + width: 100%; + gap: 1rem; +`; + +export const CategoryItemContainer = styled.button` + display: flex; + width: 29rem; + + align-items: center; + gap: 1rem; + cursor: pointer; + img { + width: 2rem; + } +`; + +export const CategoryItemInputBox = styled.div` + display: flex; + gap: 2.7rem; + align-items: center; +`; diff --git a/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryFilter.tsx b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryFilter.tsx new file mode 100644 index 000000000..d2531ca83 --- /dev/null +++ b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryFilter.tsx @@ -0,0 +1,32 @@ +import Category from '@/components/PairRoom/ReferenceCard/CategoryFilter/CategoryBox'; + +import * as S from './CategoryFilter.styles'; + +interface CategoryRecord { + value: string; + id: string; +} +interface CategoryFilterProps { + categories: CategoryRecord[]; + selectedCategory: string; + handleSelectCategory: (category: string) => void; +} + +const CategoryFilter = ({ categories, selectedCategory, handleSelectCategory }: CategoryFilterProps) => { + const isChecked = (category: string) => category === selectedCategory; + const newCategories = [...categories, { id: 0, value: '전체' }]; + return ( + + {newCategories.map((category) => ( + + ))} + + ); +}; + +export default CategoryFilter; diff --git a/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryItem.tsx b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryItem.tsx new file mode 100644 index 000000000..083256012 --- /dev/null +++ b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/CategoryItem.tsx @@ -0,0 +1,32 @@ +import { CheckBoxChecked, CheckBoxUnchecked } from '@/assets'; + +import * as S from './CategoryFilter.styles'; + + +interface CategoryItemProps { + isChecked: boolean; + category: string; + handleSelectCategory: (category: string) => void; + id: string; +} + +const CategoryItem = ({ id, isChecked, category, handleSelectCategory }: CategoryItemProps) => { + return ( + ) => { + handleSelectCategory(event.currentTarget.id); + }} + > + {isChecked + + +

{category}

+
+
+ ); +}; +export default CategoryItem; diff --git a/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/IconButton.tsx b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/IconButton.tsx new file mode 100644 index 000000000..92d744e0f --- /dev/null +++ b/frontend/src/components/PairRoom/ReferenceCard/CategoryFilter/IconButton.tsx @@ -0,0 +1,33 @@ +import { AiFillDelete } from 'react-icons/ai'; +import { FaPencilAlt, FaCheck } from 'react-icons/fa'; +import { GiCancel } from 'react-icons/gi'; + +import * as S from './CategoryFilter.styles'; + +type Icon = 'CHECK' | 'EDIT' | 'DELETE' | 'CANCEL'; + +interface IconButtonProps { + onClick?: () => void; + icon: Icon; +} + +const IconButton = ({ onClick, icon }: IconButtonProps) => { + const GET_ICON = { + CHECK: , + EDIT: , + DELETE: , + CANCEL: , + }; + return ( + { + event.stopPropagation(); + onClick && onClick(); + }} + > + {GET_ICON[icon]} + + ); +}; + +export default IconButton; diff --git a/frontend/src/components/PairRoom/ReferenceCard/ReferenceCard.styles.ts b/frontend/src/components/PairRoom/ReferenceCard/ReferenceCard.styles.ts index 1c77415a1..23a62bdc1 100644 --- a/frontend/src/components/PairRoom/ReferenceCard/ReferenceCard.styles.ts +++ b/frontend/src/components/PairRoom/ReferenceCard/ReferenceCard.styles.ts @@ -1,65 +1,61 @@ -import styled, { css } from 'styled-components'; - -export const inputStyles = css` - height: 4rem; -`; +import styled from 'styled-components'; export const Layout = styled.div` min-width: 49rem; max-height: calc(100vh - 23rem); `; -export const Body = styled.div` +export const Body = styled.div<{ $isOpen: boolean }>` display: flex; - flex-direction: column; + overflow: hidden; - height: calc(100vh - 25rem); - min-height: 42rem; + flex-direction: column; + transition: all 0.3s; + height: ${({ $isOpen }) => ($isOpen ? 'calc(100vh - 25rem)' : '0')}; + /* min-height: 42rem; */ border-top: 1px solid ${({ theme }) => theme.color.black[30]}; `; -export const Form = styled.form` +export const Footer = styled.div` display: flex; + flex-direction: column; align-items: center; - gap: 4rem; width: 100%; - padding: 0 2rem; + /* height: 12rem; */ + min-height: 6rem; + gap: 1rem; + + border-top: 1px solid ${({ theme }) => theme.color.black[30]}; `; -export const ButtonContainer = styled.div` +export const CategoryBox = styled.div` display: flex; - gap: 0.6rem; + gap: 1rem; `; -export const Footer = styled.div` +export const CategoryModalHeader = styled.div` display: flex; + font-size: ${({ theme }) => theme.fontSize.lg}; + color: ${({ theme }) => theme.color.black[80]}; + gap: 1rem; align-items: center; +`; +export const AddNewCategoryBox = styled.form` width: 100%; - height: 6rem; - min-height: 6rem; - - border-top: 1px solid ${({ theme }) => theme.color.black[30]}; + display: flex; + flex-direction: column; + gap: 1rem; + border-top: 1px solid ${({ theme }) => theme.color.black[40]}; + padding-top: 2rem; + justify-content: space-between; + height: 7rem; `; -export const FooterButton = styled.button` +export const AddNewCategoryInputBox = styled.div` display: flex; + gap: 0.5rem; align-items: center; - gap: 1rem; - - width: 100%; - height: 6rem; - padding: 2rem; - border-radius: 0 0 1.5rem 1.5rem; - - color: ${({ theme }) => theme.color.black[70]}; - font-size: ${({ theme }) => theme.fontSize.base}; - - transition: all 0.2s ease 0s; - - &:hover { - background-color: ${({ theme }) => theme.color.black[20]}; - } `; diff --git a/frontend/src/components/PairRoom/ReferenceCard/ReferenceCard.tsx b/frontend/src/components/PairRoom/ReferenceCard/ReferenceCard.tsx index e916df8f5..b620660f9 100644 --- a/frontend/src/components/PairRoom/ReferenceCard/ReferenceCard.tsx +++ b/frontend/src/components/PairRoom/ReferenceCard/ReferenceCard.tsx @@ -1,15 +1,24 @@ import { useState } from 'react'; +import { FaFilter } from 'react-icons/fa'; import { IoIosLink } from 'react-icons/io'; -import { LuPlus } from 'react-icons/lu'; +import { css } from 'styled-components'; import Button from '@/components/common/Button/Button'; import Input from '@/components/common/Input/Input'; +import { Message } from '@/components/common/Input/Input.styles'; +import { InputStatus } from '@/components/common/Input/Input.type'; +import { Modal } from '@/components/common/Modal'; import { PairRoomCard } from '@/components/PairRoom/PairRoomCard'; +import AddReferenceForm from '@/components/PairRoom/ReferenceCard/AddReferenceForm/AddReferenceForm'; +import CategoryFilter from '@/components/PairRoom/ReferenceCard/CategoryFilter/CategoryFilter'; import ReferenceList from '@/components/PairRoom/ReferenceCard/ReferenceList/ReferenceList'; import useInput from '@/hooks/common/useInput'; +import useModal from '@/hooks/common/useModal'; +import useAddCategory from '@/queries/PairRoom/category/useAddCategory'; +import useGetCategories from '@/queries/PairRoom/category/useGetCategories'; import useReferenceLinks from '@/queries/PairRoom/useReferenceLinks'; import { theme } from '@/styles/theme'; @@ -23,69 +32,143 @@ interface ReferenceCardProps { } const ReferenceCard = ({ accessCode, isOpen, toggleIsOpen }: ReferenceCardProps) => { - const [isFooterOpen, setIsFooterOpen] = useState(false); + const { categories, categoryRecord, isCategoryExist } = useGetCategories(accessCode); - const { value, status, message, handleChange, resetValue } = useInput(); - const { referenceLinks, handleAddReferenceLink, handleDeleteReferenceLink } = useReferenceLinks(accessCode); + const [selectedCategory, setSelectedCategory] = useState('전체'); + const handleSelectCategory = (category: string) => { + setSelectedCategory(category); + }; - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); + const { referenceLinks, handleAddReferenceLink, handleDeleteReferenceLink } = useReferenceLinks( + accessCode, + selectedCategory, + ); - handleAddReferenceLink(value); + const { isModalOpen, openModal, closeModal } = useModal(); + const { value, handleChange, resetValue, message, status } = useInput(''); + const { addCategory } = useAddCategory(); + const validateCategoryName = (category: string): { status: InputStatus; message: string } => { + if (category.length >= 8) + return { + status: 'ERROR', + message: '8자 이하로 입력해주세요', + }; + if (isCategoryExist(category)) + return { + status: 'ERROR', + message: '중복된 카테고리 입니다.', + }; + return { + status: 'DEFAULT', + message: '', + }; + }; + const closeCategoryModal = () => { + closeModal(); resetValue(); - setIsFooterOpen(false); }; - return ( - - - } - title="링크" - isOpen={isOpen} - toggleIsOpen={toggleIsOpen} - /> - {isOpen && ( - + <> + + + + +

카테고리 선택

+
+
+ + + + + + { + event.preventDefault(); + if (status === 'ERROR') return; + addCategory({ category: value, accessCode }); + resetValue(); + }} + > + + handleChange(event, validateCategoryName(event.target.value))} + status={status} + $css={css` + font-size: ${({ theme }) => theme.fontSize.md}; + width: 75%; + border: none; + width: 100%; + `} + /> + + + + {message} + +
+ + + } + title="링크" + isOpen={isOpen} + toggleIsOpen={toggleIsOpen} + > + + {selectedCategory && ( + + )} + + + + - {isFooterOpen ? ( - - - - - - - - ) : ( - setIsFooterOpen(true)}> - - 링크 추가하기 - - )} + - )} - - +
+
+ ); }; diff --git a/frontend/src/components/PairRoom/ReferenceCard/ReferenceList/ReferenceList.styles.ts b/frontend/src/components/PairRoom/ReferenceCard/ReferenceList/ReferenceList.styles.ts index ef5001df4..92e3118b3 100644 --- a/frontend/src/components/PairRoom/ReferenceCard/ReferenceList/ReferenceList.styles.ts +++ b/frontend/src/components/PairRoom/ReferenceCard/ReferenceList/ReferenceList.styles.ts @@ -27,6 +27,7 @@ export const List = styled.ul` `; export const EmptyLayout = styled.div` + height: 0; flex-grow: 1; padding: 2rem; diff --git a/frontend/src/components/PairRoom/ReferenceCard/ReferenceList/ReferenceList.tsx b/frontend/src/components/PairRoom/ReferenceCard/ReferenceList/ReferenceList.tsx index 1d140b761..576a45190 100644 --- a/frontend/src/components/PairRoom/ReferenceCard/ReferenceList/ReferenceList.tsx +++ b/frontend/src/components/PairRoom/ReferenceCard/ReferenceList/ReferenceList.tsx @@ -1,6 +1,6 @@ import Bookmark from '@/components/PairRoom/ReferenceCard/Bookmark/Bookmark'; -import type { Link } from '@/apis/referenceLink'; +import type { Link } from '@/apis/referenceLink/referenceLink'; import * as S from './ReferenceList.styles'; diff --git a/frontend/src/components/PairRoom/TodoListCard/TodoListCard.styles.ts b/frontend/src/components/PairRoom/TodoListCard/TodoListCard.styles.ts index 8d16b29ca..a009e1ae3 100644 --- a/frontend/src/components/PairRoom/TodoListCard/TodoListCard.styles.ts +++ b/frontend/src/components/PairRoom/TodoListCard/TodoListCard.styles.ts @@ -8,12 +8,16 @@ export const Layout = styled.div` min-width: 49rem; `; -export const Body = styled.div` +export const Body = styled.div<{ $isOpen: boolean }>` + height: ${({ $isOpen }) => ($isOpen ? 'calc(100vh - 25rem)' : '0')}; + overflow: hidden; + display: flex; flex-direction: column; + transition: all 0.3s; - height: calc(100vh - 25rem); - min-height: 42rem; + /* height: calc(100vh - 25rem); */ + /* min-height: 42rem; */ border-top: 1px solid ${({ theme }) => theme.color.black[30]}; `; diff --git a/frontend/src/components/PairRoom/TodoListCard/TodoListCard.tsx b/frontend/src/components/PairRoom/TodoListCard/TodoListCard.tsx index f598ed492..614f1e0ba 100644 --- a/frontend/src/components/PairRoom/TodoListCard/TodoListCard.tsx +++ b/frontend/src/components/PairRoom/TodoListCard/TodoListCard.tsx @@ -45,37 +45,29 @@ const TodoListCard = ({ isOpen, toggleIsOpen }: TodoListCardProps) => { isOpen={isOpen} toggleIsOpen={toggleIsOpen} /> - {isOpen && ( - - - - {isFooterOpen ? ( - - - - - - - - ) : ( - setIsFooterOpen(true)}> - - 투두 추가하기 - - )} - - - )} + + + + {isFooterOpen ? ( + + + + + + + + ) : ( + setIsFooterOpen(true)}> + + 투두 추가하기 + + )} + + ); diff --git a/frontend/src/components/common/Dropdown/Dropdown/Dropdown.styles.tsx b/frontend/src/components/common/Dropdown/Dropdown/Dropdown.styles.tsx index 3c3a6b1b5..2fa0de978 100644 --- a/frontend/src/components/common/Dropdown/Dropdown/Dropdown.styles.tsx +++ b/frontend/src/components/common/Dropdown/Dropdown/Dropdown.styles.tsx @@ -2,8 +2,20 @@ import { RiArrowDropDownLine } from 'react-icons/ri'; import styled from 'styled-components'; import Button from '@/components/common/Button/Button'; - -export const Layout = styled.div<{ $width: string }>` +import { Direction } from '@/components/common/Dropdown/Dropdown/Dropdown'; + +const getDirection = { + lower: { + open: 180, + close: 0, + }, + upper: { + open: 0, + close: 180, + }, +}; + +export const Layout = styled.div<{ $width: string; $height: string }>` display: flex; flex-direction: column; gap: 1rem; @@ -19,7 +31,7 @@ export const Layout = styled.div<{ $width: string }>` align-items: center; width: 100%; - height: 4.8rem; + height: ${({ $height }) => $height}; padding: 1rem; padding-left: 1.7rem; border-radius: 0.8rem; @@ -48,16 +60,22 @@ export const OpenButton = styled(Button)<{ $isSelected: boolean; $isOpen: boolea color: ${({ $isSelected, theme }) => ($isSelected ? theme.color.primary[700] : theme.color.black[50])}; `; -export const Icon = styled(RiArrowDropDownLine)<{ $isOpen: boolean }>` - transform: rotate(${({ $isOpen }) => ($isOpen ? 180 : 0)}deg); +export const Icon = styled(RiArrowDropDownLine)<{ $isOpen: boolean; $direction: Direction }>` + transform: rotate(${({ $isOpen, $direction }) => getDirection[$direction][$isOpen ? 'open' : 'close']}deg); transition: transform 0.2s ease-in-out; `; -export const ItemList = styled.ul<{ $width: string }>` - overflow-y: auto; +export const DropdownContainer = styled.div<{ $direction: Direction }>` + display: flex; + flex-direction: ${({ $direction }) => ($direction === 'lower' ? 'column' : 'column-reverse')}; +`; +export const ItemList = styled.ul<{ $width: string; $height: string; $direction: Direction }>` + overflow-y: auto; position: absolute; - top: calc(100% + 1rem); + bottom: ${({ $direction }) => ($direction === 'lower' ? '' : '5rem')}; + + top: ${({ $direction }) => ($direction === 'lower' ? '5rem' : '')}; left: 0; z-index: 1000; @@ -69,6 +87,8 @@ export const ItemList = styled.ul<{ $width: string }>` box-shadow: 0 0 2px grey, 1px 1px 3px lightgrey; + display: flex; + flex-direction: ${({ $direction }) => ($direction === 'lower' ? 'column' : 'column-reverse')}; `; export const Item = styled(Button)` diff --git a/frontend/src/components/common/Dropdown/Dropdown/Dropdown.tsx b/frontend/src/components/common/Dropdown/Dropdown/Dropdown.tsx index 9c4cfd59f..a177f62e3 100644 --- a/frontend/src/components/common/Dropdown/Dropdown/Dropdown.tsx +++ b/frontend/src/components/common/Dropdown/Dropdown/Dropdown.tsx @@ -7,16 +7,29 @@ import useClickOutside from '@/hooks/common/useClickOutside'; import { theme } from '@/styles/theme'; +export type Direction = 'lower' | 'upper'; + interface DropdownProps { placeholder: string; options: string[]; onSelect: (option: string) => void; defaultOption?: string; selected?: string; + direction?: Direction; width?: string; + height?: string; } -const Dropdown = ({ placeholder, options, onSelect, defaultOption, selected, width = '100%' }: DropdownProps) => { +const Dropdown = ({ + placeholder, + options, + onSelect, + defaultOption, + selected, + direction = 'lower', + height = '4.8rem', + width = '100%', +}: DropdownProps) => { const [isOpen, setIsOpen] = useState(false); const [selectedOption, setSelectedOption] = useState(defaultOption || ''); const dropdownRef = useRef(null); @@ -36,34 +49,42 @@ const Dropdown = ({ placeholder, options, onSelect, defaultOption, selected, wid useClickOutside(dropdownRef, () => setIsOpen(false)); return ( - + - setIsOpen(!isOpen)} - > - {selectedOption || placeholder} - - - {isOpen && ( - - {options.map((option, index) => ( -
  • - handleSelect(option)} - > - {option} - -
  • - ))} -
    - )} + + ) => { + event.stopPropagation(); + setIsOpen(!isOpen); + }} + > + {selectedOption || placeholder} + + + {isOpen && ( + + {options.map((option, index) => ( +
  • + ) => { + event.stopPropagation(); + handleSelect(option); + }} + > + {option} + +
  • + ))} +
    + )} +
    ); }; diff --git a/frontend/src/components/common/Modal/Header/Header.tsx b/frontend/src/components/common/Modal/Header/Header.tsx index 72afa469b..0872df1fe 100644 --- a/frontend/src/components/common/Modal/Header/Header.tsx +++ b/frontend/src/components/common/Modal/Header/Header.tsx @@ -1,15 +1,17 @@ import * as S from './Header.styles'; interface HeaderProps { - title: string; + title?: string; subTitle?: string; + children?: React.ReactNode; } -const Header = ({ title, subTitle }: HeaderProps) => { +const Header = ({ title, subTitle, children }: HeaderProps) => { return ( {title} {subTitle && {subTitle}} + {children} ); }; diff --git a/frontend/src/constants/button.ts b/frontend/src/constants/button.ts index 0b508a582..86f0772be 100644 --- a/frontend/src/constants/button.ts +++ b/frontend/src/constants/button.ts @@ -3,4 +3,6 @@ export const BUTTON_TEXT = { BACK: '이전', NEXT: '다음', COMPLETE: '완료', + CANCEL: '취소', + CONFIRM: '확인', }; diff --git a/frontend/src/constants/message.ts b/frontend/src/constants/message.ts index 8a6c2a2ec..847157fa4 100644 --- a/frontend/src/constants/message.ts +++ b/frontend/src/constants/message.ts @@ -4,6 +4,8 @@ export const ERROR_MESSAGES = { DELETE_REFERENCE_LINKS: '레퍼런스 링크 삭제에 실패했습니다.', GET_PAIR_ROOM: '페어룸 정보를 불러오지 못했습니다.', ADD_PAIR_NAMES: '페어룸 생성에 실패했습니다.', + GET_CATEGORIES: '카테고리 정보를 가져오지 못했어요🥲', + ADD_CATEGORY: '카테고리를 추가하지 못했어요🥲', SIGN_IN: '로그인에 실패했습니다.', SIGN_UP: '회원가입에 실패했습니다.', SIGN_OUT: '로그아웃에 실패했습니다.', diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts index 9ad882d82..cf4127353 100644 --- a/frontend/src/constants/queryKeys.ts +++ b/frontend/src/constants/queryKeys.ts @@ -3,6 +3,7 @@ export const QUERY_KEYS = { GET_PAIR_ROOM: 'getPairRoom', GET_REPOSITORIES: 'getRepositories', GET_BRANCHES: 'getBranches', + GET_CATEGORIES: 'getCategories', GET_SIGN_IN: 'getSignIn', GET_SIGN_OUT: 'getSignOut', }; diff --git a/frontend/src/hooks/common/useInput.ts b/frontend/src/hooks/common/useInput.ts index 82f9d65b8..34cc82c85 100644 --- a/frontend/src/hooks/common/useInput.ts +++ b/frontend/src/hooks/common/useInput.ts @@ -21,7 +21,11 @@ const useInput = (initialValue: string = '') => { setValue(event.target.value); }; - const resetValue = () => setValue(initialValue); + const resetValue = () => { + setValue(initialValue); + setStatus('DEFAULT'); + setMessage(''); + }; return { value, status, message, handleChange, resetValue } as const; }; diff --git a/frontend/src/queries/PairRoom/category/useAddCategory.ts b/frontend/src/queries/PairRoom/category/useAddCategory.ts new file mode 100644 index 000000000..4af389543 --- /dev/null +++ b/frontend/src/queries/PairRoom/category/useAddCategory.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import useToastStore from '@/stores/toastStore'; + +import { addCategory } from '@/apis/referenceLink/category'; + +import { QUERY_KEYS } from '@/constants/queryKeys'; + +const useAddCategory = () => { + const queryClient = useQueryClient(); + + const { addToast } = useToastStore(); + + const { mutate, isPending } = useMutation({ + mutationFn: addCategory, + onSuccess: () => queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_CATEGORIES] }), + onError: (error) => addToast({ status: 'ERROR', message: error.message }), + }); + + return { addCategory: mutate, isPending }; +}; + +export default useAddCategory; diff --git a/frontend/src/queries/PairRoom/category/useDeleteCategory.ts b/frontend/src/queries/PairRoom/category/useDeleteCategory.ts new file mode 100644 index 000000000..86fac228e --- /dev/null +++ b/frontend/src/queries/PairRoom/category/useDeleteCategory.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import useToastStore from '@/stores/toastStore'; + +import { deleteCategory } from '@/apis/referenceLink/category'; + +import { QUERY_KEYS } from '@/constants/queryKeys'; + +const useDeleteCategory = () => { + const queryClient = useQueryClient(); + + const { addToast } = useToastStore(); + + const { mutate, isPending } = useMutation({ + mutationFn: deleteCategory, + onSuccess: () => queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_CATEGORIES] }), + onError: (error) => addToast({ status: 'ERROR', message: error.message }), + }); + + return { deleteCategoryMutation: mutate, isPending }; +}; + +export default useDeleteCategory; diff --git a/frontend/src/queries/PairRoom/category/useGetCategories.ts b/frontend/src/queries/PairRoom/category/useGetCategories.ts new file mode 100644 index 000000000..2e814f96c --- /dev/null +++ b/frontend/src/queries/PairRoom/category/useGetCategories.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getCategories } from '@/apis/referenceLink/category'; + +import { QUERY_KEYS } from '@/constants/queryKeys'; + +const useGetCategories = (accessCode: string) => { + const { data, isFetching, isError, isSuccess } = useQuery({ + queryKey: [QUERY_KEYS.GET_CATEGORIES], + queryFn: () => getCategories(accessCode), + retry: 0, + }); + + const categories = data ? data.map((prop) => prop.value) : []; + + const isCategoryExist = (categoryName: string) => + categories.filter((category) => categoryName === category).length > 0; + + return { + isCategoryExist, + categories, + categoryRecord: data || [], + isSuccess, + isError, + isFetching, + }; +}; + +export default useGetCategories; diff --git a/frontend/src/queries/PairRoom/category/useUpdateCategory.ts b/frontend/src/queries/PairRoom/category/useUpdateCategory.ts new file mode 100644 index 000000000..9c478e89e --- /dev/null +++ b/frontend/src/queries/PairRoom/category/useUpdateCategory.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import useToastStore from '@/stores/toastStore'; + +import { updateCategory } from '@/apis/referenceLink/category'; + +import { QUERY_KEYS } from '@/constants/queryKeys'; + +const useUpdateCategory = (reset: () => void) => { + const queryClient = useQueryClient(); + + const { addToast } = useToastStore(); + + const { mutate, isPending } = useMutation({ + mutationFn: updateCategory, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_CATEGORIES] }); + reset(); + }, + onError: (error) => { + reset(); + addToast({ status: 'ERROR', message: error.message }); + }, + }); + + return { updateCategoryMutation: mutate, isPending }; +}; + +export default useUpdateCategory; diff --git a/frontend/src/queries/PairRoom/useReferenceLinks.ts b/frontend/src/queries/PairRoom/useReferenceLinks.ts index c45095fc9..186193b80 100644 --- a/frontend/src/queries/PairRoom/useReferenceLinks.ts +++ b/frontend/src/queries/PairRoom/useReferenceLinks.ts @@ -2,19 +2,18 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; import useToastStore from '@/stores/toastStore'; -import { getReferenceLinks, addReferenceLink, deleteReferenceLink } from '@/apis/referenceLink'; +import { getReferenceLinks, addReferenceLink, deleteReferenceLink } from '@/apis/referenceLink/referenceLink'; import { QUERY_KEYS } from '@/constants/queryKeys'; - -const useReferenceLinks = (accessCode: string) => { +const useReferenceLinks = (accessCode: string, currentCategory: string) => { const queryClient = useQueryClient(); const { addToast } = useToastStore(); const { data: referenceLinks } = useQuery({ - queryKey: [QUERY_KEYS.GET_REFERENCE_LINKS], - queryFn: () => getReferenceLinks({ accessCode }), + queryKey: [QUERY_KEYS.GET_REFERENCE_LINKS, currentCategory], + queryFn: () => getReferenceLinks({ accessCode, currentCategory }), }); const { mutate: addReferenceLinkMutation } = useMutation({ @@ -29,7 +28,8 @@ const useReferenceLinks = (accessCode: string) => { onError: (error) => addToast({ status: 'ERROR', message: error.message }), }); - const handleAddReferenceLink = (url: string) => addReferenceLinkMutation({ url, accessCode }); + const handleAddReferenceLink = (url: string, category: string | null) => + addReferenceLinkMutation({ url, accessCode, category }); const handleDeleteReferenceLink = (id: number) => deleteReferenceLinkMutation({ id, accessCode }); return {