diff --git a/.gitignore b/.gitignore index 8cf067e4..010ebaa5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/certificates/ \ No newline at end of file diff --git a/src/app/groups/[groupId]/page.tsx b/src/app/groups/[groupId]/page.tsx index 5eabef7e..4bad235f 100644 --- a/src/app/groups/[groupId]/page.tsx +++ b/src/app/groups/[groupId]/page.tsx @@ -1,8 +1,8 @@ -import { GroupDescription } from '@/components/atoms/group-description'; -import { GroupActionButtons } from '@/components/molecules/gorup-action-buttons'; import { Empty } from '@/components/organisms/empty'; -import { GroupDetailCard } from '@/components/organisms/group-detail-card'; -import { ReplySection } from '@/components/organisms/reply/reply-section'; +import { GroupActionButtons } from '@/features/group/components/group-action-buttons'; +import { GroupDescription } from '@/features/group/components/group-description'; +import { GroupDetailCard } from '@/features/group/components/group-detail-card'; +import { ReplySection } from '@/features/reply/components/reply-section'; import { GroupDetail } from '@/types'; import { getAuthCookieHeader } from '@/utils/cookie'; import { isBeforeToday } from '@/utils/dateUtils'; @@ -154,7 +154,7 @@ export default async function GroupDetailPage({
-
+
*/} +
⚠️ 그룹을 불러오는 중 문제가 발생했습니다. 다시 시도해주세요.
} diff --git a/src/components/atoms/share-button/index.tsx b/src/components/atoms/share-button/index.tsx index ff2874a9..6e5db4f1 100644 --- a/src/components/atoms/share-button/index.tsx +++ b/src/components/atoms/share-button/index.tsx @@ -9,9 +9,9 @@ export const ShareButton = () => { const shareButtonClickHandler = async () => { try { await navigator.clipboard.writeText(currentUrl); - toast.success('클립보드에 복사되었습니다'); + toast.success('링크가 복사되었습니다.'); } catch { - toast.error('클립보드 복사 실패!'); + toast.error('링크 복사에 실패하였습니다.'); } }; diff --git a/src/components/error-fallback/index.tsx b/src/components/error-fallback/index.tsx index 796f58c3..ec5ce68a 100644 --- a/src/components/error-fallback/index.tsx +++ b/src/components/error-fallback/index.tsx @@ -1,8 +1,9 @@ 'use client'; -import { ReactNode } from "react"; +import useAuthStore from '@/stores/useAuthStore'; import { useRouter } from 'next/navigation'; -import useAuthStore from "@/stores/useAuthStore"; +import { ReactNode } from 'react'; +import { Button } from '../ui/button'; interface ErrorFallbackProps { error: Error | null; @@ -15,38 +16,48 @@ interface ErrorFallbackProps { * @param error 에러 객체 * @param resetErrorBoundary 에러 재시도 함수 * @param children 표시할 메시지 - * @returns + * @returns */ -export const ErrorFallback = ({ error, resetErrorBoundary, children }: ErrorFallbackProps) => { - const {clearUser} = useAuthStore(); - +export const ErrorFallback = ({ + error, + resetErrorBoundary, + children, +}: ErrorFallbackProps) => { + const { clearUser } = useAuthStore(); + const router = useRouter(); const handleClick = () => { - if (error?.message.includes('401') || error?.message.toLowerCase().includes('unauthorized')) { + if ( + error?.message.includes('401') || + error?.message.toLowerCase().includes('unauthorized') + ) { clearUser(); console.log('401 에러 발생'); - router.push('/login'); // 401 에러면 로그인 페이지로 이동 + router.push('/login'); // 401 에러면 로그인 페이지로 이동 } else if (error?.message.includes('Network')) { - resetErrorBoundary(); // 네트워크 에러면 재시도 + resetErrorBoundary(); // 네트워크 에러면 재시도 } else { - resetErrorBoundary(); // 기타 에러는 기본적으로 재시도 + resetErrorBoundary(); // 기타 에러는 기본적으로 재시도 } }; return ( -
+
{/* 에러 메시지 */} -

{children}

+

{children}

{/* 에러 객체가 존재하는 경우 에러 상세 메시지 */} {/* {error &&
{error.message}
} */} {/* resetErrorBoundary이 존재하는 경우 재시도 버튼 */} - + {error?.message.includes('401') || + error?.message.toLowerCase().includes('unauthorized') + ? '로그인하기' + : '다시 시도'} +
); -}; \ No newline at end of file +}; diff --git a/src/components/organisms/reply/reply-section.tsx b/src/components/organisms/reply/reply-section.tsx deleted file mode 100644 index ceb39595..00000000 --- a/src/components/organisms/reply/reply-section.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ReplyForm } from '@/components/molecules/reply/reply-form'; -import { QueryErrorBoundary } from '@/components/query-error-boundary'; -import { ReplyList } from './reply-list'; - -export const ReplySection = () => { - return ( - <> - - - 댓글을 불러오는 중 문제가 발생했습니다. -

- } - > - -
- - ); -}; diff --git a/src/features/bookmark/components/bookmark-button-container.tsx b/src/features/bookmark/components/bookmark-button-container.tsx index ab32a444..0ee8ce43 100644 --- a/src/features/bookmark/components/bookmark-button-container.tsx +++ b/src/features/bookmark/components/bookmark-button-container.tsx @@ -1,7 +1,7 @@ 'use client'; import { request } from '@/api/request'; -import { BookmarkButton } from '@/components/atoms/bookmark-button'; +import { BookmarkButton } from '@/features/bookmark/components/bookmark-button'; import useAuthStore from '@/stores/useAuthStore'; import { useMutation } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; diff --git a/src/components/atoms/bookmark-button/index.tsx b/src/features/bookmark/components/bookmark-button.tsx similarity index 100% rename from src/components/atoms/bookmark-button/index.tsx rename to src/features/bookmark/components/bookmark-button.tsx diff --git a/src/components/atoms/apply-join-button.tsx/index.tsx b/src/features/group/components/apply-join-button.tsx similarity index 92% rename from src/components/atoms/apply-join-button.tsx/index.tsx rename to src/features/group/components/apply-join-button.tsx index 8eecf891..46628a5b 100644 --- a/src/components/atoms/apply-join-button.tsx/index.tsx +++ b/src/features/group/components/apply-join-button.tsx @@ -1,10 +1,10 @@ 'use client'; import { request } from '@/api/request'; +import { LoginRequireButton } from '@/components/atoms/login-require-button'; import { useMutation } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; import { toast } from 'sonner'; -import { LoginRequireButton } from '../login-require-button'; export const ApplyJoinButton = ({ onSuccess }: { onSuccess: () => void }) => { const { groupId } = useParams<{ groupId: string }>(); diff --git a/src/components/atoms/cancel-group-button/index.tsx b/src/features/group/components/cancel-group-button.tsx similarity index 100% rename from src/components/atoms/cancel-group-button/index.tsx rename to src/features/group/components/cancel-group-button.tsx diff --git a/src/components/atoms/cancel-join-button/index.tsx b/src/features/group/components/cancel-join-button.tsx similarity index 100% rename from src/components/atoms/cancel-join-button/index.tsx rename to src/features/group/components/cancel-join-button.tsx diff --git a/src/components/molecules/gorup-action-buttons/index.tsx b/src/features/group/components/group-action-buttons.tsx similarity index 86% rename from src/components/molecules/gorup-action-buttons/index.tsx rename to src/features/group/components/group-action-buttons.tsx index bb0d44ad..527fc2bd 100644 --- a/src/components/molecules/gorup-action-buttons/index.tsx +++ b/src/features/group/components/group-action-buttons.tsx @@ -1,10 +1,10 @@ 'use client'; import { invalidateTag } from '@/actions/invalidate'; -import { ApplyJoinButton } from '@/components/atoms/apply-join-button.tsx'; -import { CancelGroupButton } from '@/components/atoms/cancel-group-button'; -import { CancelJoinButton } from '@/components/atoms/cancel-join-button'; import { ShareButton } from '@/components/atoms/share-button'; +import { ApplyJoinButton } from '@/features/group/components/apply-join-button'; +import { CancelGroupButton } from '@/features/group/components/cancel-group-button'; +import { CancelJoinButton } from '@/features/group/components/cancel-join-button'; import useAuthStore from '@/stores/useAuthStore'; import { useState } from 'react'; diff --git a/src/components/atoms/group-description/index.tsx b/src/features/group/components/group-description.tsx similarity index 100% rename from src/components/atoms/group-description/index.tsx rename to src/features/group/components/group-description.tsx diff --git a/src/components/organisms/group-detail-card/index.tsx b/src/features/group/components/group-detail-card.tsx similarity index 95% rename from src/components/organisms/group-detail-card/index.tsx rename to src/features/group/components/group-detail-card.tsx index 55314ac7..885d1b7c 100644 --- a/src/components/organisms/group-detail-card/index.tsx +++ b/src/features/group/components/group-detail-card.tsx @@ -3,8 +3,8 @@ import { Badge } from '@/components/atoms/badge'; import { GroupProgress } from '@/components/atoms/group/particiapant-progress'; import { PositionBadge } from '@/components/molecules/position-badge'; import { SkillBadge } from '@/components/molecules/skill-badge'; -import { ParticipantListModal } from '@/components/organisms/participant-list-modal'; import { BookmarkButtonContainer } from '@/features/bookmark/components/bookmark-button-container'; +import { ParticipantListModal } from '@/features/group/components/participant-list-modal'; import { GroupDetail, GroupTypeName } from '@/types'; import { Position, Skill } from '@/types/enums'; import { formatYearMonthDayWithDot } from '@/utils/dateUtils'; @@ -102,7 +102,7 @@ export const GroupDetailCard = ({
{info.group.participants - .slice(0, 3) + .slice(0, 5) .map(({ userId, profileImage, email, nickname }, index) => ( 0 ? '-ml-3' : '' }`} /> @@ -128,7 +128,7 @@ export const GroupDetailCard = ({ }; const getZIndexClass = (index: number) => { - const zIndexMap = ['z-10', 'z-20', 'z-30']; + const zIndexMap = ['z-10', 'z-20', 'z-30', 'z-40', 'z-50']; return zIndexMap[index] ?? 'z-0'; }; diff --git a/src/components/organisms/participant-list-modal/index.tsx b/src/features/group/components/participant-list-modal.tsx similarity index 100% rename from src/components/organisms/participant-list-modal/index.tsx rename to src/features/group/components/participant-list-modal.tsx diff --git a/src/components/atoms/reply/add-rereply-button.tsx b/src/features/reply/components/add-rereply-button.tsx similarity index 100% rename from src/components/atoms/reply/add-rereply-button.tsx rename to src/features/reply/components/add-rereply-button.tsx diff --git a/src/components/molecules/reply/reply-content.tsx b/src/features/reply/components/reply-content.tsx similarity index 97% rename from src/components/molecules/reply/reply-content.tsx rename to src/features/reply/components/reply-content.tsx index 4add6bb3..b6d77a6c 100644 --- a/src/components/molecules/reply/reply-content.tsx +++ b/src/features/reply/components/reply-content.tsx @@ -1,7 +1,6 @@ 'use client'; import { request } from '@/api/request'; -import { ReplyMeta } from '@/components/molecules/reply/reply-meta'; import { Button } from '@/components/ui/button'; import useAuthStore from '@/stores/useAuthStore'; import { Reply } from '@/types'; @@ -9,6 +8,7 @@ import { useMutation } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; +import { ReplyMeta } from './reply-meta'; type ReplyContentProps = Reply & { parentId?: number; onDelete?: () => void }; @@ -110,13 +110,13 @@ export const ReplyContent = ({ className="max-h-20 w-full border-2 border-slate-800 rounded-sm p-3 resize-none" /> ) : ( -

{isLocallyDeleted ? '삭제된 댓글입니다.' : content} -

+ )}
); diff --git a/src/components/molecules/reply/reply-form.tsx b/src/features/reply/components/reply-form.tsx similarity index 100% rename from src/components/molecules/reply/reply-form.tsx rename to src/features/reply/components/reply-form.tsx diff --git a/src/components/organisms/reply/reply-item.tsx b/src/features/reply/components/reply-item.tsx similarity index 58% rename from src/components/organisms/reply/reply-item.tsx rename to src/features/reply/components/reply-item.tsx index 18903e71..47bedac6 100644 --- a/src/components/organisms/reply/reply-item.tsx +++ b/src/features/reply/components/reply-item.tsx @@ -1,6 +1,6 @@ -import { ReplyContent } from '@/components/molecules/reply/reply-content'; import { Reply } from '@/types'; -import { ReplyThread } from './reply-thread'; +import { ReplyContent } from './reply-content'; +import { RereplySection } from './rereply-section'; export const ReplyItem = ({ content, @@ -10,7 +10,7 @@ export const ReplyItem = ({ deleted, }: Reply) => { return ( -
+
- -
+ + ); }; diff --git a/src/components/organisms/reply/reply-list.tsx b/src/features/reply/components/reply-list.tsx similarity index 76% rename from src/components/organisms/reply/reply-list.tsx rename to src/features/reply/components/reply-list.tsx index 44bacfb8..67530b97 100644 --- a/src/components/organisms/reply/reply-list.tsx +++ b/src/features/reply/components/reply-list.tsx @@ -1,11 +1,12 @@ 'use client'; -import { ReplyItem } from '@/components/organisms/reply/reply-item'; +import { useReplyScrollIntoView } from '@/features/reply/hooks/useReplyScrollIntoView'; +import { useTargetReplyParams } from '@/features/reply/hooks/useTargetReplyParams '; import { useFetchInView } from '@/hooks/useFetchInView'; import { useFetchItems } from '@/hooks/useFetchItems'; -import { useReplyScrollIntoView } from '@/hooks/useReplyScrollIntoView'; -import { useTargetReplyParams } from '@/hooks/useTargetReplyParams '; +import { ReplyItem } from './reply-item'; +import { Loading } from '@/components/organisms/loading'; import { useTargetReplyStore } from '@/stores/useTargetReply'; import { Reply } from '@/types'; import flattenPages from '@/utils/flattenPages'; @@ -53,8 +54,24 @@ export const ReplyList = () => { const replies = flattenPages(data.pages); + if (isLoading) { + return ( +
+ +
+ ); + } + + if (replies.length === 0) { + return ( +
+ 아직 댓글이 없습니다. +
+ ); + } + return ( -
+
    {replies.map((reply) => (
  • { {hasNextPage && !isFetchingNextPage && (
    )} -
+
); }; diff --git a/src/components/molecules/reply/reply-meta.tsx b/src/features/reply/components/reply-meta.tsx similarity index 100% rename from src/components/molecules/reply/reply-meta.tsx rename to src/features/reply/components/reply-meta.tsx diff --git a/src/features/reply/components/reply-section.tsx b/src/features/reply/components/reply-section.tsx new file mode 100644 index 00000000..7111830e --- /dev/null +++ b/src/features/reply/components/reply-section.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { ErrorBoundary } from '@/components/error-boundary'; +import { handleError } from '@/components/error-boundary/error-handler'; +import { ReplyForm } from './reply-form'; +import { ReplyList } from './reply-list'; + +export const ReplySection = () => { + return ( +
+

+ 댓글 +

+ + handleError({ + error, + resetErrorBoundary, + defaultMessage: '댓글을 불러오는 중 문제가 발생했습니다', + }) + } + > + + + +
+ ); +}; diff --git a/src/components/molecules/reply/rereply-form-toggle.tsx b/src/features/reply/components/rereply-form-toggle.tsx similarity index 90% rename from src/components/molecules/reply/rereply-form-toggle.tsx rename to src/features/reply/components/rereply-form-toggle.tsx index c02372ad..e61cab4a 100644 --- a/src/components/molecules/reply/rereply-form-toggle.tsx +++ b/src/features/reply/components/rereply-form-toggle.tsx @@ -1,7 +1,7 @@ 'use client'; -import { AddRereplyButton } from '@/components/atoms/reply/add-rereply-button'; import { useState } from 'react'; +import { AddRereplyButton } from './add-rereply-button'; import { ReplyForm } from './reply-form'; type RereplyFormToggleProps = { diff --git a/src/components/organisms/reply/rereply-item.tsx b/src/features/reply/components/rereply-item.tsx similarity index 88% rename from src/components/organisms/reply/rereply-item.tsx rename to src/features/reply/components/rereply-item.tsx index ff71e585..b42ba913 100644 --- a/src/components/organisms/reply/rereply-item.tsx +++ b/src/features/reply/components/rereply-item.tsx @@ -1,6 +1,6 @@ -import { ReplyContent } from '@/components/molecules/reply/reply-content'; import { Reply } from '@/types'; import { forwardRef, useState } from 'react'; +import { ReplyContent } from './reply-content'; /** 대댓글 삭제를 위해 ReplyContent를 둘러싸기 위해 만든 컴포넌트 */ export const RereplyItem = forwardRef((props, ref) => { diff --git a/src/components/organisms/reply/rereply-list.tsx b/src/features/reply/components/rereply-list.tsx similarity index 94% rename from src/components/organisms/reply/rereply-list.tsx rename to src/features/reply/components/rereply-list.tsx index d1045042..ff885f63 100644 --- a/src/components/organisms/reply/rereply-list.tsx +++ b/src/features/reply/components/rereply-list.tsx @@ -1,8 +1,8 @@ 'use client'; +import { useReplyScrollIntoView } from '@/features/reply/hooks/useReplyScrollIntoView'; import { useFetchInView } from '@/hooks/useFetchInView'; import { useFetchItems } from '@/hooks/useFetchItems'; -import { useReplyScrollIntoView } from '@/hooks/useReplyScrollIntoView'; import { Reply } from '@/types'; import flattenPages from '@/utils/flattenPages'; import { useParams } from 'next/navigation'; diff --git a/src/components/organisms/reply/reply-thread.tsx b/src/features/reply/components/rereply-section.tsx similarity index 81% rename from src/components/organisms/reply/reply-thread.tsx rename to src/features/reply/components/rereply-section.tsx index 0a2197ed..675dbe9d 100644 --- a/src/components/organisms/reply/reply-thread.tsx +++ b/src/features/reply/components/rereply-section.tsx @@ -1,12 +1,16 @@ 'use client'; -import { RereplyFormToggle } from '@/components/molecules/reply/rereply-form-toggle'; -import { RereplyList } from '@/components/organisms/reply/rereply-list'; -import { useTargetReplyParams } from '@/hooks/useTargetReplyParams '; +import { useTargetReplyParams } from '@/features/reply/hooks/useTargetReplyParams '; import { useTargetReplyStore } from '@/stores/useTargetReply'; import { useEffect, useState } from 'react'; +import { RereplyFormToggle } from './rereply-form-toggle'; +import { RereplyList } from './rereply-list'; -export const ReplyThread = ({ parentReplyId }: { parentReplyId: number }) => { +export const RereplySection = ({ + parentReplyId, +}: { + parentReplyId: number; +}) => { const [isOpen, setIsOpen] = useState(false); const { notificationTargetReplyId, notificationTargetRereplyId } = useTargetReplyParams(); @@ -29,7 +33,7 @@ export const ReplyThread = ({ parentReplyId }: { parentReplyId: number }) => { }; return ( -
+
대댓글
@@ -47,6 +51,6 @@ export const ReplyThread = ({ parentReplyId }: { parentReplyId: number }) => { openRereplyList={toggleRereplyListHandler} isOpenRereplyList={isOpen} /> -
+
); }; diff --git a/src/hooks/useReplyScrollIntoView.ts b/src/features/reply/hooks/useReplyScrollIntoView.ts similarity index 55% rename from src/hooks/useReplyScrollIntoView.ts rename to src/features/reply/hooks/useReplyScrollIntoView.ts index 7290e4e2..d804106e 100644 --- a/src/hooks/useReplyScrollIntoView.ts +++ b/src/features/reply/hooks/useReplyScrollIntoView.ts @@ -37,55 +37,60 @@ export const useReplyScrollIntoView = ({ const hasSetToastRef = useRef(false); - useEffect(() => { - let target: number | undefined; + const getTargetId = (): number | undefined => { + if (replyType === 'reply') return targetReplyId ?? undefined; + if (replyType === 'rereply') return targetRereplyId ?? undefined; + }; + const clearTargetQueryAndState = () => { + const params = new URLSearchParams(searchParams.toString()); if (replyType === 'reply') { - if (!targetReplyId) return; - target = targetReplyId; + params.delete('replyId'); + setTargetReply({ targetReplyId: null }); + } else { + params.delete('rereplyId'); + setTargetReply({ targetRereplyId: null }); } + const query = params.toString(); + const newUrl = query ? `${pathname}?${query}` : pathname; + window.history.replaceState(null, '', newUrl); + }; + + const scrollToElement = (element: HTMLElement | null) => { + element?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }; + + const showReplyNotFoundToast = () => { + const params = new URLSearchParams(searchParams.toString()); - if (replyType === 'rereply') { - if (!targetRereplyId) return; - target = targetRereplyId; + if (params.has('replyId')) { + toast.error('존재하지 않는 댓글입니다.'); + } else if (params.has('rereplyId')) { + toast.error('삭제된 대댓글입니다.'); } - if (!target) return; + clearTargetQueryAndState(); + setTargetReply({ targetReplyId: null, targetRereplyId: null }); + }; + + useEffect(() => { + const targetId = getTargetId(); + if (!targetId) return; - const targetElement = itemRefs.current[target]; + const targetElement = itemRefs.current[targetId]; if (targetElement) { - targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); - - if (replyType === 'reply') { - setTargetReply({ targetReplyId: null }); - const newParams = new URLSearchParams(searchParams.toString()); - newParams.delete('replyId'); - const newQuery = newParams.toString(); - const newUrl = newQuery ? `${pathname}?${newQuery}` : pathname; - window.history.replaceState(null, '', newUrl); - } else { - setTargetReply({ targetRereplyId: null }); - window.history.replaceState(null, '', pathname); - } + scrollToElement(targetElement); + clearTargetQueryAndState(); } else { - bottomRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); + scrollToElement(bottomRef.current); + if (!hasNextPage && !hasSetToastRef.current) { hasSetToastRef.current = true; - setTimeout(() => { - const params = new URLSearchParams(searchParams.toString()); - if (params.has('replyId')) { - toast.error('존재하지 않는 댓글입니다.'); - } else if (params.has('rereplyId')) { - toast.error('삭제된 대댓글입니다.'); - } - window.history.replaceState(null, '', pathname); - }, 1000); + setTimeout(showReplyNotFoundToast, 1000); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [targetReplyId, targetRereplyId, data]); diff --git a/src/hooks/useTargetReplyParams .ts b/src/features/reply/hooks/useTargetReplyParams .ts similarity index 100% rename from src/hooks/useTargetReplyParams .ts rename to src/features/reply/hooks/useTargetReplyParams .ts