Skip to content

Epic/feed 화면 구현 -> merge 브랜치 병합 #55

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"cra-template": "1.2.0",
"date-fns": "^4.1.0",
"prop-types": "^15.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down
2 changes: 2 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Actor&display=swap" rel="stylesheet">
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" integrity="sha384-DKYJZ8NLiK8MN4/C5P2dtSmLQ4KwPaoqAfyA/DfmEc1VDxu4yyC7wy6K1Hs90nka" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<div id="modal"></div>
</body>
</html>
4 changes: 4 additions & 0 deletions src/assets/images/icons/Messages.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/images/icons/thumbs-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/images/icons/thumbs-up.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions src/assets/images/img_Header.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions src/assets/images/img_QuestionBox.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions src/assets/images/img_QusetionBox.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
109 changes: 109 additions & 0 deletions src/components/AnswerContent/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom';
import { useState } from 'react';
import formatCreatedAt from 'utils/dateUtils';

const AnswerContent = ({ answer, name, imageSource }) => {
AnswerContent.propTypes = {
answer: PropTypes.shape({
content: PropTypes.string.isRequired,
isRejected: PropTypes.bool,
createdAt: PropTypes.string.isRequired,
}),
name: PropTypes.string.isRequired,
imageSource: PropTypes.string,
};

AnswerContent.defaultProps = {
imageSource: 'https://fastly.picsum.photos/id/772/200/200.jpg?hmac=9euSj4JHTPr7uT5QWVmeNJ8JaqAXY8XmJnYfr_DfBJc',
};

const location = useLocation();
const [textareaValue, setTextareaValue] = useState('');

const isFeedPage = location.pathname.startsWith('/post/') && !location.pathname.includes('/answer');
const isAnswerPage = location.pathname.startsWith('/post/') && location.pathname.includes('/answer');

const handleTextareaChange = (event) => {
setTextareaValue(event.target.value);
};

const renderProfileImg = () => <img src={imageSource} alt={`${name}의 프로필`} className='w-[32px] h-[32px] md:w-[48px] md:h-[48px] rounded-full object-cover' />;

const renderAnswerHeader = () => (
<div className='flex items-center mb-[4px]'>
<p className='mr-[8px] inline-block text-sm leading-[18px] md:text-lg md:leading-[24px] font-actor'>{name}</p>
<p className='text-sm font-medium leading-[18px] text-gray-40'>{formatCreatedAt(answer.createdAt)}</p>
</div>
);

const renderAnswerContent = () => <p className='text-base leading-[22px]'>{answer.content}</p>;

const renderAnswerForm = () => (
<form className='flex w-full flex-col gap-[8px]'>
<textarea
className='w-full h-[186px] resize-none rounded-lg border-none p-[16px] bg-gray-20 text-base leading-[22px] text-secondary-900 placeholder:text-base placeholder:leading-[22px] placeholder:text-gray-40 focus:outline-brown-40'
placeholder='답변을 입력해주세요'
value={textareaValue}
onChange={handleTextareaChange}
/>
<button type='submit' className='py-[12px] rounded-lg bg-brown-40 text-base leading-[22px] text-gray-10 disabled:bg-brown-30' disabled={textareaValue.trim() === ''}>
답변 완료
</button>
</form>
);

if (answer && answer.isRejected) {
return (
<div className='flex gap-[12px]'>
{renderProfileImg()}
<div>
{renderAnswerHeader()}
<p className='text-base leading-[22px] text-red-50'>답변 거절</p>
</div>
</div>
);
}

if (isFeedPage) {
if (answer) {
return (
<div className='flex gap-[12px]'>
{renderProfileImg()}
<div>
{renderAnswerHeader()}
{renderAnswerContent()}
</div>
</div>
);
}
return null;
}

if (isAnswerPage) {
if (answer) {
return (
<div className='flex gap-[12px]'>
{renderProfileImg()}
<div>
{renderAnswerHeader()}
<p className='text-base leading-[22px]'>{answer.content}</p>
</div>
</div>
);
}
return (
<div className='flex gap-[12px]'>
{renderProfileImg()}
<div className='flex-1'>
<p className='mb-[4px] mr-[8px] inline-block text-sm leading-[18px] md:text-lg md:leading-[24px] font-actor'>{name}</p>
{renderAnswerForm()}
</div>
</div>
);
}

return null;
};

