Skip to content

Commit

Permalink
Merge branch 'main' of https://github.com/epigram5-9/epigram into mer…
Browse files Browse the repository at this point in the history
…ge/FE-29
  • Loading branch information
jangmoonwon committed Aug 6, 2024
2 parents 45b8120 + 34bff5a commit 79545c3
Show file tree
Hide file tree
Showing 15 changed files with 512 additions and 118 deletions.
317 changes: 307 additions & 10 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"react-dom": "^18",
"react-hook-form": "^7.52.1",
"react-toastify": "^10.0.5",
"recharts": "^2.12.7",
"sharp": "^0.33.4",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
Expand Down
30 changes: 30 additions & 0 deletions src/components/Card/UserProfileModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog_dim';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';

interface UserProfileModalProps {
username: string;
profileImage: string;
children: React.ReactNode;
}

function UserProfileModal({ username, profileImage, children }: UserProfileModalProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className='sm:max-w-[425px] bg-white'>
<div className='flex items-center space-x-4 p-6'>
<Avatar className='h-12 w-12'>
<AvatarImage src={profileImage} alt={username} />
<AvatarFallback>{username[0]}</AvatarFallback>
</Avatar>
<div>
<h4 className='text-lg font-medium'>{username}</h4>
</div>
</div>
</DialogContent>
</Dialog>
);
}

export default UserProfileModal;
45 changes: 19 additions & 26 deletions src/components/epigram/Comment/CommentItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import Image from 'next/image';
import { CommentType } from '@/schema/comment';
import { textSizeStyles, gapStyles, paddingStyles, contentWidthStyles } from '@/styles/CommentCardStyles';
import getCustomRelativeTime from '@/lib/dateUtils';
import useDeleteCommentMutation from '@/hooks/useDeleteCommentHook';
import { useToast } from '@/components/ui/use-toast';
import { Button } from '@/components/ui/button';
import useDeleteCommentMutation from '@/hooks/useDeleteCommentHook';
import UserProfileModal from '@/components/Card/UserProfileModal';
import DeleteAlertModal from '../DeleteAlertModal';
import CommentTextarea from './CommentTextarea';

Expand All @@ -18,9 +18,16 @@ interface CommentItemProps {
}

