diff --git a/src/App.tsx b/src/App.tsx index c5ac6fd6..49a55f70 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,6 @@ import { GlobalStyle } from './style/global'; import { defaultTheme } from './style/theme'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SearchFilteringProvider } from './context/SearchFilteringContext'; -import useNotification from './hooks/useNotification'; const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -16,7 +15,6 @@ const queryClient = new QueryClient({ }); function App() { - const { isSignal, getSendAlarm } = useNotification(); return ( diff --git a/src/api/alarm.api.ts b/src/api/alarm.api.ts index 515f5d3c..e4d6f488 100644 --- a/src/api/alarm.api.ts +++ b/src/api/alarm.api.ts @@ -32,3 +32,16 @@ export const patchAlarm = async (id: number) => { console.log(e); } }; + +export const testLiveAlarm = async () => { + try { + const response = await httpClient.get( + `/user/send-alarm?alarmFilter=0` + ); + + return response; + } catch (e) { + console.error(e); + throw e; + } +}; diff --git a/src/api/projectLists.api.ts b/src/api/projectLists.api.ts index 554726d2..5d93baff 100644 --- a/src/api/projectLists.api.ts +++ b/src/api/projectLists.api.ts @@ -11,7 +11,7 @@ export const getProjectLists = async (params: SearchFilters) => { params, paramsSerializer: { indexes: null }, }); - + return response.data.data; } catch (e) { console.log('getProjectLists', e); diff --git a/src/api/reply.api.ts b/src/api/reply.api.ts index ceeebb09..d1cfd4cc 100644 --- a/src/api/reply.api.ts +++ b/src/api/reply.api.ts @@ -21,7 +21,6 @@ export const postReply = async ( commentId: number, content: string ) => { - console.log(content); try { const response = await httpClient.post( `/project/${projectId}/comment/${commentId}/recomment`, diff --git a/src/api/report.api.ts b/src/api/report.api.ts new file mode 100644 index 00000000..66653b8f --- /dev/null +++ b/src/api/report.api.ts @@ -0,0 +1,15 @@ +import { ApiPostContent } from '../models/report'; +import { httpClient } from './http.api'; + +export const postReport = async (formData: ApiPostContent) => { + try { + const response = await httpClient.post(`/report`, formData); + if (response.status !== 200) { + throw new Error(`${response.status}`); + } + return response.status; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/src/components/comment/CommentLayout.styled.ts b/src/components/comment/CommentLayout.styled.ts index c62b469f..007b51d5 100644 --- a/src/components/comment/CommentLayout.styled.ts +++ b/src/components/comment/CommentLayout.styled.ts @@ -1,6 +1,8 @@ import styled from 'styled-components'; -export const Container = styled.div``; +export const Container = styled.div` + padding-bottom: 100px; +`; export const CommentCountsContainer = styled.div` margin-bottom: 20px; @@ -27,3 +29,7 @@ export const ReplyContainer = styled.div` padding-left: 100px; margin-top: 20px; `; + +export const ErrorMessage = styled.p` + color: #333; +`; diff --git a/src/components/comment/CommentLayout.tsx b/src/components/comment/CommentLayout.tsx index 6169588d..6140a2b8 100644 --- a/src/components/comment/CommentLayout.tsx +++ b/src/components/comment/CommentLayout.tsx @@ -15,22 +15,25 @@ const CommentLayout = ({ createrId, loginUserId, }: CommentLayoutProps) => { - const { getCommentList, isLoading, isFetching, isError } = - useGetComment(projectId); + const { getCommentList, isLoading, isFetching } = useGetComment(projectId); + + if (!getCommentList) { + return ( + + + 댓글 없음 + + + ); + } if (isLoading || isFetching) { return ; } - - if (isError) { - console.error(isError); - return '댓글을 불러오는 중 오류가 발생했습니다. 다시 시도해 주세요.'; - } - return ( - 댓글 {getCommentList?.length || 0}개 + 댓글 {getCommentList.length}개 diff --git a/src/components/common/dropDown/DropDownItem.styled.ts b/src/components/comment/DropDownItem.styled.ts similarity index 100% rename from src/components/common/dropDown/DropDownItem.styled.ts rename to src/components/comment/DropDownItem.styled.ts diff --git a/src/components/comment/DropDownItem.tsx b/src/components/comment/DropDownItem.tsx new file mode 100644 index 00000000..9fa01998 --- /dev/null +++ b/src/components/comment/DropDownItem.tsx @@ -0,0 +1,70 @@ +import useDeleteComment from '../../hooks/CommentHooks/useDeleteComment'; +import useDeleteReply from '../../hooks/CommentHooks/useDeleteReply'; +import { useModal } from '../../hooks/useModal'; +import ReportModal from '../reportComponent/ReportModal'; +import * as S from './DropDownItem.styled'; + +interface DropdownProps { + projectId: number; + activateEditMode?: number | null; + commentId: number; + recommentId?: number; + loginUserId?: number; + commentUserId: number; + reportTitle: { userImg: string; userName: string } | string; + reply?: boolean; + onEdit?: () => void; +} + +const DropDownItem = ({ + projectId, + onEdit, + activateEditMode, + commentId, + recommentId, + commentUserId, + loginUserId, + reply, + reportTitle, +}: DropdownProps) => { + const { removeComment } = useDeleteComment(projectId); + const { removeReply } = useDeleteReply(commentId, projectId); + const { isOpen, handleOpenReportModal, handleCloseReportModal } = useModal(); + + const onDelete = (commentId: number, recommentId?: number) => { + if (reply && recommentId) { + if (confirm('답글을 완성히 삭제할까요?')) removeReply(recommentId); + } else { + if (confirm('댓글을 완성히 삭제할까요?')) removeComment(commentId); + } + }; + + return ( + <> + + 신고하기 + + {loginUserId === commentUserId && ( + <> + + {activateEditMode === commentId ? '수정 취소하기' : '수정하기'} + + onDelete(commentId, recommentId)}> + 삭제하기 + {' '} + + )} + + {isOpen && ( + + )} + + ); +}; + +export default DropDownItem; diff --git a/src/components/comment/commentComponent/CommentComponentLayout.tsx b/src/components/comment/commentComponent/CommentComponentLayout.tsx index 630d5398..51a10dfe 100644 --- a/src/components/comment/commentComponent/CommentComponentLayout.tsx +++ b/src/components/comment/commentComponent/CommentComponentLayout.tsx @@ -1,6 +1,6 @@ import * as S from './CommentComponentLayout.styled'; import DropDown from '../../common/dropDown/DropDown'; -import DropDownItem from '../../common/dropDown/DropDownItem'; +import DropDownItem from '../DropDownItem'; import { CommentType } from '../../../models/comment'; import dropdownButton from '../../../assets/dropdownButton.svg'; import useComment from '../../../hooks/CommentHooks/useComment'; @@ -11,7 +11,7 @@ import CommentComponent from './commentComponent/CommentComponent'; interface CommentLayoutProps { projectId: number; - getCommentList: CommentType[] | undefined; + getCommentList: CommentType[]; createrId?: number; loginUserId?: number | undefined; } @@ -62,6 +62,7 @@ const CommentComponentLayout = ({ onEdit={() => onEdit(item.id)} loginUserId={loginUserId} commentUserId={item.user.id} + reportTitle={item.content} activateEditMode={activateEditMode} /> diff --git a/src/components/comment/commentComponent/commentComponent/CommentComponent.styled.ts b/src/components/comment/commentComponent/commentComponent/CommentComponent.styled.ts index a4ea128c..2584fde7 100644 --- a/src/components/comment/commentComponent/commentComponent/CommentComponent.styled.ts +++ b/src/components/comment/commentComponent/commentComponent/CommentComponent.styled.ts @@ -21,7 +21,11 @@ export const NickName = styled.p` `; export const Comment = styled.span` - margin-left: 11px; + display: inline-block; + max-width: calc(100% - 12px); + word-break: break-word; + white-space: pre-wrap; + margin-left: 12px; `; export const ReplyInputButton = styled.div` @@ -49,8 +53,3 @@ export const ErrorMessage = styled.div` margin-left: 15px; margin-bottom: 10px; `; - -export const Message = styled.p` - color: ${({ theme }) => theme.color.red}; - font-size: 10px; -`; diff --git a/src/components/comment/replyComponent/ReplyComponent.styled.ts b/src/components/comment/replyComponent/ReplyComponent.styled.ts index 7d1108cd..2923acd4 100644 --- a/src/components/comment/replyComponent/ReplyComponent.styled.ts +++ b/src/components/comment/replyComponent/ReplyComponent.styled.ts @@ -24,7 +24,11 @@ export const NickName = styled.p` `; export const Comment = styled.span` - margin-left: 11px; + display: inline-block; + max-width: calc(100% - 12px); + word-break: break-word; + white-space: pre-wrap; + margin-left: 12px; `; export const ReplyContainer = styled.div` @@ -60,8 +64,3 @@ export const ErrorMessage = styled.div` padding-left: 15px; margin-bottom: 10px; `; - -export const Message = styled.p` - color: ${({ theme }) => theme.color.red}; - font-size: 10px; -`; diff --git a/src/components/comment/replyComponent/ReplyComponent.tsx b/src/components/comment/replyComponent/ReplyComponent.tsx index 3995e2c7..cbc5599c 100644 --- a/src/components/comment/replyComponent/ReplyComponent.tsx +++ b/src/components/comment/replyComponent/ReplyComponent.tsx @@ -3,7 +3,7 @@ import * as S from './ReplyComponent.styled'; import DefaultImg from '../../../assets/defaultImg.png'; import useComment from '../../../hooks/CommentHooks/useComment'; import DropDown from '../../common/dropDown/DropDown'; -import DropDownItem from '../../common/dropDown/DropDownItem'; +import DropDownItem from '../DropDownItem'; import dropdownButton from '../../../assets/dropdownButton.svg'; import CommentInput from '../commentInput/CommentInput'; import useGetReply from '../../../hooks/CommentHooks/useGetReply'; @@ -84,6 +84,7 @@ const ReplyComponent = ({ onEdit={() => onEdit(item.id)} loginUserId={loginUserId} commentUserId={item.user.id} + reportTitle={item.content} activateEditMode={activateEditMode} reply={true} /> diff --git a/src/components/common/Toast/Toast.styled.ts b/src/components/common/Toast/Toast.styled.ts new file mode 100644 index 00000000..e0d90ae2 --- /dev/null +++ b/src/components/common/Toast/Toast.styled.ts @@ -0,0 +1,45 @@ +import styled, { keyframes } from 'styled-components'; + +const fadeInUp = keyframes` + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +`; +const fadeOut = keyframes` + from { opacity: 1; } + to { opacity: 0; } +`; + +export const Container = styled.div` + position: fixed; + width: 330px; + bottom: 30px; + right: 30px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 1000; +`; + +export const Item = styled.div<{ $exiting: boolean }>` + background-color: ${({ theme }) => theme.buttonScheme.grey.color}; + padding: 12px 20px; + border-radius: ${({ theme }) => theme.borderRadius.small}; + animation: ${fadeInUp} 0.3s ease-out, + ${({ $exiting }) => $exiting && fadeOut} 0.3s ease-in forwards; +`; + +export const LiveMessage = styled.p` + color: ${({ theme }) => theme.color.white}; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const LiveDate = styled.p` + color: ${({ theme }) => theme.color.white}; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; diff --git a/src/components/common/Toast/ToastContainer.tsx b/src/components/common/Toast/ToastContainer.tsx new file mode 100644 index 00000000..6061223e --- /dev/null +++ b/src/components/common/Toast/ToastContainer.tsx @@ -0,0 +1,26 @@ +import { createPortal } from 'react-dom'; +import { ToastMessage } from '../../../context/ToastContext'; +import ToastItem from './ToastItem'; +import * as S from './Toast.styled'; + +interface ToastContainerProps { + toasts: ToastMessage[]; + onRemove: (id: string) => void; +} + +const ToastContainer = ({ toasts, onRemove }: ToastContainerProps) => { + return createPortal( + + {toasts.map((toast) => ( + onRemove(toast.id)} + /> + ))} + , + document.body + ); +}; + +export default ToastContainer; diff --git a/src/components/common/Toast/ToastItem.tsx b/src/components/common/Toast/ToastItem.tsx new file mode 100644 index 00000000..a0b08e04 --- /dev/null +++ b/src/components/common/Toast/ToastItem.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import * as S from './Toast.styled'; +import { AlarmLive } from '../../../models/alarm'; +import { Link } from 'react-router-dom'; +import { routeSelector } from '../../../util/routeSelector'; +import { timeAgo } from '../../../util/timeAgo'; + +interface ToastItemProps { + id: string; + content: AlarmLive; + duration: number; + onRemove: () => void; +} + +const ToastItem = ({ content, duration, onRemove }: ToastItemProps) => { + const [exiting, setExiting] = useState(false); + const route = routeSelector(content.routingId, content.alarmFilterId); + + useEffect(() => { + const timer = setTimeout(() => setExiting(true), duration); + return () => clearTimeout(timer); + }, [duration]); + + const handleAnimationEnd = () => { + if (exiting) onRemove(); + }; + + return ( + + + {content.message} + {timeAgo(content.createAt)} + + + ); +}; + +export default ToastItem; diff --git a/src/components/common/Toast/ToastProvider.tsx b/src/components/common/Toast/ToastProvider.tsx new file mode 100644 index 00000000..46a54ea6 --- /dev/null +++ b/src/components/common/Toast/ToastProvider.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren, useCallback, useEffect, useState } from 'react'; +import { ToastContext, ToastMessage } from '../../../context/ToastContext'; +import ToastContainer from './ToastContainer'; +import { AlarmLive } from '../../../models/alarm'; + +export const ToastProvider = ({ children }: PropsWithChildren) => { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((content: AlarmLive, duration = 4000) => { + const id = Date.now().toString() + Math.random(); + setToasts((prev) => [...prev, { id, content, duration }]); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + useEffect(() => { + if (toasts.length >= 4) { + const oldestId = toasts[0].id; + removeToast(oldestId); + } + }, [toasts, removeToast]); + + return ( + + {children} + + + ); +}; diff --git a/src/components/common/dropDown/DropDown.tsx b/src/components/common/dropDown/DropDown.tsx index dbba3ba2..f6dd2d4f 100644 --- a/src/components/common/dropDown/DropDown.tsx +++ b/src/components/common/dropDown/DropDown.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import * as S from './DropDown.styled'; import { useOutsideClick } from '../../../hooks/useOutsideClick'; +import { DropDownContext } from '../../../context/DropDownContext'; interface DropDownProps { children: React.ReactNode; @@ -21,16 +22,18 @@ const DropDown = ({ }; return ( - - setOpen(!open)} - tabIndex={0} - {...props} - > - {toggleButton} - - {open && {children}} - + + + setOpen(!open)} + tabIndex={0} + {...props} + > + {toggleButton} + + {open && {children}} + + ); }; diff --git a/src/components/common/dropDown/DropDownItem.tsx b/src/components/common/dropDown/DropDownItem.tsx deleted file mode 100644 index f7106b4d..00000000 --- a/src/components/common/dropDown/DropDownItem.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import useDeleteComment from '../../../hooks/CommentHooks/useDeleteComment'; -import useDeleteReply from '../../../hooks/CommentHooks/useDeleteReply'; -import * as S from './DropDownItem.styled'; - -interface DropdownProps { - projectId: number; - activateEditMode?: number | null; - commentId: number; - recommentId?: number; - loginUserId?: number; - commentUserId: number; - reply?: boolean; - onEdit?: () => void; -} - -const DropDownItem = ({ - projectId, - onEdit, - activateEditMode, - commentId, - recommentId, - commentUserId, - loginUserId, - reply, -}: DropdownProps) => { - const { removeComment } = useDeleteComment(projectId); - const { removeReply } = useDeleteReply(commentId, projectId); - - const onReport = () => {}; - - const onDelete = (commentId: number, recommentId?: number) => { - if (reply && recommentId) { - if (confirm('답글을 완성히 삭제할까요?')) removeReply(recommentId); - } else { - if (confirm('댓글을 완성히 삭제할까요?')) removeComment(commentId); - } - }; - - return ( - - 신고하기 - - {loginUserId === commentUserId && ( - <> - - {activateEditMode === commentId ? '수정 취소하기' : '수정하기'} - - onDelete(commentId, recommentId)}> - 삭제하기 - {' '} - - )} - - ); -}; - -export default DropDownItem; diff --git a/src/components/common/header/Header.styled.ts b/src/components/common/header/Header.styled.ts index 56bd19f5..857c3dae 100644 --- a/src/components/common/header/Header.styled.ts +++ b/src/components/common/header/Header.styled.ts @@ -28,6 +28,18 @@ export const Alarm = styled.div` align-items: center; `; +export const BellButton = styled.div``; + +export const Dot = styled.span` + position: absolute; + top: 2px; + right: -1px; + width: 7px; + height: 7px; + background-color: #ff3b30; + border-radius: 50%; +`; + export const LogoImg = styled.img` width: 80px; height: 80px; diff --git a/src/components/common/header/Header.tsx b/src/components/common/header/Header.tsx index b8523038..1b2f65af 100644 --- a/src/components/common/header/Header.tsx +++ b/src/components/common/header/Header.tsx @@ -16,12 +16,20 @@ import { formatImgPath } from '../../../util/formatImgPath'; import bell from '../../../assets/bell.svg'; import Notification from './Notification/Notification'; import bellLogined from '../../../assets/bellLogined.svg'; +import useNotification from '../../../hooks/useNotification'; +import { useEffect } from 'react'; +import { testLiveAlarm } from '../../../api/alarm.api'; function Header() { const { isOpen, message, handleModalOpen, handleModalClose } = useModal(); const { userLogout } = useAuth(handleModalOpen); const isLoggedIn = useAuthStore((state) => state.isLoggedIn); const { myData, isLoading } = useMyProfileInfo(); + const { signalData, setSignalData } = useNotification(); + + useEffect(() => { + testLiveAlarm(); + }, []); const profileImg = myData?.profileImg ? `${import.meta.env.VITE_APP_IMAGE_CDN_URL}/${formatImgPath( @@ -45,9 +53,19 @@ function Header() { {isLoggedIn ? ( - }> + setSignalData(null)}> + 알림 + {signalData && } + + ) : ( + 알림 + ) + } + > - {/* {isSignal && '가능'} */} ) : ( 알림 @@ -74,7 +92,7 @@ function Header() { 공고관리 - + 문의하기 e.preventDefault()}> diff --git a/src/components/common/header/Notification/Notification.styled.ts b/src/components/common/header/Notification/Notification.styled.ts index f1ba9414..1250b3b1 100644 --- a/src/components/common/header/Notification/Notification.styled.ts +++ b/src/components/common/header/Notification/Notification.styled.ts @@ -1,9 +1,32 @@ import styled from 'styled-components'; export const Container = styled.div` - width: 400px; + width: 350px; + height: 200px; + overflow: hidden; display: flex; flex-direction: column; + padding: 3px; `; -export const Message = styled.p``; +export const ScrollArea = styled.div` + height: 100%; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-thumb { + border-radius: 3px; + background-color: rgba(0, 0, 0, 0.2); + } +`; + +export const NonContentsMessage = styled.p` + margin-left: 6px; +`; + +export const Line = styled.hr` + opacity: 60%; + border-width: 1px; +`; diff --git a/src/components/common/header/Notification/Notification.tsx b/src/components/common/header/Notification/Notification.tsx index 7ba56418..92da2ec5 100644 --- a/src/components/common/header/Notification/Notification.tsx +++ b/src/components/common/header/Notification/Notification.tsx @@ -1,13 +1,18 @@ -import useAlarmList from '../../../../hooks/useAlarmList'; -import LoadingSpinner from '../../loadingSpinner/LoadingSpinner'; +import React from 'react'; import * as S from './Notification.styled'; import NotificationItem from './NotificationItem/NotificationItem'; +import useAlarmList from '../../../../hooks/useAlarmList'; +import LoadingSpinner from '../../loadingSpinner/LoadingSpinner'; const Notification = () => { const { alarmListData: AlarmData, isLoading, isFetching } = useAlarmList(); if (!AlarmData) { - return 알림이 없습니다.; + return ( + + 알림이 없습니다. + + ); } if (isLoading || isFetching) { @@ -16,9 +21,14 @@ const Notification = () => { return ( - {AlarmData.map((item, index) => ( - - ))} + + {AlarmData.map((item, index) => ( + + + {index !== AlarmData.length - 1 && } + + ))} + ); }; diff --git a/src/components/common/header/Notification/NotificationItem/NotificationItem.styled.ts b/src/components/common/header/Notification/NotificationItem/NotificationItem.styled.ts index f0dada6e..954f4a7e 100644 --- a/src/components/common/header/Notification/NotificationItem/NotificationItem.styled.ts +++ b/src/components/common/header/Notification/NotificationItem/NotificationItem.styled.ts @@ -3,15 +3,9 @@ import styled from 'styled-components'; export const Container = styled.div` padding: 8px 0; font-size: 14px; - color: #333; - border-bottom: 1px solid #eee; - - &:last-child { - border-bottom: none; - } `; -export const Message = styled.p` +export const ItemContent = styled.p` margin-left: 6px; `; diff --git a/src/components/common/header/Notification/NotificationItem/NotificationItem.tsx b/src/components/common/header/Notification/NotificationItem/NotificationItem.tsx index fa9379c9..5f0fae79 100644 --- a/src/components/common/header/Notification/NotificationItem/NotificationItem.tsx +++ b/src/components/common/header/Notification/NotificationItem/NotificationItem.tsx @@ -1,29 +1,25 @@ -import { - COMMAND, - INQUIRY, - PASSNONPASS, -} from '../../../../../constants/commandConstants'; +import { Link } from 'react-router-dom'; import { Alarm } from '../../../../../models/alarm'; import * as S from './NotificationItem.styled'; +import { routeSelector } from '../../../../../util/routeSelector'; +import { useContext } from 'react'; +import { DropDownContext } from '../../../../../context/DropDownContext'; +import { timeAgo } from '../../../../../util/timeAgo'; interface NotificationItemProps { item: Alarm; } const NotificationItem = ({ item }: NotificationItemProps) => { + const { close } = useContext(DropDownContext); + const route = routeSelector(item.routingId, item.alarmFilterId); return ( - - - {/* {NotificationData.type === 'command' - ? `'${NotificationData.nickName}' ${COMMAND}${NotificationData.message}` - : NotificationData.type === 'pass/nonPass' - ? `'${NotificationData.message}' ${PASSNONPASS} ${ - NotificationData.pass ? '합격' : '불합격' - }` - : `'${NotificationData.message}' ${INQUIRY}`} */} - - - + + + {item.content} + {timeAgo(item.createdAt)} + + ); }; diff --git a/src/components/mypage/myProfile/MyProfile.styled.ts b/src/components/mypage/myProfile/MyProfile.styled.ts index b78148a2..b31897f3 100644 --- a/src/components/mypage/myProfile/MyProfile.styled.ts +++ b/src/components/mypage/myProfile/MyProfile.styled.ts @@ -14,6 +14,8 @@ export const FilterTitle = styled.h1` font-size: 1.5em; `; +export const TitleWrapper = styled.div``; + export const Container = styled.div` background-color: ${({ theme }) => theme.color.lightgrey}; border-radius: ${({ theme }) => diff --git a/src/components/projectFormComponents/projectInformationInput/languageComponent/LanguageComponent.tsx b/src/components/projectFormComponents/projectInformationInput/languageComponent/LanguageComponent.tsx index 1ffe74c5..4df97834 100644 --- a/src/components/projectFormComponents/projectInformationInput/languageComponent/LanguageComponent.tsx +++ b/src/components/projectFormComponents/projectInformationInput/languageComponent/LanguageComponent.tsx @@ -2,14 +2,14 @@ import * as S from './LanguageComponent.styled'; import { FieldErrors, UseFormSetValue } from 'react-hook-form'; import SkillTagBox from '../../../common/skillTagBox/SkillTagBox'; import { CreateProjectFormValues } from '../../../../models/createProject'; -import { Skill } from '../../../../models/projectDetail'; import useTagSelectors from '../../../../hooks/ProjectHooks/useTagSelectors'; +import { SkillTag } from '../../../../models/tags'; interface LanguageComponentProps { errors: FieldErrors; name: string; setValue: UseFormSetValue; - apiDataSkillTags: Skill[] | undefined; + apiDataSkillTags: SkillTag[] | undefined; } const LanguageComponent = ({ diff --git a/src/components/projectFormComponents/projectInformationInput/positionComponent/PositionComponent.tsx b/src/components/projectFormComponents/projectInformationInput/positionComponent/PositionComponent.tsx index 162295aa..349b8d5c 100644 --- a/src/components/projectFormComponents/projectInformationInput/positionComponent/PositionComponent.tsx +++ b/src/components/projectFormComponents/projectInformationInput/positionComponent/PositionComponent.tsx @@ -1,7 +1,6 @@ import * as S from './PositionComponent.styled'; import { FieldErrors, UseFormSetValue } from 'react-hook-form'; import { PositionTag } from '../../../../models/tags'; -import { Position } from '../../../../models/projectDetail'; import useTagSelectors from '../../../../hooks/ProjectHooks/useTagSelectors'; import { CreateProjectFormValues } from '../../../../models/createProject'; @@ -10,7 +9,7 @@ interface MozipCategoryComponentProps { name: string; setValue: UseFormSetValue; positionTagsData: PositionTag[]; - apiDataPosition: Position[] | undefined; + apiDataPosition: PositionTag[] | undefined; } const MozipCategoryComponent = ({ diff --git a/src/components/reportComponent/ReportModal.styled.ts b/src/components/reportComponent/ReportModal.styled.ts new file mode 100644 index 00000000..5b207b6a --- /dev/null +++ b/src/components/reportComponent/ReportModal.styled.ts @@ -0,0 +1,116 @@ +import styled from 'styled-components'; + +export const ModalContainer = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +`; + +export const ModalBox = styled.div` + position: relative; + width: 600px; + max-width: 90%; + background: ${({ theme }) => theme.color.white}; + border-radius: 12px; + padding: 2.5rem 2rem 2rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +`; + +export const CloseButton = styled.button` + position: absolute; + top: 1rem; + right: 1rem; + font-size: ${({ theme }) => theme.heading.semiSmall.fontSize}; + cursor: pointer; +`; + +export const Header = styled.div` + display: flex; + align-items: center; + margin-bottom: ${({ theme }) => theme.heading.semiLarge.fontSize}; +`; + +export const Avatar = styled.div` + margin-right: 0.75rem; +`; + +export const UserName = styled.p` + font-size: ${({ theme }) => theme.heading.small.fontSize}; + font-weight: 600; +`; + +export const Content = styled.p` + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const ContentContainer = styled.div``; + +export const Form = styled.form` + margin-bottom: 2rem; +`; + +export const SectionTitle = styled.div` + font-size: 1rem; + font-weight: 500; + opacity: 40%; + margin-bottom: 0.75rem; +`; + +export const CheckboxGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +`; + +export const CheckRow = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; +`; + +export const CheckItem = styled.div``; + +export const CheckInput = styled.input` + margin-right: 0.5rem; + cursor: pointer; +`; + +export const CheckContent = styled.label` + font-size: 0.9rem; +`; + +export const TextArea = styled.textarea` + width: 100%; + min-height: 100px; + padding: 0.75rem; + border: 1px solid ${({ theme }) => theme.color.border}; + border-radius: ${({ theme }) => theme.borderRadius.primary}; + resize: vertical; + font-size: 0.9rem; + &::placeholder { + color: #aaa; + } +`; + +export const Footer = styled.div` + display: flex; + justify-content: flex-end; + gap: 0.75rem; +`; + +export const ErrorMessage = styled.p` + font-size: 11px; + color: ${({ theme }) => theme.color.red}; +`; diff --git a/src/components/reportComponent/ReportModal.tsx b/src/components/reportComponent/ReportModal.tsx new file mode 100644 index 00000000..2f3507d5 --- /dev/null +++ b/src/components/reportComponent/ReportModal.tsx @@ -0,0 +1,131 @@ +import * as S from './ReportModal.styled'; +import Avatar from '../common/avatar/Avatar'; +import { useRef, useState } from 'react'; +import { postReport } from '../../api/report.api'; +import { reasons } from '../../constants/reportConstants'; +import Button from '../common/Button/Button'; +import ScrollPreventor from '../common/modal/ScrollPreventor'; + +interface ReportModalProps { + reportTitle: { userImg: string; userName: string } | string; + type: 'user' | 'project' | 'comment' | 'recomment'; + targetId: number; + onClose: () => void; +} + +const ReportModal = ({ + reportTitle, + targetId, + type, + onClose, +}: ReportModalProps) => { + const textAreaRef = useRef(null); + const [isNotExist, setIsNotExist] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const selectedReasons = formData.getAll('reason') as string[]; + + if (selectedReasons.length === 0) { + setIsNotExist(true); + return; + } else { + setIsNotExist(false); + } + + const postData = { + reportTargetId: targetId, + reportFilter: + type === 'user' + ? 1 + : type === 'project' + ? 2 + : type === 'comment' + ? 3 + : 4, + reason: selectedReasons, + detail: textAreaRef.current?.value ? textAreaRef.current?.value : '', + }; + + try { + postReport(postData); + alert('신고 되었습니다.'); + onClose(); + } catch (e) { + console.error(e); + } + }; + + return ( + + + e.stopPropagation()}> + × + + + {typeof reportTitle === 'string' ? ( + "{reportTitle}" + ) : ( + <> + + + + + {reportTitle.userName} + + )} + + + + 신고 사유 + + + {reasons.map((reason) => ( + + + + {reason} + + + ))} + + {isNotExist && ( + + 신고 사유를 하나 이상 선택해주세요. + + )} + + + 상세 작성(생략 가능) + + + + + + + + + + + ); +}; + +export default ReportModal; diff --git a/src/components/userPage/joinedProject/UserJoinProject.tsx b/src/components/userPage/joinedProject/UserJoinProject.tsx index ba46642c..faef6337 100644 --- a/src/components/userPage/joinedProject/UserJoinProject.tsx +++ b/src/components/userPage/joinedProject/UserJoinProject.tsx @@ -1,7 +1,6 @@ import { Link, useParams } from 'react-router-dom'; import { useUserJoinedProjectList } from '../../../hooks/useUserInfo'; import * as S from '../../mypage/joinedProject/MyJoinProjects.styled'; -import Title from '../../common/title/Title'; import Spinner from '../../mypage/Spinner'; import { ROUTES } from '../../../constants/routes'; import Project from '../../mypage/joinedProject/Project'; @@ -22,9 +21,9 @@ const UserJoinProject = () => { return ( - - 참여한 프로젝트 리스트 - + + 참여한 프로젝트 리스트 + {userJoinedProjectListData?.acceptedProjects && userJoinedProjectListData?.acceptedProjects?.length > 0 ? ( @@ -44,9 +43,9 @@ const UserJoinProject = () => { )} - - 기획한 프로젝트 리스트 - + + 기획한 프로젝트 리스트 + {userJoinedProjectListData?.ownProjects && userJoinedProjectListData?.ownProjects?.length > 0 ? ( diff --git a/src/components/userPage/userProfile/UserProfile.tsx b/src/components/userPage/userProfile/UserProfile.tsx index a6c19bc8..5cd3cec5 100644 --- a/src/components/userPage/userProfile/UserProfile.tsx +++ b/src/components/userPage/userProfile/UserProfile.tsx @@ -20,7 +20,7 @@ const UserProfile = () => { {userData?.nickname} - {userData?.userLevel === 'Beginner' ? ( + {userData?.beginner === true ? ( beginner void }>({ + close: () => {}, +}); diff --git a/src/context/ToastContext.tsx b/src/context/ToastContext.tsx new file mode 100644 index 00000000..7c5779de --- /dev/null +++ b/src/context/ToastContext.tsx @@ -0,0 +1,18 @@ +import { createContext } from 'react'; +import { AlarmLive } from '../models/alarm'; + +export interface ToastMessage { + id: string; + content: AlarmLive; + duration: number; +} + +export interface ToastContextProps { + addToast: (content: AlarmLive, duration?: number) => void; + removeToast: (id: string) => void; +} + +export const ToastContext = createContext({ + addToast: () => {}, + removeToast: () => {}, +}); diff --git a/src/hooks/ProjectHooks/useTagSelectors.ts b/src/hooks/ProjectHooks/useTagSelectors.ts index af023a18..1d83d5dc 100644 --- a/src/hooks/ProjectHooks/useTagSelectors.ts +++ b/src/hooks/ProjectHooks/useTagSelectors.ts @@ -1,11 +1,10 @@ import { useEffect, useState } from 'react'; import { UseFormSetValue } from 'react-hook-form'; import { CreateProjectFormValues } from '../../models/createProject'; -import { Skill } from '../../models/projectDetail'; -import { Position } from '../../models/projectDetail'; +import { PositionTag, SkillTag } from '../../models/tags'; interface useTagSelectorsProps { - apiTagData?: Skill[] | Position[] | number; + apiTagData?: SkillTag[] | PositionTag[] | number; setValue: UseFormSetValue; fieldName: 'field' | 'position' | 'languages'; } diff --git a/src/hooks/useModal.ts b/src/hooks/useModal.ts index 62cf531f..8a9c65bc 100644 --- a/src/hooks/useModal.ts +++ b/src/hooks/useModal.ts @@ -14,11 +14,21 @@ export const useModal = () => { setIsOpen(false); }; + const handleOpenReportModal = () => { + setIsOpen(true); + }; + + const handleCloseReportModal = () => { + setIsOpen(false); + }; + return { isOpen, message, setIsOpen, handleModalClose, handleModalOpen, + handleOpenReportModal, + handleCloseReportModal, }; }; diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts index c0403af6..06165741 100644 --- a/src/hooks/useNotification.ts +++ b/src/hooks/useNotification.ts @@ -1,15 +1,31 @@ import { EventSourcePolyfill, NativeEventSource } from 'event-source-polyfill'; -import { useEffect, useState } from 'react'; -import { getTokens } from '../store/authStore'; -import { httpClient } from '../api/http.api'; +import { useEffect, useRef, useState } from 'react'; +import useAuthStore, { getTokens } from '../store/authStore'; +import { useQueryClient } from '@tanstack/react-query'; +import { AlarmList } from './queries/keys'; +import { useToast } from './useToast'; +import { AlarmLive } from '../models/alarm'; const useNotification = () => { - const [isSignal, setIsSignal] = useState(false); + const [signalData, setSignalData] = useState(null); + const queryClient = useQueryClient(); + const userId = useAuthStore((state) => state.userData?.id); + const { showToast } = useToast(); - const EventSource = EventSourcePolyfill || NativeEventSource; + const eventSourceRef = useRef(null); + const EventSourceImpl = EventSourcePolyfill || NativeEventSource; useEffect(() => { - const eventSource = new EventSource( + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + if (!userId) { + return; + } + + const eventSource = new EventSourceImpl( `${import.meta.env.VITE_APP_API_BASE_URL}user/sse`, { headers: { @@ -22,9 +38,22 @@ const useNotification = () => { heartbeatTimeout: 12 * 60 * 1000, } ); + + eventSourceRef.current = eventSource; + eventSource.addEventListener('alarm', (e) => { + const event = e as MessageEvent; try { - console.log(JSON.parse(e.data)); + console.log(JSON.parse(event.data)); + const eventData: AlarmLive = JSON.parse(event.data); + + if (eventData) { + queryClient.invalidateQueries({ + queryKey: [AlarmList.myAlarmList, userId], + }); + } + + setSignalData(eventData); } catch (error) { console.error(error); } @@ -32,23 +61,21 @@ const useNotification = () => { eventSource.onerror = (e) => { console.log(e); }; - }, []); - - //테스트용 API. 추후 삭제할 예정 - const getSendAlarm = async (id: number) => { - try { - const response = await httpClient.get( - `/user/send-alarm?alarmFilter=${id}` - ); - console.log(response); - return response; - } catch (error) { - console.error(error); - throw error; - } - }; - return { isSignal, getSendAlarm }; + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }; + }, [queryClient, userId]); + + useEffect(() => { + if (signalData) { + showToast(signalData, 3000); + } + }, [signalData, showToast]); + return { signalData, setSignalData }; }; export default useNotification; diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts new file mode 100644 index 00000000..ec71f0c9 --- /dev/null +++ b/src/hooks/useToast.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; +import { ToastContext } from '../context/ToastContext'; + +export const useToast = () => { + const { addToast, removeToast } = useContext(ToastContext); + return { showToast: addToast, hideToast: removeToast }; +}; diff --git a/src/models/alarm.ts b/src/models/alarm.ts index a44229a5..287a23df 100644 --- a/src/models/alarm.ts +++ b/src/models/alarm.ts @@ -18,7 +18,14 @@ export interface Alarm { id: number; routingId: number; content: string; - AlarmFilterId: number; + alarmFilterId: number; createdAt: string; enabled: boolean; } + +export interface AlarmLive { + alarmFilterId: number; + createAt: string; + message: string; + routingId: number; +} diff --git a/src/models/projectDetail.ts b/src/models/projectDetail.ts index 608868f2..5f84f3e3 100644 --- a/src/models/projectDetail.ts +++ b/src/models/projectDetail.ts @@ -1,13 +1,5 @@ import { ApiCommonType, User } from './apiCommon'; -import { joinProject } from './joinProject'; -import type { PositionTag, SkillTag } from './tags'; - -export interface SkillTag { - id: number; - name: string; - img: string; - createdAt: string; -} +import type { MethodTag, PositionTag, SkillTag } from './tags'; export interface ProjectSkillTagList { SkillTag: SkillTag; @@ -21,50 +13,6 @@ export interface ProjectPositionTag { PositionTag: PositionTag; } -export interface Method { - id: number; - name: string; - createdAt: string; -} - -export interface ProjectDetail { - id: number; - title: string; - description: string; - totalMember: number; - startDate: string; - estimatedPeriod: string; - methodId: number; - authorId: number; - views: number; - isBeginner: boolean; - isDone: boolean; - recruitmentEndDate: string; - recruitmentStartDate: string; - createdAt: string; - updatedAt: string; -} - -export interface ProjectDetailExtended extends ProjectDetail { - User: User; - skillTags: SkillTag[]; - ProjectSkillTag: ProjectSkillTagList[]; - Method: Method; - ProjectPositionTag: ProjectPositionTag[]; - Applicant: joinProject[]; -} - -export interface Position { - id: number; - name: string; -} - -export interface Skill { - id: number; - name: string; - img: string; -} - export interface ProjectDetailPlus { id: number; title: string; @@ -82,9 +30,9 @@ export interface ProjectDetailPlus { export interface ProjectDetailPlusExtended extends ProjectDetailPlus { user: User; - methodType: Method; - positions: Position[]; - skills: Skill[]; + methodType: MethodTag; + positions: PositionTag[]; + skills: SkillTag[]; } export interface dataPlus extends ApiCommonType { diff --git a/src/models/report.ts b/src/models/report.ts new file mode 100644 index 00000000..16fa8720 --- /dev/null +++ b/src/models/report.ts @@ -0,0 +1,6 @@ +export interface ApiPostContent { + reportTargetId: number; + reportFilter: number; + reason: string[]; + detail: string; +} diff --git a/src/models/userProject.ts b/src/models/userProject.ts index f843a084..e1aa1ab0 100644 --- a/src/models/userProject.ts +++ b/src/models/userProject.ts @@ -27,7 +27,7 @@ export interface ApiAppliedProject extends ApiCommonType { export interface SelectUserProject { acceptedProjects: JoinedProject[]; - ownProjects: AppliedProject[]; + ownProjects: JoinedProject[]; } export interface ApiSelectUserProject extends ApiCommonType { diff --git a/src/pages/main/Create.tsx b/src/pages/main/Create.tsx index c463cba2..233ff70d 100644 --- a/src/pages/main/Create.tsx +++ b/src/pages/main/Create.tsx @@ -1,4 +1,4 @@ -import * as S from './about.styled'; +import * as S from './About.styled'; import createImg from '../../assets/create.svg'; import { forwardRef } from 'react'; interface CreateProps { diff --git a/src/pages/main/Manage.tsx b/src/pages/main/Manage.tsx index ba1628a7..1f0401da 100644 --- a/src/pages/main/Manage.tsx +++ b/src/pages/main/Manage.tsx @@ -1,4 +1,4 @@ -import * as S from './about.styled'; +import * as S from './About.styled'; import manageImg from '../../assets/manage_project.svg'; import { forwardRef } from 'react'; interface ManageProps { diff --git a/src/pages/main/Project.tsx b/src/pages/main/Project.tsx index ef2f8d55..56dd61e5 100644 --- a/src/pages/main/Project.tsx +++ b/src/pages/main/Project.tsx @@ -1,6 +1,6 @@ import { forwardRef } from 'react'; import ProjectImg from '../../assets/project.svg'; -import * as S from './about.styled'; +import * as S from './About.styled'; interface ProjectProps { ref?: React.Ref; diff --git a/src/pages/main/Result.tsx b/src/pages/main/Result.tsx index 6166ffcd..2fa7bcb6 100644 --- a/src/pages/main/Result.tsx +++ b/src/pages/main/Result.tsx @@ -1,4 +1,4 @@ -import * as S from './about.styled'; +import * as S from './About.styled'; import resultImg from '../../assets/result_project.svg'; import { forwardRef } from 'react'; interface ResultProps { diff --git a/src/pages/register/Register.tsx b/src/pages/register/Register.tsx index 4ac20e00..39dcc482 100644 --- a/src/pages/register/Register.tsx +++ b/src/pages/register/Register.tsx @@ -273,7 +273,7 @@ const Register = () => { radius='large' type='button' onClick={() => { - handleDuplicationNickname(text); + handleDuplicationNickname(nicknameText); }} > 중복 확인 diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index f4ea88fd..045d33d4 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -1,6 +1,7 @@ import { createBrowserRouter, Navigate, + Outlet, RouterProvider, } from 'react-router-dom'; import { lazy, Suspense } from 'react'; @@ -11,6 +12,7 @@ import useAuthStore from '../store/authStore'; import ProtectRoute from '../components/common/ProtectRoute'; import NotFoundPage from '../pages/notFoundPage/NotFoundPage'; import QueryErrorBoundary from '../components/common/error/QueryErrorBoundary'; +import { ToastProvider } from '../components/common/Toast/ToastProvider'; const Login = lazy(() => import('../pages/login/Login')); const Register = lazy(() => import('../pages/register/Register')); const ChangePassword = lazy( @@ -310,7 +312,16 @@ const AppRoutes = () => { }; }); - const router = createBrowserRouter(newRouteList); + const router = createBrowserRouter([ + { + element: ( + + + + ), + children: [...newRouteList, { path: '*', element: }], + }, + ]); return ; }; diff --git a/src/util/routeSelector.ts b/src/util/routeSelector.ts new file mode 100644 index 00000000..842e0ce6 --- /dev/null +++ b/src/util/routeSelector.ts @@ -0,0 +1,12 @@ +export const routeSelector = (routerId: number, filter: number) => { + switch (filter) { + case 1: + return `/mypage/notifications/applied-projects`; + case 3: + return `/project-detail/${routerId}`; + case 2: + return `/manage/${routerId}`; + default: + return `/mypage/notifications`; + } +}; diff --git a/src/util/timeAgo.ts b/src/util/timeAgo.ts new file mode 100644 index 00000000..4dd251e4 --- /dev/null +++ b/src/util/timeAgo.ts @@ -0,0 +1,24 @@ +export function timeAgo(isoDate: string): string { + const now = new Date(); + const then = new Date(isoDate); + const diffMs = now.getTime() - then.getTime(); + + const seconds = Math.floor(diffMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) { + return '방금 전'; + } + if (minutes < 60) { + return `${minutes}분 전`; + } + if (hours < 24) { + return `${hours}시간 전`; + } + if (days === 1) { + return '하루 전'; + } + return `${days}일 전`; +}