export default AnswerContent;
23 changes: 23 additions & 0 deletions src/components/AnswerStatus/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import PropTypes from 'prop-types';

const AnswerStatus = ({ answer }) => {
AnswerStatus.propTypes = {
answer: PropTypes.shape({
id: PropTypes.number.isRequired,
questionId: PropTypes.number.isRequired,
content: PropTypes.string.isRequired,
isRejected: PropTypes.bool.isRequired,
createdAt: PropTypes.string.isRequired,
}),
};

const ANSWER_STATUS_COLOR = answer ? 'border-brown-40 text-brown-40' : 'border-gray-40 text-gray-40';

return (
<div>
<span className={`inline-block max-w-max rounded-lg border border-solid px-[12px] py-[4px] text-sm font-medium ${ANSWER_STATUS_COLOR}`}>{answer ? '답변 완료' : '미답변'}</span>
</div>
);
};

export default AnswerStatus;
13 changes: 12 additions & 1 deletion src/components/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ import { RouterProvider } from 'react-router-dom';
import router from 'routes';
import 'assets/styles/index.scss';

const App = () => <RouterProvider router={router} />;
import { AppProvider } from 'components/Context';
import ModalPortal from 'utils/portal';
import Modal from 'components/Modals';

const App = () => (
<AppProvider>
<RouterProvider router={router} />
<ModalPortal>
<Modal />
</ModalPortal>
</AppProvider>
);

export default App;
48 changes: 48 additions & 0 deletions src/components/Context/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createContext, useState, useMemo } from 'react';
import PropTypes from 'prop-types';

const AppContext = createContext();

