From f7f8ee4db03c19294265013a5fe6ee7ddfb8fa6e Mon Sep 17 00:00:00 2001 From: minseok Date: Fri, 6 Dec 2024 11:43:44 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20QFEED-117=20=ED=86=A0=EB=A1=A0?= =?UTF-8?q?=EB=B0=A9=20=EC=83=9D=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?api=20=EC=97=B0=EB=8F=99=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: qfeed-117 취미 카테고리 type 변경 (string->number) * feat: qfeed-117 토론방 생성 페이지 api 연동 * refactor: qfeed-117 formatlastupdated 함수 수정 --- .../CategorySelectContainer.tsx | 22 ++++----- src/components/ui/ImageUpload/ImageUpload.tsx | 18 ++++++-- .../QSpace/QSpacePost/CategorySelectPage.tsx | 6 +-- .../QSpace/QSpacePost/PostGroupPage.styles.ts | 4 +- src/pages/QSpace/QSpacePost/PostGroupPage.tsx | 36 +++++++++------ src/pages/QSpace/api/fetchGroups.ts | 2 +- src/pages/QSpace/api/imageUpload.ts | 15 ++++++ src/pages/QSpace/hooks/usePostGroupForm.ts | 42 +++++++++++++++++ src/pages/QSpace/types/group.ts | 37 ++++++++++++++- src/pages/QSpace/utils/createGroup.ts | 46 +++++++++++++++++++ src/pages/QSpace/utils/uploadImage.ts | 46 +++++++++++++++++++ src/utils/formatLastUpdated.ts | 10 ++-- src/utils/getQSpaceCard.ts | 23 ++++------ 13 files changed, 255 insertions(+), 52 deletions(-) create mode 100644 src/pages/QSpace/api/imageUpload.ts create mode 100644 src/pages/QSpace/hooks/usePostGroupForm.ts create mode 100644 src/pages/QSpace/utils/createGroup.ts create mode 100644 src/pages/QSpace/utils/uploadImage.ts diff --git a/src/components/ui/CategorySelectContainer/CategorySelectContainer.tsx b/src/components/ui/CategorySelectContainer/CategorySelectContainer.tsx index 7ef5a92..fa6084d 100644 --- a/src/components/ui/CategorySelectContainer/CategorySelectContainer.tsx +++ b/src/components/ui/CategorySelectContainer/CategorySelectContainer.tsx @@ -1,12 +1,12 @@ import { useState } from 'react'; -// 이미지 import import travelImg from '@/assets/images/airplane.jpg'; import sportsImg from '@/assets/images/sports.jpg'; import fashionImg from '@/assets/images/fashion.jpg'; import cultureImg from '@/assets/images/culture.jpg'; import matzipImg from '@/assets/images/matzip.jpg'; import etcImg from '@/assets/images/etc.jpg'; + import { CategoryCard, CategoryIcon, @@ -18,29 +18,29 @@ import { } from '@/components/ui/CategorySelectContainer/CategorySelectContainer.styles'; interface CategoryItem { - id: string; + id: number; koreanName: string; englishName: string; image: string; } const categories: CategoryItem[] = [ - { id: 'travel', koreanName: '여행', englishName: 'Travel', image: travelImg }, - { id: 'sports', koreanName: '스포츠', englishName: 'Sports', image: sportsImg }, - { id: 'fashion', koreanName: '패션', englishName: 'Fashion', image: fashionImg }, - { id: 'culture', koreanName: '문화', englishName: 'Culture', image: cultureImg }, - { id: 'restaurant', koreanName: '맛집', englishName: 'Matzip', image: matzipImg }, - { id: 'etc', koreanName: '기타', englishName: 'Etc', image: etcImg }, + { id: 1, koreanName: '여행', englishName: 'Travel', image: travelImg }, + { id: 2, koreanName: '스포츠', englishName: 'Sports', image: sportsImg }, + { id: 3, koreanName: '패션', englishName: 'Fashion', image: fashionImg }, + { id: 4, koreanName: '문화', englishName: 'Culture', image: cultureImg }, + { id: 5, koreanName: '맛집', englishName: 'Matzip', image: matzipImg }, + { id: 6, koreanName: '기타', englishName: 'Etc', image: etcImg }, ]; interface CategorySelectContainerProps { - onCategorySelect?: (categoryId: string) => void; + onCategorySelect?: (categoryId: number) => void; } const CategorySelectContainer = ({ onCategorySelect }: CategorySelectContainerProps) => { - const [selectedCategory, setSelectedCategory] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null); - const handleCategoryClick = (categoryId: string) => { + const handleCategoryClick = (categoryId: number) => { setSelectedCategory(categoryId); onCategorySelect?.(categoryId); }; diff --git a/src/components/ui/ImageUpload/ImageUpload.tsx b/src/components/ui/ImageUpload/ImageUpload.tsx index cc142c7..3b534af 100644 --- a/src/components/ui/ImageUpload/ImageUpload.tsx +++ b/src/components/ui/ImageUpload/ImageUpload.tsx @@ -12,10 +12,16 @@ import { } from '@/components/ui/ImageUpload/ImageUpload.styles'; interface ImageUploadProps { - onImageUpload?: (file: File | null) => void; // 이미지 업로드 이벤트 콜백 + onImageUpload?: (file: File | null) => void; + accept?: string; + maxSize?: number; } -export const ImageUpload: React.FC = ({ onImageUpload }) => { +export const ImageUpload = ({ + onImageUpload, + accept = 'image/*', + maxSize = 5 * 1024 * 1024, +}: ImageUploadProps) => { const [preview, setPreview] = useState(null); const [error, setError] = useState(null); @@ -26,6 +32,12 @@ export const ImageUpload: React.FC = ({ onImageUpload }) => { setError('이미지 파일(JPEG, PNG, GIF, WEBP)만 허용됩니다.'); return; } + + if (file.size > maxSize) { + setError(`파일 크기는 ${Math.floor(maxSize / (1024 * 1024))}MB 이하여야 합니다.`); + return; + } + const reader = new FileReader(); reader.onloadend = () => { setPreview(reader.result as string); @@ -66,7 +78,7 @@ export const ImageUpload: React.FC = ({ onImageUpload }) => { onClick={handleBoxClick} hasPreview={!!preview} > - + {preview ? ( diff --git a/src/pages/QSpace/QSpacePost/CategorySelectPage.tsx b/src/pages/QSpace/QSpacePost/CategorySelectPage.tsx index 546ee1c..d14d46b 100644 --- a/src/pages/QSpace/QSpacePost/CategorySelectPage.tsx +++ b/src/pages/QSpace/QSpacePost/CategorySelectPage.tsx @@ -13,15 +13,15 @@ import { useState } from 'react'; import { useNavigate } from 'react-router'; const CategorySelectPage = () => { - const [selectedCategory, setSelectedCategory] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null); const navigate = useNavigate(); - const handleCategorySelect = (categoryId: string) => { + const handleCategorySelect = (categoryId: number) => { setSelectedCategory(categoryId); }; const handleNext = () => { - navigate('/qspace/post'); + navigate('/qspace/post', { state: { categoryId: selectedCategory } }); }; return ( diff --git a/src/pages/QSpace/QSpacePost/PostGroupPage.styles.ts b/src/pages/QSpace/QSpacePost/PostGroupPage.styles.ts index 7a07372..239bd52 100644 --- a/src/pages/QSpace/QSpacePost/PostGroupPage.styles.ts +++ b/src/pages/QSpace/QSpacePost/PostGroupPage.styles.ts @@ -4,7 +4,7 @@ import styled from '@emotion/styled'; export const Container = styled.div` background-color: ${theme.colors.background}; - padding-bottom: 6rem; + height: 100vh; position: relative; `; @@ -15,11 +15,13 @@ export const Header = styled.header` `; export const Content = styled.main` + background-color: ${theme.colors.background}; padding: 0 1.5rem; display: flex; flex-direction: column; gap: 3rem; margin-top: 2rem; + padding-bottom: 6rem; `; export const InputWrapper = styled.div` diff --git a/src/pages/QSpace/QSpacePost/PostGroupPage.tsx b/src/pages/QSpace/QSpacePost/PostGroupPage.tsx index 68c07f5..65e55be 100644 --- a/src/pages/QSpace/QSpacePost/PostGroupPage.tsx +++ b/src/pages/QSpace/QSpacePost/PostGroupPage.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; import { Input, Textarea } from '@chakra-ui/react'; + import theme from '@/styles/theme'; import BackButton from '@/components/ui/BackButton/BackButton'; import { ImageUpload } from '@/components/ui/ImageUpload/ImageUpload'; + import { CharCount, Container, @@ -11,22 +12,19 @@ import { Header, InputWrapper, Label, -} from '@/pages/QSpace/QSpacePost/PostGroupPage.styles'; -import { useNavigate } from 'react-router'; +} from './PostGroupPage.styles'; + +import { createGroup } from '@/pages/QSpace/utils/createGroup'; +import { useGroupForm } from '@/pages/QSpace/hooks/usePostGroupForm'; const PostGroupPage = () => { - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [imageFile, setImageFile] = useState(null); - const navigate = useNavigate(); + const { formData, formActions, formState, toast, navigate } = useGroupForm(); + const { title, description } = formData; + const { setTitle, setDescription, setImageFile } = formActions; + const { isPending, setIsPending } = formState; const handleCreateGroup = () => { - navigate('/qspace'); - }; - - const handleImageUpload = (file: File | null) => { - console.log('Uploading image:', imageFile); - setImageFile(file); + createGroup({ formData, setIsPending, toast, navigate }); }; return ( @@ -46,6 +44,7 @@ const PostGroupPage = () => { borderRadius={12} maxLength={24} bg="white" + placeholder="방 제목을 입력해주세요" /> {title.length}/24 @@ -61,13 +60,14 @@ const PostGroupPage = () => { borderRadius={12} bg="white" resize="none" + placeholder="그룹에 대한 설명을 입력해주세요" /> {description.length}/300 - + { width="100%" height="3.5rem" onClick={handleCreateGroup} + isPending={isPending} + isDisabled={!title || !description || isPending} + _disabled={{ + bg: theme.colors.gray[300], + cursor: 'not-allowed', + }} > - 방 만들기 + {isPending ? '생성 중...' : '방 만들기'} diff --git a/src/pages/QSpace/api/fetchGroups.ts b/src/pages/QSpace/api/fetchGroups.ts index d4dc05b..d9076c3 100644 --- a/src/pages/QSpace/api/fetchGroups.ts +++ b/src/pages/QSpace/api/fetchGroups.ts @@ -9,7 +9,7 @@ export const groupAPI = { getGroupDetail: (groupId: number) => apiClient.get(`/groups/${groupId}`), // 새로운 그룹 생성 - createGroup: (data: CreateGroupRequest) => apiClient.post('/groups', data), + createGroup: (data: CreateGroupRequest) => apiClient.post('/groups/create', data), // 그룹 정보 수정 updateGroup: ({ groupId, ...data }: UpdateGroupRequest) => diff --git a/src/pages/QSpace/api/imageUpload.ts b/src/pages/QSpace/api/imageUpload.ts new file mode 100644 index 0000000..62f533e --- /dev/null +++ b/src/pages/QSpace/api/imageUpload.ts @@ -0,0 +1,15 @@ +import { apiClient } from '@/api/fetch'; +import { UploadResponse } from '@/pages/QSpace/types/group'; + +export const uploadAPI = { + uploadImage: (file: File) => { + const formData = new FormData(); + formData.append('file', file); + + return apiClient.post('/upload/image', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, +}; diff --git a/src/pages/QSpace/hooks/usePostGroupForm.ts b/src/pages/QSpace/hooks/usePostGroupForm.ts new file mode 100644 index 0000000..6ddfbce --- /dev/null +++ b/src/pages/QSpace/hooks/usePostGroupForm.ts @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useToast } from '@chakra-ui/react'; +import { UseGroupFormReturn, GroupFormData } from '../types/group'; + +export const useGroupForm = (): UseGroupFormReturn => { + const location = useLocation(); + const navigate = useNavigate(); + const toast = useToast(); + const { categoryId } = location.state as { categoryId: number }; + + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [imageFile, setImageFile] = useState(null); + const [isPending, setIsPending] = useState(false); + + const formData: GroupFormData = { + title, + description, + imageFile, + categoryId, + }; + + const formActions = { + setTitle, + setDescription, + setImageFile, + }; + + const formState = { + isPending, + setIsPending, + }; + + return { + formData, + formActions, + formState, + toast, + navigate, + }; +}; diff --git a/src/pages/QSpace/types/group.ts b/src/pages/QSpace/types/group.ts index 16ae041..e6ccdee 100644 --- a/src/pages/QSpace/types/group.ts +++ b/src/pages/QSpace/types/group.ts @@ -1,3 +1,5 @@ +import { useToast } from '@chakra-ui/react'; +import { NavigateFunction } from 'react-router'; export interface Group { groupId: number; url: string; @@ -13,9 +15,42 @@ export interface CreateGroupRequest { description: string; categoryId: number; url: string; - isOpen: true; + isOpen: boolean; +} + +export interface UploadResponse { + imageUrl: string; } export interface UpdateGroupRequest extends Partial { groupId: number; } + +export interface GroupFormData { + title: string; + description: string; + imageFile: File | null; + categoryId: number; +} + +export interface CreateGroupParams { + formData: GroupFormData; + setIsPending: (value: boolean) => void; + toast: ReturnType; + navigate: NavigateFunction; +} + +export interface UseGroupFormReturn { + formData: GroupFormData; + formActions: { + setTitle: (value: string) => void; + setDescription: (value: string) => void; + setImageFile: (file: File | null) => void; + }; + formState: { + isPending: boolean; + setIsPending: (value: boolean) => void; + }; + toast: ReturnType; + navigate: NavigateFunction; +} diff --git a/src/pages/QSpace/utils/createGroup.ts b/src/pages/QSpace/utils/createGroup.ts new file mode 100644 index 0000000..a2b2aba --- /dev/null +++ b/src/pages/QSpace/utils/createGroup.ts @@ -0,0 +1,46 @@ +import { groupAPI } from '@/pages/QSpace/api/fetchGroups'; +import { CreateGroupParams, CreateGroupRequest } from '@/pages/QSpace/types/group'; +import { showErrorToast, showSuccessToast, uploadImage } from '@/pages/QSpace/utils/uploadImage'; + +export const createGroup = async ({ + formData, + setIsPending, + toast, + navigate, +}: CreateGroupParams) => { + const { title, description, imageFile, categoryId } = formData; + + if (!title || !description) { + toast({ + title: '입력 확인', + description: '방 제목과 설명을 입력해주세요.', + status: 'warning', + duration: 3000, + isClosable: true, + }); + return; + } + + try { + setIsPending(true); + const createGroupData: Partial = { + groupName: title, + description, + categoryId, + isOpen: true, + }; + + if (imageFile) { + const imageUrl = await uploadImage(imageFile, toast); + if (imageUrl) createGroupData.url = imageUrl; + } + + await groupAPI.createGroup(createGroupData as CreateGroupRequest); + showSuccessToast(toast); + navigate('/qspace'); + } catch (error) { + showErrorToast(toast, error); + } finally { + setIsPending(false); + } +}; diff --git a/src/pages/QSpace/utils/uploadImage.ts b/src/pages/QSpace/utils/uploadImage.ts new file mode 100644 index 0000000..893965d --- /dev/null +++ b/src/pages/QSpace/utils/uploadImage.ts @@ -0,0 +1,46 @@ +import { uploadAPI } from '@/pages/QSpace/api/imageUpload'; +import { useToast } from '@chakra-ui/react'; + +export const uploadImage = async ( + imageFile: File, + toast: ReturnType +): Promise => { + try { + const { data: uploadResponse } = await uploadAPI.uploadImage(imageFile); + if (!uploadResponse?.imageUrl) { + throw new Error('이미지 업로드에 실패했습니다.'); + } + return uploadResponse.imageUrl; + } catch (error) { + console.error('Failed to upload image:', error); + toast({ + title: '이미지 업로드 실패', + description: '이미지 업로드에 실패했습니다. 다시 시도해주세요.', + status: 'error', + duration: 3000, + isClosable: true, + }); + return null; + } +}; + +export const showSuccessToast = (toast: ReturnType) => { + toast({ + title: '그룹 생성 완료', + description: '새로운 그룹이 생성되었습니다.', + status: 'success', + duration: 3000, + isClosable: true, + }); +}; + +export const showErrorToast = (toast: ReturnType, error: unknown) => { + toast({ + title: '그룹 생성 실패', + description: + error instanceof Error ? error.message : '그룹 생성에 실패했습니다. 다시 시도해주세요.', + status: 'error', + duration: 3000, + isClosable: true, + }); +}; diff --git a/src/utils/formatLastUpdated.ts b/src/utils/formatLastUpdated.ts index e7774b9..2270924 100644 --- a/src/utils/formatLastUpdated.ts +++ b/src/utils/formatLastUpdated.ts @@ -3,11 +3,13 @@ export const formatLastUpdated = (dateString: string) => { const now = new Date(); const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60)); - if (diffInMinutes < 60) { - return '방금 전 게시'; + if (diffInMinutes < 1) { + return '방금 전'; + } else if (diffInMinutes < 60) { + return `${diffInMinutes}분 전`; // 1~59분 } else if (diffInMinutes < 1440) { - return `${Math.floor(diffInMinutes / 60)}시간 전 게시`; + return `${Math.floor(diffInMinutes / 60)}시간 전`; // 1~23시간 } else { - return `${Math.floor(diffInMinutes / 1440)}일 전 게시`; + return `${Math.floor(diffInMinutes / 1440)}일 전`; // 1일 이상 } }; diff --git a/src/utils/getQSpaceCard.ts b/src/utils/getQSpaceCard.ts index e375661..be50a10 100644 --- a/src/utils/getQSpaceCard.ts +++ b/src/utils/getQSpaceCard.ts @@ -1,15 +1,12 @@ -import { QSpaceCardProps } from '@/types/qCard'; -import { formatLastUpdated } from '@/utils/formatLastUpdated'; import { Group } from '@/pages/QSpace/types/group'; +import { formatLastUpdated } from '@/utils/formatLastUpdated'; -export const getQSpaceCard = (group: Group): QSpaceCardProps => { - return { - key: group.groupId, - imageUrl: group.url, - title: group.groupName, - description: group.description, - memberCount: group.membersCount, - isRecruiting: group.isOpen, - lastUpdated: formatLastUpdated(group.createdAt), - }; -}; +export const getQSpaceCard = (group: Group) => ({ + id: group.groupId.toString(), + imageUrl: group.url, + title: group.groupName, + description: group.description, + memberCount: group.membersCount, + isRecruiting: group.isOpen, + lastUpdated: formatLastUpdated(group.createdAt), +});