diff --git a/public/reaction_plus.svg b/public/reaction_plus.svg new file mode 100644 index 0000000..58f2f73 --- /dev/null +++ b/public/reaction_plus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/app/App.tsx b/src/app/App.tsx index 1736fab..4cb0f64 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,15 +4,26 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import ToastContainer from '@/features/Toast/ui/ToastContainer'; import { RouterProvider } from 'react-router-dom'; import router from './router'; +import ReactionSelector from '@/widgets/reaction-selector/ui/ReactionSelector'; const queryClient = new QueryClient(); const App: React.FC = () => { + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmpvb24iLCJlbWFpbCI6ImFubmF3YTZAbmF2ZXIuY29tLmNvbSIsImlhdCI6MTczMDc3MjU3MiwiZXhwIjoxNzMwNzgzMzcyfQ.jOVHX1RsVVr0hYB9bF9E3CRL3cQzYb9bYr9b35AYFg0'; + return ( - + {/* */} + ); }; diff --git a/src/entities/ReactionButtonContainer/model/reaction.ts b/src/entities/ReactionButtonContainer/model/reaction.ts new file mode 100644 index 0000000..855038e --- /dev/null +++ b/src/entities/ReactionButtonContainer/model/reaction.ts @@ -0,0 +1,7 @@ +import { Emotions } from '@/shared/model/EmotionEnum'; + +export interface Reaction { + emotion: Emotions; + reactionCnt: number; + isClicked: boolean; +} diff --git a/src/entities/ReactionButtonContainer/ui/ReactionButtonContainer.stories.tsx b/src/entities/ReactionButtonContainer/ui/ReactionButtonContainer.stories.tsx new file mode 100644 index 0000000..b2ea674 --- /dev/null +++ b/src/entities/ReactionButtonContainer/ui/ReactionButtonContainer.stories.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ReactionButtonContainer from './ReactionButtonContainer'; +import { Emotions } from '../../../shared/model/EmotionEnum'; + +const meta: Meta = { + component: ReactionButtonContainer, + title: 'entities/ReactionButtonContainer', + tags: ['autodocs'], + argTypes: { + isHorizontal: { + description: '버튼 배열 방식: true일 경우 가로 배열, false일 경우 세로 배열입니다.', + }, + isAddBtnVisible: { + description: '이모티콘 추가 버튼을 추가하세요 (기본 : false)', + }, + reactions: { + description: '감정 버튼 목록입니다. 각 버튼의 감정, 반응 수, 클릭 상태를 포함합니다.', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isHorizontal: true, + isAddBtnVisible: true, + reactions: [ + { emotion: Emotions.Happy, reactionCnt: 10, isClicked: false }, + { emotion: Emotions.Angry, reactionCnt: 5, isClicked: false }, + { emotion: Emotions.Annoyed, reactionCnt: 8, isClicked: false }, + { emotion: Emotions.Awkward, reactionCnt: 10, isClicked: false }, + { emotion: Emotions.Blank, reactionCnt: 5, isClicked: false }, + { emotion: Emotions.Comfortable, reactionCnt: 8, isClicked: false }, + { emotion: Emotions.Confident, reactionCnt: 10, isClicked: false }, + { emotion: Emotions.Depressed, reactionCnt: 5, isClicked: false }, + { emotion: Emotions.Disappointed, reactionCnt: 8, isClicked: false }, + { emotion: Emotions.Embarrassed, reactionCnt: 10, isClicked: false }, + { emotion: Emotions.Excited, reactionCnt: 5, isClicked: false }, + { emotion: Emotions.Fun, reactionCnt: 8, isClicked: false }, + { emotion: Emotions.Grateful, reactionCnt: 10, isClicked: false }, + { emotion: Emotions.Lonely, reactionCnt: 5, isClicked: false }, + { emotion: Emotions.Lovely, reactionCnt: 8, isClicked: false }, + { emotion: Emotions.Not_sure, reactionCnt: 10, isClicked: false }, + { emotion: Emotions.Sad, reactionCnt: 8, isClicked: false }, + ], + onReactionUpdate: (emotion: Emotions, count: number) => { + console.log(`Emotion: ${emotion}, Updated Count: ${count}`); + }, + }, + + play: ({ args }) => { + const logReactionUpdate = (emotion: Emotions, count: number) => { + console.log(`Updated ${emotion}: ${count}`); + }; + + args.onReactionUpdate = logReactionUpdate; + }, +}; + +export const Vertical: Story = { + args: { + isHorizontal: false, + isAddBtnVisible: true, + reactions: [ + { emotion: Emotions.Angry, reactionCnt: 3, isClicked: false }, + { emotion: Emotions.Surprised, reactionCnt: 12, isClicked: false }, + ], + onReactionUpdate: (emotion: Emotions, count: number) => { + console.log(`Emotion: ${emotion}, Updated Count: ${count}`); + }, + }, +}; \ No newline at end of file diff --git a/src/entities/ReactionButtonContainer/ui/ReactionButtonContainer.styled.ts b/src/entities/ReactionButtonContainer/ui/ReactionButtonContainer.styled.ts new file mode 100644 index 0000000..b413876 --- /dev/null +++ b/src/entities/ReactionButtonContainer/ui/ReactionButtonContainer.styled.ts @@ -0,0 +1,44 @@ +import theme from '@/app/styles/theme'; +import styled from 'styled-components'; + +export const StyledReactionContainer = styled.div` + width: 40%; +`; + +export const StyledReactionBtnContainer = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: center; + width: 100%; + + & > button { + margin: 5px; + } +`; + +export const StyledEmotionContainer = styled.div` + width: 50%; + position: absolute; + left: 50%; + transform: translateX(-50%); + z-index: 998; + background: white; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + padding: 10px; + border-radius: 8px; +`; + +export const StyledCloseButton = styled.button` + background-color: transparent; + border: none; + cursor: pointer; + font-size: 16px; + position: absolute; + top: 10px; + right: 10px; + z-index: 999; + + &:hover { + color: ${theme.colors.orange_primary}; + } +`; diff --git a/src/entities/ReactionButtonContainer/ui/ReactionButtonContainer.tsx b/src/entities/ReactionButtonContainer/ui/ReactionButtonContainer.tsx new file mode 100644 index 0000000..8753eb0 --- /dev/null +++ b/src/entities/ReactionButtonContainer/ui/ReactionButtonContainer.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { + StyledEmotionContainer, + StyledReactionContainer, + StyledCloseButton, + StyledReactionBtnContainer +} from './ReactionButtonContainer.styled'; +import { Emotions } from '../../../shared/model/EmotionEnum'; +import ReactionButton from '../../../shared/ReactionButton/ui/ReactionButton'; +import ReactionAddButton from '../../../shared/ReactionAddButton/ui/ReactionAddButton'; +import useModal from '@/shared/hooks/useModal'; +import EmotionList from '@/shared/EmotionButtonList/ui/EmotionButtonList'; +import { Reaction } from '../model/reaction'; + +interface ReactionListProps { + reactions: Reaction[]; + isHorizontal: boolean; + isAddBtnVisible: boolean; + onReactionUpdate: ( + emotion: Emotions, + count: number, + isAlreadyClicked: boolean + ) => void; + onSelectedEmotionsChange: (selectedEmotions: Emotions[]) => void; +} + +const ReactionButtonContainer: React.FC = ({ + reactions = [], + isHorizontal, + isAddBtnVisible = false, + onReactionUpdate, + onSelectedEmotionsChange +}) => { + const [clickedEmotions, setClickedEmotions] = useState([]); + const [updatedReactions, setUpdatedReactions] = + useState(reactions); + const [isModalOpen, setIsModalOpen] = useState(false); + + const { openModal, ModalComponent } = useModal(); + + useEffect(() => { + const initialClickedEmotions = reactions + .filter((reaction) => reaction.isClicked) + .map((reaction) => reaction.emotion); + setClickedEmotions(initialClickedEmotions); + }, [reactions]); + + const handleClick = (emotion: Emotions) => { + setClickedEmotions((prev) => { + const isAlreadyClicked = prev.includes(emotion); + const updatedCount = updatedReactions.map((reaction) => { + if (reaction.emotion === emotion) { + const newCount = isAlreadyClicked + ? reaction.reactionCnt - 1 + : reaction.reactionCnt + 1; + + onReactionUpdate(emotion, newCount, isAlreadyClicked); + + return { + ...reaction, + reactionCnt: newCount + }; + } + return reaction; + }); + + setUpdatedReactions(updatedCount); + + return isAlreadyClicked + ? prev.filter((e) => e !== emotion) + : [...prev, emotion]; + }); + }; + + const toggleModal = () => { + setIsModalOpen((prev) => !prev); + }; + + const handleOnClickAddButton = () => { + toggleModal(); + }; + + const onClickTest = (selectedEmotions: Emotions[]) => { + onSelectedEmotionsChange(selectedEmotions); + }; + + const initialSelectedEmotions = reactions + .filter((reaction) => reaction.isClicked) + .map((reaction) => reaction.emotion); + + return ( + + + {updatedReactions.map(({ emotion, reactionCnt }) => ( + + ))} + + {isAddBtnVisible && ( + + )} + + + {isModalOpen && ( + + + x + + + + )} + + ); +}; + +export default ReactionButtonContainer; diff --git a/src/shared/ReactionAddButton/ui/ReactionAddButton.stories.tsx b/src/shared/ReactionAddButton/ui/ReactionAddButton.stories.tsx new file mode 100644 index 0000000..bf4d283 --- /dev/null +++ b/src/shared/ReactionAddButton/ui/ReactionAddButton.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ReactionAddButton from './ReactionAddButton'; + +const meta: Meta = { + component: ReactionAddButton, + title: 'shared/ReactionAddButton', + tags: ['autodocs'], + argTypes: {}, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; \ No newline at end of file diff --git a/src/shared/ReactionAddButton/ui/ReactionAddButton.styled.ts b/src/shared/ReactionAddButton/ui/ReactionAddButton.styled.ts new file mode 100644 index 0000000..e63774e --- /dev/null +++ b/src/shared/ReactionAddButton/ui/ReactionAddButton.styled.ts @@ -0,0 +1,31 @@ +import theme from '@/app/styles/theme'; +import styled from 'styled-components'; + +interface reactionAddBtnProps { + isHorizontal: boolean; + clicked: boolean; +} + +export const styledReactionAddBtn = styled.button` + background-color: ${({ clicked }) => + clicked ? theme.colors.orange_selected : theme.colors.white_bg}; + border: 1px solid + ${({ clicked }) => + clicked ? theme.colors.orange_primary : theme.colors.gray_normal}; + border-radius: ${({ isHorizontal }) => (isHorizontal ? '30px' : '20px')}; + cursor: pointer; + transition: + background-color 0.2s, + border-color 0.2s; + + &:hover { + background-color: ${({ clicked }) => + clicked + ? theme.colors.orange_selected + : theme.colors.orange_selected}; + border-color: ${({ clicked }) => + clicked + ? theme.colors.orange_primary + : theme.colors.orange_primary}; + } +`; diff --git a/src/shared/ReactionAddButton/ui/ReactionAddButton.tsx b/src/shared/ReactionAddButton/ui/ReactionAddButton.tsx new file mode 100644 index 0000000..d10a8e1 --- /dev/null +++ b/src/shared/ReactionAddButton/ui/ReactionAddButton.tsx @@ -0,0 +1,34 @@ +import { StyledReactionButton } from '../../ReactionButton/ui/ReactionButton.styled'; +import React from 'react'; +import { StyledEmotionIcon } from '../../EmotionIcon/ui/EmotionIcon.styled'; + +interface ReactionAddButtonProps { + isClicked: boolean; + isHorizontal: boolean; + onClick: () => void; +} + +/** 일기 글에 대한 반응을 추가하기 위한 버튼입니다. */ +const ReactionAddButton = ({ + isClicked, + isHorizontal, + onClick +}: ReactionAddButtonProps) => { + const handleClick = () => { + onClick(); + }; + + return ( + + + 이미지 추가 + + + ); +}; + +export default ReactionAddButton; diff --git a/src/shared/ReactionButton/ui/ReactionButton.stories.tsx b/src/shared/ReactionButton/ui/ReactionButton.stories.tsx new file mode 100644 index 0000000..3ac8a8e --- /dev/null +++ b/src/shared/ReactionButton/ui/ReactionButton.stories.tsx @@ -0,0 +1,26 @@ + + +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ReactionButton from './ReactionButton'; +import { Emotions } from '../../../shared/model/EmotionEnum'; + +const meta: Meta = { + component: ReactionButton, + title: 'shared/ReactionButton', + tags: ['autodocs'], + argTypes: {}, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + emotion: Emotions.Happy, + reactionCnt: 10, + isClicked : true, + isHorizontal : true, + }, +}; \ No newline at end of file diff --git a/src/shared/ReactionButton/ui/ReactionButton.styled.ts b/src/shared/ReactionButton/ui/ReactionButton.styled.ts new file mode 100644 index 0000000..736961b --- /dev/null +++ b/src/shared/ReactionButton/ui/ReactionButton.styled.ts @@ -0,0 +1,38 @@ +import theme from '../../../app/styles/theme'; +import styled from 'styled-components'; + +interface reactionBtnProps { + isHorizontal: boolean; + clicked: boolean; +} +export const StyledReactionButton = styled.button` + display: flex; + flex-direction: ${({ isHorizontal }) => (isHorizontal ? 'row' : 'column')}; + align-items: ${({ isHorizontal }) => (isHorizontal ? 'center' : 'center')}; + padding: ${({ isHorizontal }) => (isHorizontal ? '0 20px' : '5px')}; + width: ${({ isHorizontal }) => (isHorizontal ? '70px' : '80px')}; + height: ${({ isHorizontal }) => (isHorizontal ? '30px' : '100px')}; + + gap: 5px; + background-color: ${({ clicked }) => + clicked ? theme.colors.orange_selected : theme.colors.white_bg}; + border: 1px solid + ${({ clicked }) => + clicked ? theme.colors.orange_primary : theme.colors.gray_normal}; + border-radius: ${({ isHorizontal }) => (isHorizontal ? '30px' : '20px')}; + cursor: pointer; + transition: + background-color 0.2s, + border-color 0.2s; + + &:hover { + background-color: ${({ clicked }) => + clicked + ? theme.colors.orange_selected + : theme.colors.orange_selected}; + border-color: ${({ clicked }) => + clicked + ? theme.colors.orange_primary + : theme.colors.orange_primary}; + } +`; diff --git a/src/shared/ReactionButton/ui/ReactionButton.tsx b/src/shared/ReactionButton/ui/ReactionButton.tsx new file mode 100644 index 0000000..14a04ce --- /dev/null +++ b/src/shared/ReactionButton/ui/ReactionButton.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { StyledReactionButton } from './ReactionButton.styled'; +import EmotionIcon from '../../EmotionIcon/ui/EmotionIcon'; +import { Emotions, getEmotionInfo } from '../../model/EmotionEnum'; + +interface ReactionButtonProps { + emotion: Emotions; + reactionCnt: number; + isHorizontal: boolean; + isClicked: boolean; + onClick: (emotion: Emotions) => void; +} + +/** 일기 작성물에 대한 반응을 표현하기 위한 버튼 컴포넌트입니다. + * 반응에 대한 이모티콘과 반응수를 표현합니다. + */ +export const ReactionButton = ({ + emotion, + reactionCnt, + isHorizontal = true, + isClicked, + onClick +}: ReactionButtonProps) => { + const handleClick = () => { + onClick(emotion); + }; + + return ( + + +

{reactionCnt.toLocaleString()}

+
+ ); +}; + +export default ReactionButton; diff --git a/src/shared/model/EmotionEnum.ts b/src/shared/model/EmotionEnum.ts index 53c7cd8..c7b6878 100644 --- a/src/shared/model/EmotionEnum.ts +++ b/src/shared/model/EmotionEnum.ts @@ -22,7 +22,7 @@ export enum Emotions { Awkward = 'Awkward' } -const emotionDescriptions: Record = { +export const emotionDescriptions: Record = { [Emotions.Happy]: '기뻐요', [Emotions.Confident]: '자신있어요', [Emotions.Grateful]: '감사해요', @@ -54,3 +54,27 @@ export const getEmotionInfo = (emotion: Emotions) => { const imagePath = `${imageBasePath}/emoji_${emotionName}.svg`; return { description, imagePath }; }; + +export const emotionMapping: Record = { + 기뻐요: Emotions.Happy, + '자신 있어요': Emotions.Confident, + 감사해요: Emotions.Grateful, + 편안해요: Emotions.Comfortable, + 신이나요: Emotions.Fun, + 즐거워요: Emotions.Excited, + 만족스러워요: Emotions.Satisfied, + 사랑스러워요: Emotions.Lovely, + 모르겠어요: Emotions.Not_sure, + 부끄러워요: Emotions.Embarrassed, + 놀라워요: Emotions.Surprised, + '아무생각이 없어요': Emotions.Blank, + 슬퍼요: Emotions.Sad, + 우울해요: Emotions.Depressed, + 실망스러워요: Emotions.Disappointed, + 후회돼요: Emotions.Regret, + 짜증나요: Emotions.Annoyed, + 화나요: Emotions.Angry, + 외로워요: Emotions.Lonely, + 충격받았어요: Emotions.Shocked, + 곤란해요: Emotions.Awkward +}; diff --git a/src/shared/model/reactionType.ts b/src/shared/model/reactionType.ts index f6293f3..bd44e48 100644 --- a/src/shared/model/reactionType.ts +++ b/src/shared/model/reactionType.ts @@ -1,6 +1,6 @@ export interface ReactionType { - id: number; - type: string; + reaction_id: number; + reaction_type: string; user_email: string; created_at: string; } diff --git a/src/widgets/reaction-selector/api/deleteReactionData.ts b/src/widgets/reaction-selector/api/deleteReactionData.ts new file mode 100644 index 0000000..27cf0a8 --- /dev/null +++ b/src/widgets/reaction-selector/api/deleteReactionData.ts @@ -0,0 +1,29 @@ +import axios, { AxiosError } from 'axios'; + +const API_URL = + 'https://td3axvf8x7.execute-api.ap-northeast-2.amazonaws.com/moodi/reaction'; + +interface DeleteReactionProps { + id: number; + token: string; +} + +const deleteReaction = async ({ id, token }: DeleteReactionProps) => { + try { + const response = await axios.delete(`${API_URL}?id=${id}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + console.log('Reaction deleted successfully:', response.data); + } catch (error) { + const axiosError = error as AxiosError; + console.error( + 'Failed to delete reaction:', + axiosError.response?.data || axiosError.message + ); + } +}; + +export default deleteReaction; diff --git a/src/widgets/reaction-selector/api/fetchReactionData.ts b/src/widgets/reaction-selector/api/fetchReactionData.ts new file mode 100644 index 0000000..fb0f326 --- /dev/null +++ b/src/widgets/reaction-selector/api/fetchReactionData.ts @@ -0,0 +1,20 @@ +import { DiaryType } from '../../../shared/model/diaryType'; + +export const fetchReactionData = async ( + diaryId: number +): Promise => { + const apiBaseUrl = `https://td3axvf8x7.execute-api.ap-northeast-2.amazonaws.com/moodi/diary/${diaryId}`; + try { + const response = await fetch(`${apiBaseUrl}`); + + if (!response.ok) { + throw new Error('데이터를 가져오는데 실패했습니다.'); + } + const data: DiaryType = await response.json(); + + return data; + } catch (error) { + console.error(error); + return null; + } +}; diff --git a/src/widgets/reaction-selector/api/updateReactionData.ts b/src/widgets/reaction-selector/api/updateReactionData.ts new file mode 100644 index 0000000..fdf6ffc --- /dev/null +++ b/src/widgets/reaction-selector/api/updateReactionData.ts @@ -0,0 +1,33 @@ +import axios, { AxiosError } from 'axios'; + +const API_URL = + 'https://td3axvf8x7.execute-api.ap-northeast-2.amazonaws.com/moodi/reaction'; + +export interface ReactionData { + diary_id: number; + reaction_type: string; + user_email: string; +} + +export const postReaction = async ( + reactionData: ReactionData, + token: string +) => { + try { + const response = await axios.post(API_URL, reactionData, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + console.log('Reaction update successfully:', response.data); + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + console.error( + 'Error posting reaction:', + axiosError.response?.data || axiosError.message + ); + throw error; + } +}; diff --git a/src/widgets/reaction-selector/model/reactionListType.ts b/src/widgets/reaction-selector/model/reactionListType.ts new file mode 100644 index 0000000..085ac35 --- /dev/null +++ b/src/widgets/reaction-selector/model/reactionListType.ts @@ -0,0 +1,7 @@ +import { Emotions } from '../../../shared/model/EmotionEnum'; + +export interface ReactionList { + emotion: Emotions; + reactionCnt: number; + isClicked: boolean; +} diff --git a/src/widgets/reaction-selector/ui/ReactionSelector.stories.tsx b/src/widgets/reaction-selector/ui/ReactionSelector.stories.tsx new file mode 100644 index 0000000..e4df864 --- /dev/null +++ b/src/widgets/reaction-selector/ui/ReactionSelector.stories.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import ReactionSelector from '@/widgets/reaction-selector/ui/ReactionSelector'; + +const queryClient = new QueryClient(); + +const meta: Meta = { + component: ReactionSelector, + title: 'widgets/ReactionSelector', + tags: ['autodocs'], + argTypes: { + diaryId: { control: 'number' }, + userEmail: { control: 'text' }, + isHorizontal: { control: 'boolean' }, + isAddBtnVisible: { control: 'boolean' }, + token: { control: 'text' }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + diaryId: 31, + userEmail: "annawa6@naver.com", + isHorizontal: true, + isAddBtnVisible: true, + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmpvb24iLCJlbWFpbCI6ImFubmF3YTZAbmF2ZXIuY29tLmNvbSIsImlhdCI6MTczMDY4MDc2MywiZXhwIjoxNzMwNjkxNTYzfQ.REWAk04BSzMz81HMBEJY67GYwwV2s1CBPLrFkNvYB48", // Replace with a valid token for real testing + }, + render: (args) => ( + + + + ), +}; diff --git a/src/widgets/reaction-selector/ui/ReactionSelector.styled.ts b/src/widgets/reaction-selector/ui/ReactionSelector.styled.ts new file mode 100644 index 0000000..ec3fa8c --- /dev/null +++ b/src/widgets/reaction-selector/ui/ReactionSelector.styled.ts @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +export const StyledReactionSelector = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + width: 100%; + + & > button { + width: calc(20%-10px); + margin: 5px; + } +`; diff --git a/src/widgets/reaction-selector/ui/ReactionSelector.tsx b/src/widgets/reaction-selector/ui/ReactionSelector.tsx new file mode 100644 index 0000000..f8bd4c4 --- /dev/null +++ b/src/widgets/reaction-selector/ui/ReactionSelector.tsx @@ -0,0 +1,249 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-use-before-define */ +/* eslint-disable camelcase */ +import React, { useMemo } from 'react'; +import { useEffect, useState } from 'react'; +import { StyledReactionSelector } from './ReactionSelector.styled'; +import { fetchReactionData } from '../api/fetchReactionData'; +import { DiaryType } from '@/shared/model/diaryType'; +import { ReactionList } from '../model/reactionListType'; +import { + emotionDescriptions, + emotionMapping, + Emotions +} from '@/shared/model/EmotionEnum'; +import ReactionButtonContainer from '@/entities/ReactionButtonContainer/ui/ReactionButtonContainer'; +import { ReactionType } from '@/shared/model/reactionType'; +import { postReaction, ReactionData } from '../api/updateReactionData'; +import deleteReaction from '../api/deleteReactionData'; + +interface ReactBtnProps { + diaryId: number; + userEmail: string; + isHorizontal: boolean; + isAddBtnVisible: boolean; + token: string; +} + +const ReactionSelector = ({ + diaryId, + userEmail, + isHorizontal, + isAddBtnVisible, + token +}: ReactBtnProps) => { + const [diary, setDiary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reactions, setReactions] = useState([]); + const [previousEmotions, setPreviousEmotions] = useState([]); + + const updateReactions = async (selectedEmotions: Emotions[]) => { + const updatedReactions = [...reactions]; + + for (const emotion of selectedEmotions) { + const existingReaction = updatedReactions.find( + (reaction) => reaction.emotion === emotion + ); + + if (existingReaction) { + if (!existingReaction.isClicked) { + await handlePostReaction({ + diary_id: diaryId, + reaction_type: emotionDescriptions[emotion], + user_email: userEmail + }); + } + } else { + await handlePostReaction({ + diary_id: diaryId, + reaction_type: emotionDescriptions[emotion], + user_email: userEmail + }); + } + } + + for (const reaction of updatedReactions) { + if ( + reaction.isClicked && + !selectedEmotions.includes(reaction.emotion as Emotions) + ) { + const selectedReactions: ReactionType[] = + diary?.reactions.filter( + (e) => + e.reaction_type === + emotionDescriptions[reaction.emotion] && + e.user_email === userEmail + ) || []; + + if (selectedReactions.length > 0) { + const selectedReaction = selectedReactions[0]; + await deleteReaction({ + id: selectedReaction.reaction_id, + token + }); + } + } + } + + await getDiaryData(); + }; + + const handleSelectedEmotions = (selectedEmotions: Emotions[]) => { + if ( + JSON.stringify(selectedEmotions) !== + JSON.stringify(previousEmotions) + ) { + setPreviousEmotions(selectedEmotions); + updateReactions(selectedEmotions); + } + }; + + const processReactions = (reactionsArray: ReactionType[]) => { + const reactionMap: Record = {}; + + reactionsArray.forEach((reaction) => { + const { reaction_type } = reaction; + const reaction_userEmail = reaction.user_email; + const emotion = emotionMapping[reaction_type]; + if (!emotion) return; + + if (!reactionMap[emotion]) { + reactionMap[emotion] = { + emotion, + reactionCnt: 0, + isClicked: false + }; + } + + reactionMap[emotion].reactionCnt += 1; + + if (reaction_userEmail === userEmail) { + reactionMap[emotion].isClicked = true; + } + }); + + const sortedReactions = Object.values(reactionMap).sort((a, b) => { + const indexA = Object.values(Emotions).indexOf(a.emotion); + const indexB = Object.values(Emotions).indexOf(b.emotion); + return indexA - indexB; + }); + + setReactions(sortedReactions); + }; + + const getDiaryData = async () => { + setLoading(true); + try { + const data = await fetchReactionData(diaryId); + + if (data) { + setDiary(data); + processReactions(data.reactions); + } else { + setError('일기를 불러오는 데 실패했습니다.'); + } + } catch (e) { + setError('일기를 불러오는 데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + const handlePostReaction = async ({ + diary_id, + reaction_type, + user_email + }: ReactionData) => { + const reaction: ReactionData = { + diary_id, + reaction_type, + user_email + }; + + try { + await postReaction(reaction, token); + await getDiaryData(); + } catch (e) { + console.error('Failed to post reaction:', e); + } + }; + + useEffect(() => { + getDiaryData(); + }, [diaryId]); + + const memoizedReactions = useMemo(() => { + return reactions.map((reaction) => ({ + ...reaction + // 필요한 추가 데이터 처리 + })); + }, [reactions]); + + if (loading) { + return null; + } + + if (error) { + return
{error}
; + } + + const args = { + isHorizontal, + isAddBtnVisible, + reactions: memoizedReactions, + onReactionUpdate: async ( + emotion: Emotions, + count: number, + isAlreadyClicked: boolean + ) => { + if (isAlreadyClicked) { + if (diary) { + const selectedReactions: ReactionType[] = + diary.reactions.filter( + (e) => + e.reaction_type === + emotionDescriptions[emotion] && + e.user_email === userEmail + ); + + if (selectedReactions.length > 0) { + const selectedReaction = selectedReactions[0]; + await deleteReaction({ + id: selectedReaction.reaction_id, + token + }); + console.log('Reaction deleted successfully'); + await getDiaryData(); + } else { + console.log('No selected reaction found.'); + } + } else { + console.log('Diary data is not available.'); + } + } else { + await handlePostReaction({ + diary_id: diaryId, + reaction_type: emotionDescriptions[emotion], + user_email: userEmail + }); + await getDiaryData(); + } + } + }; + + return ( + + + + ); +}; + +export default ReactionSelector; diff --git a/src/widgets/reaction-selector/util/processReactions.ts b/src/widgets/reaction-selector/util/processReactions.ts new file mode 100644 index 0000000..9d65f49 --- /dev/null +++ b/src/widgets/reaction-selector/util/processReactions.ts @@ -0,0 +1,39 @@ +// processReactions.ts +import { ReactionType } from '@/shared/model/reactionType'; +import { ReactionList } from '../model/reactionListType'; +import { emotionMapping, Emotions } from '@/shared/model/EmotionEnum'; + +export const processReactions = ( + reactionsArray: ReactionType[], + userEmail: string +): ReactionList[] => { + const reactionMap: Record = {}; + + reactionsArray.forEach((reaction) => { + const reactionType = reaction.reaction_type; + const reactionEmail = reaction.user_email; + const emotion = emotionMapping[reactionType]; + if (!emotion) return; + + if (!reactionMap[emotion]) { + reactionMap[emotion] = { + emotion, + reactionCnt: 0, + isClicked: false + }; + } + + reactionMap[emotion].reactionCnt += 1; + + if (reactionEmail === userEmail) { + reactionMap[emotion].isClicked = true; + } + }); + + // Sort By EmotionEnum + return Object.values(reactionMap).sort((a, b) => { + const indexA = Object.values(Emotions).indexOf(a.emotion); + const indexB = Object.values(Emotions).indexOf(b.emotion); + return indexA - indexB; + }); +};