Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/apis/banner.ts
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;
};
30 changes: 30 additions & 0 deletions src/mocks/handlers/banners.ts
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 });
}),
];
3 changes: 2 additions & 1 deletion src/mocks/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
18 changes: 18 additions & 0 deletions src/pages/admin/banner/BannerManagePage.tsx
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;
157 changes: 157 additions & 0 deletions src/pages/admin/banner/BannerUploadSection.tsx
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;
74 changes: 74 additions & 0 deletions src/pages/admin/banner/CurrentBannerSection.tsx
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;
3 changes: 2 additions & 1 deletion src/route/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -73,7 +74,7 @@ const AppRoutes = () =>
{ path: 'departments', element: <div>분과 관리</div> },
{ path: 'votes', element: <div>투표 관리</div> },
{ path: 'notices', element: <div>공지 관리</div> },
{ path: 'banners', element: <div>배너 관리</div> },
{ path: 'banners', element: <BannerManagePage /> },
// 통계
{ path: 'statistics', element: <div>대회 통계</div> },
],
Expand Down