function CommentItem({ comment, status, onEditComment, isEditing, epigramId }: CommentItemProps) {
const deleteCommentMutation = useDeleteCommentMutation();
const { toast } = useToast();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const deleteCommentMutation = useDeleteCommentMutation({
onSuccess: () => {
setIsDeleteModalOpen(false);
},
});

const handleDeleteComment = async () => {
deleteCommentMutation.mutate({ commentId: comment.id, epigramId });
};

const handleEditClick = () => {
onEditComment(comment.id);
Expand All @@ -31,32 +38,18 @@ function CommentItem({ comment, status, onEditComment, isEditing, epigramId }: C
return <CommentTextarea epigramId={epigramId} editingComment={comment} onEditComplete={() => onEditComment(0)} />;
}

const handleDeleteComment = async () => {
try {
await deleteCommentMutation.mutateAsync(comment.id);
setIsDeleteModalOpen(false);
toast({
title: '댓글이 삭제되었습니다.',
variant: 'destructive',
});
} catch (error) {
toast({
title: '댓글 삭제 실패했습니다.',
variant: 'destructive',
});
}
};

return (
<div
className={`h-auto bg-slate-100 border-t border-slate-300 flex-col justify-start items-start gap-2.5 inline-flex w-[360px] md:w-[384px] lg:w-[640px] ${paddingStyles.sm} ${paddingStyles.md} ${paddingStyles.lg}`}
>
<div className='h-full justify-start items-start gap-4 inline-flex'>
<div className='w-12 h-12 relative'>
<div className='w-12 h-12 bg-zinc-300 rounded-full overflow-hidden flex items-center justify-center'>
<Image src={comment.writer.image || '/ProfileTestImage.jpg'} alt='프로필 이미지' layout='fill' objectFit='cover' className='rounded-full' />
<UserProfileModal username={comment.writer.nickname} profileImage={comment.writer.image || '/ProfileTestImage.jpg'}>
<div className='w-12 h-12 relative cursor-pointer rounded-full'>
<div>
<Image src={comment.writer.image || '/ProfileTestImage.jpg'} alt='프로필 이미지' layout='fill' objectFit='cover' className='rounded-full' />
</div>
</div>
</div>
</UserProfileModal>
<div className={`flex-col justify-start items-start ${gapStyles.sm} ${gapStyles.md} ${gapStyles.lg} inline-flex ${contentWidthStyles.sm} ${contentWidthStyles.md} ${contentWidthStyles.lg}`}>
<div className='justify-between items-center w-full inline-flex'>
<div className='justify-start items-start gap-2 flex'>
Expand All @@ -69,14 +62,14 @@ function CommentItem({ comment, status, onEditComment, isEditing, epigramId }: C
{status === 'edit' && (
<div className='justify-start items-start gap-4 flex'>
<Button
className={`w-3 text-neutral-700 leading-[18px] cursor-pointer ${textSizeStyles.sm.action} ${textSizeStyles.md.action} ${textSizeStyles.lg.action}`}
className={`h-5 p-0 text-neutral-700 leading-[18px] cursor-pointer ${textSizeStyles.sm.action} ${textSizeStyles.md.action} ${textSizeStyles.lg.action}`}
onClick={handleEditClick}
type='button'
>
수정
</Button>
<Button
className={`w-3 text-red-400 leading-[18px] cursor-pointer ${textSizeStyles.sm.action} ${textSizeStyles.md.action} ${textSizeStyles.lg.action}`}
className={`h-5 p-0 text-red-400 leading-[18px] cursor-pointer ${textSizeStyles.sm.action} ${textSizeStyles.md.action} ${textSizeStyles.lg.action}`}
onClick={() => setIsDeleteModalOpen(true)}
type='button'
>
Expand Down
4 changes: 2 additions & 2 deletions src/components/main/FAB.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ function FAB() {
<button
type='button'
onClick={scrollToTop}
className='fixed z-10 bottom-20 right-6 w-12 h-12 md:w-16 md:h-16 bg-blue-900 rounded-full flex items-center justify-center shadow-lg'
className='fixed z-10 bottom-[80px] right-6 w-12 h-12 md:w-16 md:h-16 bg-blue-900 rounded-full flex items-center justify-center shadow-lg'
aria-label='Scroll to top'
>
<Image src='/icon/FAB-icon-lg.svg' alt='Scroll to top' width={24} height={24} className='md:w-16 md:h-16' />
<Image src='/icon/FAB-icon-lg.svg' alt='Scroll to top' width={24} height={24} className='w-full h-full' />
</button>
);
}
Expand Down
83 changes: 41 additions & 42 deletions src/components/mypage/Chart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EmotionLog, EmotionTypeEN } from '@/types/emotion';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
import Image from 'next/image';
import { iconPaths } from '../../user/utill/constants';

Expand All @@ -22,52 +23,51 @@ export default function Chart({ monthlyEmotionLogs }: ChartProps) {
// 감정 종류 및 총 감정 수 계산
const TOTAL_COUNT = monthlyEmotionLogs.length;
const EMOTIONS: EmotionTypeEN[] = ['MOVED', 'HAPPY', 'WORRIED', 'SAD', 'ANGRY'];
const RADIUS = 90; // 원의 반지름
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

// 가장 많이 나타나는 감정 찾기
const maxEmotion = EMOTIONS.reduce((max, emotion) => (emotionCounts[emotion] > emotionCounts[max] ? emotion : max), EMOTIONS[0]);

// 원형 차트의 각 감정에 대한 strokeDasharray와 strokeDashoffset 계산
let offset = 0;
// 원형 차트 데이터 생성 및 정렬 (가장 많은 감정부터)
const chartData = EMOTIONS.map((emotion) => ({
name: emotion,
value: emotionCounts[emotion] || 0,
})).sort((a, b) => b.value - a.value);

// 감정 색상 설정
const COLORS = {
MOVED: '#48BB98',
HAPPY: '#FBC85B',
WORRIED: '#C7D1E0',
SAD: '#E3E9F1',
ANGRY: '#EFF3F8',
};

return (
<div className='flex flex-col w-full lg:max-w-[640px] md:max-w-[640px] mt-[160px] space-y-0 md:mb-10 mb-5 gap-[48px]'>
<h2 className='text-neutral-700 text-2xl font-semibold leading-loose'>감정 차트</h2>
<div className='flex justify-between items-center px-[112px]'>
<div className='w-[200px] h-[200px] relative'>
<svg viewBox='0 0 200 200'>
<circle cx='100' cy='100' r={RADIUS} fill='none' stroke='beige' strokeWidth='20' />
{EMOTIONS.map((emotion) => {
const count = emotionCounts[emotion] || 0;
const percentage = TOTAL_COUNT > 0 ? count / TOTAL_COUNT : 0; // 0으로 나누기 방지
const strokeDasharray = `${CIRCUMFERENCE * percentage} ${CIRCUMFERENCE * (1 - percentage)}`;

// 색상 설정
let strokeColor;
switch (emotion) {
case 'HAPPY':
strokeColor = '#FBC85B';
break;
case 'SAD':
strokeColor = '#E3E9F1';
break;
case 'WORRIED':
strokeColor = '#C7D1E0';
break;
case 'ANGRY':
strokeColor = '#EFF3F8';
break;
default:
strokeColor = '#48BB98';
}

const circle = <circle key={emotion} cx='100' cy='100' r={RADIUS} fill='none' stroke={strokeColor} strokeWidth='20' strokeDasharray={strokeDasharray} strokeDashoffset={offset} />;

offset += CIRCUMFERENCE * percentage; // 다음 원을 위한 offset 업데이트
return circle;
})}
</svg>
<ResponsiveContainer width='100%' height='100%'>
<PieChart>
<Pie
data={chartData}
dataKey='value'
cx='50%'
cy='50%'
outerRadius={90}
innerRadius={70} // 도넛 차트를 위해 innerRadius 설정
startAngle={90} // 12시 방향에서 시작
endAngle={-270} // 시계 방향으로 회전
fill='#8884d8'
>
{chartData.map((emotion, index) => (
// TODO: index 값 Lint error. 임시로 주석 사용. 추후 수정 예정
// eslint-disable-next-line react/no-array-index-key
<Cell key={`cell-${index}`} fill={COLORS[emotion.name]} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
{/* 중앙에 가장 많이 나타나는 감정 출력 */}
<div className='absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-3'>
<Image src={iconPaths[maxEmotion].path} alt='감정' width={40} height={40} />
Expand All @@ -76,14 +76,13 @@ export default function Chart({ monthlyEmotionLogs }: ChartProps) {
</div>
<div>
<div className='flex flex-col gap-4'>
{EMOTIONS.map((emotion) => {
const count = emotionCounts[emotion] || 0;
const percentage = TOTAL_COUNT > 0 ? Math.floor((count / TOTAL_COUNT) * 100) : 0; // 퍼센트 계산 및 소수점 버리기
{chartData.map((emotion) => {
const percentage = TOTAL_COUNT > 0 ? Math.floor((emotion.value / TOTAL_COUNT) * 100) : 0;

return (
<div key={emotion} className='flex items-center gap-3'>
<p className={`${iconPaths[emotion].color} w-[16px] h-[16px]`}></p>
<Image src={iconPaths[emotion].path} alt='감정' width={24} height={24} />
<div key={emotion.name} className='flex items-center gap-3'>
<div style={{ backgroundColor: COLORS[emotion.name], width: '16px', height: '16px' }}></div>
<Image src={iconPaths[emotion.name].path} alt='감정' width={24} height={24} />
<p>{percentage}%</p>
</div>
);
Expand Down
65 changes: 65 additions & 0 deletions src/components/ui/dialog_dim.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';

import cn from '@/lib/utils';

const Dialog = DialogPrimitive.Root;

const DialogTrigger = DialogPrimitive.Trigger;

const DialogPortal = DialogPrimitive.Portal;

const DialogClose = DialogPrimitive.Close;

const DialogOverlay = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const DialogContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className='absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;

function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />;
}
DialogHeader.displayName = 'DialogHeader';

function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />;
}
DialogFooter.displayName = 'DialogFooter';

const DialogTitle = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;

const DialogDescription = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;

export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
37 changes: 23 additions & 14 deletions src/hooks/useDeleteCommentHook.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteComment } from '@/apis/epigramComment';
import { toast } from '@/components/ui/use-toast';
import queries from '@/apis/queries';

const useDeleteCommentMutation = () => {
const queryClient = useQueryClient();
interface DeleteCommentVariables {
commentId: number;
epigramId?: number;
}

return useMutation({
mutationFn: (commentId: number) => deleteComment(commentId),
onSuccess: () => {
// 댓글 목록 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['epigramComments'] });
const useDeleteCommentMutation = (options?: { onSuccess?: (variables: DeleteCommentVariables) => void }) => {
const queryClient = useQueryClient();

// 성공 메시지 표시
return useMutation<unknown, Error, DeleteCommentVariables>({
mutationFn: ({ commentId }) => deleteComment(commentId),
onSuccess: (_, variables) => {
if (variables.epigramId) {
queryClient.invalidateQueries({
queryKey: queries.epigramComment.getComments(variables.epigramId).queryKey,
});
}
toast({
title: '댓글 삭제 성공',
description: '댓글이 성공적으로 삭제되었습니다.',
title: '댓글이 삭제되었습니다.',
variant: 'destructive',
});

if (options?.onSuccess) {
options.onSuccess(variables);
}
},
onError: (error) => {
// 에러 메시지 표시
onError: () => {
toast({
title: '댓글 삭제 실패',
description: `댓글 삭제 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`,
title: '댓글 삭제 실패했습니다.',
variant: 'destructive',
});
},
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useEpigramCommentsQueryHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const useEpigramCommentsQuery = (epigramId: number) =>
useInfiniteQuery<CommentResponseType, Error, InfiniteData<CommentResponseType>>({
...queries.epigramComment.getComments(epigramId),
initialPageParam: undefined,
getNextPageParam: (lastPage: CommentResponseType) => lastPage.nextCursor ?? undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});

export default useEpigramCommentsQuery;
Loading

0 comments on commit 79545c3

Please sign in to comment.