diff --git a/src/App.tsx b/src/App.tsx index a16d4d71..08c507cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import TalkPickVotes from '@/pages/MyPage/TalkPick/TalkPickVotes'; import TalkPickComments from '@/pages/MyPage/TalkPick/TalkPickComments'; import BalanceGameWritten from '@/pages/MyPage/BalanceGame/BalanceGameWritten'; import BalanceGameEditPage from '@/pages/BalanceGameEditPage/BalanceGameEditPage'; +import MyMobilePage from '@/pages/mobile/MyMobilePage/MyMobilePage'; import ProtectedRoutes from './components/Routes/ProtectedRoutes'; import { PATH } from './constants/path'; import { useTokenRefresh } from './hooks/common/useTokenRefresh'; @@ -119,20 +120,24 @@ const App: React.FC = () => { }> }> - }> - }> - } /> - } /> - } /> - } /> + {isMobile ? ( + } /> + ) : ( + }> + }> + } /> + } /> + } /> + } /> + + }> + } /> + } /> + } /> + + } /> - }> - } /> - } /> - } /> - - } /> - + )} }> } /> diff --git a/src/assets/images/my-card-sample-first.png b/src/assets/images/my-card-sample-first.png new file mode 100644 index 00000000..ad94d696 Binary files /dev/null and b/src/assets/images/my-card-sample-first.png differ diff --git a/src/assets/images/my-card-sample-second.png b/src/assets/images/my-card-sample-second.png new file mode 100644 index 00000000..1d522010 Binary files /dev/null and b/src/assets/images/my-card-sample-second.png differ diff --git a/src/assets/index.ts b/src/assets/index.ts index 89f74f11..ed489c59 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -153,6 +153,9 @@ export { default as MobileDefaultPerson } from './svg/mobile-default-person.svg' export { default as MobilePlus } from './svg/mobile-plus.svg'; export { default as MobileReport } from './svg/mobile-report.svg'; export { default as PickIcon } from './svg/pick-icon.svg'; +export { default as MobileCardSampleFirst } from './images/my-card-sample-first.png'; +export { default as MobileCardSampleSecond } from './images/my-card-sample-second.png'; +export { default as SmileEmoji } from './svg/smile-emoji.svg'; // TODO: 이전 SVG export { default as Email } from './svg/email.svg'; diff --git a/src/assets/svg/smile-emoji.svg b/src/assets/svg/smile-emoji.svg new file mode 100644 index 00000000..47cb96b3 --- /dev/null +++ b/src/assets/svg/smile-emoji.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/atoms/MenuTap/MenuTap.style.ts b/src/components/atoms/MenuTap/MenuTap.style.ts index 9746b063..c235f546 100644 --- a/src/components/atoms/MenuTap/MenuTap.style.ts +++ b/src/components/atoms/MenuTap/MenuTap.style.ts @@ -1,38 +1,39 @@ -import { css } from '@emotion/react'; -import color from '@/styles/color'; -import typo from '@/styles/typo'; - -export const menuTapStyling = css({ - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-end', -}); - -export const menuIconStlying = css({ - width: '11px', - height: '17px', - cursor: 'pointer', -}); - -export const menuStlying = css({ - position: 'absolute', - marginTop: '23px', - marginRight: '4px', - width: '86px', - backgroundColor: color.WT, - border: `1px solid ${color.GY[2]}`, - borderRadius: '10px', - overflow: 'hidden', - boxShadow: '1px 2px 10px rgba(0, 0, 0, 0.07)', -}); - -export const menuItemStyling = css(typo.Comment.SemiBold, { - width: '100%', - padding: '10px', - cursor: 'pointer', - color: color.BK, - borderBottom: `1px solid ${color.GY[2]}`, - ':last-child': { - borderBottom: 'none', - }, -}); +import { css } from '@emotion/react'; +import color from '@/styles/color'; +import typo from '@/styles/typo'; + +export const menuTapStyling = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', +}); + +export const menuIconStlying = css({ + width: '11px', + height: '17px', + cursor: 'pointer', +}); + +export const menuStlying = css({ + position: 'absolute', + marginTop: '23px', + marginRight: '4px', + minWidth: '86px', + width: 'max-content', + backgroundColor: color.WT, + border: `1px solid ${color.GY[2]}`, + borderRadius: '10px', + overflow: 'hidden', + boxShadow: '1px 2px 10px rgba(0, 0, 0, 0.07)', +}); + +export const menuItemStyling = css(typo.Comment.SemiBold, { + width: '100%', + padding: '10px', + cursor: 'pointer', + color: color.BK, + borderBottom: `1px solid ${color.GY[2]}`, + ':last-child': { + borderBottom: 'none', + }, +}); diff --git a/src/components/atoms/MenuTap/MenuTap.tsx b/src/components/atoms/MenuTap/MenuTap.tsx index be39143c..7dfde815 100644 --- a/src/components/atoms/MenuTap/MenuTap.tsx +++ b/src/components/atoms/MenuTap/MenuTap.tsx @@ -1,59 +1,60 @@ -import React, { useState, useEffect } from 'react'; -import { Menu } from '@/assets'; -import { - menuIconStlying, - menuItemStyling, - menuStlying, - menuTapStyling, -} from './MenuTap.style'; - -export type MenuItem = { - label?: string; - onClick?: () => void; -}; - -export interface MenuTapProps { - menuData: MenuItem[]; -} - -const MenuTap = ({ menuData }: MenuTapProps) => { - const [view, setView] = useState(false); - - const handleMenuClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setView(!view); - }; - - const handleOutsideClick = () => { - setView(false); - }; - - useEffect(() => { - window.addEventListener('click', handleOutsideClick); - return () => { - window.removeEventListener('click', handleOutsideClick); - }; - }, []); - - return ( -
- - {view && ( -
- {menuData.map((item) => ( - - ))} -
- )} -
- ); -}; - -export default MenuTap; +import React, { useState, useEffect, ReactNode } from 'react'; +import { Menu } from '@/assets'; +import { + menuIconStlying, + menuItemStyling, + menuStlying, + menuTapStyling, +} from './MenuTap.style'; + +export type MenuItem = { + id: number; + label?: ReactNode; + onClick?: () => void; +}; + +export interface MenuTapProps { + menuData: MenuItem[]; +} + +const MenuTap = ({ menuData }: MenuTapProps) => { + const [view, setView] = useState(false); + + const handleMenuClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setView(!view); + }; + + const handleOutsideClick = () => { + setView(false); + }; + + useEffect(() => { + window.addEventListener('click', handleOutsideClick); + return () => { + window.removeEventListener('click', handleOutsideClick); + }; + }, []); + + return ( +
+ + {view && ( +
+ {menuData.map((item) => ( + + ))} +
+ )} +
+ ); +}; + +export default MenuTap; diff --git a/src/components/mobile/molecules/ProfileListItem/ProfileListItem.stories.tsx b/src/components/mobile/molecules/ProfileListItem/ProfileListItem.stories.tsx index 4a01e99f..68c949a1 100644 --- a/src/components/mobile/molecules/ProfileListItem/ProfileListItem.stories.tsx +++ b/src/components/mobile/molecules/ProfileListItem/ProfileListItem.stories.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import ProfileListItem from '@/components/mobile/molecules/ProfileListItem/ProfileListItem'; import { ProfileInfoSample } from '@/assets'; +import { MemoryRouter } from 'react-router-dom'; import { storyContainer, storyInnerContainer } from '@/stories/story.styles'; const meta: Meta = { @@ -21,6 +22,11 @@ const meta: Meta = { description: '이미지 URL', defaultValue: ProfileInfoSample, }, + to: { + control: 'text', + description: '이동할 경로', + defaultValue: '/talkpick/1', + }, }, }; @@ -31,22 +37,46 @@ export const Default: Story = { args: { title: '제목제목제목제목', imgUrl: ProfileInfoSample, + to: '/talkpick/1', }, + render: (args) => ( + + + + ), }; export const All: Story = { render: (args) => ( -
    -
  • - - - - -
  • -
+ +
    +
  • + + + + +
  • +
+
), }; diff --git a/src/components/mobile/molecules/ProfileListItem/ProfileListItem.tsx b/src/components/mobile/molecules/ProfileListItem/ProfileListItem.tsx index 930c84b6..f01e9c17 100644 --- a/src/components/mobile/molecules/ProfileListItem/ProfileListItem.tsx +++ b/src/components/mobile/molecules/ProfileListItem/ProfileListItem.tsx @@ -1,21 +1,57 @@ -import React from 'react'; +import React, { ComponentPropsWithoutRef, useMemo } from 'react'; import MobileProfileImage from '@/components/mobile/atoms/MobileProfileImage/MobileProfileImage'; +import { + RandomBlackFrame, + RandomBlueFrame, + RandomGreenFrame, + RandomPinkFrame, + RandomPurpleFrame, + RandomTealFrame, +} from '@/assets'; +import { Link } from 'react-router-dom'; import * as S from './ProfileListItem.style'; -export interface ProfileListItemProps { +const randomImages = [ + RandomBlackFrame, + RandomBlueFrame, + RandomGreenFrame, + RandomPinkFrame, + RandomPurpleFrame, + RandomTealFrame, +]; + +const useRandomImage = () => { + return useMemo(() => { + const randomIndex = Math.floor(Math.random() * randomImages.length); + return randomImages[randomIndex]; + }, []); +}; + +export interface ProfileListItemProps + extends Omit, 'href'> { title: string; - imgUrl: string; + imgUrl?: string; + to: string; } -const ProfileListItem = ({ title, imgUrl }: ProfileListItemProps) => ( -
- {title} - -
-); +const ProfileListItem = ({ + title, + imgUrl, + ...restProps +}: ProfileListItemProps) => { + const randomImage = useRandomImage(); + const displayImgUrl = imgUrl ?? randomImage; + + return ( + + {title} + + + ); +}; export default ProfileListItem; diff --git a/src/components/mobile/organisms/BalanceGameSection/BalanceGameSection.tsx b/src/components/mobile/organisms/BalanceGameSection/BalanceGameSection.tsx index 01ceeadb..aeaffbac 100644 --- a/src/components/mobile/organisms/BalanceGameSection/BalanceGameSection.tsx +++ b/src/components/mobile/organisms/BalanceGameSection/BalanceGameSection.tsx @@ -1,266 +1,269 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { MobileBookmarkDF, MobileBookmarkPR, MobileShare } from '@/assets'; -import { useNavigate } from 'react-router-dom'; -import { useNewSelector } from '@/store'; -import { selectAccessToken } from '@/store/auth'; -import { GameDetail, GameSet } from '@/types/game'; -import { createArrayFromCommaString } from '@/utils/array'; -import { PATH } from '@/constants/path'; -import { ERROR, PROMPT } from '@/constants/message'; -import MenuTap, { MenuItem } from '@/components/atoms/MenuTap/MenuTap'; -import useToastModal from '@/hooks/modal/useToastModal'; -import { VoteRecord } from '@/types/vote'; -import Button from '@/components/mobile//atoms/Button/Button'; -import IconButton from '@/components/mobile//atoms/IconButton/IconButton'; -import GameTag from '@/components/mobile//atoms/GameTag/GameTag'; -import GameTagChip from '@/components/mobile//atoms/GameTagChip/GameTagChip'; -import GameStageLabel from '@/components/mobile//atoms/GameStageLabel/GameStageLabel'; -import ToastModal from '@/components/atoms/ToastModal/ToastModal'; -import BalanceGameBox from '@/components/mobile/molecules/BalanceGameBox/BalanceGameBox'; -import { useGuestGameVote } from '@/hooks/game/useBalanceGameVote'; -import { useGameBookmark } from '@/hooks/game/useBalanceGameBookmark'; -import { useDeleteGameSetMutation } from '@/hooks/api/game/useDeleteGameSetMutation'; -import ShareModal from '@/components/mobile/molecules/ShareModal/ShareModal'; -import TextModal from '@/components/mobile/molecules/TextModal/TextModal'; -import ReportModal from '@/components/mobile/molecules/ReportModal/ReportModal'; -import * as S from './BalanceGameSection.style'; - -export interface BalanceGameSectionProps { - gameSetId: number; - game?: GameSet; - isMyGame: boolean; - currentStage: number; - setCurrentStage: React.Dispatch>; - changeStage: (step: number) => void; -} - -const gameDefaultDetail: GameDetail[] = Array.from({ length: 10 }, () => ({ - id: 0, - title: '', - description: '', - gameOptions: [], - votesCountOfOptionA: 0, - votesCountOfOptionB: 0, - myBookmark: false, - votedOption: null, -})); - -const BalanceGameSection = ({ - gameSetId, - game, - isMyGame, - currentStage, - setCurrentStage, - changeStage, -}: BalanceGameSectionProps) => { - const initialRender = useRef(true); - const navigate = useNavigate(); - - const gameStages: GameDetail[] = - game?.gameDetailResponses ?? gameDefaultDetail; - const isGuest = !useNewSelector(selectAccessToken); - - const [guestVotedList, setGuestVotedList] = useState([]); - - const currentGame: GameDetail = gameStages[currentStage]; - const subTagList = createArrayFromCommaString(game?.subTag ?? ''); - - const { handleGuestGameVote } = useGuestGameVote( - guestVotedList, - setGuestVotedList, - gameSetId, - currentStage, - game, - ); - - const { isVisible, modalText, showToastModal } = useToastModal(); - const { mutate: deleteBalanceGame } = useDeleteGameSetMutation(); - - const [activeModal, setActiveModal] = useState< - 'reportGame' | 'reportText' | 'deleteText' | 'share' | 'none' - >('none'); - - const onCloseModal = () => { - setActiveModal('none'); - }; - - useEffect(() => { - if (game && initialRender.current) { - const bookmarkedIndex = gameStages.findIndex( - (gameDetail) => gameDetail.myBookmark, - ); - - if (bookmarkedIndex !== -1) { - setCurrentStage(bookmarkedIndex); - } - initialRender.current = false; - } - }, [game, gameStages, setCurrentStage]); - - const handleNextButton = () => { - if ( - (isGuest && !guestVotedList[currentStage]?.votedOption) || - (!isGuest && !currentGame.votedOption) - ) - return; - changeStage(1); - }; - - const handleGameDeleteButton = () => { - deleteBalanceGame( - { gameSetId }, - { - onSuccess: () => { - navigate('/'); - }, - onError: () => { - showToastModal(ERROR.DELETEGAME.FAIL); - }, - }, - ); - }; - - const { handleBookmarkClick } = useGameBookmark( - isGuest, - isMyGame, - currentGame.myBookmark, - gameSetId, - currentGame.id, - showToastModal, - game, - ); - - const myGameItem: MenuItem[] = [ - { - label: '수정', - onClick: () => { - navigate(`/${PATH.CREATE.GAME}`, { state: { game, gameSetId } }); - }, - }, - { - label: '삭제', - onClick: () => { - setActiveModal('deleteText'); - }, - }, - ]; - const otherGameItem: MenuItem[] = [ - { - label: '신고', - onClick: () => { - setActiveModal('reportText'); - }, - }, - ]; - - return ( -
- {isVisible && ( -
- {modalText} -
- )} -
- {}} - onClose={onCloseModal} - /> - - setActiveModal('reportGame')} - onClose={onCloseModal} - /> - {}} - onClose={onCloseModal} - /> -
-
- -
- } - onClick={() => setActiveModal('share')} - /> - - ) : ( - - ) - } - onClick={() => - handleBookmarkClick(() => navigate(`/${PATH.LOGIN}`)) - } - /> -
-
- {game && ( -
-
-
-
- -
-
- -
-
-
{game.title}
-
{currentGame.description}
- changeStage(1)} - handleGuestGameVote={handleGuestGameVote} - /> -
-
- {game.subTag && - subTagList.map((tag) => )} -
-
- )} -
- - -
-
- ); -}; - -export default BalanceGameSection; +import React, { useState, useEffect, useRef } from 'react'; +import { MobileBookmarkDF, MobileBookmarkPR, MobileShare } from '@/assets'; +import { useNavigate } from 'react-router-dom'; +import { useNewSelector } from '@/store'; +import { selectAccessToken } from '@/store/auth'; +import { GameDetail, GameSet } from '@/types/game'; +import { createArrayFromCommaString } from '@/utils/array'; +import { PATH } from '@/constants/path'; +import { ERROR, PROMPT } from '@/constants/message'; +import MenuTap, { MenuItem } from '@/components/atoms/MenuTap/MenuTap'; +import useToastModal from '@/hooks/modal/useToastModal'; +import { VoteRecord } from '@/types/vote'; +import Button from '@/components/mobile//atoms/Button/Button'; +import IconButton from '@/components/mobile//atoms/IconButton/IconButton'; +import GameTag from '@/components/mobile//atoms/GameTag/GameTag'; +import GameTagChip from '@/components/mobile//atoms/GameTagChip/GameTagChip'; +import GameStageLabel from '@/components/mobile//atoms/GameStageLabel/GameStageLabel'; +import ToastModal from '@/components/atoms/ToastModal/ToastModal'; +import BalanceGameBox from '@/components/mobile/molecules/BalanceGameBox/BalanceGameBox'; +import { useGuestGameVote } from '@/hooks/game/useBalanceGameVote'; +import { useGameBookmark } from '@/hooks/game/useBalanceGameBookmark'; +import { useDeleteGameSetMutation } from '@/hooks/api/game/useDeleteGameSetMutation'; +import ShareModal from '@/components/mobile/molecules/ShareModal/ShareModal'; +import TextModal from '@/components/mobile/molecules/TextModal/TextModal'; +import ReportModal from '@/components/mobile/molecules/ReportModal/ReportModal'; +import * as S from './BalanceGameSection.style'; + +export interface BalanceGameSectionProps { + gameSetId: number; + game?: GameSet; + isMyGame: boolean; + currentStage: number; + setCurrentStage: React.Dispatch>; + changeStage: (step: number) => void; +} + +const gameDefaultDetail: GameDetail[] = Array.from({ length: 10 }, () => ({ + id: 0, + title: '', + description: '', + gameOptions: [], + votesCountOfOptionA: 0, + votesCountOfOptionB: 0, + myBookmark: false, + votedOption: null, +})); + +const BalanceGameSection = ({ + gameSetId, + game, + isMyGame, + currentStage, + setCurrentStage, + changeStage, +}: BalanceGameSectionProps) => { + const initialRender = useRef(true); + const navigate = useNavigate(); + + const gameStages: GameDetail[] = + game?.gameDetailResponses ?? gameDefaultDetail; + const isGuest = !useNewSelector(selectAccessToken); + + const [guestVotedList, setGuestVotedList] = useState([]); + + const currentGame: GameDetail = gameStages[currentStage]; + const subTagList = createArrayFromCommaString(game?.subTag ?? ''); + + const { handleGuestGameVote } = useGuestGameVote( + guestVotedList, + setGuestVotedList, + gameSetId, + currentStage, + game, + ); + + const { isVisible, modalText, showToastModal } = useToastModal(); + const { mutate: deleteBalanceGame } = useDeleteGameSetMutation(); + + const [activeModal, setActiveModal] = useState< + 'reportGame' | 'reportText' | 'deleteText' | 'share' | 'none' + >('none'); + + const onCloseModal = () => { + setActiveModal('none'); + }; + + useEffect(() => { + if (game && initialRender.current) { + const bookmarkedIndex = gameStages.findIndex( + (gameDetail) => gameDetail.myBookmark, + ); + + if (bookmarkedIndex !== -1) { + setCurrentStage(bookmarkedIndex); + } + initialRender.current = false; + } + }, [game, gameStages, setCurrentStage]); + + const handleNextButton = () => { + if ( + (isGuest && !guestVotedList[currentStage]?.votedOption) || + (!isGuest && !currentGame.votedOption) + ) + return; + changeStage(1); + }; + + const handleGameDeleteButton = () => { + deleteBalanceGame( + { gameSetId }, + { + onSuccess: () => { + navigate('/'); + }, + onError: () => { + showToastModal(ERROR.DELETEGAME.FAIL); + }, + }, + ); + }; + + const { handleBookmarkClick } = useGameBookmark( + isGuest, + isMyGame, + currentGame.myBookmark, + gameSetId, + currentGame.id, + showToastModal, + game, + ); + + const myGameItem: MenuItem[] = [ + { + id: 0, + label: '수정', + onClick: () => { + navigate(`/${PATH.CREATE.GAME}`, { state: { game, gameSetId } }); + }, + }, + { + id: 1, + label: '삭제', + onClick: () => { + setActiveModal('deleteText'); + }, + }, + ]; + const otherGameItem: MenuItem[] = [ + { + id: 0, + label: '신고', + onClick: () => { + setActiveModal('reportText'); + }, + }, + ]; + + return ( +
+ {isVisible && ( +
+ {modalText} +
+ )} +
+ {}} + onClose={onCloseModal} + /> + + setActiveModal('reportGame')} + onClose={onCloseModal} + /> + {}} + onClose={onCloseModal} + /> +
+
+ +
+ } + onClick={() => setActiveModal('share')} + /> + + ) : ( + + ) + } + onClick={() => + handleBookmarkClick(() => navigate(`/${PATH.LOGIN}`)) + } + /> +
+
+ {game && ( +
+
+
+
+ +
+
+ +
+
+
{game.title}
+
{currentGame.description}
+ changeStage(1)} + handleGuestGameVote={handleGuestGameVote} + /> +
+
+ {game.subTag && + subTagList.map((tag) => )} +
+
+ )} +
+ + +
+
+ ); +}; + +export default BalanceGameSection; diff --git a/src/components/mobile/organisms/DateGroupedCard/DateGroupedCard.stories.tsx b/src/components/mobile/organisms/DateGroupedCard/DateGroupedCard.stories.tsx new file mode 100644 index 00000000..247000cc --- /dev/null +++ b/src/components/mobile/organisms/DateGroupedCard/DateGroupedCard.stories.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import DateGroupedCard, { + DateGroupedCardProps, +} from '@/components/mobile/organisms/DateGroupedCard/DateGroupedCard'; +import { MobileCardSampleFirst, MobileCardSampleSecond } from '@/assets'; +import { storyContainer } from '@/stories/story.styles'; + +const sampleItems: DateGroupedCardProps['items'] = [ + { + id: '1', + title: '첫 번째 콘텐츠', + mainTag: 'Tag1', + subTag: 'Sub1', + images: [MobileCardSampleFirst, MobileCardSampleSecond], + onClick: () => {}, + }, + { + id: '2', + title: '두 번째 콘텐츠', + mainTag: 'Tag2', + subTag: 'Sub2', + images: [MobileCardSampleFirst, MobileCardSampleSecond], + onClick: () => {}, + }, + { + id: '3', + title: '세 번째 콘텐츠', + mainTag: 'Tag3', + subTag: 'Sub3', + images: [MobileCardSampleFirst, MobileCardSampleSecond], + onClick: () => {}, + }, +]; + +const meta: Meta = { + title: 'mobile/organisms/DateGroupedCard', + component: DateGroupedCard, + parameters: { + layout: 'centered', + }, + argTypes: { + date: { + control: 'text', + description: '날짜를 나타냅니다.', + }, + items: { + control: 'object', + description: 'ContentsButton 배열을 포함하는 리스트 항목들입니다.', + }, + }, + args: { + date: '2025-02-25', + items: sampleItems, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + date: '2025-02-25', + items: sampleItems, + }, +}; + +export const All: Story = { + render: () => ( +
+ console.log('콘텐츠 1 클릭'), + }, + { + id: '2', + title: '콘텐츠 2', + mainTag: 'Tag2', + subTag: 'Sub2', + images: [MobileCardSampleFirst, MobileCardSampleSecond], + onClick: () => console.log('콘텐츠 2 클릭'), + }, + ]} + /> + console.log('콘텐츠 3 클릭'), + }, + { + id: '4', + title: '콘텐츠 4', + mainTag: 'Tag4', + subTag: 'Sub4', + images: [MobileCardSampleFirst, MobileCardSampleSecond], + onClick: () => console.log('콘텐츠 4 클릭'), + }, + ]} + /> +
+ ), +}; diff --git a/src/components/mobile/organisms/DateGroupedCard/DateGroupedCard.style.ts b/src/components/mobile/organisms/DateGroupedCard/DateGroupedCard.style.ts new file mode 100644 index 00000000..bef39de6 --- /dev/null +++ b/src/components/mobile/organisms/DateGroupedCard/DateGroupedCard.style.ts @@ -0,0 +1,27 @@ +import { css } from '@emotion/react'; +import color from '@/styles/color'; +import typo from '@/styles/typo'; + +export const containerStyle = css({ + display: 'flex', + flexDirection: 'column', + width: '333px', + border: 'none', + backgroundColor: color.WT, +}); + +export const dateStyle = css(typo.Mobile.Main.Regular_12, { + color: color.GY[1], + marginBottom: '6px', +}); + +export const listStyle = css({ + listStyle: 'none', + margin: 0, + padding: 0, + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + gap: '8px', +}); + +export const listItemStyle = css({}); diff --git a/src/components/mobile/organisms/DateGroupedCard/DateGroupedCard.tsx b/src/components/mobile/organisms/DateGroupedCard/DateGroupedCard.tsx new file mode 100644 index 00000000..a5860907 --- /dev/null +++ b/src/components/mobile/organisms/DateGroupedCard/DateGroupedCard.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import ContentsButton, { + ContentsButtonProps, +} from '@/components/molecules/ContentsButton/ContentsButton'; +import * as S from './DateGroupedCard.style'; + +export interface DateGroupedCardProps { + date: string; + items: ContentsButtonProps[]; +} + +const DateGroupedCard = ({ date, items }: DateGroupedCardProps) => ( +
+ {date} +
    + {items.map(({ id, ...buttonProps }) => ( +
  • + +
  • + ))} +
+
+); + +export default DateGroupedCard; diff --git a/src/components/mobile/organisms/DateGroupedList/DateGroupedList.tsx b/src/components/mobile/organisms/DateGroupedList/DateGroupedList.tsx index 618a5c1b..b6541144 100644 --- a/src/components/mobile/organisms/DateGroupedList/DateGroupedList.tsx +++ b/src/components/mobile/organisms/DateGroupedList/DateGroupedList.tsx @@ -5,7 +5,7 @@ import * as S from './DateGroupedList.style'; export interface DateGroupedListItem { id: number; title: string; - imgUrl: string; + imgUrl?: string; } export interface DateGroupedListProps { @@ -19,7 +19,11 @@ const DateGroupedList = ({ date, items }: DateGroupedListProps) => (
    {items.map(({ id, title, imgUrl }) => (
  • - +
  • ))}
diff --git a/src/components/mobile/organisms/IconButtonArea/IconButtonArea.tsx b/src/components/mobile/organisms/IconButtonArea/IconButtonArea.tsx index 6c32d3fd..2072aa9a 100644 --- a/src/components/mobile/organisms/IconButtonArea/IconButtonArea.tsx +++ b/src/components/mobile/organisms/IconButtonArea/IconButtonArea.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactElement } from 'react'; import IconButton from '@/components/mobile/molecules/IconButton/IconButton'; import { IcMobileBookmark, @@ -10,66 +10,78 @@ import { IcMobileWritten, IcMobileWrittenDF, } from '@/assets'; +import { ButtonType, TabType } from '@/types/mypages'; import * as S from './IconButtonArea.style'; +export interface IconButtonConfig { + id: ButtonType; + label: Record; + activeIcon: ReactElement; + inactiveIcon: ReactElement; + showIn: TabType[]; +} + export interface IconButtonAreaProps { - activeTab: 'talkPick' | 'balanceGame'; - onButtonClick: (id: string) => void; - activeButton: string | null; + activeTab: TabType; + activeButton: ButtonType | null; + onButtonClick: (id: ButtonType) => void; } +const allButtons: IconButtonConfig[] = [ + { + id: 'saved', + label: { talkPick: '내가 저장한', balanceGame: '내가 저장한' }, + activeIcon: , + inactiveIcon: , + showIn: ['talkPick', 'balanceGame'], + }, + { + id: 'voted', + label: { talkPick: '내가 투표한', balanceGame: '내가 투표한' }, + activeIcon: , + inactiveIcon: , + showIn: ['talkPick', 'balanceGame'], + }, + { + id: 'commented', + label: { talkPick: '내가 댓글 단', balanceGame: '' }, + activeIcon: , + inactiveIcon: , + showIn: ['talkPick'], + }, + { + id: 'created', + label: { talkPick: '내가 작성한', balanceGame: '내가 만든' }, + activeIcon: , + inactiveIcon: , + showIn: ['talkPick', 'balanceGame'], + }, +]; + const IconButtonArea = ({ activeTab, onButtonClick, activeButton, }: IconButtonAreaProps) => { - const allButtons = [ - { - id: 'saved', - label: { talkPick: '내가 저장한', balanceGame: '내가 저장한' }, - activeIcon: , - inactiveIcon: , - showIn: ['talkPick', 'balanceGame'], - }, - { - id: 'voted', - label: { talkPick: '내가 투표한', balanceGame: '내가 투표한' }, - activeIcon: , - inactiveIcon: , - showIn: ['talkPick', 'balanceGame'], - }, - { - id: 'commented', - label: { talkPick: '내가 댓글 단', balanceGame: '' }, - activeIcon: , - inactiveIcon: , - showIn: ['talkPick'], - }, - { - id: 'created', - label: { talkPick: '내가 작성한', balanceGame: '내가 만든' }, - activeIcon: , - inactiveIcon: , - showIn: ['talkPick', 'balanceGame'], - }, - ]; - const filteredButtons = allButtons.filter(({ showIn }) => showIn.includes(activeTab), ); return (
- {filteredButtons.map(({ id, label, activeIcon, inactiveIcon }) => ( - onButtonClick(id)} - /> - ))} + {filteredButtons.map(({ id, label, activeIcon, inactiveIcon }) => { + const isActive = activeButton === id; + return ( + onButtonClick(id)} + /> + ); + })}
); }; diff --git a/src/components/mobile/organisms/ProfileInfoCard/ProfileInfoCard.stories.tsx b/src/components/mobile/organisms/ProfileInfoCard/ProfileInfoCard.stories.tsx index 5019dde7..74b57691 100644 --- a/src/components/mobile/organisms/ProfileInfoCard/ProfileInfoCard.stories.tsx +++ b/src/components/mobile/organisms/ProfileInfoCard/ProfileInfoCard.stories.tsx @@ -43,6 +43,7 @@ export const Default: Story = { bookmarkCount: 34, menuData: [ { + id: 0, label: '회원정보 수정', onClick: () => console.log('회원정보 수정 클릭됨'), }, @@ -62,6 +63,7 @@ export const All: Story = { bookmarkCount={34} menuData={[ { + id: 0, label: '회원정보 수정', onClick: () => console.log('Aycho 회원정보 수정 클릭됨'), }, @@ -77,6 +79,7 @@ export const All: Story = { bookmarkCount={22} menuData={[ { + id: 1, label: '회원정보 수정', onClick: () => console.log('김안녕 회원정보 수정 클릭됨'), }, diff --git a/src/components/mobile/organisms/ProfileInfoCard/ProfileInfoCard.tsx b/src/components/mobile/organisms/ProfileInfoCard/ProfileInfoCard.tsx index 5de858eb..93f84f69 100644 --- a/src/components/mobile/organisms/ProfileInfoCard/ProfileInfoCard.tsx +++ b/src/components/mobile/organisms/ProfileInfoCard/ProfileInfoCard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import MobileProfileImage from '@/components/mobile/atoms/MobileProfileImage/MobileProfileImage'; import LabelCountBox from '@/components/mobile/molecules/LabelCountBox/LabelCountBox'; import MenuTap from '@/components/atoms/MenuTap/MenuTap'; @@ -10,7 +10,7 @@ export interface ProfileInfoCardProps { username: string; postCount: number; bookmarkCount: number; - menuData: { label: string; onClick: () => void }[]; + menuData: { id: number; label: ReactNode; onClick: () => void }[]; } const ProfileInfoCard = ({ diff --git a/src/components/mobile/organisms/TalkPickSection/TalkPickSection.tsx b/src/components/mobile/organisms/TalkPickSection/TalkPickSection.tsx index fcf97700..6dba412a 100644 --- a/src/components/mobile/organisms/TalkPickSection/TalkPickSection.tsx +++ b/src/components/mobile/organisms/TalkPickSection/TalkPickSection.tsx @@ -1,238 +1,241 @@ -import React, { useState } from 'react'; -import { - AngleSmallUp, - AngleSmallDown, - MobileBookmarkDF, - MobileBookmarkPR, - MobileShare, - PickIcon, -} from '@/assets'; -import { useNavigate } from 'react-router-dom'; -import { TalkPickDetail } from '@/types/talk-pick'; -import { PATH } from '@/constants/path'; -import { ERROR, PROMPT } from '@/constants/message'; -import { formatDate, formatNumber } from '@/utils/formatData'; -import Button from '@/components/atoms/Button/Button'; -import IconButton from '@/components/mobile/atoms/IconButton/IconButton'; -import SummaryBox from '@/components/mobile/molecules/SummaryBox/SummaryBox'; -import ProfileIcon from '@/components/atoms/ProfileIcon/ProfileIcon'; -import ToastModal from '@/components/atoms/ToastModal/ToastModal'; -import VoteToggle from '@/components/mobile/molecules/VoteToggle/VoteToggle'; -import MenuTap, { MenuItem } from '@/components/atoms/MenuTap/MenuTap'; -import TextModal from '@/components/mobile/molecules/TextModal/TextModal'; -import ShareModal from '@/components/mobile/molecules/ShareModal/ShareModal'; -import ReportModal from '@/components/mobile/molecules/ReportModal/ReportModal'; -import { useCreateTalkPickBookmarkMutation } from '@/hooks/api/bookmark/useCreateTalkPickBookmarkMutation'; -import { useDeleteTalkPickBookmarkMutation } from '@/hooks/api/bookmark/useDeleteTalkPickBookmarkMutation'; -import { useDeleteTalkPickMutation } from '@/hooks/api/talk-pick/useDeleteTalkPickMutation'; -import useToastModal from '@/hooks/modal/useToastModal'; -import * as S from './TalkPickSection.style'; - -export interface TalkPickProps { - talkPick: TalkPickDetail; - myTalkPick: boolean; - isTodayTalkPick: boolean; -} - -const TalkPickSection = ({ - talkPick, - myTalkPick, - isTodayTalkPick, -}: TalkPickProps) => { - const navigate = useNavigate(); - - const [isExpanded, setIsExpanded] = useState(false); - const { isVisible, modalText, showToastModal } = useToastModal(); - - const [activeModal, setActiveModal] = useState< - 'reportTalkPick' | 'reportText' | 'deleteText' | 'share' | 'none' - >('none'); - - const onCloseModal = () => { - setActiveModal('none'); - }; - - const { mutate: createBookmark } = useCreateTalkPickBookmarkMutation( - talkPick?.id ?? 0, - ); - - const { mutate: deleteBookmark } = useDeleteTalkPickBookmarkMutation( - talkPick?.id ?? 0, - ); - - const handleBookmarkClick = () => { - if (!talkPick) return; - - if (myTalkPick) { - showToastModal(ERROR.BOOKMARK.MY_TALKPICK); - return; - } - - if (talkPick.myBookmark) { - deleteBookmark(); - } else { - createBookmark(); - } - }; - - const handleContentToggle = () => { - setIsExpanded((prev) => !prev); - }; - - const myTalkPickItem: MenuItem[] = [ - { - label: '수정', - onClick: () => { - navigate(`/${PATH.CREATE.TALK_PICK}`, { state: { talkPick } }); - }, - }, - { - label: '삭제', - onClick: () => { - setActiveModal('deleteText'); - }, - }, - ]; - - const otherTalkPickItem: MenuItem[] = [ - { - label: '신고', - onClick: () => { - setActiveModal('reportText'); - }, - }, - ]; - - const { mutate: deleteTalkPick } = useDeleteTalkPickMutation( - talkPick?.id ?? 0, - ); - - const handleDeleteButton = () => { - deleteTalkPick(); - onCloseModal(); - }; - - return ( -
- {isVisible && ( -
- {modalText} -
- )} -
- {}} - onClose={onCloseModal} - /> - - { - setActiveModal('reportTalkPick'); - }} - onClose={onCloseModal} - /> - {}} - onClose={onCloseModal} - /> -
-
-
- {isTodayTalkPick ? '오늘의 톡픽' : '톡픽'} - -
-
- } - onClick={() => setActiveModal('share')} - /> - : - } - onClick={handleBookmarkClick} - /> -
-
-
-
-
-
{talkPick?.baseFields.title}
- -
-
-
- -
-
{talkPick?.writer}
-
-
- {formatDate(talkPick?.createdAt ?? '')} -
-
-
-
- 조회 {formatNumber(talkPick?.views ?? '')} -
-
-
-
- - {isExpanded && ( -
-
- {talkPick?.baseFields.content} -
- {talkPick?.imgUrls.length !== 0 && ( -
- {talkPick?.imgUrls.map((url) => ( - {url} - ))} -
- )} -
- )} - -
-
- -
-
-
- ); -}; - -export default TalkPickSection; +import React, { useState } from 'react'; +import { + AngleSmallUp, + AngleSmallDown, + MobileBookmarkDF, + MobileBookmarkPR, + MobileShare, + PickIcon, +} from '@/assets'; +import { useNavigate } from 'react-router-dom'; +import { TalkPickDetail } from '@/types/talk-pick'; +import { PATH } from '@/constants/path'; +import { ERROR, PROMPT } from '@/constants/message'; +import { formatDate, formatNumber } from '@/utils/formatData'; +import Button from '@/components/atoms/Button/Button'; +import IconButton from '@/components/mobile/atoms/IconButton/IconButton'; +import SummaryBox from '@/components/mobile/molecules/SummaryBox/SummaryBox'; +import ProfileIcon from '@/components/atoms/ProfileIcon/ProfileIcon'; +import ToastModal from '@/components/atoms/ToastModal/ToastModal'; +import VoteToggle from '@/components/mobile/molecules/VoteToggle/VoteToggle'; +import MenuTap, { MenuItem } from '@/components/atoms/MenuTap/MenuTap'; +import TextModal from '@/components/mobile/molecules/TextModal/TextModal'; +import ShareModal from '@/components/mobile/molecules/ShareModal/ShareModal'; +import ReportModal from '@/components/mobile/molecules/ReportModal/ReportModal'; +import { useCreateTalkPickBookmarkMutation } from '@/hooks/api/bookmark/useCreateTalkPickBookmarkMutation'; +import { useDeleteTalkPickBookmarkMutation } from '@/hooks/api/bookmark/useDeleteTalkPickBookmarkMutation'; +import { useDeleteTalkPickMutation } from '@/hooks/api/talk-pick/useDeleteTalkPickMutation'; +import useToastModal from '@/hooks/modal/useToastModal'; +import * as S from './TalkPickSection.style'; + +export interface TalkPickProps { + talkPick: TalkPickDetail; + myTalkPick: boolean; + isTodayTalkPick: boolean; +} + +const TalkPickSection = ({ + talkPick, + myTalkPick, + isTodayTalkPick, +}: TalkPickProps) => { + const navigate = useNavigate(); + + const [isExpanded, setIsExpanded] = useState(false); + const { isVisible, modalText, showToastModal } = useToastModal(); + + const [activeModal, setActiveModal] = useState< + 'reportTalkPick' | 'reportText' | 'deleteText' | 'share' | 'none' + >('none'); + + const onCloseModal = () => { + setActiveModal('none'); + }; + + const { mutate: createBookmark } = useCreateTalkPickBookmarkMutation( + talkPick?.id ?? 0, + ); + + const { mutate: deleteBookmark } = useDeleteTalkPickBookmarkMutation( + talkPick?.id ?? 0, + ); + + const handleBookmarkClick = () => { + if (!talkPick) return; + + if (myTalkPick) { + showToastModal(ERROR.BOOKMARK.MY_TALKPICK); + return; + } + + if (talkPick.myBookmark) { + deleteBookmark(); + } else { + createBookmark(); + } + }; + + const handleContentToggle = () => { + setIsExpanded((prev) => !prev); + }; + + const myTalkPickItem: MenuItem[] = [ + { + id: 0, + label: '수정', + onClick: () => { + navigate(`/${PATH.CREATE.TALK_PICK}`, { state: { talkPick } }); + }, + }, + { + id: 1, + label: '삭제', + onClick: () => { + setActiveModal('deleteText'); + }, + }, + ]; + + const otherTalkPickItem: MenuItem[] = [ + { + id: 0, + label: '신고', + onClick: () => { + setActiveModal('reportText'); + }, + }, + ]; + + const { mutate: deleteTalkPick } = useDeleteTalkPickMutation( + talkPick?.id ?? 0, + ); + + const handleDeleteButton = () => { + deleteTalkPick(); + onCloseModal(); + }; + + return ( +
+ {isVisible && ( +
+ {modalText} +
+ )} +
+ {}} + onClose={onCloseModal} + /> + + { + setActiveModal('reportTalkPick'); + }} + onClose={onCloseModal} + /> + {}} + onClose={onCloseModal} + /> +
+
+
+ {isTodayTalkPick ? '오늘의 톡픽' : '톡픽'} + +
+
+ } + onClick={() => setActiveModal('share')} + /> + : + } + onClick={handleBookmarkClick} + /> +
+
+
+
+
+
{talkPick?.baseFields.title}
+ +
+
+
+ +
+
{talkPick?.writer}
+
+
+ {formatDate(talkPick?.createdAt ?? '')} +
+
+
+
+ 조회 {formatNumber(talkPick?.views ?? '')} +
+
+
+
+ + {isExpanded && ( +
+
+ {talkPick?.baseFields.content} +
+ {talkPick?.imgUrls.length !== 0 && ( +
+ {talkPick?.imgUrls.map((url) => ( + {url} + ))} +
+ )} +
+ )} + +
+
+ +
+
+
+ ); +}; + +export default TalkPickSection; diff --git a/src/components/molecules/CommentItem/CommentItem.tsx b/src/components/molecules/CommentItem/CommentItem.tsx index 5fe8d4b3..199a54d2 100644 --- a/src/components/molecules/CommentItem/CommentItem.tsx +++ b/src/components/molecules/CommentItem/CommentItem.tsx @@ -103,12 +103,14 @@ const CommentItem = ({ const myComment: MenuItem[] = [ { + id: 0, label: '수정', onClick: () => { setEditCommentClicked(true); }, }, { + id: 1, label: '삭제', onClick: () => { setActiveModal('deleteText'); @@ -118,6 +120,7 @@ const CommentItem = ({ const reportComment: MenuItem[] = [ { + id: 0, label: '신고', onClick: () => { setActiveModal('reportText'); diff --git a/src/components/molecules/ContentsButton/ContentsButton.tsx b/src/components/molecules/ContentsButton/ContentsButton.tsx index 53104e03..79a8bad4 100644 --- a/src/components/molecules/ContentsButton/ContentsButton.tsx +++ b/src/components/molecules/ContentsButton/ContentsButton.tsx @@ -17,7 +17,7 @@ export interface ContentsButtonProps extends ComponentPropsWithRef<'button'> { title: string; mainTag: string; subTag?: string; - images: string[]; + images?: string[]; bookmarked?: BookmarkProps['bookmarked']; showBookmark?: boolean; size?: 'large' | 'medium' | 'small' | 'extraSmall'; diff --git a/src/components/molecules/ReplyItem/ReplyItem.tsx b/src/components/molecules/ReplyItem/ReplyItem.tsx index a256160f..ad012333 100644 --- a/src/components/molecules/ReplyItem/ReplyItem.tsx +++ b/src/components/molecules/ReplyItem/ReplyItem.tsx @@ -1,180 +1,183 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Comment } from '@/types/comment'; -import { useMemberQuery } from '@/hooks/api/member/useMemberQuery'; -import { formatDateFromISOWithTime } from '@/utils/formatData'; -import { useCommentActions } from '@/hooks/comment/useCommentActions'; -import MenuTap, { MenuItem } from '@/components/atoms/MenuTap/MenuTap'; -import ToastModal from '@/components/atoms/ToastModal/ToastModal'; -import LikeButton from '@/components/atoms/LikeButton/LikeButton'; -import CategoryBarChip from '@/components/atoms/CategoryBarChip/CategoryBarChip'; -import TextArea from '@/components/molecules/TextArea/TextArea'; -import TextModal from '@/components/molecules/TextModal/TextModal'; -import ReportModal from '@/components/molecules/ReportModal/ReportModal'; -import useToastModal from '@/hooks/modal/useToastModal'; -import useOutsideClick from '@/hooks/common/useOutsideClick'; -import * as S from './ReplyItem.style'; - -export interface ReplyItemProps { - reply: Comment; - selectedPage: number; - talkPickWriter: string; - parentId: number; -} - -const ReplyItem = ({ - reply, - selectedPage, - talkPickWriter, - parentId, -}: ReplyItemProps) => { - const { member } = useMemberQuery(); - - const isMyReply = useMemo(() => { - return reply?.nickname === member?.nickname; - }, [reply?.nickname, member?.nickname]); - - const isTalkPickWriter = useMemo(() => { - return reply?.nickname === talkPickWriter; - }, [reply?.nickname, talkPickWriter]); - - const replyRef = useRef(null); - const { isVisible, modalText, showToastModal } = useToastModal(); - - const [editReplyClicked, setEditReplyClicked] = useState(false); - const [editReplyText, setEditReplyText] = useState(reply.content); - - const [activeModal, setActiveModal] = useState< - 'reportReply' | 'reportText' | 'deleteText' | 'none' - >('none'); - - const onCloseModal = () => { - setActiveModal('none'); - }; - - const { handleEditSubmit, handleDelete, handleLikeToggle, handleReport } = - useCommentActions( - reply, - editReplyText, - selectedPage, - setEditReplyClicked, - showToastModal, - parentId, - ); - - useEffect(() => { - setEditReplyText(reply.content); - }, [reply.content]); - - useOutsideClick(replyRef, () => setEditReplyClicked(false)); - - const handleDeleteReplyButton = () => { - onCloseModal(); - handleDelete(); - }; - - const myReply: MenuItem[] = [ - { - label: '수정', - onClick: () => { - setEditReplyClicked(true); - }, - }, - { - label: '삭제', - onClick: () => { - setActiveModal('deleteText'); - }, - }, - ]; - - const reportReply: MenuItem[] = [ - { - label: '신고', - onClick: () => { - setActiveModal('reportText'); - }, - }, - ]; - - const handleReportReplyButton = (reason: string) => { - handleReport(reason); - onCloseModal(); - }; - - return ( -
- {isVisible && ( -
- {modalText} -
- )} -
- { - handleDeleteReplyButton(); - }} - onClose={onCloseModal} - /> - { - setActiveModal('reportReply'); - }} - onClose={onCloseModal} - /> - handleReportReplyButton(reason)} - onClose={onCloseModal} - /> -
-
-
-
-
- {isTalkPickWriter && ( - 작성자 - )} - {reply?.nickname} - - {formatDateFromISOWithTime(reply?.createdAt ?? '')} - - {reply.edited && 수정됨} -
- {!editReplyClicked && ( - - )} -
- {editReplyClicked ? ( -