diff --git a/src/apis/banner.ts b/src/apis/banner.ts new file mode 100644 index 00000000..fa7cf99a --- /dev/null +++ b/src/apis/banner.ts @@ -0,0 +1,27 @@ +import apiClient from './apiClient'; + +export const getBanner = async (contestId: number): Promise => { + const response = await apiClient.get(`/contests/${contestId}/image/banner`, { + responseType: 'blob', + }); + + if (response.status === 202) { + const error = new Error('배너 이미지 처리 중입니다.'); + (error as any).response = response; + throw error; + } + + return response.data; +}; + +export const postBanner = async (contestId: number, formData: FormData) => { + const res = await apiClient.post(`/contests/${contestId}/image/banner`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return res; +}; + +export const deleteBanner = async (contestId: number) => { + const res = await apiClient.delete(`/contests/${contestId}/image/banner`); + return res; +}; diff --git a/src/hooks/useImageBlob.ts b/src/hooks/useImageBlob.ts new file mode 100644 index 00000000..aa9cedd4 --- /dev/null +++ b/src/hooks/useImageBlob.ts @@ -0,0 +1,27 @@ +import { QueryKey, useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +export const useImageBlob = ( + queryOptions: UseQueryOptions, +) => { + const [imageURL, setImageURL] = useState(null); + + const query = useQuery(queryOptions); + + useEffect(() => { + if (query.data) { + const newUrl = URL.createObjectURL(query.data); + setImageURL(newUrl); + + return () => { + URL.revokeObjectURL(newUrl); + }; + } + }, [query.data]); + + useEffect(() => { + if (query.isError) setImageURL(null); + }, [query.isError]); + + return { ...query, imageURL }; +}; diff --git a/src/mocks/handlers/banners.ts b/src/mocks/handlers/banners.ts new file mode 100644 index 00000000..d91a7121 --- /dev/null +++ b/src/mocks/handlers/banners.ts @@ -0,0 +1,30 @@ +import { API_BASE_URL } from '@constants/index'; +import { http, HttpResponse } from 'msw'; + +export const bannersHandler = [ + http.post(`${API_BASE_URL}/api/contests/:contestId/image/banner`, (req) => { + return HttpResponse.json({}, { status: 201 }); + }), + + http.head(`${API_BASE_URL}/api/contests/:contestId/image/banner`, ({ params }) => { + if (params.contestId === '1') { + return HttpResponse.json({}, { status: 200 }); + } + return HttpResponse.json({}, { status: 404 }); + }), + + http.get(`${API_BASE_URL}/api/contests/:contestId/image/banner`, ({ params }) => { + if (params.contestId === '1') { + const svg = `Mock Banner`; + return new HttpResponse(svg, { status: 200, headers: { 'Content-Type': 'image/svg+xml' } }); + } + return HttpResponse.json({}, { status: 404 }); + }), + + http.delete(`${API_BASE_URL}/api/contests/:contestId/image/banner`, ({ params }) => { + if (params.contestId === '1') { + return HttpResponse.json({}, { status: 204 }); + } + return HttpResponse.json({}, { status: 404 }); + }), +]; diff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts index bfeecd1f..6d457a6f 100644 --- a/src/mocks/handlers/index.ts +++ b/src/mocks/handlers/index.ts @@ -3,5 +3,6 @@ import { noticesHandler } from './notices'; import { signInHandlers } from './sign-in'; import { signUpHandlers } from './sign-up'; import { teamsHandlers } from './teams'; +import { bannersHandler } from './banners'; -export const handlers = [...contestsHandler, ...noticesHandler]; +export const handlers = [...contestsHandler, ...noticesHandler, ...bannersHandler]; diff --git a/src/pages/admin/banner/BannerManagePage.tsx b/src/pages/admin/banner/BannerManagePage.tsx new file mode 100644 index 00000000..a41bb7de --- /dev/null +++ b/src/pages/admin/banner/BannerManagePage.tsx @@ -0,0 +1,14 @@ +import { useState } from 'react'; +import CurrentBannerSection from './CurrentBannerSection'; +import BannerUploadSection from './BannerUploadSection'; + +const BannerManagePage = () => { + return ( +
+ + +
+ ); +}; + +export default BannerManagePage; diff --git a/src/pages/admin/banner/BannerUploadSection.tsx b/src/pages/admin/banner/BannerUploadSection.tsx new file mode 100644 index 00000000..86b07819 --- /dev/null +++ b/src/pages/admin/banner/BannerUploadSection.tsx @@ -0,0 +1,154 @@ +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { MdOutlineFileUpload } from 'react-icons/md'; +import { FiX } from 'react-icons/fi'; +import { useToast } from 'hooks/useToast'; +import { postBanner } from 'apis/banner'; +import Button from '@components/Button'; +import { cn } from '@components/lib/utils'; +import { bannerOption } from 'queries/banner'; + +const BannerUploadSection = () => { + const { contestId } = useParams(); + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [newBannerFile, setNewBannerFile] = useState(null); + const [newBannerPreview, setNewBannerPreview] = useState(null); + const toast = useToast(); + + const { refetch: refetchBanner } = useQuery(bannerOption(Number(contestId ?? 0))); + const uploadMutation = useMutation({ + mutationKey: ['banner', Number(contestId ?? 0)], + mutationFn: (formData: FormData) => postBanner(Number(contestId ?? 0), formData), + }); + + useEffect(() => { + return () => { + if (newBannerPreview) URL.revokeObjectURL(newBannerPreview); + }; + }, [newBannerPreview]); + + const setFileAndPreview = (file?: File) => { + if (!file) return; + + const validTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (!validTypes.includes(file.type)) { + toast('지원되는 파일 형식: jpg, png, webp', 'error'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + toast('파일 크기는 5MB 이하여야 합니다', 'error'); + return; + } + + setNewBannerFile(file); + setNewBannerPreview(URL.createObjectURL(file)); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + setFileAndPreview(file); + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files?.[0]; + setFileAndPreview(file); + }; + + const handleRemoveFile = (e: React.MouseEvent) => { + e.stopPropagation(); + setNewBannerFile(null); + setNewBannerPreview(null); + }; + + const handleSubmit = () => { + if (!contestId) return; + if (!newBannerFile) return toast('배너 이미지를 등록해주세요.', 'error'); + + const formData = new FormData(); + formData.append('image', newBannerFile); + + uploadMutation.mutate(formData, { + onSuccess: () => { + setNewBannerFile(null); + setNewBannerPreview(null); + refetchBanner(); + }, + onError: (error: any) => { + toast(error.response?.data?.message || '배너 등록에 실패했습니다.', 'error'); + }, + }); + }; + + return ( +
+

신규 배너 등록

+
fileInputRef.current?.click()} + > + {newBannerPreview ? ( +
+ 새 배너 미리보기 + +
+ ) : ( + <> + +

새로운 배너 이미지를 업로드 하세요.

+

지원되는 파일 형식: jpg, png, webp

+ + )} + +
+ +
+ ); +}; + +export default BannerUploadSection; diff --git a/src/pages/admin/banner/CurrentBannerSection.tsx b/src/pages/admin/banner/CurrentBannerSection.tsx new file mode 100644 index 00000000..fa16a10a --- /dev/null +++ b/src/pages/admin/banner/CurrentBannerSection.tsx @@ -0,0 +1,63 @@ +import { useMutation } from '@tanstack/react-query'; +import { MdImage } from 'react-icons/md'; +import { useParams } from 'react-router-dom'; +import Button from '@components/Button'; +import { deleteBanner } from 'apis/banner'; +import { useToast } from 'hooks/useToast'; +import { useImageBlob } from 'hooks/useImageBlob'; +import { bannerOption } from 'queries/banner'; + +const CurrentBannerSection = () => { + const { contestId } = useParams(); + const toast = useToast(); + const { isFetching, imageURL, refetch } = useImageBlob(bannerOption(Number(contestId ?? 0))); + + const deleteMutation = useMutation({ + mutationKey: ['banner', Number(contestId ?? 0)], + mutationFn: () => deleteBanner(Number(contestId ?? 0)), + }); + + const handleDelete = () => { + if (!window.confirm('정말로 배너를 삭제하시겠습니까?')) return; + + deleteMutation.mutate(undefined, { + onSuccess: () => { + refetch(); + toast('배너가 삭제되었습니다', 'success'); + }, + onError: (error: any) => { + toast(error.response?.data?.message || '배너 삭제에 실패했습니다.', 'error'); + }, + }); + }; + + return ( +
+

현재 배너

+
+ {isFetching ? ( +
+ ) : imageURL ? ( + 현재 배너 + ) : ( +
+
+ +
+

등록된 배너가 없습니다

+

아래에서 새로운 배너를 업로드해주세요

+
+ )} +
+ +
+ ); +}; + +export default CurrentBannerSection; diff --git a/src/queries/banner.ts b/src/queries/banner.ts new file mode 100644 index 00000000..fe5c0c71 --- /dev/null +++ b/src/queries/banner.ts @@ -0,0 +1,19 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getBanner } from 'apis/banner'; +import { AxiosError } from 'axios'; + +export const bannerOption = (contestId: number) => { + return queryOptions({ + queryKey: ['banner', contestId], + queryFn: () => getBanner(contestId), + enabled: !!contestId, + staleTime: Infinity, + retry: (failureCount, error: AxiosError | any) => { + const status = error.response?.status; + if (status >= 400) { + return false; + } + return failureCount < 3; + }, + }); +}; diff --git a/src/route/AppRoutes.tsx b/src/route/AppRoutes.tsx index d4b5b76a..9b076fc0 100644 --- a/src/route/AppRoutes.tsx +++ b/src/route/AppRoutes.tsx @@ -19,6 +19,7 @@ import AdminContestLayout from '@layout/admin/contest/AdminContestLayout'; import FullContainer from '@layout/FullContainer'; import SidebarLayout from '@layout/SidebarLayout'; import TeamOrderAdminPage from '@pages/admin/team-order/TeamOrderAdminPage'; +import BannerManagePage from '@pages/admin/banner/BannerManagePage'; const AppRoutes = () => createBrowserRouter([ @@ -73,7 +74,7 @@ const AppRoutes = () => { path: 'departments', element:
분과 관리
}, { path: 'votes', element:
투표 관리
}, { path: 'notices', element:
공지 관리
}, - { path: 'banners', element:
배너 관리
}, + { path: 'banners', element: }, // 통계 { path: 'statistics', element:
대회 통계
}, ],