{/* eslint-disable-next-line */}
{/* 줄무늬를 만들려면 비어있는 div가 필요합니다. */}
+ {/* 테스트 텍스트입니다. */}
오랫동안 꿈을 그리는 사람은 마침내 그 꿈을 닮아 간다.
- - 앙드레 말로 -
+ {/* 테스트 텍스트입니다. */}- 앙드레 말로 -
+ {/* 테스트 텍스트입니다. */}
#나아가야할때
+ {/* 테스트 텍스트입니다. */}
#꿈을이루고싶을때
diff --git a/src/components/Emotion/card/EmotionIconCard.tsx b/src/components/Emotion/EmotionCard.tsx
similarity index 93%
rename from src/components/Emotion/card/EmotionIconCard.tsx
rename to src/components/Emotion/EmotionCard.tsx
index a230deec..1896f7cd 100644
--- a/src/components/Emotion/card/EmotionIconCard.tsx
+++ b/src/components/Emotion/EmotionCard.tsx
@@ -1,3 +1,9 @@
+/*
+ 1개의 감정 아이콘 카드를 랜더링 합니다.
+ 아이콘의 타입, 상태, 크기, 클릭 이벤트를 관리합니다.
+ 아이콘 타입과 상태에 따라 아이콘의 모양과 스타일을 조정합니다.
+ */
+
import React from 'react';
import cn from '@/lib/utils';
import Image from 'next/image';
@@ -104,11 +110,4 @@ function EmotionIconCard({ iconType = '감동', state = 'Default', size = 'sm',
);
}
-EmotionIconCard.displayName = 'EmotionIconCard';
-
-// 기본 props 설정
-EmotionIconCard.defaultProps = {
- onClick: () => {},
-};
-
export default EmotionIconCard;
diff --git a/src/components/Emotion/card/EmotionSelector.tsx b/src/components/Emotion/EmotionSelector.tsx
similarity index 84%
rename from src/components/Emotion/card/EmotionSelector.tsx
rename to src/components/Emotion/EmotionSelector.tsx
index 29b3f104..5a73639e 100644
--- a/src/components/Emotion/card/EmotionSelector.tsx
+++ b/src/components/Emotion/EmotionSelector.tsx
@@ -1,5 +1,10 @@
+/*
+ 여러 개의 EmotionIconCard를 관리합니다.
+ 사용자 인터페이스에 필요한 상호 작용 로직을 포함합니다.
+ */
+
import React, { useState } from 'react';
-import InteractiveEmotionIconCard from '@/components/Emotion/card/InteractiveEmotionIconCard';
+import EmotionIconCard from '@/components/Emotion/EmotionCard';
import useMediaQuery from '@/hooks/useMediaQuery';
import { EmotionType, EmotionState } from '@/types/EmotionTypes';
@@ -52,7 +57,7 @@ function EmotionSelector() {
return (
{(['감동', '기쁨', '고민', '슬픔', '분노'] as const).map((iconType) => (
- handleCardClick(iconType)} />
+ handleCardClick(iconType)} />
))}
);
diff --git a/src/components/Emotion/card/InteractiveEmotionIconCard.tsx b/src/components/Emotion/card/InteractiveEmotionIconCard.tsx
deleted file mode 100644
index f8448315..00000000
--- a/src/components/Emotion/card/InteractiveEmotionIconCard.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-import EmotionIconCard from '@/components/Emotion/card/EmotionIconCard';
-import { InteractiveEmotionIconCardProps } from '@/types/EmotionTypes';
-
-// InteractiveEmotionIconCard 컴포넌트 함수 선언
-function InteractiveEmotionIconCard(props: InteractiveEmotionIconCardProps) {
- return
;
-}
-
-InteractiveEmotionIconCard.displayName = 'InteractiveEmotionIconCard';
-
-export default InteractiveEmotionIconCard;
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
index 5c893fed..d342347f 100644
--- a/src/components/Header/Header.tsx
+++ b/src/components/Header/Header.tsx
@@ -1,7 +1,6 @@
import React from 'react';
import { useRouter } from 'next/router';
import Image from 'next/image';
-import { HeaderProps } from '../../types/Header';
import { useToast } from '../ui/use-toast';
import LOGO_ICON from '../../../public/epigram-icon.png';
import ARROW_LEFT_ICON from '../../../public/icon/arrow-left-icon.svg';
@@ -9,15 +8,28 @@ import PROFILE_ICON from '../../../public/icon/profile-icon.svg';
import SEARCH_ICON from '../../../public/icon/search-icon.svg';
import SHARE_ICON from '../../../public/icon/share-icon.svg';
-// TODO 네비게이션 바를 나타내는 컴포넌트 입니다.
-// TODO 상위 컴포넌트에서 Props를 받아 원하는 스타일을 보여줍니다.
-// TODO 사용 예시
-// TODO
-// TODO
{}} />;
-// TODO icon: 'back'을 사용할 경우 routerPage의 값을 무조건 지정해줘야 합니다.
-// TODO isLogo={false}일 경우 insteadOfLogo의 값을 무조건 지정해줘야 합니다.
-// TODO isButton 일 경우 textInButton의 값을 무조건 지정해줘야 합니다.
-// TODO SHARE_ICON 추가 시 토스트 기능도 사용하려면 해당 컴포넌트 아래 를 추가해주세요.
+// NOTE 네비게이션 바를 나타내는 컴포넌트 입니다.
+// NOTE 상위 컴포넌트에서 Props를 받아 원하는 스타일을 보여줍니다.
+// NOTE 사용 예시
+// NOTE
+// NOTE {}} />;
+// NOTE icon: 'back'을 사용할 경우 routerPage의 값을 무조건 지정해줘야 합니다.
+// NOTE isLogo={false}일 경우 insteadOfLogo의 값을 무조건 지정해줘야 합니다.
+// NOTE isButton 일 경우 textInButton의 값을 무조건 지정해줘야 합니다.
+// NOTE SHARE_ICON 추가 시 토스트 기능도 사용하려면 해당 컴포넌트 아래 를 추가해주세요.
+
+export interface HeaderProps {
+ icon: 'back' | 'search' | '';
+ routerPage: string;
+ isLogo: boolean;
+ insteadOfLogo: string;
+ isProfileIcon: boolean;
+ isShareIcon: boolean;
+ isButton: boolean;
+ textInButton: string;
+ disabled: boolean;
+ onClick: (e: React.MouseEvent) => void;
+}
function Header({ isLogo, icon, insteadOfLogo, isButton, isProfileIcon, isShareIcon, textInButton, routerPage, disabled, onClick }: HeaderProps) {
const router = useRouter();
diff --git a/src/components/epigram/Comment/CommentItem.tsx b/src/components/epigram/Comment/CommentItem.tsx
index 194011bf..791447e2 100644
--- a/src/components/epigram/Comment/CommentItem.tsx
+++ b/src/components/epigram/Comment/CommentItem.tsx
@@ -3,7 +3,7 @@ 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 { CommentCardProps } from '@/types/CommentCardTypes';
+import { CommentCardProps } from '@/components/Card/CommentCard';
interface CommentItemProps extends CommentCardProps {
comment: CommentType;
diff --git a/src/hooks/epigramQueryHook.ts b/src/hooks/epigramQueryHook.ts
new file mode 100644
index 00000000..e2ca6679
--- /dev/null
+++ b/src/hooks/epigramQueryHook.ts
@@ -0,0 +1,24 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { AddEpigramFormType, AddEpigramResponseType } from '@/schema/addEpigram';
+import { MutationOptions } from '@/types/query';
+import postEpigram from '@/apis/add';
+import { AxiosError } from 'axios';
+
+// TODO: 에피그램 수정과 삭제에도 사용 가능하게 훅 수정 예정
+
+const useAddEpigram = (options?: MutationOptions) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (newEpigram: AddEpigramFormType) => postEpigram(newEpigram),
+ ...options,
+ onSuccess: (...args) => {
+ queryClient.invalidateQueries({ queryKey: ['epigrams'] });
+ if (options?.onSuccess) {
+ options.onSuccess(...args);
+ }
+ },
+ });
+};
+
+export default useAddEpigram;
diff --git a/src/hooks/useSignInMutation.ts b/src/hooks/useSignInMutation.ts
new file mode 100644
index 00000000..eaf9fd76
--- /dev/null
+++ b/src/hooks/useSignInMutation.ts
@@ -0,0 +1,22 @@
+import postSignin from '@/apis/auth';
+import { toast } from '@/components/ui/use-toast';
+import { useMutation } from '@tanstack/react-query';
+import { useRouter } from 'next/router';
+
+const useSigninMutation = () => {
+ const router = useRouter();
+
+ return useMutation({
+ mutationFn: postSignin,
+ onSuccess: (data) => {
+ localStorage.setItem('accessToken', data.accessToken);
+ localStorage.setItem('refreshToken', data.refreshToken);
+ router.push('/');
+ },
+ onError: () => {
+ toast({ description: '이메일 혹은 비밀번호를 확인해주세요.', className: 'border-state-error text-state-error font-semibold' });
+ },
+ });
+};
+
+export default useSigninMutation;
diff --git a/src/hooks/useTagManagementHook.ts b/src/hooks/useTagManagementHook.ts
new file mode 100644
index 00000000..dd0082de
--- /dev/null
+++ b/src/hooks/useTagManagementHook.ts
@@ -0,0 +1,47 @@
+import { useState } from 'react';
+import { UseFormSetValue, UseFormGetValues, UseFormSetError } from 'react-hook-form';
+import { AddEpigramFormType } from '@/schema/addEpigram';
+
+// NOTE: setError메서드로 FormField에 에러 설정 가능
+const useTagManagement = ({
+ setValue,
+ getValues,
+ setError,
+}: {
+ setValue: UseFormSetValue;
+ getValues: UseFormGetValues;
+ setError: UseFormSetError;
+}) => {
+ const [currentTag, setCurrentTag] = useState('');
+
+ const handleAddTag = () => {
+ if (!currentTag || currentTag.length > 10) {
+ return;
+ }
+ const currentTags = getValues('tags') || [];
+
+ if (currentTags.length >= 3) {
+ return;
+ }
+ if (currentTags.includes(currentTag)) {
+ setError('tags', { type: 'manual', message: '이미 저장된 태그입니다.' });
+ return;
+ }
+
+ setValue('tags', [...currentTags, currentTag]);
+ setCurrentTag('');
+ setError('tags', { type: 'manual', message: '' });
+ };
+
+ const handleRemoveTag = (tagToRemove: string) => {
+ const currentTags = getValues('tags') || [];
+ setValue(
+ 'tags',
+ currentTags.filter((tag) => tag !== tagToRemove),
+ );
+ };
+
+ return { currentTag, setCurrentTag, handleAddTag, handleRemoveTag };
+};
+
+export default useTagManagement;
diff --git a/src/pageLayout/Epigram/AddEpigram.tsx b/src/pageLayout/Epigram/AddEpigram.tsx
new file mode 100644
index 00000000..f314c730
--- /dev/null
+++ b/src/pageLayout/Epigram/AddEpigram.tsx
@@ -0,0 +1,316 @@
+import React, { KeyboardEvent, useCallback, useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import Header from '@/components/Header/Header';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Textarea } from '@/components/ui/textarea';
+import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
+import { AddEpigramFormSchema, AddEpigramFormType } from '@/schema/addEpigram';
+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';
+
+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({
+ resolver: zodResolver(AddEpigramFormSchema),
+ defaultValues: {
+ content: '',
+ author: '',
+ referenceTitle: '',
+ referenceUrl: '',
+ tags: [],
+ },
+ });
+
+ // NOTE: 필수항목들에 값이 들어있는지 확인 함수
+ const checkFormEmpty = useCallback(() => {
+ const { content, author, tags } = form.getValues();
+ return content.trim() !== '' && author.trim() !== '' && tags.length > 0;
+ }, [form]);
+
+ // NOTE: form값이 변경될때 필수항목들이 들어있는지 확인
+ const watchForm = useCallback(() => {
+ setIsFormValid(checkFormEmpty());
+ }, [checkFormEmpty]);
+
+ useEffect(() => {
+ const subscription = form.watch(watchForm);
+ return () => subscription.unsubscribe();
+ }, [form, watchForm]);
+
+ const { currentTag, setCurrentTag, handleAddTag, handleRemoveTag } = useTagManagement({
+ setValue: form.setValue,
+ getValues: form.getValues,
+ setError: form.setError,
+ });
+ const addEpigramMutation = useAddEpigram({
+ onSuccess: () => {
+ setAlertContent({
+ title: '등록 완료',
+ description: '등록이 완료되었습니다.',
+ });
+ setIsAlertOpen(true);
+ form.reset();
+ },
+ onError: () => {
+ setAlertContent({
+ title: '등록 실패',
+ description: '다시 시도해주세요.',
+ });
+ setIsAlertOpen(true);
+ },
+ });
+
+ const handleAlertClose = () => {
+ setIsAlertOpen(false);
+ if (alertContent.title === '등록 완료') {
+ router.push(`/epigram/${addEpigramMutation.data?.id}`);
+ }
+ };
+
+ 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) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddTag();
+ }
+ };
+
+ // NOTE: url와title은 필수 항목이 아니라서 빈칸으로 제출할 때 항목에서 제외
+ const handleSubmit = (data: AddEpigramFormType) => {
+ const submitData = { ...data };
+
+ if (!submitData.referenceUrl) {
+ delete submitData.referenceUrl;
+ }
+
+ if (!submitData.referenceTitle) {
+ delete submitData.referenceTitle;
+ }
+
+ addEpigramMutation.mutate(submitData);
+ };
+
+ return (
+ <>
+ {}} />
+
+
+
+
+
+ {alertContent.title}
+ {alertContent.description}
+
+
+ 확인
+
+
+
+ >
+ );
+}
+
+export default AddEpigram;
diff --git a/src/pages/addEpigram.tsx b/src/pages/addEpigram.tsx
new file mode 100644
index 00000000..28e1645e
--- /dev/null
+++ b/src/pages/addEpigram.tsx
@@ -0,0 +1,7 @@
+import AddEpigram from '@/pageLayout/Epigram/AddEpigram';
+
+function Add() {
+ return ;
+}
+
+export default Add;
diff --git a/src/pages/auth/SignIn.tsx b/src/pages/auth/SignIn.tsx
new file mode 100644
index 00000000..400d0edd
--- /dev/null
+++ b/src/pages/auth/SignIn.tsx
@@ -0,0 +1,100 @@
+import Image from 'next/image';
+import Link from 'next/link';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
+import { PostSigninRequest, PostSigninRequestType } from '@/schema/auth';
+import useSigninMutation from '@/hooks/useSignInMutation';
+
+export default function SignIn() {
+ const mutationSignin = useSigninMutation();
+ // 폼 정의
+ const form = useForm({
+ resolver: zodResolver(PostSigninRequest),
+ mode: 'onBlur',
+ defaultValues: {
+ email: '',
+ password: '',
+ },
+ });
+
+ // TODO: 나중에 컴포넌트 분리하기
+ return (
+
+
+
+
+
+
+
회원이 아니신가요?
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/schema/addEpigram.ts b/src/schema/addEpigram.ts
new file mode 100644
index 00000000..d12285b1
--- /dev/null
+++ b/src/schema/addEpigram.ts
@@ -0,0 +1,44 @@
+import { z } from 'zod';
+
+const urlRegex = /^https?:\/\/.+/;
+
+export const AddEpigramRequestSchema = z.object({
+ tags: z.array(z.string().min(1).max(10)).max(3),
+ referenceUrl: z.string().url().regex(urlRegex).optional().nullable(),
+ referenceTitle: z.string().max(100).optional().nullable(),
+ author: z.string().min(1).max(30),
+ content: z.string().min(1).max(500),
+});
+
+export const AddEpigramResponseSchema = z.object({
+ likeCount: z.number(),
+ tags: z.array(
+ z.object({
+ name: z.string().min(1).max(10),
+ id: z.number().int().positive(),
+ }),
+ ),
+ writerId: z.number().int().positive(),
+ referenceUrl: z.string().url().regex(urlRegex).nullable(),
+ referenceTitle: z.string().max(100).nullable(),
+ author: z.string().min(1).max(30),
+ content: z.string().min(1).max(500),
+ id: z.number().int().positive(),
+});
+
+export const AddEpigramFormSchema = z
+ .object({
+ tags: z.array(z.string().min(1).max(10)).min(1, { message: '최소 1개의 태그를 추가해주세요.' }).max(3),
+ author: z.string().min(1, { message: '저자의 이름을 입력해주세요' }).max(30, { message: '30자 이내로 입력해주세요.' }),
+ content: z.string().min(1, { message: '내용을 입력해주세요.' }).max(500, { message: '500자 이내로 입력해주세요.' }),
+ referenceUrl: z.union([z.string().regex(urlRegex, { message: '올바른 URL 형식이 아닙니다.' }), z.literal('')]).optional(),
+ referenceTitle: z.union([z.string().max(100, { message: '100자 이내로 입력해주세요.' }), z.literal('')]).optional(),
+ })
+ .refine((data) => (data.referenceUrl === '' && data.referenceTitle === '') || (data.referenceUrl !== '' && data.referenceTitle !== ''), {
+ message: 'URL과 제목을 모두 입력하거나 모두 비워주세요.',
+ path: ['referenceUrl', 'referenceTitle'],
+ });
+
+export type AddEpigramRequestType = z.infer;
+export type AddEpigramResponseType = z.infer;
+export type AddEpigramFormType = z.infer;
diff --git a/src/schema/auth.ts b/src/schema/auth.ts
new file mode 100644
index 00000000..33466608
--- /dev/null
+++ b/src/schema/auth.ts
@@ -0,0 +1,25 @@
+import * as z from 'zod';
+
+export const PostSigninRequest = z.object({
+ email: z.string().min(1, { message: '이메일은 필수 입력입니다.' }).email({ message: '올바른 이메일 주소가 아닙니다.' }),
+ password: z.string().min(1, { message: '비밀번호는 필수 입력입니다.' }),
+});
+
+const User = z.object({
+ id: z.number(),
+ email: z.string().email(),
+ nickname: z.string(),
+ teamId: z.string(),
+ updatedAt: z.coerce.date(),
+ createdAt: z.coerce.date(),
+ image: z.string(),
+});
+
+export const PostSigninResponse = z.object({
+ accessToken: z.string(),
+ refreshToken: z.string(),
+ user: User,
+});
+
+export type PostSigninRequestType = z.infer;
+export type PostSigninResponseType = z.infer;
diff --git a/src/types/CommentCardTypes.ts b/src/types/CommentCardTypes.ts
deleted file mode 100644
index abb0a28b..00000000
--- a/src/types/CommentCardTypes.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface CommentCardProps {
- status: 'edit' | 'complete';
-}
diff --git a/src/types/Header.ts b/src/types/Header.ts
deleted file mode 100644
index a75d1fce..00000000
--- a/src/types/Header.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export interface HeaderProps {
- icon: 'back' | 'search' | '';
- routerPage: string;
- isLogo: boolean;
- insteadOfLogo: string;
- isProfileIcon: boolean;
- isShareIcon: boolean;
- isButton: boolean;
- textInButton: string;
- disabled: boolean;
- onClick: (e: React.MouseEvent) => void;
-}