diff --git a/package-lock.json b/package-lock.json index ce9380b1..b326f467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,6 @@ }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.50.0", - "@types/lodash": "^4.17.7", "@types/node": "^20.14.10", "@types/qs": "^6.9.15", "@types/react": "^18.3.3", @@ -1937,12 +1936,6 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "node_modules/@types/lodash": { - "version": "4.17.7", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", - "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", - "dev": true - }, "node_modules/@types/node": { "version": "20.14.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", diff --git a/public/arrow-left.svg b/public/arrow-left.svg new file mode 100644 index 00000000..225646e9 --- /dev/null +++ b/public/arrow-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/icon/cancelIcon.svg b/public/icon/cancelIcon.svg new file mode 100644 index 00000000..678aaf2f --- /dev/null +++ b/public/icon/cancelIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/likeIcon.svg b/public/likeIcon.svg new file mode 100644 index 00000000..b88fa457 --- /dev/null +++ b/public/likeIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 00000000..fc1d4f6a --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/meatballIcon.svg b/public/meatballIcon.svg new file mode 100644 index 00000000..49a9368e --- /dev/null +++ b/public/meatballIcon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/placeLink.svg b/public/placeLink.svg new file mode 100644 index 00000000..1cdd89a7 --- /dev/null +++ b/public/placeLink.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/profile.svg b/public/profile.svg new file mode 100644 index 00000000..10fe1c70 --- /dev/null +++ b/public/profile.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/share.svg b/public/share.svg new file mode 100644 index 00000000..0ff446dd --- /dev/null +++ b/public/share.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/apis/epigram.ts b/src/apis/epigram.ts new file mode 100644 index 00000000..418d9bc6 --- /dev/null +++ b/src/apis/epigram.ts @@ -0,0 +1,75 @@ +import axios, { AxiosError } from 'axios'; +import { GetEpigramResponseType, EpigramRequestType } from '@/schema/epigram'; +import { DeleteEpigramType } from '@/types/epigram.types'; +import { AddEpigramResponseType, EditEpigramRequestType } from '@/schema/addEpigram'; +import httpClient from '.'; + +export const getEpigram = async (request: EpigramRequestType): Promise => { + const { id } = request; + + if (id === undefined) { + throw new Error('Epigram ID가 제공되지 않았습니다.'); + } + + try { + const response = await httpClient.get(`/epigrams/${id}`); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new Error(`API 에러: ${axiosError.response.status}`); + } else if (axiosError.request) { + throw new Error('서버로부터 응답을 받지 못했습니다.'); + } else { + throw new Error('요청 설정 중 오류가 발생했습니다.'); + } + } else { + throw new Error('예상치 못한 오류가 발생했습니다.'); + } + } +}; + +export const deleteEpigram = async (id: number): Promise => { + const response = await httpClient.delete(`/epigrams/${id}`); + return response.data; +}; + +// NOTE: 에피그램 수정 api 함수 +export const patchEpigram = async (request: EditEpigramRequestType): Promise => { + const { id, ...data } = request; + const response = await httpClient.patch(`/epigrams/${id}`, data); + return response.data; +}; + +export const toggleEpigramLike = async (request: EpigramRequestType): Promise => { + const { id } = request; + + if (id === undefined) { + throw new Error('Epigram ID가 제공되지 않았습니다.'); + } + + try { + const response = await httpClient.post(`/epigrams/${id}/like`); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response?.status === 400) { + // 이미 좋아요를 눌렀다면, 좋아요 취소 요청을 보냅니다. + const response = await httpClient.delete(`/epigrams/${id}/like`); + return response.data; + } + // 그 외의 에러 처리 + if (axiosError.response) { + throw new Error(`API 에러: ${axiosError.response.status}`); + } else if (axiosError.request) { + throw new Error('서버로부터 응답을 받지 못했습니다.'); + } else { + throw new Error('요청 설정 중 오류가 발생했습니다.'); + } + } else { + throw new Error('예상치 못한 오류가 발생했습니다.'); + } + } +}; diff --git a/src/apis/queries.ts b/src/apis/queries.ts index 0010c375..ddfed703 100644 --- a/src/apis/queries.ts +++ b/src/apis/queries.ts @@ -1,10 +1,14 @@ import { createQueryKeyStore } from '@lukemorales/query-key-factory'; import { GetUserRequestType } from '@/schema/user'; +import { EpigramRequestType } from '@/schema/epigram'; +import { CommentRequestType } from '@/schema/comment'; import { GetMonthlyEmotionLogsRequestType } from '@/schema/emotion'; import { getMe, getUser } from './user'; +import { getEpigram } from './epigram'; +import { getEpigramComments } from './epigramComment'; import getMonthlyEmotionLogs from './emotion'; -const quries = createQueryKeyStore({ +const queries = createQueryKeyStore({ user: { getMe: () => ({ queryKey: ['getMe'], @@ -15,6 +19,25 @@ const quries = createQueryKeyStore({ queryFn: () => getUser(request), }), }, + // NOTE: Epigram 관련 query함수 + epigram: { + getEpigram: (request: EpigramRequestType) => ({ + queryKey: ['epigram', request.id, request], + queryFn: () => { + if (request.id === undefined) { + throw new Error('Epigram ID가 제공되지 않았습니다.'); + } + return getEpigram(request); + }, + enabled: request.id !== undefined, + }), + }, + epigramComment: { + getComments: (request: CommentRequestType) => ({ + queryKey: ['epigramComments', request], + queryFn: () => getEpigramComments(request), + }), + }, emotion: { getMonthlyEmotionLogs: (request: GetMonthlyEmotionLogsRequestType) => ({ queryKey: ['getMonthlyEmotionLogs', request], @@ -23,4 +46,4 @@ const quries = createQueryKeyStore({ }, }); -export default quries; +export default queries; diff --git a/src/components/epigram/Comment/CommentItem.tsx b/src/components/epigram/Comment/CommentItem.tsx new file mode 100644 index 00000000..dd65937c --- /dev/null +++ b/src/components/epigram/Comment/CommentItem.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import { CommentType } from '@/schema/comment'; +import { sizeStyles, textSizeStyles, gapStyles, paddingStyles, contentWidthStyles } from '@/styles/CommentCardStyles'; +import getCustomRelativeTime from '@/lib/dateUtils'; +import useDeleteCommentMutation from '@/hooks/useDeleteCommentHook'; +import { CommentCardProps } from '@/components/Card/CommentCard'; +import { useToast } from '@/components/ui/use-toast'; +import { Button } from '@/components/ui/button'; +import DeleteAlertModal from '../DeleteAlertModal'; + +interface CommentItemProps extends CommentCardProps { + comment: CommentType; + onEditComment: (id: number, content: string, isPrivate: boolean) => void; +} + +function CommentItem({ comment, status, onEditComment }: CommentItemProps) { + const deleteCommentMutation = useDeleteCommentMutation(); + const { toast } = useToast(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const handleEditClick = () => { + onEditComment(comment.id, comment.content, comment.isPrivate); + }; + + // NOTE: 댓글 삭제 + const handleDeleteComment = async () => { + try { + await deleteCommentMutation.mutateAsync(comment.id); + setIsDeleteModalOpen(false); + toast({ + title: '댓글이 삭제되었습니다.', + variant: 'destructive', + }); + } catch (error) { + toast({ + title: '댓글 삭제 실패했습니다.', + variant: 'destructive', + }); + } + }; + + return ( + + + + + + + + + + + {comment.writer.nickname} + + {getCustomRelativeTime(comment.createdAt)} + + + {status === 'edit' && ( + + + 수정 + + setIsDeleteModalOpen(true)} + type='button' + > + 삭제 + + + )} + + + {comment.content} + + + + + + ); +} + +export default CommentItem; diff --git a/src/components/epigram/Comment/CommentList.tsx b/src/components/epigram/Comment/CommentList.tsx new file mode 100644 index 00000000..4546016b --- /dev/null +++ b/src/components/epigram/Comment/CommentList.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useRef, useCallback } from 'react'; +import { EpigramCommentProps } from '@/types/epigram.types'; +import useEpigramCommentsQuery from '@/hooks/useEpigramCommentsQueryHook'; +import CommentItem from './CommentItem'; +import NoComment from './NoComment'; + +interface CommentListProps extends Omit { + onEditComment: (id: number, content: string, isPrivate: boolean) => void; +} + +function CommentList({ epigramId, currentUserId, onEditComment }: CommentListProps) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = useEpigramCommentsQuery(epigramId); + + const observerRef = useRef(null); + const lastCommentRef = useRef(null); + + // NOTE: Observer 콜백: 마지막 요소가 화면에 보이면 다음 페이지(댓글 최대3개를 묶어 1페이지 취급) 로드 + const handleObserver = useCallback( + (entries: IntersectionObserverEntry[]) => { + const [target] = entries; + if (target.isIntersecting && hasNextPage) { + fetchNextPage(); + } + }, + [fetchNextPage, hasNextPage], + ); + + useEffect(() => { + const options = { + root: null, + rootMargin: '20px', + threshold: 1.0, + }; + + observerRef.current = new IntersectionObserver(handleObserver, options); + + if (lastCommentRef.current) { + observerRef.current.observe(lastCommentRef.current); + } + + return () => { + // NOTE: effect가 실행되기전에 호출해서 메모리 누수를 방지해줌 + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [handleObserver]); + + if (status === 'pending') return 댓글을 불러오는 중...; + if (status === 'error') return 에러: 댓글을 불러오는데 실패했습니다.; + + const allComments = data?.pages.flatMap((page) => page.list) || []; + const totalCount = data?.pages[0]?.totalCount || 0; + + return ( + + 댓글({totalCount}) + {allComments.length > 0 ? ( + <> + {allComments.map((comment) => ( + + ))} + {isFetchingNextPage && 더 많은 댓글을 불러오는 중...} + > + ) : ( + + )} + + ); +} + +export default CommentList; diff --git a/src/components/epigram/Comment/CommentTextarea.tsx b/src/components/epigram/Comment/CommentTextarea.tsx new file mode 100644 index 00000000..707ee760 --- /dev/null +++ b/src/components/epigram/Comment/CommentTextarea.tsx @@ -0,0 +1,132 @@ +import React, { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem } from '@/components/ui/form'; +import Label from '@/components/ui/label'; +import Switch from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import Image from 'next/image'; +import { CommentFormSchema, CommentFormValues } from '@/schema/comment'; +import usePostCommentMutation from '@/hooks/usePostCommentHook'; +import usePatchCommentMutation from '@/hooks/usePatchCommentHook'; + +interface CommentTextareaProps { + epigramId: number; + editingComment: { id: number; content: string; isPrivate: boolean } | null; + onEditComplete: () => void; +} + +function CommentTextarea({ epigramId, editingComment, onEditComplete }: CommentTextareaProps) { + const postCommentMutation = usePostCommentMutation(); + const patchCommentMutation = usePatchCommentMutation(); + + const form = useForm({ + resolver: zodResolver(CommentFormSchema), + defaultValues: { + content: '', + isPrivate: false, + }, + }); + + // NOTE: 수정중인지 새댓글작성중인지의 상태가 변활때 폼 초기화 + useEffect(() => { + if (editingComment !== null) { + form.reset({ + content: editingComment.content, + isPrivate: editingComment.isPrivate, + }); + } else { + form.reset({ + content: '', + isPrivate: false, + }); + } + }, [editingComment, form]); + + const onSubmit = (values: CommentFormValues) => { + if (editingComment) { + // NOTE: 댓글 수정 시 + patchCommentMutation.mutate( + { commentId: editingComment.id, ...values }, + { + onSuccess: () => { + form.reset({ content: '', isPrivate: false }); + onEditComplete(); + }, + }, + ); + } else { + // NOTE: 새 댓글 작성 시 + const commentData = { + epigramId, + ...values, + }; + postCommentMutation.mutate(commentData, { + onSuccess: () => { + form.reset({ content: '', isPrivate: false }); + }, + }); + } + }; + + // NOTE: 수정 취소 + const handleCancel = () => { + form.reset(); + onEditComplete(); + }; + + return ( + + + ( + + + + + {editingComment && ( + + + + )} + + + + )} + /> + + + ( + + + + + + {field.value ? '비공개' : '공개'} + + + )} + /> + + + {editingComment ? '수정' : '저장'} + + + + + ); +} + +export default CommentTextarea; diff --git a/src/components/epigram/Comment/NoComment.tsx b/src/components/epigram/Comment/NoComment.tsx new file mode 100644 index 00000000..def6acda --- /dev/null +++ b/src/components/epigram/Comment/NoComment.tsx @@ -0,0 +1,9 @@ +import { sizeStyles } from '@/styles/CommentCardStyles'; + +export default function NoComment() { + return ( + + 댓글을 작성해보세요! + + ); +} diff --git a/src/components/epigram/DeleteAlertModal.tsx b/src/components/epigram/DeleteAlertModal.tsx new file mode 100644 index 00000000..fdbed327 --- /dev/null +++ b/src/components/epigram/DeleteAlertModal.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '../ui/alert-dialog'; + +interface ConfirmationModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + title: string; +} +// NOTE: 댓글 삭제기능에도 쓰일거같아 컴포넌트 분리 +function DeleteAlertModal({ isOpen, onOpenChange, onConfirm, title }: ConfirmationModalProps) { + return ( + + + + {title} + + + 취소 + + 삭제하기 + + + + + ); +} + +export default DeleteAlertModal; diff --git a/src/components/epigram/EditEpigram.tsx b/src/components/epigram/EditEpigram.tsx new file mode 100644 index 00000000..84865223 --- /dev/null +++ b/src/components/epigram/EditEpigram.tsx @@ -0,0 +1,292 @@ +import React, { KeyboardEvent, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter } from 'next/router'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; +import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; +import { AddEpigramFormSchema, AddEpigramFormType, EditEpigramRequestType } from '@/schema/addEpigram'; +import useEditEpigram from '@/hooks/useEditEpigramHook'; +import useTagManagement from '@/hooks/useTagManagementHook'; +import { GetEpigramResponseType } from '@/schema/epigram'; +import { useAuthorSelection } from '@/hooks/useAuthorSelectionHook'; +import { AxiosError } from 'axios'; +import Header from '../Header/Header'; +import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; + +interface EditEpigramProps { + epigram: GetEpigramResponseType; +} + +function EditEpigram({ epigram }: EditEpigramProps) { + const router = useRouter(); + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [alertContent, setAlertContent] = useState({ title: '', description: '' }); + + const form = useForm({ + resolver: zodResolver(AddEpigramFormSchema), + defaultValues: { + content: epigram.content, + author: epigram.author, + referenceTitle: epigram.referenceTitle || '', + referenceUrl: epigram.referenceUrl || '', + tags: epigram.tags.map((tag) => tag.name), + }, + }); + + const { selectedAuthorOption, handleAuthorChange, AUTHOR_OPTIONS } = useAuthorSelection({ + setValue: form.setValue, + initialAuthor: epigram.author, + }); + + useEffect(() => { + if (epigram) { + form.reset({ + content: epigram.content, + author: epigram.author, + referenceTitle: epigram.referenceTitle || '', + referenceUrl: epigram.referenceUrl || '', + tags: epigram.tags.map((tag) => tag.name), + }); + } + }, [epigram, form]); + + const { currentTag, setCurrentTag, handleAddTag, handleRemoveTag } = useTagManagement({ + setValue: form.setValue, + getValues: form.getValues, + setError: form.setError, + }); + + const editEpigramMutation = useEditEpigram({ + onSuccess: () => { + setAlertContent({ + title: '수정 완료', + description: '에피그램이 성공적으로 수정되었습니다.', + }); + setIsAlertOpen(true); + }, + onError: (error) => { + let errorMessage = '다시 시도해주세요.'; + + if (error instanceof AxiosError) { + if (error.response?.status === 400) { + errorMessage = '입력 내용을 다시 확인해주세요.'; + } else if (error.response?.status === 401) { + errorMessage = '로그인이 필요합니다.'; + } else if (error.response?.status === 403) { + errorMessage = '수정 권한이 없습니다.'; + } else if (error.response?.status === 404) { + errorMessage = '해당 에피그램을 찾을 수 없습니다.'; + } + } + + setAlertContent({ + title: '수정 실패', + description: errorMessage, + }); + setIsAlertOpen(true); + }, + }); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + const handleSubmit = (data: AddEpigramFormType) => { + const editRequest: EditEpigramRequestType = { + id: epigram.id, + ...data, + referenceTitle: data.referenceTitle?.trim() || null, + referenceUrl: data.referenceUrl?.trim() || null, + }; + + if (!editRequest.referenceTitle && !editRequest.referenceUrl) { + delete editRequest.referenceTitle; + delete editRequest.referenceUrl; + } + editEpigramMutation.mutate(editRequest); + }; + + const handleAlertClose = () => { + setIsAlertOpen(false); + if (alertContent.title === '수정 완료') { + router.push(`/epigram/${epigram.id}`); + } + }; + + return ( + <> + {}} + /> + + + + ( + + + 내용 + + + + + + + )} + /> + + + 저자 + * + + + + {AUTHOR_OPTIONS.map((option) => ( + + + + {option.label} + + + ))} + + + + ( + + + + + + + )} + /> + + 출처 + + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + + + ( + + + 태그 + * + + + { + setCurrentTag(e.target.value); + form.clearErrors('tags'); + }} + onKeyDown={handleKeyDown} + maxLength={10} + /> + = 3 || currentTag.length === 0} + > + 저장 + + + + {/* NOTE: 태그의 키값을 변경하는 대신 중복된 태그를 저장 못하게 설정 */} + + {field.value.map((tag) => ( + + {tag} + handleRemoveTag(tag)}> + × + + + ))} + + + )} + /> + + 수정하기 + + + + + + + + {alertContent.title} + {alertContent.description} + + + 확인 + + + + + > + ); +} + +export default EditEpigram; diff --git a/src/components/epigram/MoreOptionMenu.tsx b/src/components/epigram/MoreOptionMenu.tsx new file mode 100644 index 00000000..457469e6 --- /dev/null +++ b/src/components/epigram/MoreOptionMenu.tsx @@ -0,0 +1,71 @@ +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; +import useDeleteEpigram from '@/hooks/useDeleteEpigramHook'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu'; +import { toast } from '../ui/use-toast'; +import { Button } from '../ui/button'; +import DeleteAlertModal from './DeleteAlertModal'; + +interface MoreOptionsMenuProps { + epigram: number; +} + +function MoreOptionsMenu({ epigram }: MoreOptionsMenuProps) { + const router = useRouter(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const { mutate: deleteEpigram } = useDeleteEpigram({ + onSuccess: (data) => { + toast({ + title: '삭제 완료', + description: `에피그램 ${data.id}가 성공적으로 삭제되었습니다.`, + }); + router.push('/'); + }, + onError: () => { + toast({ + title: '삭제 실패', + description: '에피그램 삭제 중 오류가 발생했습니다.', + variant: 'destructive', + }); + }, + }); + + const handleEditClick = () => { + router.push(`/epigram/${epigram}/edit`); + }; + + const handleDeleteClick = () => { + setIsModalOpen(true); + }; + + const handleDeleteConfirm = () => { + deleteEpigram(epigram); + setIsModalOpen(false); + }; + + return ( + + + + + + + + {/* NOTE: width를 조정할려면 Dropdown컴포넌트에서 min-w 수정 필요 */} + + + 수정 + + + 삭제 + + + + + + ); +} + +export default MoreOptionsMenu; diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index c1d27397..0307913a 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -6,14 +6,14 @@ import cn from '@/lib/utils'; const Switch = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( )); diff --git a/src/hooks/useAuthorSelectionHook.ts b/src/hooks/useAuthorSelectionHook.ts new file mode 100644 index 00000000..596bbc1b --- /dev/null +++ b/src/hooks/useAuthorSelectionHook.ts @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { UseFormSetValue } from 'react-hook-form'; +import { useMeQuery } from '@/hooks/userQueryHooks'; +import { AddEpigramFormType } from '@/schema/addEpigram'; + +export const AUTHOR_OPTIONS = [ + { value: 'directly', label: '직접 입력' }, + { value: 'unknown', label: '알 수 없음' }, + { value: 'me', label: '본인' }, +] as const; + +type AuthorOption = (typeof AUTHOR_OPTIONS)[number]['value']; + +interface UseAuthorSelectionProps { + setValue: UseFormSetValue; + initialAuthor?: string; +} + +export const useAuthorSelection = ({ setValue, initialAuthor = '' }: UseAuthorSelectionProps) => { + const [selectedAuthorOption, setSelectedAuthorOption] = useState('directly'); + const { data: userData, isPending } = useMeQuery(); + + const handleAuthorChange = (value: AuthorOption) => { + setSelectedAuthorOption(value); + let authorValue: string; + + switch (value) { + case 'unknown': + authorValue = '알 수 없음'; + break; + case 'me': + if (isPending) { + authorValue = '로딩 중...'; + } else if (userData) { + authorValue = userData.nickname; + } else { + authorValue = '본인 (정보 없음)'; + } + break; + default: + authorValue = initialAuthor; + } + setValue('author', authorValue); + }; + + return { + selectedAuthorOption, + handleAuthorChange, + AUTHOR_OPTIONS, + }; +}; diff --git a/src/hooks/useDeleteEpigramHook.ts b/src/hooks/useDeleteEpigramHook.ts new file mode 100644 index 00000000..24f9e878 --- /dev/null +++ b/src/hooks/useDeleteEpigramHook.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteEpigram } from '@/apis/epigram'; +import { MutationOptions } from '@/types/query'; +import { DeleteEpigramType } from '@/types/epigram.types'; + +const useDeleteEpigram = (options?: MutationOptions) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => deleteEpigram(id), + ...options, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['epigrams'] }); + if (options?.onSuccess) { + options.onSuccess(data, variables, context); + } + }, + }); +}; + +export default useDeleteEpigram; diff --git a/src/hooks/useEditEpigramHook.ts b/src/hooks/useEditEpigramHook.ts new file mode 100644 index 00000000..b3eae613 --- /dev/null +++ b/src/hooks/useEditEpigramHook.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { patchEpigram } from '@/apis/epigram'; // API 함수 import +import { EditEpigramRequestType, AddEpigramResponseType } from '@/schema/addEpigram'; +import { MutationOptions } from '@/types/query'; + +const useEditEpigram = (options?: MutationOptions) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: EditEpigramRequestType) => patchEpigram(data), + ...options, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['epigram', variables.id] }); + + if (options?.onSuccess) { + options.onSuccess(data, variables, context); + } + }, + }); +}; + +export default useEditEpigram; diff --git a/src/hooks/useEpigramCommentsQueryHook.ts b/src/hooks/useEpigramCommentsQueryHook.ts new file mode 100644 index 00000000..86519f25 --- /dev/null +++ b/src/hooks/useEpigramCommentsQueryHook.ts @@ -0,0 +1,14 @@ +import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'; +import { CommentResponseType } from '@/schema/comment'; +import { getEpigramComments } from '@/apis/epigramComment'; + +const useEpigramCommentsQuery = (epigramId: number) => + // NOTE: 순서대로 API응답타입, 에러타입, 반환되는데이터타입, 쿼리 키 타입, 페이지 파라미터의 타입 + useInfiniteQuery, [string, number], number | undefined>({ + queryKey: ['epigramComments', epigramId], + queryFn: ({ pageParam }) => getEpigramComments({ id: epigramId, limit: 3, cursor: pageParam }), + initialPageParam: undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }); + +export default useEpigramCommentsQuery; diff --git a/src/hooks/useEpigramLike.ts b/src/hooks/useEpigramLike.ts new file mode 100644 index 00000000..1702d554 --- /dev/null +++ b/src/hooks/useEpigramLike.ts @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toggleEpigramLike } from '@/apis/epigram'; +import { GetEpigramResponseType, EpigramRequestType } from '@/schema/epigram'; +import { toast } from '@/components/ui/use-toast'; + +const useEpigramLike = (epigram: GetEpigramResponseType) => { + const [isLiked, setIsLiked] = useState(epigram.isLiked || false); + const [likeCount, setLikeCount] = useState(epigram.likeCount); + const queryClient = useQueryClient(); + + const { mutate: toggleLike, isPending } = useMutation({ + mutationFn: (request: EpigramRequestType) => toggleEpigramLike(request), + onSuccess: (updatedEpigram) => { + setIsLiked(!isLiked); + setLikeCount(updatedEpigram.likeCount); + queryClient.setQueryData(['epigram', epigram.id], updatedEpigram); + }, + onError: () => { + toast({ + title: '좋아요 오류', + description: '잠시 후 다시 시도해 주세요.', + variant: 'destructive', + }); + }, + }); + + const handleLikeClick = () => { + if (!isPending) { + toggleLike({ id: epigram.id }); + } else { + toast({ + title: '처리 중', + description: '이전 요청이 처리되고 있습니다. 잠시만 기다려 주세요.', + variant: 'default', + }); + } + }; + + return { likeCount, handleLikeClick, isPending }; +}; + +export default useEpigramLike; diff --git a/src/hooks/useEpigramQueryHook.ts b/src/hooks/useEpigramQueryHook.ts new file mode 100644 index 00000000..25142a2b --- /dev/null +++ b/src/hooks/useEpigramQueryHook.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import queries from '@/apis/queries'; +import { EpigramRequestType } from '@/schema/epigram'; + +const useEpigramQuery = (request: EpigramRequestType | undefined, enabled = true) => + useQuery({ + ...queries.epigram.getEpigram(request ?? { id: undefined }), + enabled: enabled && request?.id !== undefined, + }); + +export default useEpigramQuery; diff --git a/src/hooks/usePostCommentHook.ts b/src/hooks/usePostCommentHook.ts new file mode 100644 index 00000000..f926b184 --- /dev/null +++ b/src/hooks/usePostCommentHook.ts @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { postComment } from '@/apis/epigramComment'; +import { PostCommentRequest } from '@/types/epigram.types'; +import { toast } from '@/components/ui/use-toast'; + +const usePostCommentMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (commentData: PostCommentRequest) => postComment(commentData), + onSuccess: () => { + // 댓글 목록 쿼리 무효화 + queryClient.invalidateQueries({ queryKey: ['epigramComments'] }); + + // 성공 메시지 표시 + toast({ + title: '댓글 등록 성공', + description: '댓글이 성공적으로 등록되었습니다.', + }); + }, + onError: (error) => { + // 에러 메시지 표시 + toast({ + title: '댓글 등록 실패', + description: `댓글 등록 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, + variant: 'destructive', + }); + }, + }); +}; + +export default usePostCommentMutation; diff --git a/src/hooks/userQueryHooks.ts b/src/hooks/userQueryHooks.ts index f5043b15..4665c85a 100644 --- a/src/hooks/userQueryHooks.ts +++ b/src/hooks/userQueryHooks.ts @@ -1,12 +1,12 @@ -import quries from '@/apis/queries'; +import queries from '@/apis/queries'; import { updateMe, createPresignedUrl } from '@/apis/user'; import { GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user'; import { MutationOptions } from '@/types/query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -export const useMeQuery = () => useQuery(quries.user.getMe()); +export const useMeQuery = () => useQuery(queries.user.getMe()); -export const useUserQuery = (requset: GetUserRequestType) => useQuery(quries.user.getUser(requset)); +export const useUserQuery = (request: GetUserRequestType) => useQuery(queries.user.getUser(request)); export const useUpdateMe = (options: MutationOptions) => { const queryClient = useQueryClient(); @@ -14,7 +14,7 @@ export const useUpdateMe = (options: MutationOptions) => { mutationFn: (request: PatchMeRequestType) => updateMe(request), ...options, onSuccess: (...arg) => { - queryClient.invalidateQueries(quries.user.getMe()); + queryClient.invalidateQueries(queries.user.getMe()); if (options?.onSuccess) { options?.onSuccess(...arg); } diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts new file mode 100644 index 00000000..4b8eecc5 --- /dev/null +++ b/src/lib/dateUtils.ts @@ -0,0 +1,28 @@ +import { formatDistanceToNow, parseISO } from 'date-fns'; +import { ko } from 'date-fns/locale/ko'; + +/** + * @param date 날짜 문자열 또는 Date 객체 + * @return 시간을 나타내는 문자열 (예: "3일 전", "2시간 전") + */ +function getCustomRelativeTime(date: string | Date): string { + const dateToUse = typeof date === 'string' ? parseISO(date) : date; + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - dateToUse.getTime()) / 1000); + + if (diffInSeconds < 60) { + return '방금 전'; + } + if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60); + return `${minutes}분 전`; + } + if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600); + return `${hours}시간 전`; + } + // 1일(date-fns에선 23시59분30초) 이상 차이나는 경우 date-fns의 formatDistanceToNow 사용 + return formatDistanceToNow(dateToUse, { addSuffix: true, locale: ko }); +} + +export default getCustomRelativeTime; diff --git a/src/pageLayout/Epigram/AddEpigram.tsx b/src/pageLayout/Epigram/AddEpigram.tsx index f314c730..ab4ff2bd 100644 --- a/src/pageLayout/Epigram/AddEpigram.tsx +++ b/src/pageLayout/Epigram/AddEpigram.tsx @@ -12,14 +12,12 @@ import useAddEpigram from '@/hooks/epigramQueryHook'; import { useRouter } from 'next/router'; import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; import useTagManagement from '@/hooks/useTagManagementHook'; -import { useMeQuery } from '@/hooks/userQueryHooks'; +import { useAuthorSelection } from '@/hooks/useAuthorSelectionHook'; function AddEpigram() { const router = useRouter(); - const { data: userData, isPending, isError } = useMeQuery(); const [isAlertOpen, setIsAlertOpen] = useState(false); const [alertContent, setAlertContent] = useState({ title: '', description: '' }); - const [selectedAuthorOption, setSelectedAuthorOption] = useState('directly'); // 기본값을 'directly'로 설정 const [isFormValid, setIsFormValid] = useState(false); const form = useForm({ @@ -33,6 +31,10 @@ function AddEpigram() { }, }); + const { selectedAuthorOption, handleAuthorChange, AUTHOR_OPTIONS } = useAuthorSelection({ + setValue: form.setValue, + }); + // NOTE: 필수항목들에 값이 들어있는지 확인 함수 const checkFormEmpty = useCallback(() => { const { content, author, tags } = form.getValues(); @@ -79,47 +81,8 @@ function AddEpigram() { } }; - const AUTHOR_OPTIONS = [ - { value: 'directly', label: '직접 입력' }, - { value: 'unknown', label: '알 수 없음' }, - { value: 'me', label: '본인' }, - ]; - - // NOTE: default를 직접 입력으로 설정 - // NOTE: 본인을 선택 시 유저의 nickname이 들어감 - const handleAuthorChange = async (value: string) => { - setSelectedAuthorOption(value); - let authorValue: string; - - switch (value) { - case 'unknown': - authorValue = '알 수 없음'; - break; - case 'me': - if (isPending) { - authorValue = '로딩 중...'; - } else if (userData) { - authorValue = userData.nickname; - } else { - authorValue = '본인 (정보 없음)'; - } - break; - default: - authorValue = ''; - } - form.setValue('author', authorValue); - }; - - if (isPending) { - return 사용자 정보를 불러오는 중...; - } - - if (isError) { - return 사용자 정보를 불러오는 데 실패했습니다. 페이지를 새로고침 해주세요.; - } - - // NOTE: 태그를 저장하려고 할때 enter키를 누르면 폼제출이 되는걸 방지 - const handleKeyUp = (e: KeyboardEvent) => { + // NOTE: handleKeyUp을 사용했더니 폼제출이 먼저 실행돼서 다시 handleKeyDown으로 수정 + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag(); @@ -262,10 +225,9 @@ function AddEpigram() { setCurrentTag(e.target.value); form.clearErrors('tags'); }} - onKeyUp={handleKeyUp} + onKeyDown={handleKeyDown} maxLength={10} /> - (null); + + const handleEditComment = (id: number, content: string, isPrivate: boolean) => { + setEditingComment({ id, content, isPrivate }); + }; + + const handleEditComplete = () => { + setEditingComment(null); + }; + + return ( + + + 댓글 작성 + + + + {userImage ? ( + + ) : ( + + )} + + + + + + + + ); +} + +export default EpigramComment; diff --git a/src/pageLayout/Epigram/EpigramFigure.tsx b/src/pageLayout/Epigram/EpigramFigure.tsx new file mode 100644 index 00000000..72447974 --- /dev/null +++ b/src/pageLayout/Epigram/EpigramFigure.tsx @@ -0,0 +1,52 @@ +import Image from 'next/image'; +import MoreOptionsMenu from '@/components/epigram/MoreOptionMenu'; +import { EpigramFigureProps } from '@/types/epigram.types'; +import useEpigramLike from '@/hooks/useEpigramLike'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; + +function EpigramFigure({ epigram, currentUserId }: EpigramFigureProps) { + const isAuthor = currentUserId === epigram.writerId; + const { likeCount, handleLikeClick, isPending } = useEpigramLike(epigram); + + return ( + + + + + {epigram.tags.map((tag) => ( + + #{tag.name} + + ))} + + {isAuthor && } + + + {epigram.content} + + -{epigram.author}- + + + + + {likeCount} + + + {epigram.referenceTitle && ( + + + + {epigram.referenceTitle} + + + + + )} + + + + ); +} + +export default EpigramFigure; diff --git a/src/pages/epigram/[id]/edit.tsx b/src/pages/epigram/[id]/edit.tsx new file mode 100644 index 00000000..1de7c981 --- /dev/null +++ b/src/pages/epigram/[id]/edit.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import useEpigramQuery from '@/hooks/useEpigramQueryHook'; +import { useMeQuery } from '@/hooks/userQueryHooks'; +import { EpigramRequestSchema } from '@/schema/epigram'; +import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; +import EditEpigram from '@/components/epigram/EditEpigram'; + +function EditPage() { + const router = useRouter(); + const { id } = router.query; + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [alertContent, setAlertContent] = useState({ title: '', description: '' }); + + const parsedId = EpigramRequestSchema.safeParse({ id }); + + const { data: epigram, isLoading: isEpigramLoading, error: epigramError } = useEpigramQuery(parsedId.success ? parsedId.data : undefined, parsedId.success); + const { data: currentUser, isLoading: isUserLoading } = useMeQuery(); + + useEffect(() => { + if (!isEpigramLoading && !isUserLoading && epigram && currentUser) { + if (epigram.writerId !== currentUser.id) { + setAlertContent({ + title: '접근 제한', + description: '작성자만 수정할 수 있습니다.', + }); + setIsAlertOpen(true); + } + } + }, [epigram, currentUser, isEpigramLoading, isUserLoading]); + + const handleAlertClose = () => { + setIsAlertOpen(false); + router.push('/'); + }; + + if (isEpigramLoading || isUserLoading) return 로딩 중...; + if (!parsedId.success) return 잘못된 Epigram ID입니다.; + if (epigramError) return 에러 발생!! {(epigramError as Error).message}; + if (!epigram) return Epigram을 찾을 수 없습니다.; + + if (epigram.writerId !== currentUser?.id) { + return ( + + + + {alertContent.title} + {alertContent.description} + + + 확인 + + + + ); + } + + return ; +} + +export default EditPage; diff --git a/src/pages/epigram/[id]/index.tsx b/src/pages/epigram/[id]/index.tsx new file mode 100644 index 00000000..0182da5a --- /dev/null +++ b/src/pages/epigram/[id]/index.tsx @@ -0,0 +1,32 @@ +import { EpigramRequestSchema } from '@/schema/epigram'; +import useEpigramQuery from '@/hooks/useEpigramQueryHook'; +import EpigramComment from '@/pageLayout/Epigram/EpigramComment'; +import EpigramFigure from '@/pageLayout/Epigram/EpigramFigure'; +import { useRouter } from 'next/router'; +import { useMeQuery } from '@/hooks/userQueryHooks'; +import Header from '@/components/Header/Header'; + +function DetailPage() { + const router = useRouter(); + const { id } = router.query; + + const parsedId = EpigramRequestSchema.safeParse({ id }); + + const { data: epigram, isLoading, error } = useEpigramQuery(parsedId.success ? parsedId.data : undefined, parsedId.success); + const { data: userData } = useMeQuery(); + + if (isLoading) return 로딩 중...; + if (!parsedId.success) return 잘못된 Epigram ID입니다.; + if (error) return 에러 발생!! {(error as Error).message}; + if (!epigram) return Epigram을 찾을 수 없습니다.; + + return ( + + {}} />; + + + + ); +} + +export default DetailPage; diff --git a/src/schema/addEpigram.ts b/src/schema/addEpigram.ts index d12285b1..ee1c8461 100644 --- a/src/schema/addEpigram.ts +++ b/src/schema/addEpigram.ts @@ -36,9 +36,14 @@ export const AddEpigramFormSchema = z }) .refine((data) => (data.referenceUrl === '' && data.referenceTitle === '') || (data.referenceUrl !== '' && data.referenceTitle !== ''), { message: 'URL과 제목을 모두 입력하거나 모두 비워주세요.', - path: ['referenceUrl', 'referenceTitle'], + path: ['referenceUrl'], }); +export const EditEpigramRequestSchema = AddEpigramRequestSchema.partial().extend({ + id: z.number().int().positive(), +}); + export type AddEpigramRequestType = z.infer; export type AddEpigramResponseType = z.infer; export type AddEpigramFormType = z.infer; +export type EditEpigramRequestType = z.infer; diff --git a/src/schema/user.ts b/src/schema/user.ts index 5c6881cb..6cc1cf00 100644 --- a/src/schema/user.ts +++ b/src/schema/user.ts @@ -10,7 +10,7 @@ export const GetUserRequest = z.object({ id: z.number(), }); -export const GetUserReponse = z.object({ +export const GetUserResponse = z.object({ image: z.string(), updatedAt: z.coerce.date(), createdAt: z.coerce.date(), @@ -30,7 +30,7 @@ export const PostPresignedUrlResponse = z.object({ url: z.string().url(), }); -export type GetUserResponseType = z.infer; +export type GetUserResponseType = z.infer; export type GetUserRequestType = z.infer; export type PatchMeRequestType = z.infer; diff --git a/src/types/epigram.types.ts b/src/types/epigram.types.ts index 226ee9ee..53208f74 100644 --- a/src/types/epigram.types.ts +++ b/src/types/epigram.types.ts @@ -18,6 +18,10 @@ export interface PostCommentRequest { content: string; } +export interface DeleteEpigramType { + id: number; +} + export interface PatchCommentRequest { isPrivate: boolean; content: string;
댓글을 작성해보세요!
출처
+ #{tag.name} +
+ {epigram.content} +
{epigram.content}
{likeCount}
{epigram.referenceTitle}