-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 배너관리 페이지 추가 #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
yuhoyeong
wants to merge
10
commits into
develop
Choose a base branch
from
feat/banners
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
feat: 배너관리 페이지 추가 #202
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
07b4585
feat: 배너관리 페이지 ui 작업
yuhoyeong c0d0ed8
feat: 배너관리 api 연결
yuhoyeong 32c2e6d
feat: useBanner 훅 추출
yuhoyeong 1662eaa
feat: 리뷰 피드백 반영 및 배너 미리보기 URL revoke 처리
yuhoyeong b0f503d
chore: 배너 관리 페이지 별도 폴더로 이동
koty08 c842daa
fix: 불필요 Ref 사용 로직 제거, 단순 클린업 동작하도록 수정
koty08 07880d9
feat: 현재 배너 및 신규 배너 등록 영역 분리, 배너 삭제 버튼 현재 배너 영역으로 이동, 불필요 코드 삭제 및 리팩토링
koty08 9ef2d60
feat: 배너 관리 페이지에서 브라우저 캐싱 활용하도록, 별도 처리하지 않고 배너 출력하도록 개선
koty08 070eff5
style: 배너 등록 영역 드래그 시 스타일 추가
koty08 d1f1831
fix: 배너 업로드 파라미터 바뀌어 정상 동작하지 않는 부분 수정, 업로드/삭제 이후 기존 배너 조회/삭제되도록 수정
koty08 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import apiClient from './apiClient'; | ||
|
|
||
| export const postBanner = async (contestId: number, formData: FormData) => { | ||
| const response = await apiClient.post(`/contests/${contestId}/image/banner`, formData, { | ||
| headers: { 'Content-Type': 'multipart/form-data' }, | ||
| }); | ||
| return response; | ||
| }; | ||
|
|
||
| export const deleteBanner = async (contestId: number) => { | ||
| const res = await apiClient.delete(`/contests/${contestId}/image/banner`); | ||
| return res; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = `<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="400" height="200"><rect width="100%" height="100%" fill="#e5e7eb"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#9ca3af" font-size="20">Mock Banner</text></svg>`; | ||
| 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 }); | ||
| }), | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { useState } from 'react'; | ||
| import CurrentBannerSection from './CurrentBannerSection'; | ||
| import BannerUploadSection from './BannerUploadSection'; | ||
|
|
||
| const BannerManagePage = () => { | ||
| const [bannerVersion, setBannerVersion] = useState(Date.now()); | ||
|
|
||
| const handleBannerUpdate = () => setBannerVersion(Date.now()); | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-10"> | ||
| <CurrentBannerSection bannerVersion={bannerVersion} onBannerUpdate={handleBannerUpdate} /> | ||
| <BannerUploadSection onBannerUpdate={handleBannerUpdate} /> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default BannerManagePage; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import { useEffect, useRef, useState } from 'react'; | ||
| import { useParams } from 'react-router-dom'; | ||
| import { useMutation } 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'; | ||
|
|
||
| interface BannerUploadSectionProps { | ||
| onBannerUpdate: () => void; | ||
| } | ||
|
|
||
| const BannerUploadSection = ({ onBannerUpdate }: BannerUploadSectionProps) => { | ||
| const { contestId } = useParams(); | ||
| const fileInputRef = useRef<HTMLInputElement>(null); | ||
| const [isDragging, setIsDragging] = useState(false); | ||
| const [newBannerFile, setNewBannerFile] = useState<File | null>(null); | ||
| const [newBannerPreview, setNewBannerPreview] = useState<string | null>(null); | ||
| const toast = useToast(); | ||
|
|
||
| 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<HTMLInputElement>) => { | ||
| const file = e.target.files?.[0]; | ||
| setFileAndPreview(file); | ||
| }; | ||
|
|
||
| const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| setIsDragging(true); | ||
| }; | ||
|
|
||
| const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| setIsDragging(false); | ||
| }; | ||
|
|
||
| const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { | ||
| e.preventDefault(); | ||
| }; | ||
|
|
||
| const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { | ||
| e.preventDefault(); | ||
| const file = e.dataTransfer.files?.[0]; | ||
| setFileAndPreview(file); | ||
| }; | ||
|
|
||
| const handleRemoveFile = (e: React.MouseEvent<HTMLButtonElement>) => { | ||
| 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: async () => { | ||
| setNewBannerFile(null); | ||
| setNewBannerPreview(null); | ||
| onBannerUpdate(); | ||
| toast('배너가 등록되었습니다', 'success'); | ||
| }, | ||
| onError: (error: any) => { | ||
| toast(error.response?.data?.message || '배너 등록에 실패했습니다.', 'error'); | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| return ( | ||
| <section className="flex flex-col gap-5"> | ||
| <h2 className="text-lg font-bold">신규 배너 등록</h2> | ||
| <div | ||
| className={cn( | ||
| 'hover:border-mainBlue flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-gray-300 p-10 transition-colors hover:bg-blue-50', | ||
| { | ||
| 'border-mainBlue bg-blue-50': isDragging, | ||
| }, | ||
| )} | ||
| onDrop={handleDrop} | ||
| onDragEnter={handleDragEnter} | ||
| onDragLeave={handleDragLeave} | ||
| onDragOver={handleDragOver} | ||
| onClick={() => fileInputRef.current?.click()} | ||
| > | ||
| {newBannerPreview ? ( | ||
| <div className="relative"> | ||
| <img src={newBannerPreview} alt="새 배너 미리보기" className="max-h-48 w-auto rounded" /> | ||
| <button | ||
| onClick={handleRemoveFile} | ||
| className="absolute top-[-4px] right-[-4px] rounded-full p-1 hover:bg-gray-200" | ||
| > | ||
| <FiX className="stroke-red-600" size={22} /> | ||
| </button> | ||
| </div> | ||
| ) : ( | ||
| <> | ||
| <MdOutlineFileUpload className="text-4xl text-gray-400" /> | ||
| <p className="text-gray-500">새로운 배너 이미지를 업로드 하세요.</p> | ||
| <p className="text-sm text-gray-400">지원되는 파일 형식: jpg, png, webp</p> | ||
| </> | ||
| )} | ||
| <input | ||
| ref={fileInputRef} | ||
| type="file" | ||
| accept="image/jpeg,image/png,image/webp" | ||
| className="hidden" | ||
| onChange={handleFileChange} | ||
| /> | ||
| </div> | ||
| <Button | ||
| className="bg-mainBlue ml-auto px-4 py-1 hover:bg-blue-600" | ||
| onClick={handleSubmit} | ||
| disabled={!newBannerFile || !contestId || uploadMutation.isPending} | ||
| > | ||
| {uploadMutation.isPending ? '등록 중...' : '등록하기'} | ||
| </Button> | ||
| </section> | ||
| ); | ||
| }; | ||
|
|
||
| export default BannerUploadSection; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { useEffect, useState } from 'react'; | ||
| import { useMutation } from '@tanstack/react-query'; | ||
| import { MdImage } from 'react-icons/md'; | ||
| import { useParams } from 'react-router-dom'; | ||
| import Button from '@components/Button'; | ||
| import { API_BASE_URL } from '@constants/index'; | ||
| import { deleteBanner } from 'apis/banner'; | ||
| import { useToast } from 'hooks/useToast'; | ||
|
|
||
| interface CurrentBannerSectionProps { | ||
| bannerVersion: number; | ||
| onBannerUpdate: () => void; | ||
| } | ||
|
|
||
| const CurrentBannerSection = ({ bannerVersion, onBannerUpdate }: CurrentBannerSectionProps) => { | ||
| const { contestId } = useParams(); | ||
| const toast = useToast(); | ||
| const [bannerURL, setBannerURL] = useState<string | null>(null); | ||
|
|
||
| const deleteMutation = useMutation({ | ||
| mutationKey: ['banner', Number(contestId ?? 0)], | ||
| mutationFn: () => deleteBanner(Number(contestId ?? 0)), | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| if (contestId) setBannerURL(`${API_BASE_URL}/api/contests/${contestId}/image/banner?v=${bannerVersion}`); | ||
| }, [contestId, bannerVersion]); | ||
|
|
||
| const handleImageError = () => { | ||
| setBannerURL(null); | ||
| }; | ||
|
|
||
| const handleDelete = () => { | ||
| if (!window.confirm('정말로 배너를 삭제하시겠습니까?')) return; | ||
|
|
||
| deleteMutation.mutate(undefined, { | ||
| onSuccess: () => { | ||
| onBannerUpdate(); | ||
| toast('배너가 삭제되었습니다', 'success'); | ||
| }, | ||
| onError: (error: any) => { | ||
| toast(error.response?.data?.message || '배너 삭제에 실패했습니다.', 'error'); | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| return ( | ||
| <section className="flex flex-col gap-5"> | ||
| <h2 className="text-lg font-bold">현재 배너</h2> | ||
| <div className="overflow-hidden rounded-lg border border-gray-200 shadow-sm"> | ||
| {bannerURL ? ( | ||
| <img src={bannerURL} alt="현재 배너" className="h-auto w-full object-cover" onError={handleImageError} /> | ||
| ) : ( | ||
| <div className="flex h-48 flex-col items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100"> | ||
| <div className="rounded-full bg-gray-200 p-4 shadow-sm"> | ||
| <MdImage className="text-3xl text-gray-400" /> | ||
| </div> | ||
| <p className="mt-4 text-lg font-medium text-gray-600">등록된 배너가 없습니다</p> | ||
| <p className="mt-1 text-sm text-gray-500">아래에서 새로운 배너를 업로드해주세요</p> | ||
| </div> | ||
| )} | ||
| </div> | ||
| <Button | ||
| className="bg-mainRed disabled:bg-midGray ml-auto px-4 py-1 hover:bg-red-600" | ||
| onClick={handleDelete} | ||
| disabled={!bannerURL || deleteMutation.isPending} | ||
| > | ||
| {deleteMutation.isPending ? '삭제 중...' : '현재 배너 삭제'} | ||
| </Button> | ||
| </section> | ||
| ); | ||
| }; | ||
|
|
||
| export default CurrentBannerSection; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.