diff --git a/src/app/admin/banner/BannerRegistrationModal.tsx b/src/app/admin/banner/BannerRegistrationModal.tsx new file mode 100644 index 0000000..6d2d6e4 --- /dev/null +++ b/src/app/admin/banner/BannerRegistrationModal.tsx @@ -0,0 +1,606 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import ModalCloseSvg from "@/app/svgs/closexsvg"; +import CloseXOSvg from "@/app/svgs/closexo"; +import apiService from "@/app/lib/apiService"; + +export type Banner = { + id: number; + order: number; + imageUrl: string; + title: string; + description: string; + registeredDate: string; +}; + +type Props = { + open: boolean; + onClose: () => void; + onSave?: (title: string, description: string, imageKey?: string) => void; + onDelete?: () => void; + editingBanner?: Banner | null; +}; + +export default function BannerRegistrationModal({ open, onClose, onSave, onDelete, editingBanner }: Props) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [errors, setErrors] = useState>({}); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isImageDeleted, setIsImageDeleted] = useState(false); + const modalRef = useRef(null); + const fileInputRef = useRef(null); + + // previewUrl 변경 시 이전 Blob URL 정리 + useEffect(() => { + return () => { + if (previewUrl && previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + + // 수정 모드일 때 기존 데이터 채우기 + useEffect(() => { + if (open && editingBanner) { + setTitle(editingBanner.title); + setDescription(editingBanner.description); + + // 수정 모드일 때 이미지 로드 + if (editingBanner.imageUrl) { + setIsImageDeleted(false); + setSelectedImage(null); + setPreviewUrl(editingBanner.imageUrl); + } else { + setIsImageDeleted(false); + setSelectedImage(null); + setPreviewUrl(null); + } + } else if (!open) { + setTitle(""); + setDescription(""); + setErrors({}); + setIsDeleteConfirmOpen(false); + setSelectedImage(null); + setPreviewUrl(null); + setIsDragging(false); + setIsImageDeleted(false); + } + }, [open, editingBanner]); + + // 모달 클릭 시 이벤트 전파 방지 + const handleModalClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + // 저장 버튼 클릭 핸들러 + const handleSave = async () => { + const nextErrors: Record = {}; + + if (!title.trim()) { + nextErrors.title = "배너 제목을 입력해 주세요."; + } + if (!description.trim()) { + nextErrors.description = "배너 설명을 입력해 주세요."; + } + + setErrors(nextErrors); + + if (Object.keys(nextErrors).length > 0) { + return; + } + + if (isSaving) return; + + setIsSaving(true); + setErrors((prev) => { + const next = { ...prev }; + delete next.submit; + return next; + }); + + try { + let imageKey: string | null = null; + + // 새 이미지가 선택된 경우 업로드 + if (selectedImage) { + try { + const uploadResponse = await apiService.uploadFile(selectedImage); + + if (uploadResponse.data) { + imageKey = uploadResponse.data.imageKey + || uploadResponse.data.key + || uploadResponse.data.id + || uploadResponse.data.fileKey + || uploadResponse.data.fileId + || (uploadResponse.data.data && (uploadResponse.data.data.imageKey || uploadResponse.data.data.key)) + || null; + } + } catch (uploadError) { + const errorMessage = uploadError instanceof Error ? uploadError.message : '이미지 업로드 중 오류가 발생했습니다.'; + console.error('이미지 업로드 오류:', errorMessage); + setErrors((prev) => ({ + ...prev, + image: '이미지 업로드 중 오류가 발생했습니다. 이미지 없이 계속 진행됩니다.', + })); + } + } + + // TODO: 배너 API가 추가되면 아래 주석을 해제하고 실제 API 호출 + /* + if (editingBanner && editingBanner.id) { + // 수정 모드 + await apiService.updateBanner(editingBanner.id, { + title: title.trim(), + description: description.trim(), + imageKey: isImageDeleted ? "null" : (imageKey || editingBanner.imageUrl || "null"), + }); + } else { + // 등록 모드 + await apiService.createBanner({ + title: title.trim(), + description: description.trim(), + imageKey: imageKey || undefined, + }); + } + */ + + // 성공 시 onSave 콜백 호출 및 모달 닫기 + if (onSave) { + onSave(title.trim(), description.trim(), imageKey || undefined); + } + onClose(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.'; + console.error('배너 저장 오류:', errorMessage); + setErrors({ submit: errorMessage }); + } finally { + setIsSaving(false); + } + }; + + // 삭제 버튼 클릭 핸들러 + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsDeleteConfirmOpen(true); + }; + + // 삭제 확인 핸들러 + const handleDeleteConfirm = async () => { + if (!editingBanner || !editingBanner.id) { + console.error('삭제할 배너 정보가 없습니다.'); + setErrors({ submit: '삭제할 배너 정보가 없습니다.' }); + return; + } + + if (isDeleting) return; + + setIsDeleting(true); + setErrors((prev) => { + const next = { ...prev }; + delete next.submit; + return next; + }); + + try { + // TODO: 배너 API가 추가되면 아래 주석을 해제하고 실제 API 호출 + // await apiService.deleteBanner(editingBanner.id); + + // 성공 시 모달 닫기 및 콜백 호출 + setIsDeleteConfirmOpen(false); + if (onDelete) { + onDelete(); + } + onClose(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '배너 삭제 중 오류가 발생했습니다.'; + console.error('배너 삭제 실패:', errorMessage); + setErrors({ submit: errorMessage }); + } finally { + setIsDeleting(false); + } + }; + + // 삭제 취소 핸들러 + const handleDeleteCancel = () => { + setIsDeleteConfirmOpen(false); + }; + + // 파일 유효성 검사 + const validateImageFile = (file: File): string | null => { + const maxSize = 30 * 1024 * 1024; // 30MB + const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg']; + + if (!allowedTypes.includes(file.type)) { + return "PNG 또는 JPG 파일만 업로드 가능합니다."; + } + + if (file.size > maxSize) { + return "파일 크기는 30MB 미만이어야 합니다."; + } + + return null; + }; + + // 이미지 파일 처리 + const handleImageFile = (file: File) => { + const error = validateImageFile(file); + if (error) { + setErrors((prev) => ({ ...prev, image: error })); + return; + } + + setSelectedImage(file); + setIsImageDeleted(false); + setErrors((prev) => { + const next = { ...prev }; + delete next.image; + return next; + }); + + // 기존 previewUrl이 Blob URL인 경우 메모리 해제 + if (previewUrl && previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + + // 미리보기 URL 생성 + const reader = new FileReader(); + reader.onloadend = () => { + setPreviewUrl(reader.result as string); + }; + reader.readAsDataURL(file); + }; + + // 파일 선택 핸들러 + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + handleImageFile(file); + } + }; + + // 클릭으로 파일 선택 + const handleImageAreaClick = () => { + fileInputRef.current?.click(); + }; + + // 드래그 오버 핸들러 + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + // 드래그 리브 핸들러 + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + // 드롭 핸들러 + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const file = e.dataTransfer.files?.[0]; + if (file) { + handleImageFile(file); + } + }; + + // 이미지 삭제 핸들러 + const handleRemoveImage = (e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedImage(null); + // previewUrl이 Blob URL인 경우 메모리 해제 + if (previewUrl && previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + setPreviewUrl(null); + setIsImageDeleted(true); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + setErrors((prev) => { + const next = { ...prev }; + delete next.image; + return next; + }); + }; + + if (!open && !isDeleteConfirmOpen) return null; + + return ( + <> + {/* 메인 모달 */} + {open && ( +
+