diff --git a/src/api/usersAPI.ts b/src/api/usersAPI.ts index 95496d21..fb843dee 100644 --- a/src/api/usersAPI.ts +++ b/src/api/usersAPI.ts @@ -76,3 +76,17 @@ export const checkNickname = async (nickname: string) => { throw error; } }; + +/** + * 클릭한 사용자의 정보를 반환합니다. + */ + +export const getOthersInfo = async (userId: number) => { + try { + const res = await fetch(`${BASE_URL}/api/v1/users/${userId}`); + const { data } = await res.json(); + return data; + } catch (error) { + throw error; + } +}; diff --git a/src/app/(test)/mj/page.tsx b/src/app/(test)/mj/page.tsx index 83aede83..ada82c00 100644 --- a/src/app/(test)/mj/page.tsx +++ b/src/app/(test)/mj/page.tsx @@ -3,8 +3,9 @@ import classNames from 'classnames/bind'; // import ProfileImage from '@/components/ProfileImage/ProfileImage'; import styles from './page.module.scss'; -import ReviewModalTest from './test/ReviewModalTest'; +// import ReviewModalTest from './test/ReviewModalTest'; // import OrderListModalTest from './OrderListModatTest'; +// import ImageZoomText from './test/ImageZoomText'; const cn = classNames.bind(styles); @@ -13,7 +14,7 @@ export default function Page() {
{/* */} {/* */} - + {/* */}
); } diff --git a/src/app/(test)/mj/test/ImageZoomText.tsx b/src/app/(test)/mj/test/ImageZoomText.tsx new file mode 100644 index 00000000..481506dc --- /dev/null +++ b/src/app/(test)/mj/test/ImageZoomText.tsx @@ -0,0 +1,10 @@ +import ImageZoom from '@/components/ImageZoom/ImageZoom'; + +export default function ImageZoomText() { + const imageUrl = 'https://cdn.imweb.me/thumbnail/20220404/12007f769b366.jpg'; + return ( +
+ +
+ ); +} diff --git a/src/app/community/_components/AuthorCard.module.scss b/src/app/community/_components/AuthorCard.module.scss index d732f1b2..2024ec2b 100644 --- a/src/app/community/_components/AuthorCard.module.scss +++ b/src/app/community/_components/AuthorCard.module.scss @@ -1,6 +1,7 @@ .container { @include flex-center; + position: relative; width: 100%; height: 6.4rem; } @@ -16,6 +17,12 @@ & > .user-name { @include pretendard-18-500; + + cursor: pointer; + + &:hover { + text-decoration: underline; + } } & > .sub-info { diff --git a/src/app/community/_components/AuthorCard.tsx b/src/app/community/_components/AuthorCard.tsx index 78079720..09f4ca92 100644 --- a/src/app/community/_components/AuthorCard.tsx +++ b/src/app/community/_components/AuthorCard.tsx @@ -1,8 +1,9 @@ import classNames from 'classnames/bind'; -import { MouseEvent } from 'react'; +import { useRef, useState } from 'react'; import ProfileImage from '@/components/ProfileImage/ProfileImage'; import { PopOver } from '@/components'; +import UserProfileCard from './UserProfileCard'; import styles from './AuthorCard.module.scss'; @@ -12,12 +13,13 @@ interface AuthorCardProps { nickname: string; dateText: string; userImage: string | null; + userId: number; onClickPopOver: () => void; onClosePopOver: () => void; isOpenPopOver: boolean; popOverOptions: { label: string; - onClick: (e: React.MouseEvent) => void; + onClick?: () => void; }[]; } @@ -25,26 +27,57 @@ export default function AuthorCard({ nickname, dateText, userImage, + userId, onClickPopOver, onClosePopOver, isOpenPopOver, popOverOptions, }: AuthorCardProps) { + const userProfileCardRef = useRef(null); + const [isOpenUserCard, setIsOpenUserCard] = useState(false); + const [isHovering, setIsHovering] = useState(false); + + const handleOpenProfile = () => { + setIsOpenUserCard(true); + }; + + const handleCloseProfile = () => { + setIsOpenUserCard(false); + }; + return ( -
- +
e.stopPropagation()} + onMouseEnter={() => setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > +
+ + +
-

{nickname}

+

+ {nickname} +

{dateText}

-
- -
+ {isHovering && ( +
+ +
+ )}
); } diff --git a/src/app/community/_components/Comment.module.scss b/src/app/community/_components/Comment.module.scss index 217646fd..7fad0aa4 100644 --- a/src/app/community/_components/Comment.module.scss +++ b/src/app/community/_components/Comment.module.scss @@ -1,7 +1,16 @@ .container { display: flex; + position: relative; width: 100%; gap: 1.2rem; + + &:hover { + background-color: $gray-5; + } +} + +.user-profile { + position: relative; } .profile-image { @@ -34,6 +43,12 @@ & > .nickname { @include pretendard-16-400; + + cursor: pointer; + + &:hover { + text-decoration: underline; + } } & > .time-ago { diff --git a/src/app/community/_components/Comment.tsx b/src/app/community/_components/Comment.tsx index cf321bb1..189ba781 100644 --- a/src/app/community/_components/Comment.tsx +++ b/src/app/community/_components/Comment.tsx @@ -1,18 +1,19 @@ 'use client'; import classNames from 'classnames/bind'; -import { useState, forwardRef } from 'react'; +import { forwardRef, useState, MouseEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import ProfileImage from '@/components/ProfileImage/ProfileImage'; import { calculateTimeDifference } from '@/libs/calculateDate'; -import type { Users } from '@/types/userType'; +import type { UserDataResponseType } from '@/types/userType'; import { PopOver } from '@/components'; import { deleteComment } from '@/api/communityAPI'; - import { communityPopOverOption } from '@/libs/communityPopOverOption'; + import styles from './Comment.module.scss'; +import UserProfileCard from './UserProfileCard'; const cn = classNames.bind(styles); @@ -27,16 +28,17 @@ interface CommentDataType { interface CommentProps { cardId: number; + onOpenPopOver: (commentId: number) => void; + onClosePopOver: () => void; commentData: CommentDataType; + isOpenedPopOver: boolean; + onOpenProfileCard: () => void; } -interface UserDataType { - data: Users; - status: string; - message: string; -} - -export default forwardRef(function Comment({ cardId, commentData }, ref) { +export default forwardRef(function Comment( + { cardId, commentData, onOpenPopOver, onClosePopOver, isOpenedPopOver, onOpenProfileCard }, + ref, +) { const queryClient = useQueryClient(); const { @@ -47,12 +49,17 @@ export default forwardRef(function Comment({ cardI content: comment, createdAt: createdTime, } = commentData; - const createdTimeToDate = new Date(createdTime); + const createdTimeToDate = new Date(createdTime); const [isOpenPopOver, setIsOpenPopOver] = useState(false); + const [showIcon, setShowIcon] = useState(false); + const [isOpenProfileCard, setIsOpenProfileCard] = useState(false); + const [commentPositionTop, setCommentPositionTop] = useState(0); + + const userData = queryClient.getQueryData(['userData']); - const userData = queryClient.getQueryData(['userData']); const userID = userData?.data?.id; + const timeAgo = calculateTimeDifference(createdTimeToDate); const { mutate: deleteCommentMutation } = useMutation({ mutationFn: deleteComment, @@ -68,44 +75,64 @@ export default forwardRef(function Comment({ cardI }, }); - const handleClickPopOver = () => { - setIsOpenPopOver(!isOpenPopOver); + const handleOpenProfile = (e: MouseEvent) => { + onOpenProfileCard(); + const { top } = e.currentTarget.getBoundingClientRect(); + setCommentPositionTop(top); + setIsOpenProfileCard(true); }; - const handleClosePopOver = () => { - setIsOpenPopOver(false); + const handleCloseProfile = () => { + setIsOpenProfileCard(false); + }; + + const handleClickPopOver = () => { + if (isOpenPopOver) { + onClosePopOver(); + setIsOpenPopOver(false); + } else { + onOpenPopOver(commentId); + setIsOpenPopOver(true); + } }; const handleClickDelete = () => { deleteCommentMutation(commentId); }; - const handleClickEdit = () => {}; - - const handleClickReport = () => {}; - - const timeAgo = calculateTimeDifference(createdTimeToDate); - return ( -
- +
setShowIcon(true)} + onMouseLeave={() => (isOpenedPopOver ? setShowIcon(true) : setShowIcon(false))} + > +
+ + +
-
+

{nickname}

{timeAgo}

- + {showIcon && ( + + )}
{comment}
diff --git a/src/app/community/_components/PostCard.tsx b/src/app/community/_components/PostCard.tsx index fe3c4615..b0a28377 100644 --- a/src/app/community/_components/PostCard.tsx +++ b/src/app/community/_components/PostCard.tsx @@ -17,7 +17,7 @@ import { communityPopOverOption } from '@/libs/communityPopOverOption'; import AuthorCard from './AuthorCard'; import PostCardDetailModal from './PostCardDetailModal/PostCardDetailModal'; import { PostInteractions } from './PostInteractions'; -import DetailModalSkeleton from './PostCardDetailModal/ModalSkeleton'; +import DetailModalSkeleton from '../../../components/ModalSkeleton/ModalSkeleton'; import ErrorFallbackDetailModal from './PostCardDetailModal/ErrorFallbackDetailModal'; import styles from './PostCard.module.scss'; @@ -36,10 +36,10 @@ export default function PostCard({ cardData, isMine }: PostCardProps) { const [isPopOverOpen, setIsPopOverOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const { id, nickName, updateAt, title, thumbnail, likeCount, commentCount, userImage, isLiked } = cardData; + const { userId, id, nickName, updateAt, title, thumbnail, likeCount, commentCount, userImage, isLiked } = cardData; - const ApdatedDate = new Date(updateAt); - const timeToString = calculateTimeDifference(ApdatedDate); + const updatedDate = new Date(updateAt); + const timeToString = calculateTimeDifference(updatedDate); const handleClickPopOver = () => { setIsPopOverOpen((prevIsOpen) => !prevIsOpen); @@ -100,25 +100,23 @@ export default function PostCard({ cardData, isMine }: PostCardProps) { } }; - const handleClickReport = () => {}; - return (
+
-
}> void; isMine?: boolean; commentCount: number; } -export default function PostCardDetailModal({ cardId, onClose, isMine, commentCount }: PostCardDetailModalProps) { +export default function PostCardDetailModal({ + cardId, + userId, + onClose, + isMine, + commentCount, +}: PostCardDetailModalProps) { const containerRef = useRef(null); const lastCommentRef = useRef(null); const queryClient = useQueryClient(); @@ -50,10 +59,37 @@ export default function PostCardDetailModal({ cardId, onClose, isMine, commentCo const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isPopupOpen, setIsPopupOpen] = useState(false); + const [clickedPopOverCommentId, setClickedPopOverCommentId] = useState(0); + const [lastCommentId, setLastCommentId] = useState(0); const [visibleComments, setVisibleComments] = useState([]); - const { data, refetch } = useQuery({ + const handleOpenAuthorPopOver = () => { + setIsPopupOpen((prevIsOpen) => !prevIsOpen); + setClickedPopOverCommentId(0); + }; + + const handleClosePopOver = () => { + setIsPopupOpen(false); + }; + + const handleOpenCommentPopOver = (commentId: number) => { + setClickedPopOverCommentId(commentId); + setIsPopupOpen(false); + }; + + const handleCloseCommentPopOver = () => { + setClickedPopOverCommentId(0); + }; + + const handleOpenCommentProfileCard = () => { + setClickedPopOverCommentId(0); + setIsPopupOpen(false); + }; + + const userData = queryClient.getQueryData(['userData']); + + const { data: postCardListData, refetch } = useQuery({ queryKey: ['postData', cardId], queryFn: () => getPostDetail(cardId), }); @@ -104,16 +140,20 @@ export default function PostCardDetailModal({ cardId, onClose, isMine, commentCo useEffect(() => { const handleSubmitComment = () => { + if (!userData) { + return toast.error('로그인이 필요합니다.'); + } if (commentRef) { const commentContent = commentRef.value; postCommentMutation({ id: cardId, content: commentContent }); } + return null; }; const removeEvent = addEnterKeyEvent({ element: { current: commentRef }, callback: handleSubmitComment }); return () => { removeEvent(); }; - }, [cardId, postCommentMutation, commentRef]); + }, [cardId, postCommentMutation, commentRef, userData]); const isLastCommentIntersecting = useIntersectionObserver(lastCommentRef, { threshold: 1 }); @@ -143,19 +183,19 @@ export default function PostCardDetailModal({ cardId, onClose, isMine, commentCo useEffect(() => { // 처음에 가져온 댓글 데이터 queryClient.setQueryData(['infiniteCommentData'], null); - if (data?.data) { - const initialComments = data.data.comments; + if (postCardListData?.data) { + const initialComments = postCardListData.data.comments; const lastCommentData = initialComments[initialComments.length - 1]; setVisibleComments(initialComments); setLastCommentId(lastCommentData?.id); } - }, [data, queryClient]); + }, [postCardListData, queryClient]); - if (!data) return ; + if (!postCardListData) return ; - const { data: postData, status, message } = data; + const { data: postData, status, message } = postCardListData; - if (status === 'ERROR' || postData === null) { + if (status === 'ERROR') { toast.error(message); onClose(); return null; @@ -165,14 +205,6 @@ export default function PostCardDetailModal({ cardId, onClose, isMine, commentCo const createdDateString = formatDateToString(new Date(updatedAt)); - const handleClickPopup = () => { - setIsPopupOpen((prevIsOpen) => !prevIsOpen); - }; - - const handleClosePopOver = () => { - setIsPopupOpen(false); - }; - const handleClickThumbnail = (i: number) => { setClickedImage(reviewImages[i].imgUrl); }; @@ -196,7 +228,7 @@ export default function PostCardDetailModal({ cardId, onClose, isMine, commentCo }; const handleClickEditModalButton = () => { - setIsEditModalOpen(false); + setIsEditModalOpen(true); queryClient.invalidateQueries({ queryKey: ['myCustomReview'], }); @@ -209,25 +241,13 @@ export default function PostCardDetailModal({ cardId, onClose, isMine, commentCo setIsEditModalOpen(false); }; - const handleClickReport = () => {}; - - const POPOVER_OPTION = isMine - ? [ - { - label: '삭제하기', - onClick: handleClickDeleteAlertButon, - }, - { - label: '수정하기', - onClick: handleClickEditModalButton, - }, - ] - : [ - { - label: '신고하기', - onClick: handleClickReport, - }, - ]; + const setCommentRefType = (commentId: number) => { + if (commentId === lastCommentId) { + return lastCommentRef; + } + + return null; + }; return ( @@ -245,16 +265,11 @@ export default function PostCardDetailModal({ cardId, onClose, isMine, commentCo
- 0 ? reviewImages[0].imgUrl : keydeukImg)} + 0 ? reviewImages[0].imgUrl : keydeukImg)} alt='키보드 이미지' - fill - onError={() => setClickedImage('')} - className={cn('selected-image-wrapper')} - sizes='(max-width: 1200px) 100%' - priority - placeholder={IMAGE_BLUR.placeholder} - blurDataURL={IMAGE_BLUR.blurDataURL} + width={493} + height={reviewImages.length > 1 ? 536 : 604} />
{reviewImages.length > 1 && ( @@ -281,12 +296,17 @@ export default function PostCardDetailModal({ cardId, onClose, isMine, commentCo

{title}

{content}

@@ -299,7 +319,11 @@ export default function PostCardDetailModal({ cardId, onClose, isMine, commentCo key={comment.id} cardId={cardId} commentData={comment} - ref={lastCommentId === comment.id ? lastCommentRef : null} + ref={setCommentRefType(comment.id)} + onOpenPopOver={handleOpenCommentPopOver} + onClosePopOver={handleCloseCommentPopOver} + isOpenedPopOver={clickedPopOverCommentId === comment.id} + onOpenProfileCard={handleOpenCommentProfileCard} /> ))}
{isCommentLoading && }
diff --git a/src/app/community/_components/SortDropdown.module.scss b/src/app/community/_components/SortDropdown.module.scss new file mode 100644 index 00000000..60d77ccb --- /dev/null +++ b/src/app/community/_components/SortDropdown.module.scss @@ -0,0 +1,4 @@ +.container { + width: min-content; + margin-left: auto; +} diff --git a/src/app/community/_components/SortDropdown.tsx b/src/app/community/_components/SortDropdown.tsx index 0b0766f0..7d400b57 100644 --- a/src/app/community/_components/SortDropdown.tsx +++ b/src/app/community/_components/SortDropdown.tsx @@ -1,14 +1,21 @@ 'use client'; +import classNames from 'classnames/bind'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { Dropdown } from '@/components'; import { COMMUNITY_REVIEW_SORT_OPTIONS } from '@/constants/dropdownOptions'; +import styles from './SortDropdown.module.scss'; + +const cn = classNames.bind(styles); + export default function SortDropdown() { const [selectedOption, setSelectedOption] = useState(COMMUNITY_REVIEW_SORT_OPTIONS[0].label); const router = useRouter(); + const queryClient = useQueryClient(); const updateQuery = (queryValue: string) => { const query = new URLSearchParams(window.location.search); @@ -23,14 +30,19 @@ export default function SortDropdown() { return; } updateQuery(queryValue); + queryClient.invalidateQueries({ + queryKey: ['postCardsList'], + }); }; return ( - option.label)} - sizeVariant='xs' - onChange={handleDropdownChange} - value={selectedOption} - /> +
+ option.label)} + sizeVariant='xs' + onChange={handleDropdownChange} + value={selectedOption} + /> +
); } diff --git a/src/app/community/_components/UserProfileCard.module.scss b/src/app/community/_components/UserProfileCard.module.scss new file mode 100644 index 00000000..7fe918be --- /dev/null +++ b/src/app/community/_components/UserProfileCard.module.scss @@ -0,0 +1,82 @@ +.user-detail-profile-card { + display: flex; + position: absolute; + z-index: $zindex-popover; + top: 6rem; + left: 0; + width: 45rem; + height: 15rem; + padding: 1rem; + animation: fade-in 0.5s cubic-bezier(0.39, 0.575, 0.565, 1) both; + border: 0.2rem solid $gray-20; + border-radius: 1rem; + background-color: white; + box-shadow: 0 0 1.5rem 0 rgb(0 0 0 / 35%); + gap: 1rem; +} + +.loading-div { + @include flex-center; +} + +.above-profile { + top: -16rem; +} + +.display-none { + display: none; +} + +.profile-image { + position: relative; + width: 12rem; + height: 100%; + overflow: hidden; + border-radius: 0.8rem; + background-color: $gray-10; + object-fit: cover; +} + +.info-wrapper { + @include flex-column(0.2rem); + + height: 100%; + padding-left: 1rem; + border-left: 0.2rem solid $gray-20; +} + +.info-wrapper p { + @include pretendard-14-400; + + & > strong { + @include pretendard-14-600; + } +} + +.info-wrapper .nickname { + @include pretendard-20-700; +} + +.not-found-text { + @include pretendard-20-700; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes fade-out { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} diff --git a/src/app/community/_components/UserProfileCard.tsx b/src/app/community/_components/UserProfileCard.tsx new file mode 100644 index 00000000..b88a7c43 --- /dev/null +++ b/src/app/community/_components/UserProfileCard.tsx @@ -0,0 +1,87 @@ +'use client'; + +import classNames from 'classnames/bind'; +import { forwardRef, useEffect, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import Image from 'next/image'; + +import { getOthersInfo } from '@/api/usersAPI'; +import { SpinLoading, keydeukProfileImg } from '@/public/index'; + +import styles from './UserProfileCard.module.scss'; + +const cn = classNames.bind(styles); + +interface UserProfileCardProps { + isOpenProfileCard: boolean; + userId: number; + positionTop?: number; +} +export default forwardRef(function UserProfileCard( + { isOpenProfileCard, userId, positionTop }, + ref, +) { + const { + data: userInfo, + refetch, + isFetching, + } = useQuery({ + queryKey: ['clickedUserInfo'], + queryFn: () => getOthersInfo(userId), + enabled: false, + }); + const queryClient = useQueryClient(); + + const [isAboveProfile, setIsAboveProfile] = useState(false); + + useEffect(() => { + const VIEWPORT_HEIGHT = window.innerHeight; + + if (isOpenProfileCard && positionTop && positionTop > VIEWPORT_HEIGHT / 2) { + setIsAboveProfile(true); + } + }, [isOpenProfileCard, positionTop, isAboveProfile]); + + useEffect(() => { + queryClient.removeQueries({ queryKey: ['clickedUserInfo'] }); + refetch(); + }, [userId, refetch, isOpenProfileCard, queryClient]); + + return positionTop && positionTop === 0 ? null : ( +
e.stopPropagation()} + > + {isFetching ? ( + + ) : ( + <> +
+ 프로필 이미지 +
+
+

{userInfo?.nickname || '사용자를 찾을 수 없습니다.'}

+

+ email: {userInfo?.email} +

+

+ birthday: {userInfo?.birth} +

+

+ phone: {userInfo?.phone} +

+

+ gender: {userInfo?.gender} +

+
+ + )} +
+ ); +}); diff --git a/src/app/community/_components/WritePostButton.tsx b/src/app/community/_components/WritePostButton.tsx index ba8aa756..245016fe 100644 --- a/src/app/community/_components/WritePostButton.tsx +++ b/src/app/community/_components/WritePostButton.tsx @@ -11,33 +11,53 @@ import Dialog from '@/components/Dialog/Dialog'; import { ROUTER } from '@/constants/route'; import WriteEditModal from '@/components/WriteEditModal/WriteEditModal'; import type { PostCardDetailModalCustomKeyboardType } from '@/types/CommunityTypes'; +import type { Users } from '@/types/userType'; import { getCustomOrderList } from '@/api/communityAPI'; -import styles from './WritePostButton.module.scss'; +import SignInModal from '@/components/SignInModal/SignInModal'; import OrderListModal from './OrderListModal'; +import styles from './WritePostButton.module.scss'; + const cn = classNames.bind(styles); +interface UserDataType { + data: Users; + status: string; + message: string; +} + export default function WritePostButton() { const queryClient = useQueryClient(); const router = useRouter(); const [isOpenOrderListModal, setIsOpenOrderListModal] = useState(false); + const [isOpenSignInModal, setIsOpenSignInModal] = useState(false); const [isOpenReviewModal, setIsOpenReviewModal] = useState(false); const [selectedOrder, setSelectedOrder] = useState(null); - const { data: orderListData } = useQuery({ + const { data: orderListData, refetch: refetchCustomOrderList } = useQuery({ queryKey: ['orderList'], queryFn: getCustomOrderList, + enabled: false, }); - const handleClickProductList = (i: number) => { - setSelectedOrder(orderListData.data[i]); - }; + const handleClickButton = () => { + const userData = queryClient.getQueryData(['userData']); + queryClient.invalidateQueries({ queryKey: ['orderList'] }); + + if (!userData?.data) { + setIsOpenSignInModal(true); + return; + } - const openOrderListModal = () => { + refetchCustomOrderList(); setIsOpenOrderListModal(true); }; + const handleClickProductList = (i: number) => { + setSelectedOrder(orderListData.data[i]); + }; + const closeOrderListModal = () => { setIsOpenOrderListModal(false); }; @@ -60,7 +80,14 @@ export default function WritePostButton() { return (
-
); } diff --git a/src/app/community/layout.module.scss b/src/app/community/layout.module.scss index 6cbbe838..1d84c216 100644 --- a/src/app/community/layout.module.scss +++ b/src/app/community/layout.module.scss @@ -1,8 +1,7 @@ .container { width: 100%; - height: 100vh; - padding: 12.8rem 12rem 0; - overflow: auto; + height: max-content; + padding: 12.8rem 12rem; } .filter-write-button-wrapper { diff --git a/src/app/community/layout.tsx b/src/app/community/layout.tsx index 690ef12a..4619c66b 100644 --- a/src/app/community/layout.tsx +++ b/src/app/community/layout.tsx @@ -14,12 +14,12 @@ interface CommunityLayoutProps { export default async function CommunityLayout({ children }: CommunityLayoutProps) { return (
-

커뮤니티

+
+

커뮤니티

+ +
-
- - -
+ {children}
diff --git a/src/app/my-info/(account)/my-posts/_components/MyPostsEmptyCase.module.scss b/src/app/my-info/(account)/my-posts/_components/MyPostsEmptyCase.module.scss new file mode 100644 index 00000000..ffa8d6f4 --- /dev/null +++ b/src/app/my-info/(account)/my-posts/_components/MyPostsEmptyCase.module.scss @@ -0,0 +1,9 @@ +.container { + @include flex-center; + @include pretendard-20-600; + + flex-direction: column; + gap: 3rem; + height: 70vh; + color: $gray-30; +} diff --git a/src/app/my-info/(account)/my-posts/_components/MyPostsEmptyCase.tsx b/src/app/my-info/(account)/my-posts/_components/MyPostsEmptyCase.tsx new file mode 100644 index 00000000..03a064e6 --- /dev/null +++ b/src/app/my-info/(account)/my-posts/_components/MyPostsEmptyCase.tsx @@ -0,0 +1,26 @@ +import classNames from 'classnames/bind'; +import { NoReviewIcon } from '@/public/index'; + +import { Button } from '@/components'; +import Link from 'next/link'; + +import styles from './MyPostsEmptyCase.module.scss'; + +const cn = classNames.bind(styles); + +export default function MyPostsEmptyCase() { + return ( +
+ 내 게시글이 없어요! + +
+ ); +} diff --git a/src/app/my-info/(account)/my-posts/page.tsx b/src/app/my-info/(account)/my-posts/page.tsx index 56affb4e..22c17995 100644 --- a/src/app/my-info/(account)/my-posts/page.tsx +++ b/src/app/my-info/(account)/my-posts/page.tsx @@ -6,6 +6,7 @@ import { MyInfoEmptyCase } from '../../_components'; import MyPostCardList from './_components/MyPostCardList'; import styles from './page.module.scss'; +import MyPostsEmptyCase from './_components/MyPostsEmptyCase'; const cn = classNames.bind(styles); @@ -28,6 +29,10 @@ export default async function MyPostsPage({ searchParams }: MyPostsPageProps) { const data = await getMyPosts(initialParams); + if (!data) { + return ; + } + const { content, ...rest } = data; return ( diff --git a/src/components/Buttons/HeartButton/HeartButton.tsx b/src/components/Buttons/HeartButton/HeartButton.tsx index 0650ad89..76e15e3e 100644 --- a/src/components/Buttons/HeartButton/HeartButton.tsx +++ b/src/components/Buttons/HeartButton/HeartButton.tsx @@ -181,7 +181,6 @@ export default function HeartButton({ id, usage, isLiked, likeCount }: HeartButt )}
- setIsSignInModalOpen(false)} /> ); diff --git a/src/components/ImageZoom/ImageZoom.module.scss b/src/components/ImageZoom/ImageZoom.module.scss new file mode 100644 index 00000000..10101906 --- /dev/null +++ b/src/components/ImageZoom/ImageZoom.module.scss @@ -0,0 +1,22 @@ +.container { + position: relative; +} + +.image-wrapper { + position: relative; + width: 100%; + height: 100%; +} + +.image { + object-fit: contain; +} + +.scanner { + position: absolute; + border: 1px solid $gray-10; + border-radius: 0.8rem; + background-color: rgb(0 0 0 / 20%); + cursor: pointer; + pointer-events: none; +} diff --git a/src/components/ImageZoom/ImageZoom.tsx b/src/components/ImageZoom/ImageZoom.tsx new file mode 100644 index 00000000..e6d2f2ab --- /dev/null +++ b/src/components/ImageZoom/ImageZoom.tsx @@ -0,0 +1,133 @@ +'use client'; + +import classNames from 'classnames/bind'; +import { useState, MouseEvent, useRef, SyntheticEvent } from 'react'; +import Image, { StaticImageData } from 'next/image'; + +import { IMAGE_BLUR } from '@/constants/blurImage'; +import { keydeukProfileImg } from '@/public/index'; +import ZoomView from './ZoomView'; + +import styles from './ImageZoom.module.scss'; + +const cn = classNames.bind(styles); + +interface ImageZoomProps { + image: string | StaticImageData; + alt: string; + width: number; + height: number; +} + +type Position = Pick; + +interface ScannerProps { + position: Position; +} + +const scannerSize = 250; + +function Scanner({ position }: ScannerProps) { + return ( +
+ ); +} + +export default function ImageZoom({ image, alt, width, height }: ImageZoomProps) { + const containerRef = useRef(null); + + const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 }); + const [isImageError, setIsImageError] = useState(false); + const [scannerPosition, setScannerPosition] = useState(null); + const [viewPosition, setViewPosition] = useState(null); + + const handleImageLoadComplete = (e: SyntheticEvent) => { + const target = e.target as HTMLImageElement; + setImageDimensions({ + width: target.naturalWidth, + height: target.naturalHeight, + }); + }; + + const handleMouseMove = (e: MouseEvent) => { + const updatedScannerPosition = { left: 0, top: 0 }; + const imageAspectRatio = imageDimensions.width / imageDimensions.height; + const containerRect = containerRef.current?.getBoundingClientRect(); + + if (!containerRect) return; + + const scannerPosLeft = e.clientX - scannerSize / 2 - containerRect.x; + const scannerPosTop = e.clientY - scannerSize / 2 - containerRect.y; + + const allowedPosLeft = scannerPosLeft >= 0 && scannerPosLeft <= width - scannerSize; + const allowedPosTop = scannerPosTop >= 0 && scannerPosTop <= height - scannerSize; + + if (allowedPosLeft) { + updatedScannerPosition.left = scannerPosLeft; + } else if (scannerPosLeft < 0) { + updatedScannerPosition.left = 0; + } else { + updatedScannerPosition.left = containerRect.width - scannerSize; + } + + if (allowedPosTop) { + updatedScannerPosition.top = scannerPosTop; + } else if (scannerPosTop < 0) { + updatedScannerPosition.top = 0; + } else { + updatedScannerPosition.top = containerRect.height - scannerSize; + } + + setScannerPosition(updatedScannerPosition); + + if (imageDimensions.width > imageDimensions.height) { + setViewPosition({ + left: updatedScannerPosition.left * -2.0, + top: (updatedScannerPosition.top + scannerSize / 2) * -(2 / imageAspectRatio), + }); + } else { + setViewPosition({ + left: updatedScannerPosition.left * -(2 * imageAspectRatio), + top: (updatedScannerPosition.top + scannerSize / 2) * -2, + }); + } + }; + + const handleMouseLeave = () => { + setScannerPosition(null); + setViewPosition(null); + }; + + return ( +
+
handleMouseMove(e)} onMouseLeave={handleMouseLeave}> + {alt} setIsImageError(true)} + priority + placeholder={IMAGE_BLUR.placeholder} + blurDataURL={IMAGE_BLUR.blurDataURL} + sizes='(max-width: 768px) 30rem' + /> +
+ {scannerPosition && } + {viewPosition && containerRef?.current && ( + + )} +
+ ); +} diff --git a/src/components/ImageZoom/ZoomView.module.scss b/src/components/ImageZoom/ZoomView.module.scss new file mode 100644 index 00000000..6257c0cd --- /dev/null +++ b/src/components/ImageZoom/ZoomView.module.scss @@ -0,0 +1,8 @@ +.container { + position: absolute; + z-index: 999; + overflow: hidden; + border: 1px solid black; + background-color: white; + background-repeat: no-repeat; +} diff --git a/src/components/ImageZoom/ZoomView.tsx b/src/components/ImageZoom/ZoomView.tsx new file mode 100644 index 00000000..d777d93a --- /dev/null +++ b/src/components/ImageZoom/ZoomView.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames/bind'; +import { StaticImageData } from 'next/image'; +import styles from './ZoomView.module.scss'; + +const cn = classNames.bind(styles); + +interface Position { + left: number; + top: number; +} + +interface Props { + image: string | StaticImageData; + position: Position; + left: number; + viewWidth: number; + viewHeight: number; + imageDimensions: { width: number; height: number }; +} + +export default function ZoomView({ image, position, left, viewWidth, viewHeight, imageDimensions }: Props) { + const { width: imageWidth, height: imageHeight } = imageDimensions; + + const imageAspectRatio = imageWidth / imageHeight; + const marginTop = viewHeight === 536 ? 250 : 300; + + const calcRatio = (width: number, height: number): { widthRatio: number; heightRatio: number } => { + if (width > height) { + const widthRatio = 200; + const heightRatio = 200 / imageAspectRatio; + return { widthRatio, heightRatio }; + } else { + const widthRatio = 200 * imageAspectRatio; + const heightRatio = 200; + return { widthRatio, heightRatio }; + } + }; + + const { widthRatio, heightRatio } = calcRatio(imageWidth, imageHeight); + + return ( +
+ ); +} diff --git a/src/app/community/_components/PostCardDetailModal/ModalSkeleton.module.scss b/src/components/ModalSkeleton/ModalSkeleton.module.scss similarity index 100% rename from src/app/community/_components/PostCardDetailModal/ModalSkeleton.module.scss rename to src/components/ModalSkeleton/ModalSkeleton.module.scss diff --git a/src/app/community/_components/PostCardDetailModal/ModalSkeleton.tsx b/src/components/ModalSkeleton/ModalSkeleton.tsx similarity index 100% rename from src/app/community/_components/PostCardDetailModal/ModalSkeleton.tsx rename to src/components/ModalSkeleton/ModalSkeleton.tsx diff --git a/src/components/PopOver/PopOver.module.scss b/src/components/PopOver/PopOver.module.scss index 2bc4436e..3d72f479 100644 --- a/src/components/PopOver/PopOver.module.scss +++ b/src/components/PopOver/PopOver.module.scss @@ -1,5 +1,5 @@ .container { - position: relative; + position: absolute; margin-left: auto; cursor: pointer; } diff --git a/src/components/PopOver/PopOver.tsx b/src/components/PopOver/PopOver.tsx index 3eed24e8..68add0d6 100644 --- a/src/components/PopOver/PopOver.tsx +++ b/src/components/PopOver/PopOver.tsx @@ -1,7 +1,7 @@ 'use client'; import classNames from 'classnames/bind'; -import { useRef, MouseEvent } from 'react'; +import { useRef } from 'react'; import { useOutsideClick } from '@/hooks/useOutsideClick'; import { VerticalTripleDotIcon } from '@/public/index'; @@ -11,7 +11,7 @@ const cn = classNames.bind(styles); interface OptionType { label: string; - onClick: (e: React.MouseEvent) => void; + onClick?: () => void; } interface PopOverProps { @@ -19,19 +19,28 @@ interface PopOverProps { onHandleClose: () => void; onHandleOpen: () => void; isOpenPopOver: boolean; + position?: { left: number; top: number }; } -export default function PopOver({ optionsData, onHandleClose, onHandleOpen, isOpenPopOver }: PopOverProps) { +export default function PopOver({ optionsData, onHandleClose, onHandleOpen, isOpenPopOver, position }: PopOverProps) { const ref = useRef(null); useOutsideClick(ref, onHandleClose); const handleClickDotIcon = () => { - onHandleOpen(); + if (isOpenPopOver) { + onHandleClose(); + } else { + onHandleOpen(); + } }; return ( -
e.stopPropagation()}> +
e.stopPropagation()} + style={{ top: position?.top, left: position?.left }} + > {isOpenPopOver && (
diff --git a/src/components/ProfileImage/ProfileImage.module.scss b/src/components/ProfileImage/ProfileImage.module.scss index 4e0d73a2..2990c8e2 100644 --- a/src/components/ProfileImage/ProfileImage.module.scss +++ b/src/components/ProfileImage/ProfileImage.module.scss @@ -8,6 +8,7 @@ .profile-image { overflow: hidden; border-radius: 100%; + cursor: pointer; } .image-input-wrapper { diff --git a/src/components/index.ts b/src/components/index.ts index e18d1423..d9e138c3 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -20,6 +20,7 @@ export { default as ImageInput } from './WriteEditModal/ImageInput'; export { default as Button } from './Buttons/Button/Button'; export { default as ScrollUpButton } from './Buttons/ScrollUpButton/ScrollUpButton'; +export { default as ModalSkeleton } from './ModalSkeleton/ModalSkeleton'; export { default as HeartButton } from './Buttons/HeartButton/HeartButton'; diff --git a/src/libs/communityPopOverOption.ts b/src/libs/communityPopOverOption.ts index bc0d239b..b933ca0f 100644 --- a/src/libs/communityPopOverOption.ts +++ b/src/libs/communityPopOverOption.ts @@ -1,16 +1,10 @@ interface CommunityPopOverOptionProps { isMine?: boolean; onClickDelete: () => void; - onClickEdit: () => void; - onClickReport: () => void; + onClickEdit?: () => void; } -export const communityPopOverOption = ({ - isMine, - onClickDelete, - onClickReport, - onClickEdit, -}: CommunityPopOverOptionProps) => { +export const communityPopOverOption = ({ isMine, onClickDelete, onClickEdit }: CommunityPopOverOptionProps) => { return isMine ? [ { @@ -25,7 +19,6 @@ export const communityPopOverOption = ({ : [ { label: '신고하기', - onClick: onClickReport, }, ]; }; diff --git a/src/types/CommunityTypes.ts b/src/types/CommunityTypes.ts index 3d221252..b97ac3f9 100644 --- a/src/types/CommunityTypes.ts +++ b/src/types/CommunityTypes.ts @@ -7,6 +7,7 @@ export interface CommunityParamsType { } export interface CommunityPostCardDataType { + userId: number; id: number; title: string; likeCount: number; diff --git a/src/types/userType.ts b/src/types/userType.ts index d25014ab..58f6f169 100644 --- a/src/types/userType.ts +++ b/src/types/userType.ts @@ -7,3 +7,9 @@ export interface Users { gender: 'MALE' | 'FEMALE'; imgUrl: string; } + +export interface UserDataResponseType { + data: Users; + status: string; + message: string; +}