const AppProvider = ({ children }) => {
/**
* children prop 유효성 검사
* PropType.node : 해당 prop 값이 렌더링 가능한 값이어야 됨
* isRequired : children prop은 필수임
*/
AppProvider.propTypes = {
children: PropTypes.node.isRequired,
};

const [isModalOpen, setIsModalOpen] = useState(false);
const [profile, setProfile] = useState(null);
const [postObject, setPostObject] = useState(null);
/**
* openModal 함수 사용 시 data 객체를 아래처럼 주어야합니다.
* return 되는 값은 data 객체를 가지고 있는 내부 함수 입니다.
* onClick={openModal({ id : 1, name : 'test', imageSource : 'https://example.com'})}처럼 리턴받는 함수를 핸들러로 등록하면 됩니다.
* @param {{ id : 1, name : 'test', imageSource : 'https://exmaple.com/'}} data
* @returns { () => {setProfile(data); setIsModalOpen(true); } }
*/
const openModal = (data) => () => {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollBarWidth}px`;
document.documentElement.style.setProperty('--scroll-bar-width', `${scrollBarWidth}px`); // CSS 변수 설정

setProfile(data);
setIsModalOpen(true);
};

const closeModal = () => {
document.body.style.overflow = '';
document.body.style.paddingRight = 0;
document.documentElement.style.removeProperty('--scroll-bar-width'); // CSS 변수 제거
setIsModalOpen(false);
};

const contextValue = useMemo(() => ({ isModalOpen, profile, openModal, closeModal, postObject, setPostObject }), [isModalOpen, profile, postObject]);

return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
};

export { AppProvider, AppContext };
52 changes: 52 additions & 0 deletions src/components/CountFavorite/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useState } from 'react';
// eslint-disable-next-line
import { postReaction } from 'api/questions';
import { ReactComponent as ThumbsUpIcon } from 'assets/images/icons/thumbs-up.svg';
import { ReactComponent as ThumbsDownIcon } from 'assets/images/icons/thumbs-down.svg';
import PropTypes from 'prop-types';

const CountFavorite = ({ like, dislike, id }) => {
CountFavorite.propTypes = {
like: PropTypes.number.isRequired,
dislike: PropTypes.number.isRequired,
id: PropTypes.number.isRequired,
};

const [favoriteCount, setFavoriteCount] = useState(like);
const [unFavoriteCount, setUnFavoriteCount] = useState(dislike);
const [clickFavorite, setClickFavorite] = useState(false);
const [clickUnFavorite, setClickUnFavorite] = useState(false);

const countingHandleFavorite = () => {
if (!clickFavorite && !clickUnFavorite) {
setClickFavorite(true);
setClickUnFavorite(false);
setFavoriteCount((prev) => prev + 1);
postReaction(id, { type: 'like' });
}
};

const countingHandleUnFavorite = () => {
if (!clickFavorite && !clickUnFavorite) {
setClickFavorite(false);
setClickUnFavorite(true);
setUnFavoriteCount((prev) => prev + 1);
postReaction(id, { type: 'dislike' });
}
};

return (
<div className=' flex gap-[32px] text-gray-60 border-t pt-[25px] '>
<button type='button' onClick={countingHandleFavorite} className='flex justify-center items-center gap-[6px]'>
<ThumbsUpIcon className={`${clickFavorite ? 'fill-blue-50' : 'fill-gray-40'}`} />
<p className={`text-sm leading-[18px] font-medium ${clickFavorite ? 'text-blue-50' : 'text-gray-40'}`}>좋아요 {`${favoriteCount || ''}`}</p>
</button>
<button type='button' onClick={countingHandleUnFavorite} className='flex justify-center items-center gap-[6px]'>
<ThumbsDownIcon className={`${clickUnFavorite ? 'fill-red-50' : 'fill-gray-40'}`} />
<p className={`text-sm leading-[18px] font-medium ${clickUnFavorite ? 'text-red-50' : 'text-gray-40'} `}>싫어요 {`${unFavoriteCount || ''}`}</p>
</button>
</div>
);
};

export default CountFavorite;
17 changes: 17 additions & 0 deletions src/components/CountQuestion/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import PropTypes from 'prop-types';
import { ReactComponent as MessagesIcon } from 'assets/images/icons/ic_Messages.svg';

const CountQuestion = ({ count }) => {
CountQuestion.propTypes = {
count: PropTypes.number.isRequired,
};

return (
<div className='flex items-center justify-center gap-[8px] py-[16px]'>
<MessagesIcon className='w-[22px] h-[22px] fill-brown-40 md:w-[24px] md:h-[24px]' />
<p className='text-lg leading-6 text-brown-40 font-actor md:text-xl md:leading-[25px]'>{count === 0 ? '아직 질문이 없습니다' : `${count}개의 질문이 있습니다`}</p>
</div>
);
};

export default CountQuestion;
26 changes: 26 additions & 0 deletions src/components/DeleteIdBtn/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import PropTypes from 'prop-types';

const DeleteIdBtn = ({ onClick, id }) => {
DeleteIdBtn.propTypes = {
onClick: PropTypes.func,
id: PropTypes.string,
};

const handleDelete = () => {
onClick(id);
};

return (
<div className='relative w-full max-w-[716px] min-w-[144px] h-[25px] md:h-[35px] mx-6 mb-1.5 md:mx-8 md:mb-[9px]'>
<button
type='button'
className='absolute right-0 w-[70px] min-w-[70px] h-[25px] md:w-[100px] md:h-[35px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] px-[17.5px] md:px-[24px] md:py-[5px] rounded-[200px] bg-brown-40 font-normal text-gray-10 text-[10px]/[25px] md:text-[15px]/[25px]'
onClick={handleDelete}
>
삭제하기
</button>
</div>
);
};

export default DeleteIdBtn;
Loading
Loading