diff --git a/src/Assets/icons/setting.svg b/src/Assets/icons/setting.svg new file mode 100644 index 00000000..1abecdbb --- /dev/null +++ b/src/Assets/icons/setting.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Assets/icons/studyThumbnail.svg b/src/Assets/icons/studyThumbnail.svg new file mode 100644 index 00000000..90b6ff4a --- /dev/null +++ b/src/Assets/icons/studyThumbnail.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Assets/index.tsx b/src/Assets/index.tsx index 3176d96b..585f0fdc 100644 --- a/src/Assets/index.tsx +++ b/src/Assets/index.tsx @@ -27,6 +27,8 @@ export { default as Loading } from './icons/lodaing.svg?react'; export { default as Logout } from './icons/logout.svg?react'; export { default as Article } from './icons/article.svg?react'; export { default as Study } from './icons/study.svg?react'; +export { default as StudyThumbnail } from './icons/studyThumbnail.svg?react'; +export { default as Setting } from './icons/setting.svg?react'; // Logo export { default as BlankLogo } from './images/blank-logo.png'; diff --git a/src/Components/Common/ChipMenu/index.tsx b/src/Components/Common/ChipMenu/index.tsx index 2f402ac1..70ea3f8e 100644 --- a/src/Components/Common/ChipMenu/index.tsx +++ b/src/Components/Common/ChipMenu/index.tsx @@ -30,9 +30,13 @@ const ChipMenuContainer = styled.div<{ checked: boolean }>` font-style: normal; font-weight: 600; line-height: 40px; + white-space: nowrap; &:hover { cursor: pointer; + border: 1px solid ${({ theme }) => theme.color.black1}; + background: ${({ theme }) => theme.color.orange4}; + color: ${({ theme }) => theme.color.white}; } `; diff --git a/src/Components/Common/InfoField/index.tsx b/src/Components/Common/InfoField/index.tsx index 5c1cb411..c3eb696c 100644 --- a/src/Components/Common/InfoField/index.tsx +++ b/src/Components/Common/InfoField/index.tsx @@ -5,11 +5,12 @@ export interface InfoFieldProps { title: string; content: string | number; width?: string; - titleWidth?: string; + titleWidth?: number; contentWidth?: string; flexDirection?: string; - gap?: string; + gap?: number; disabled?: boolean; + fontSize?: number; } export const InfoField = ({ @@ -18,6 +19,7 @@ export const InfoField = ({ contentWidth, title, content, + fontSize, flexDirection, gap, disabled = false, @@ -30,6 +32,7 @@ export const InfoField = ({ flexDirection={flexDirection} gap={gap} disabled={disabled} + fontSize={fontSize} >
{title}
{content}
@@ -39,20 +42,21 @@ export const InfoField = ({ const InfoFieldWrapper = styled.div<{ width?: string; - titleWidth?: string; + titleWidth?: number; contentWidth?: string; flexDirection?: string; - gap?: string; + gap?: number; disabled?: boolean; + fontSize?: number; }>` display: flex; flex-direction: ${(props) => props.flexDirection || 'row'}; width: ${(props) => props.width}; text-align: start; gap: ${(props) => (props.flexDirection ? '4px' : '24px')}; - font-size: ${(props) => props.theme.font.medium}; + font-size: ${(props) => (props.fontSize ? props.fontSize : props.theme.font.medium)}; font-weight: 500; - line-height: 40px; + line-height: 24px; .field { &__title { @@ -62,10 +66,15 @@ const InfoFieldWrapper = styled.div<{ ${media.tablet} { width: auto; } + + ${media.mobile} { + width: ${({ titleWidth }) => `${titleWidth}px` || 'auto'}; + } } &__content { color: ${({ theme, disabled }) => (disabled ? 'rgba(0, 0, 0, 0.25)' : theme.color.black2)}; + overflow-x: hidden; } } `; diff --git a/src/Components/Common/InputText/index.tsx b/src/Components/Common/InputText/index.tsx index 6635d22d..a569fcea 100644 --- a/src/Components/Common/InputText/index.tsx +++ b/src/Components/Common/InputText/index.tsx @@ -1,14 +1,39 @@ -import React, { ForwardedRef } from 'react'; +import { ComponentProps, ForwardedRef, ReactNode, forwardRef } from 'react'; import styled from 'styled-components'; - interface InputTextProps extends React.InputHTMLAttributes { placeholder?: string; inputType?: 'text' | 'email' | 'password' | 'member'; + defaultValue?: string; + currentLength?: number; + maxLength?: number; + icon?: ReactNode; } -const InputText = React.forwardRef( - ({ placeholder, inputType, onChange, ...props }: InputTextProps, ref: ForwardedRef) => { - return ; +const InputText = forwardRef & InputTextProps>( + ( + { name, placeholder, defaultValue, inputType, onChange, maxLength, currentLength, icon, ...props }: InputTextProps, + ref: ForwardedRef, + ) => { + return ( + + + {maxLength && ( + + {currentLength} / {maxLength} + + )} + {icon && {icon}} + + ); }, ); @@ -17,18 +42,42 @@ const InputWrapper = styled.input` padding: 10px 16px; border: 1px solid ${({ theme }) => theme.color.black1}; border-radius: ${({ theme }) => theme.borderRadius.small}; - font-size: ${({ theme }) => theme.font.medium}; + font-size: ${({ theme }) => theme.font.small}; line-height: 1.5; color: ${({ theme }) => theme.color.black}; - ::placeholder { color: ${({ theme }) => theme.color.black2}; font-family: 'Pretendard400'; - font-size: ${({ theme }) => theme.font.medium}; + font-size: ${({ theme }) => theme.font.small}; font-style: normal; font-weight: 400; line-height: 28px; } `; +const Box = styled.div` + position: relative; + display: flex; +`; + +const IconWrapper = styled.div` + display: flex; + position: absolute; + top: 10px; + right: 16px; + &:hover { + cursor: pointer; + } +`; +const LengthIndicator = styled.div` + position: absolute; + top: 10px; + right: 16px; + color: #00000073; + font-family: Pretendard400; + font-weight: 400; + font-size: 14px; + line-height: 20px; +`; + export default InputText; diff --git a/src/Components/Common/StudyToken/index.tsx b/src/Components/Common/StudyToken/index.tsx index 1230da6a..a5840ab0 100644 --- a/src/Components/Common/StudyToken/index.tsx +++ b/src/Components/Common/StudyToken/index.tsx @@ -21,9 +21,10 @@ const StudyToken = ({ status }: StudyTokenProps) => { const StudyTokenWrapper = styled.span<{ status: ApplyStatus | MemberStatus | StudyStatus }>` display: flex; - padding: 4px 12px; + padding: 0px 12px; justify-content: center; align-items: center; + white-space: nowrap; color: ${({ status, theme }) => status === 'PARTICIPATED' @@ -45,7 +46,7 @@ const StudyTokenWrapper = styled.span<{ status: ApplyStatus | MemberStatus | Stu font-family: 'Pretendard500'; font-style: normal; font-weight: 500; - line-height: 30px; + line-height: 32px; `; export default StudyToken; diff --git a/src/Components/Common/TechStack/index.tsx b/src/Components/Common/TechStack/index.tsx index 54d8ef08..0ddbefc4 100644 --- a/src/Components/Common/TechStack/index.tsx +++ b/src/Components/Common/TechStack/index.tsx @@ -23,7 +23,7 @@ export const TechStack = ({ name, imageUrl, selected = false, onClick }: TechSta const TechStackWrapper = styled.div<{ selected: boolean; imageUrl?: string }>` display: inline-flex; - padding: 8px 12px 8px 8px; + padding: 8px; justify-content: center; align-items: center; gap: 12px; @@ -44,14 +44,13 @@ const TechStackWrapper = styled.div<{ selected: boolean; imageUrl?: string }>` } .stack__name { - width: 108px; + width: 84px; color: ${({ theme, selected }) => (selected ? theme.color.purple1 : theme.color.black3)}; - font-family: Pretendard600; + font-family: 'Pretendard600'; font-size: ${({ theme }) => theme.font.xsmall}; font-style: normal; font-weight: 600; - line-height: 32px; - white-space: nowrap; + line-height: 16px; } &:hover { diff --git a/src/Components/DropdownFilter/index.tsx b/src/Components/DropdownFilter/index.tsx index 50c33047..5d53cc47 100644 --- a/src/Components/DropdownFilter/index.tsx +++ b/src/Components/DropdownFilter/index.tsx @@ -6,7 +6,7 @@ import { useOutSideClick } from '@/Hooks/useOutsideClick'; import { Up, Down } from '@/Assets'; import { useFilterOptionsStore } from '@/store/filter'; import { media } from '@/Styles/theme'; -import StackModal from '../Modal/StackModal'; +import { StackModal } from '../Modal/StackModal'; export interface DropdownFilterProps { filterName: string; @@ -94,10 +94,6 @@ const DropdownFilterWrapper = styled.ul` text-overflow: ellipsis; } - &:hover { - cursor: pointer; - } - ${media.custom(800)} { width: 90px; } @@ -123,6 +119,7 @@ const DropdownSelectWrapper = styled.div<{ checked?: boolean }>` svg > path { fill: ${(props) => props.theme.color.white}; } + cursor: pointer; } svg > path { fill: ${({ theme, checked }) => (checked ? theme.color.orange2 : theme.color.black3)}; diff --git a/src/Components/Footer/index.tsx b/src/Components/Footer/index.tsx index 777beb29..0eba18a6 100644 --- a/src/Components/Footer/index.tsx +++ b/src/Components/Footer/index.tsx @@ -60,7 +60,7 @@ const FooterWrapper = styled.footer` background-color: ${({ theme }) => theme.color.gray1}; ${media.custom(800)} { - width: 400px; + width: 100%; margin: 20px auto 0 auto; } `; diff --git a/src/Components/Icons.mdx b/src/Components/Icons.mdx index ccd7212f..80c42e8f 100644 --- a/src/Components/Icons.mdx +++ b/src/Components/Icons.mdx @@ -27,7 +27,9 @@ import { Two, Up, Views, -} from '@/Assets' + StudyThumbnail, + Setting, +} from '@/Assets'; @@ -60,11 +62,7 @@ import { - {[ - One, - Two, - Three, - ].map((Icon, i) => ( + {[One, Two, Three].map((Icon, i) => ( @@ -72,12 +70,7 @@ import { - {[ - Up, - Down, - Left, - Right, - ].map((Icon, i) => ( + {[Up, Down, Left, Right].map((Icon, i) => ( @@ -85,11 +78,7 @@ import { - {[ - Google, - Naver, - Kakao - ].map((Icon, i) => ( + {[Google, Naver, Kakao].map((Icon, i) => ( diff --git a/src/Components/Modal/EditProfileModal/EditProfileModal.stories.ts b/src/Components/Modal/EditProfileModal/EditProfileModal.stories.ts index 8188a2a3..8835142d 100644 --- a/src/Components/Modal/EditProfileModal/EditProfileModal.stories.ts +++ b/src/Components/Modal/EditProfileModal/EditProfileModal.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import EditProfileModal from '.'; +import { EditProfileModal } from '.'; import { fn } from '@storybook/test'; const meta = { diff --git a/src/Components/Modal/EditProfileModal/index.tsx b/src/Components/Modal/EditProfileModal/index.tsx index d5e1e5a3..e666c913 100644 --- a/src/Components/Modal/EditProfileModal/index.tsx +++ b/src/Components/Modal/EditProfileModal/index.tsx @@ -14,7 +14,7 @@ interface EdiptProfileModalProps { handleEdit: React.Dispatch>; } -const EditProfileModal = ({ userNickname, handleEdit }: EdiptProfileModalProps) => { +export const EditProfileModal = ({ userNickname, handleEdit }: EdiptProfileModalProps) => { const { closeModal } = useModalStore(); const queryClient = useQueryClient(); const submitSuccessHandler = () => { @@ -157,6 +157,10 @@ const FormWrapper = styled.div` .input__section { display: flex; gap: 8px; + + & > div { + width: 100%; + } } .error__message { @@ -183,5 +187,3 @@ const ModalBtnsWrapper = styled.div` width: 100%; } `; - -export default EditProfileModal; diff --git a/src/Components/Modal/StackModal/StackModal.stories.ts b/src/Components/Modal/StackModal/StackModal.stories.ts new file mode 100644 index 00000000..3afa4cd9 --- /dev/null +++ b/src/Components/Modal/StackModal/StackModal.stories.ts @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { StackModal } from '.'; + +const meta = { + component: StackModal, + args: { + handleModal: () => {}, + initialSelectedStacks: [ + { + id: 93, + name: 'ReactJS', + imageUrl: '/static/stack/images/reactjs.png', + }, + { + id: 96, + name: 'React Query', + imageUrl: '/static/stack/images/react_query.png', + }, + ], + handleSelectedStacks: () => {}, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = {}; diff --git a/src/Components/Modal/StackModal/index.tsx b/src/Components/Modal/StackModal/index.tsx index 0d3ae097..4debf495 100644 --- a/src/Components/Modal/StackModal/index.tsx +++ b/src/Components/Modal/StackModal/index.tsx @@ -1,10 +1,14 @@ import { Close, Search } from '@/Assets'; import Button from '@/Components/Common/Button'; import ChipMenu from '@/Components/Common/ChipMenu'; +import InputText from '@/Components/Common/InputText/index'; import { TechStack } from '@/Components/Common/TechStack'; import { useStack } from '@/Hooks/stack/useStack'; +import useDebounce from '@/Hooks/useDebounce'; +import { useOutSideClick } from '@/Hooks/useOutsideClick'; +import { media } from '@/Styles/theme'; import { Stack } from '@/Types/study'; -import { SetStateAction, useEffect, useRef, useState } from 'react'; +import { SetStateAction, useRef, useState } from 'react'; import styled from 'styled-components'; const STACK_CATEGORY = { @@ -18,33 +22,50 @@ const STACK_CATEGORY = { type StackCategory = keyof typeof STACK_CATEGORY; +const getFilteredStacks = ( + stacksByCategory: { id: number; name: StackCategory; stacks: Stack[] }[], + selectedCategory: StackCategory, + deBouncedKeyword: string, +) => { + let filteredStacks: Stack[] = []; + stacksByCategory + ?.filter( + (stacksByCategory: { id: number; name: StackCategory; stacks: Stack[] }) => + selectedCategory === null || STACK_CATEGORY[selectedCategory] === stacksByCategory.id, + ) + .map((stacksByCategory: { id: number; name: StackCategory; stacks: Stack[] }) => { + filteredStacks = [ + ...filteredStacks, + ...stacksByCategory.stacks.filter( + (stack: Stack) => + deBouncedKeyword.length === 0 || stack.name.toLowerCase().includes(deBouncedKeyword.toLowerCase()), + ), + ]; + }); + filteredStacks.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)); + return filteredStacks; +}; + interface StackModalProps { handleModal: React.Dispatch>; initialSelectedStacks?: Stack[]; handleSelectedStacks?: (stacks: Stack[]) => void; } -const StackModal = ({ handleModal, initialSelectedStacks, handleSelectedStacks }: StackModalProps) => { +export const StackModal = ({ handleModal, initialSelectedStacks, handleSelectedStacks }: StackModalProps) => { const stackModalRef = useRef(null); const { data } = useStack(); const [selectedCategory, setSelectedCategory] = useState(null); const [selectedStacks, setSelectedStacks] = useState(initialSelectedStacks); - const stacksSortedByCategory = data?.data; + const [keyword, setKeyword] = useState(''); + const deBouncedKeyword = useDebounce(keyword); - useEffect(() => { - const handleOutSideClick = (event: MouseEvent) => { - if (stackModalRef.current && !stackModalRef.current.contains(event.target as Node)) { - handleModal(false); - } - }; + const stacksSortedByCategory = data?.data; - document.addEventListener('mousedown', handleOutSideClick); + useOutSideClick(stackModalRef, () => handleModal(false)); - return () => { - document.removeEventListener('mousedown', handleOutSideClick); - }; - }, [stackModalRef, handleModal]); + const filteredStacks = getFilteredStacks(stacksSortedByCategory, selectedCategory, deBouncedKeyword); return ( @@ -52,14 +73,11 @@ const StackModal = ({ handleModal, initialSelectedStacks, handleSelectedStacks }
기술 스택
handleModal(false)}> - +
- -
- -
+ } onChange={(e) => setKeyword(e.target.value)} />
setSelectedCategory(null)}> @@ -85,30 +103,21 @@ const StackModal = ({ handleModal, initialSelectedStacks, handleSelectedStacks } - {stacksSortedByCategory && - stacksSortedByCategory.map((stacksByCategory: { id: number; name: StackCategory; stacks: Stack[] }) => { - return ( - (selectedCategory === null || STACK_CATEGORY[selectedCategory] === stacksByCategory.id) && - stacksByCategory.stacks.map((stack: Stack) => ( - { - if (selectedStacks.filter((selectedStack: Stack) => selectedStack.id === stack.id).length === 0) - setSelectedStacks([...selectedStacks, { ...stack }]); - else { - setSelectedStacks( - selectedStacks.filter((selectedStack: Stack) => selectedStack.id !== stack.id), - ); - } - }} - selected={ - selectedStacks.filter((selectedStack: Stack) => selectedStack.id === stack.id).length !== 0 - } - /> - )) - ); - })} + {filteredStacks && + filteredStacks.map((stack: Stack) => ( + { + if (selectedStacks.filter((selectedStack: Stack) => selectedStack.id === stack.id).length === 0) + setSelectedStacks([...selectedStacks, { ...stack }]); + else { + setSelectedStacks(selectedStacks.filter((selectedStack: Stack) => selectedStack.id !== stack.id)); + } + }} + selected={selectedStacks.filter((selectedStack: Stack) => selectedStack.id === stack.id).length !== 0} + /> + ))} @@ -138,19 +147,37 @@ const StackModal = ({ handleModal, initialSelectedStacks, handleSelectedStacks } const StackModalWrapper = styled.div` display: flex; + padding: 32px; + flex-direction: column; align-items: flex-start; + align-self: stretch; position: fixed; top: 50%; left: 50%; - width: 1200px; - display: flex; - flex-direction: column; - padding: 32px 52px; - gap: 40px; - background: ${({ theme }) => theme.color.white2}; - border-radius: ${({ theme }) => theme.borderRadius.large}; transform: translate(-50%, -50%); + width: 990px; + gap: 32px; + border-radius: 16px; + border: 1px solid ${({ theme }) => theme.color.black1}; + background: ${({ theme }) => theme.color.white}; z-index: 100; + + ${media.custom(990)} { + width: 834px; + } + + ${media.custom(834)} { + width: 678px; + } + + ${media.custom(678)} { + width: 522px; + } + + ${media.custom(522)} { + width: 350px; + padding: 32px 24px; + } `; const ModalContentWrapper = styled.div` @@ -165,55 +192,30 @@ const TitleWrapper = styled.div` display: flex; align-items: center; justify-content: space-between; - height: 40px; gap: 24px; align-self: stretch; .title { display: flex; - width: 50%; + flex: 1 0 0; color: ${({ theme }) => theme.color.black5}; font-family: 'Pretendard800'; - font-size: 24px; + font-size: ${({ theme }) => theme.font.large}; font-style: normal; - line-height: 32px; font-weight: 800; + line-height: 32px; } .close__icon { display: flex; - flex-direction: row-reverse; - width: 50%; &:hover { cursor: pointer; } - & > svg { - padding-top: 2.5px; - } } `; const SearchInputWrapper = styled.div` - display: flex; - padding: 10px 16px; - align-items: center; - gap: 8px; - align-self: stretch; - border-radius: ${({ theme }) => theme.borderRadius.small}; - border: 1px solid ${({ theme }) => theme.color.black1}; - background: ${({ theme }) => theme.color.white}; - - input { - width: 100%; - } - - & > input::placeholder { - color: ${({ theme }) => theme.color.black2}; - font-family: Pretendard400; - font-size: ${({ theme }) => theme.font.small}; - font-style: normal; - line-height: 24px; - } + width: 100%; `; const CategoryChipsWrapper = styled.div` @@ -221,6 +223,7 @@ const CategoryChipsWrapper = styled.div` width: 100%; align-items: flex-start; gap: 8px; + overflow-x: hidden; `; const TechStackListWrapper = styled.div` @@ -240,6 +243,8 @@ const BtnsWrapper = styled.div` width: 100%; gap: 24px; align-self: stretch; -`; -export default StackModal; + ${media.custom(522)} { + gap: 12px; + } +`; diff --git a/src/Components/MyStudyCard/MyStudyCard.stories.ts b/src/Components/MyStudyCard/MyStudyCard.stories.ts index a1298d73..6e6b9eb0 100644 --- a/src/Components/MyStudyCard/MyStudyCard.stories.ts +++ b/src/Components/MyStudyCard/MyStudyCard.stories.ts @@ -1,44 +1,96 @@ import type { Meta, StoryObj } from '@storybook/react'; -import MyStudyCard from '.'; +import { MyStudyCard } from '.'; const meta = { component: MyStudyCard, args: { id: 1, - title: '스터디', + title: '스터디 제목', status: 'PROGRESS', position: { id: 1, name: '백엔드', }, - period: '2021-01-01 ~ 2021-12-31', - participantCount: 3, }, } satisfies Meta; export default meta; + type Story = StoryObj; -export const Primary: Story = {}; +export const Primary: Story = { + args: { + period: '2021-01-01 ~ 2021-12-31', + participantCount: 3, + }, +}; -/** 스터디 모집 공고가 없을 때 */ +/** 팀장 / 상태 : 진행중 / 스터디 모집 공고가 없는 경우 */ export const WithoutRecruitment: Story = { args: { hasRecruitment: false, + status: 'PROGRESS', isOwner: true, + period: '2021-01-01 ~ 2021-12-31', + participantCount: 3, }, }; -/** 지원을 넣었을 때 */ -export const CancelApply: Story = { +/** 팀장 / 상태 : 모집중 / 스터디 모집 공고가 있는 경우 */ +export const WithRecruitment: Story = { + args: { + hasRecruitment: true, + status: 'RECRUITING', + isOwner: true, + period: '2021-01-01 ~ 2021-12-31', + participantCount: 3, + }, +}; + +/** 팀원 / 상태 : 진행중 / 스터디에 참여중인 경우 */ +export const ParticipatedStudy: Story = { + args: { + status: 'PROGRESS', + period: '2021-01-01 ~ 2021-12-31', + participantCount: 3, + }, +}; + +/** 팀장 또는 팀원 / 상태 : 모집완료 / 모집완료 되었으나 스터디가 시작되지 않은 경우 */ +export const RecruitedStudy: Story = { + args: { + status: 'RECRUITED', + period: '2021-01-01 ~ 2021-12-31', + participantCount: 3, + }, +}; + +/** 팀장 또는 팀원 / 상태 : 완료 / 스터디가 완료된 경우 */ +export const CompletedStudy: Story = { + args: { + status: 'COMPLETED', + period: '2021-01-01 ~ 2021-12-31', + participantCount: 3, + }, +}; + +/** 팀원 / 상태 : 지원중 / 지원을 하였으나 스터디장의 수락/거절이 진행되지 않은 경우 */ +export const UnCheckedApply: Story = { args: { status: 'UNCHECKED', }, }; -/** 지원 요청이 수락되었을 때 */ +/** 팀원 / 상태 : 지원 수락 / 지원 요청이 수락되었을 때 */ export const DeleteApply: Story = { args: { status: 'ACCEPTED', }, }; + +/** 팀원 / 상태 : 지원 거절 / 지원 요청이 거절되었을 때 */ +export const RefusedApply = { + args: { + status: 'REFUSED', + }, +}; diff --git a/src/Components/MyStudyCard/index.tsx b/src/Components/MyStudyCard/index.tsx index a4807475..9c08876c 100644 --- a/src/Components/MyStudyCard/index.tsx +++ b/src/Components/MyStudyCard/index.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { BlankSquare } from '../Common/BlankSquare'; +import { StudyThumbnail } from '@/Assets'; import StudyToken from '../Common/StudyToken'; import { InfoField } from '../Common/InfoField'; import Button from '../Common/Button'; @@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom'; import { useCancelAppyMutation } from '@/Hooks/study/useCancelAppyMutation'; import { useQueryClient } from '@tanstack/react-query'; import { STUDY } from '@/Constants/queryString'; +import { media } from '@/Styles/theme'; interface MyStudyCardProps { id: number; @@ -35,7 +36,7 @@ interface MyStudyCardProps { } /** 자신이 현재 참여하고 있는/참여 신청을 넣은 스터디를 나타냅니다. */ -const MyStudyCard = ({ +export const MyStudyCard = ({ id, title, status, @@ -51,89 +52,175 @@ const MyStudyCard = ({ queryClient.invalidateQueries({ queryKey: [...STUDY.MYPAGE_INFO()] }); }; const { mutate: cancelMutate } = useCancelAppyMutation(1, id, cancelApplySuccessHandler); + const isApplyStatus = status === 'UNCHECKED' || status === 'REFUSED' || status === 'ACCEPTED'; return ( { - navigate( - `/studies/${id}${ - status === 'UNCHECKED' || status === 'REFUSED' || status === 'ACCEPTED' ? '/recruitment' : '' - }`, - ); + navigate(`/studies/${id}${isApplyStatus ? '/recruitment' : ''}`); }} + isApplyStatus={isApplyStatus} > - - -
- {title} -
- + {!isApplyStatus && } + + + +
+ {title} +
+ +
+
+
+ + {period && ( + + )} + {participantCount && ( + + )}
-
-
- - {period && } - {participantCount && ( - + + + {(status === 'PROGRESS' || status === 'RECRUITING') && !hasRecruitment && isOwner && ( + + )} + {status === 'UNCHECKED' && ( + )} -
+ {(status === 'REFUSED' || status === 'ACCEPTED') && } + - - {(status === 'PROGRESS' || status === 'RECRUITING') && !hasRecruitment && isOwner && ( - - )} - {status === 'UNCHECKED' && ( - - )} - {(status === 'REFUSED' || status === 'ACCEPTED') && } - ); }; -const MyStudyCardWrapper = styled.div` - position: relative; +const MyStudyCardWrapper = styled.div<{ + isApplyStatus: boolean; +}>` display: flex; width: 100%; - padding: 32px 40px; + height: ${(props) => (props.isApplyStatus ? '158px' : '246px')}; align-items: flex-start; - gap: 40px; - border-radius: ${({ theme }) => theme.borderRadius.small}; + align-content: flex-start; + align-self: stretch; + flex-wrap: wrap; + align-content: flex-start; + align-self: stretch; + flex-wrap: wrap; + border-radius: 16px; border: 1px solid ${({ theme }) => theme.color.black1}; - background: ${({ theme }) => theme.color.white}; box-shadow: 0px 0px 20px 0px ${({ theme }) => theme.color.black0}; - & > div:first-child { - border-radius: ${({ theme }) => theme.borderRadius.small}; - background: ${({ theme }) => theme.color.gray5}; - } - &:hover { cursor: pointer; } + + svg { + border-radius: 16px 0 0 16px; + } + + ${media.mobile} { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + width: 302px; + height: auto; + + svg { + width: 100%; + border-radius: 16px 16px 0 0; + } + } + + svg { + border-radius: 16px 0 0 16px; + } + + ${media.mobile} { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + width: 302px; + height: auto; + + svg { + width: 100%; + border-radius: 16px 16px 0 0; + } + } +`; + +const StudyInfoWrapper = styled.div<{ + status: StudyStatus | ApplyStatus; + hasRecruitment: boolean; + isOwner: boolean; + isApplyStatus: boolean; +}>` + display: flex; + height: 100%; + padding: 24px 32px; + flex-direction: column; + align-items: flex-start; + flex: 1 0 0; + + ${media.mobile} { + display: flex; + width: 100%; + padding: 16px 24px; + flex-direction: column; + align-items: center; + gap: ${(props) => + ((props.status === 'PROGRESS' || props.status === 'RECRUITING') && !props.hasRecruitment && props.isOwner) || + props.isApplyStatus + ? '24px' + : 0}; + align-self: stretch; + } `; -const StudyInfoWrapper = styled.div<{ status: StudyStatus | ApplyStatus }>` +const StudyDetailWrapper = styled.div<{ status: StudyStatus | ApplyStatus }>` display: flex; - width: (100%-180px); flex-direction: column; align-items: flex-start; - gap: 8px; + gap: 12px; + flex: 1 0 0; + flex: 1 0 0; .study__status { display: flex; @@ -144,24 +231,92 @@ const StudyInfoWrapper = styled.div<{ status: StudyStatus | ApplyStatus }>` .title { color: ${({ theme }) => theme.color.black5}; font-family: 'Pretendard700'; - font-size: ${({ theme }) => theme.font.xxlarge}; + font-size: ${({ theme }) => theme.font.medium}; + font-family: 'Pretendard700'; + font-size: ${({ theme }) => theme.font.medium}; font-style: normal; font-weight: 700; - line-height: 40px; + font-weight: 700; + line-height: 32px; } .studyTokens { display: flex; align-items: center; - gap: 8px; + gap: 4px; } } -`; -const MyStudyCardButtonsWrapper = styled.div` - position: absolute; - bottom: 32px; - right: 40px; + .detail__info { + display: flex; + width: 100%; + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + ${media.mobile} { + .study__status { + display: flex; + align-items: center; + align-content: center; + gap: 16px; + align-self: stretch; + flex-wrap: wrap; + } + + .detail__info { + display: flex; + gap: 12px; + + & > div { + width: 252px; + gap: 0; + } + gap: 4px; + } + } + + .detail__info { + display: flex; + width: 100%; + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + ${media.mobile} { + .study__status { + display: flex; + align-items: center; + align-content: center; + gap: 16px; + align-self: stretch; + flex-wrap: wrap; + } + + .detail__info { + display: flex; + gap: 12px; + + & > div { + width: 252px; + gap: 0; + } + } + } `; -export default MyStudyCard; +const MyStudyCardButtonsWrapper = styled.div<{ isApplyStatus: boolean }>` + display: flex; + width: 100%; + justify-content: flex-end; + + button { + padding: ${(props) => (props.isApplyStatus ? `0 16px` : `8px 24px`)}; + } + + ${media.mobile} { + justify-content: center; + } +`; diff --git a/src/Components/TemporarySavedCard/index.tsx b/src/Components/TemporarySavedCard/index.tsx index 137759d7..a8a65f58 100644 --- a/src/Components/TemporarySavedCard/index.tsx +++ b/src/Components/TemporarySavedCard/index.tsx @@ -1,61 +1,71 @@ import styled from 'styled-components'; import { useNavigate } from 'react-router-dom'; import { ROUTES } from '@/Constants/route'; -import { Card } from '@/Types/study'; +import { RecruitmentForm } from '@/Types/study'; import Button from '../Common/Button'; +import { useSavedKeyStore, useSelectedCardStore } from '@/store/study'; -export interface TemporarySavedCardProps { - id?: number; +const TemporarySavedCard = ({ savedKey, title }: Partial & { savedKey: string }) => { + const navigate = useNavigate(); - /** 카드 제목 */ - title: string; + const { setSelectedCard } = useSelectedCardStore(); - card: Card; + const [studyOrRecruitment, id] = savedKey?.split('-') ?? []; - /** 카드 내용 */ - content?: string; -} + // 클릭된 savedKey는 스토어에 저장한다. + const setSavedKey = useSavedKeyStore((state) => state.setSavedKey); -/** 임시저장 카드 */ -const TemporarySavedCard = ({ id, title, card, content }: TemporarySavedCardProps) => { - const navigate = useNavigate(); return ( { - navigate(card === 'STUDY' ? ROUTES.STUDY.CREATE : `/studies/${id}/recruitments/create`); + navigate(studyOrRecruitment === 'STUDY' ? ROUTES.STUDY.CREATE : `/studies/${id}/recruitments/create`); + setSavedKey(savedKey); }} > {title} - {content &&
{content}
} - - - +
); }; const TemporarySavedCardWrapper = styled.div` display: flex; - padding: 32px 40px; - align-items: center; width: 100%; + padding: 24px 32px; justify-content: space-between; - gap: 40px; - border-radius: ${({ theme }) => theme.borderRadius.large}; + align-items: flex-start; + gap: 32px; + align-self: stretch; + border-radius: ${({ theme }) => theme.borderRadius.medium}; border: 1px solid ${({ theme }) => theme.color.black1}; background: ${({ theme }) => theme.color.white}; + box-shadow: 0px 0px 20px 0px ${({ theme }) => theme.color.black0}; + + .title { + color: ${({ theme }) => theme.color.black5}; + font-family: 'Pretendard800'; + font-size: ${({ theme }) => theme.font.large}; + font-style: normal; + font-weight: 800; + line-height: 32px; + } &:hover { cursor: pointer; } - /* Card */ - box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.05); -`; - -const ButtonsWrapper = styled.div` - display: flex; - gap: 40px; + button { + padding: 0 32px; + } `; export default TemporarySavedCard; diff --git a/src/Components/UserCard/index.tsx b/src/Components/UserCard/index.tsx index b794101e..42fe1380 100644 --- a/src/Components/UserCard/index.tsx +++ b/src/Components/UserCard/index.tsx @@ -1,11 +1,11 @@ -import { Profile, More } from '@/Assets'; +import { Profile, Setting } from '@/Assets'; import { Member } from '@/Types/study'; import styled from 'styled-components'; import Button from '../Common/Button'; import DropdownItem from '../Common/DropdownItem'; import { useState } from 'react'; import { useModalStore } from '@/store/modal'; -import EditProfileModal from '../Modal/EditProfileModal'; +import { EditProfileModal } from '../Modal/EditProfileModal'; import Modal from '../Common/Modal'; import { PROFILE } from '@/Constants/messages'; @@ -25,7 +25,7 @@ const UserCard = ({ nickname, email }: UserCardProps) => { {isOpened && ( @@ -45,6 +45,13 @@ const UserCard = ({ nickname, email }: UserCardProps) => { > 프로필 사진 변경하기 + { + setIsOpened(false); + }} + > + 설정 + )} {editState === 'EDIT' && isModalOpen && } @@ -66,33 +73,40 @@ const UserCard = ({ nickname, email }: UserCardProps) => { const UserCardWrapper = styled.div` position: relative; display: flex; - width: 100%; - padding: 28px 0px; align-items: flex-start; + width: 100%; gap: 24px; `; const UserProfileWrapper = styled.div` display: flex; - padding: 20px 20px 18px 32px; + padding: 16px 32px; flex-direction: column; align-items: flex-start; - gap: 4px; + gap: 12px; + flex: 1 0 0; + align-self: stretch; .nickname { - font-size: ${({ theme }) => theme.font.xxlarge}; color: ${({ theme }) => theme.color.black5}; - font-weight: 700; + font-family: 'Pretendard800'; + font-size: ${({ theme }) => theme.font.large}; + font-style: normal; + font-weight: 800; + line-height: 32px; } .email { - font-size: ${({ theme }) => theme.font.medium}; color: rgba(0, 0, 0, 0.4); - font-weight: 400; + font-family: 'Pretendard500'; + font-size: ${({ theme }) => theme.font.small}; + font-style: normal; + font-weight: 500; + line-height: 32px; } `; const UserProfileEditSectionWrapper = styled.div` position: absolute; - top: 28px; + top: 0px; right: 0px; display: flex; flex-direction: column; @@ -104,16 +118,24 @@ const UserProfileEditSectionWrapper = styled.div` border: none; &:hover { border: none; + + svg { + path { + fill-opacity: 0.85; + } + } } } `; const DropdownListWrapper = styled.div` - display: flex; + display: inline-flex; flex-direction: column; + align-items: flex-start; padding: 8px 0; - border: 1px solid ${({ theme }) => theme.color.gray1}; border-radius: ${({ theme }) => theme.borderRadius.small}; + border: 1px solid ${({ theme }) => theme.color.black1}; + box-shadow: 0px 4px 10px 0px ${({ theme }) => theme.color.black1}; `; export default UserCard; diff --git a/src/Hooks/useDebounce.ts b/src/Hooks/useDebounce.ts new file mode 100644 index 00000000..e3d12a09 --- /dev/null +++ b/src/Hooks/useDebounce.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +const DEFAULT_DELAY = 500; + +const useDebounce = (value: T, delay: number = DEFAULT_DELAY) => { + const [debouncedValue, setDebounceValue] = useState(value); + + useEffect(() => { + const timer: NodeJS.Timeout = setTimeout(() => { + setDebounceValue(value); + }, delay); + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +}; + +export default useDebounce; diff --git a/src/Pages/MyPage/index.tsx b/src/Pages/MyPage/index.tsx index 1fe2af6f..a721fb0c 100644 --- a/src/Pages/MyPage/index.tsx +++ b/src/Pages/MyPage/index.tsx @@ -1,15 +1,14 @@ import { Loading, MemberImage, Study, StudyInfo } from '@/Assets'; import UserCard from '@/Components/UserCard'; -import MyStudyCard from '@/Components/MyStudyCard'; +import { MyStudyCard } from '@/Components/MyStudyCard'; import styled from 'styled-components'; -import TemporarySavedCard, { TemporarySavedCardProps } from '@/Components/TemporarySavedCard'; +import TemporarySavedCard from '@/Components/TemporarySavedCard'; import Button from '@/Components/Common/Button'; import { useMyPageInfo } from '@/Hooks/study/useMyPageInfo'; import { getPeriod } from '@/utils/date'; import ChipMenu from '@/Components/Common/ChipMenu'; -import { User, ParticipateStudy, ApplicantRecruitment, CompletedStudy } from '@/Types/study'; +import { User, ParticipateStudy, ApplicantRecruitment, CompletedStudy, RecruitmentForm } from '@/Types/study'; import { useSelectedCardStore, useSelectedMyStudyStore } from '@/store/study'; -import { temporarySavedCardMockData } from '@/Shared/dummy'; import { useLogOutMutation } from '@/Hooks/auth/useLogOutMutation'; import { useLocation } from 'react-router-dom'; import { useEffect } from 'react'; @@ -24,12 +23,25 @@ const MyPage = () => { const { selectedMyStudyStatus, setSelectedMyStudyStatus } = useSelectedMyStudyStore(); const { selectedCard, setSelectedCard } = useSelectedCardStore(); - const { mutate: logoutMutate } = useLogOutMutation(); + useEffect(() => { window.scrollTo(0, 0); }, [pathname]); + const getTempList = (selectedCard: 'STUDY' | 'RECRUITMENT') => { + // TODO: 스터디 타입도 추가 + + const savedList: Array & { savedKey: string }> = []; + for (const key in window.localStorage) { + // hasOwnProperty로 빌트인 속성 제거 + if (window.localStorage.hasOwnProperty(key) && key.toUpperCase().includes(selectedCard)) { + savedList.push({ ...JSON.parse(localStorage.getItem(key)), savedKey: key }); + } + } + return savedList; + }; + return ( {isLoading ? ( @@ -38,14 +50,14 @@ const MyPage = () => { <>
- + 회원정보
- + 스따-디 @@ -68,43 +80,45 @@ const MyPage = () => { 진행 완료된 스터디 - {selectedMyStudyStatus === 'PARTICIPATED' - ? participateStudies?.map((participateStudy: ParticipateStudy) => ( - - )) - : selectedMyStudyStatus === 'APPLIED' - ? applicantRecruitments.map((applicantRecruitment: ApplicantRecruitment) => ( + + {selectedMyStudyStatus === 'PARTICIPATED' + ? participateStudies?.map((participateStudy: ParticipateStudy) => ( )) - : completedStudies.map((completedStudy: CompletedStudy) => ( - - ))} + : selectedMyStudyStatus === 'APPLIED' + ? applicantRecruitments.map((applicantRecruitment: ApplicantRecruitment) => ( + + )) + : completedStudies.map((completedStudy: CompletedStudy) => ( + + ))} +
@@ -119,15 +133,11 @@ const MyPage = () => { 스터디 모집공고 - {selectedCard === 'STUDY' - ? temporarySavedCardMockData - .filter((temporarySavedCard: TemporarySavedCardProps) => temporarySavedCard.card === 'STUDY') - .map((studySavedCard: TemporarySavedCardProps) => ) - : temporarySavedCardMockData - .filter((temporarySavedCard: TemporarySavedCardProps) => temporarySavedCard.card === 'RECRUITMENT') - .map((recruitmentSavedCard: TemporarySavedCardProps) => ( - - ))} + + {getTempList(selectedCard)?.map((form: Partial & { savedKey: string }) => ( + + ))} + @@ -146,19 +156,22 @@ const MyPageWrapper = styled.div` flex-direction: column; max-width: 1224px; margin: 40px auto 80px auto; - gap: 32px; + gap: 40px; .title { display: flex; align-items: center; - gap: 12px; + gap: 8px; align-self: stretch; - color: ${({ theme }) => theme.color.black5}; - font-family: 'Pretendard800'; - font-size: ${({ theme }) => theme.font.xxlarge}; - font-style: normal; - font-weight: 800; - line-height: 48px; + + span { + color: ${({ theme }) => theme.color.black5}; + font-family: 'Pretendard700'; + font-size: ${({ theme }) => theme.font.medium}; + font-style: normal; + font-weight: 700; + line-height: 32px; + } } `; @@ -174,6 +187,7 @@ const CardsWrapper = styled.div` display: flex; flex-direction: column; align-items: flex-start; + min-height: 368px; gap: 24px; align-self: stretch; `; @@ -181,6 +195,26 @@ const CardsWrapper = styled.div` const MyStudyTitleWrapper = styled.div` display: flex; align-items: center; + gap: 8px; + align-self: stretch; + + span { + color: ${({ theme }) => theme.color.black5}; + font-family: 'Pretendard700'; + font-size: ${({ theme }) => theme.font.medium}; + font-style: normal; + font-weight: 700; + line-height: 32px; + } +`; + +const CardListWrapper = styled.div` + display: flex; + width: 100%; + flex-direction: column; + flex-wrap: wrap; + align-items: center; + align-content: center; gap: 12px; align-self: stretch; `; @@ -190,14 +224,15 @@ const ChipMenusWrapper = styled.div` align-items: flex-start; gap: 12px; align-self: stretch; + overflow-x: hidden; `; const MypageButtonsWrapper = styled.div` display: flex; - justify-content: center; + padding-top: 24px; + flex-direction: column; align-items: center; - gap: 12px; - align-self: stretch; + gap: 24px; `; export default MyPage; diff --git a/src/Pages/Recruitments/index.tsx b/src/Pages/Recruitments/index.tsx index ab9e2d9d..aee347f5 100644 --- a/src/Pages/Recruitments/index.tsx +++ b/src/Pages/Recruitments/index.tsx @@ -7,9 +7,7 @@ import { Create, Up } from '@/Assets'; import Button from '@/Components/Common/Button'; import UtiltiyButtons from '@/Components/UtilityButtons'; import { useLocation, useNavigate } from 'react-router-dom'; -import { useStack } from '@/Hooks/stack/useStack'; import { ALL, CATEGORIES, POSITIONS, PROGRESS_METHODS } from '@/Shared/study'; -import { Stack } from '@/Types/study'; import { useLoginStore } from '@/store/auth'; import { useModalStore } from '@/store/modal'; import { CREATE_STUDY } from '@/Constants/messages'; @@ -18,7 +16,6 @@ import { ROUTES } from '@/Constants/route'; import { useEffect } from 'react'; const RecruitmentsPage = () => { - const { data, isLoading } = useStack(); const navigate = useNavigate(); const { isLoggedIn } = useLoginStore(); const { isModalOpen, openModal } = useModalStore(); @@ -32,10 +29,6 @@ const RecruitmentsPage = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }; - const stacks = data?.stacks?.map((stack: Stack) => { - return { id: stack.id, name: stack.name }; - }); - return ( diff --git a/src/Shared/study.ts b/src/Shared/study.ts index e0707278..c432530b 100644 --- a/src/Shared/study.ts +++ b/src/Shared/study.ts @@ -1,4 +1,4 @@ -import { Position } from '@/Types/study'; +import { Position, Option } from '@/Types/study'; export const STUDY_STATUS = { PROGRESS: '진행 중', @@ -17,12 +17,12 @@ export const MEMBER_STATUS = { PARTICIPATED: '참여 중', }; -export const POSITION = { - 1: '백엔드', - 2: '프론트엔드', - 3: '디자이너', - 4: '데브옵스', -}; +export const POSITION: Array> = [ + { value: 1, label: '백엔드' }, + { value: 2, label: '프론트엔드' }, + { value: 3, label: '디자이너' }, + { value: 4, label: '데브옵스' }, +]; export const PROGRESS_METHOD = { ONLINE: '온라인', diff --git a/src/Types/study.ts b/src/Types/study.ts index 80c1d364..5176cb1b 100644 --- a/src/Types/study.ts +++ b/src/Types/study.ts @@ -1,4 +1,4 @@ -import { APPLY_STATUS, MEMBER_STATUS, PLATFORM, PROGRESS_METHOD, ROLE, STUDY_STATUS } from '@/Shared/study'; +import { APPLY_STATUS, POSITION, MEMBER_STATUS, PLATFORM, PROGRESS_METHOD, ROLE, STUDY_STATUS } from '@/Shared/study'; export type CategoryPropertyType = 'category' | 'stacks' | 'positions' | 'way' | 'sort'; export type StudyStatus = keyof typeof STUDY_STATUS; @@ -7,6 +7,7 @@ export type ApplyStatus = keyof typeof APPLY_STATUS; export type ProgressMethod = keyof typeof PROGRESS_METHOD; export type Role = keyof typeof ROLE; export type Platform = keyof typeof PLATFORM; +export type PositionId = keyof typeof POSITION; export type Card = 'STUDY' | 'RECRUITMENT'; export type Sort = '최신순' | '조회순'; export type ApplyTryStatus = 'NOT APPLY' | 'SUCCESS' | 'CLOSED' | 'ALREDAY_APPLY' | 'ALREDY_PARTICIPATED'; @@ -102,6 +103,18 @@ export interface Recruitments { recruitments: Recruitment[]; } +// TODO: recruitment 타입 중복 속성 개선, stackId 객체 만들기 +export interface RecruitmentForm { + title: string; + stackIds: number[]; + positionIds: PositionId[]; + applicantCount: number; + recruitmentEndDateTime: string; + contact: 'KAKAO' | 'EMAIL'; + callUrl: string; + content: string; +} + export interface FilterOptionParams { pageParam?: number; last?: number; @@ -175,6 +188,11 @@ export interface ApplicantRecruitment { applicantStatus: ApplyStatus; } +export interface Option { + value: T; + label: K; +} + export interface CompletedStudy { studyId: number; title: string; diff --git a/src/store/study.ts b/src/store/study.ts index 5abc245c..57521f70 100644 --- a/src/store/study.ts +++ b/src/store/study.ts @@ -23,7 +23,19 @@ export interface SetSelectedCardAction { setSelectedCard: (newSelectedCard: Card) => void; } +// 임시저장된 글 카드 export const useSelectedCardStore = create((set) => ({ selectedCard: 'STUDY', setSelectedCard: (newSelectedCard: Card) => set({ selectedCard: newSelectedCard }), })); + +export interface SavedKeyState { + savedKey: string; + setSavedKey: (newKey: string) => void; +} + +// 선택된 카드 키 +export const useSavedKeyStore = create((set) => ({ + savedKey: '', + setSavedKey: (newKey: string) => set(() => ({ savedKey: newKey })), +})); diff --git a/src/utils/axios.ts b/src/utils/axios.ts index e344ce59..a5155a6e 100644 --- a/src/utils/axios.ts +++ b/src/utils/axios.ts @@ -1,5 +1,4 @@ import { logOut } from '@/Apis/auth'; -import { HttpStatus } from '@/Constants/StatusCodes'; import axios, { AxiosError, AxiosRequestConfig } from 'axios'; export const createClient = (config?: AxiosRequestConfig) => { @@ -21,7 +20,7 @@ export const createClient = (config?: AxiosRequestConfig) => { return response; }, async (error: AxiosError) => { - if (error.response?.status === HttpStatus.UNAUTHORIZED) { + if (error.response?.status === 401) { try { const data = await logOut(); // 로그아웃 통해 쿠키 제거 if (data) window.location.href = '/';