banner page
This commit is contained in:
606
src/app/admin/banner/BannerRegistrationModal.tsx
Normal file
606
src/app/admin/banner/BannerRegistrationModal.tsx
Normal file
@@ -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<Record<string, string>>({});
|
||||
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isImageDeleted, setIsImageDeleted] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<string, string> = {};
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleImageFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 클릭으로 파일 선택
|
||||
const handleImageAreaClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// 드래그 오버 핸들러
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
// 드래그 리브 핸들러
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// 드롭 핸들러
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
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 && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
aria-hidden={!open}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="relative z-10 shadow-xl"
|
||||
onClick={handleModalClick}
|
||||
>
|
||||
<div className="bg-white border border-[var(--color-neutral-40)] rounded-[12px] w-full min-w-[480px] max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-[10px] p-6">
|
||||
<h2 className="text-[20px] font-bold leading-normal text-[var(--color-neutral-700)]">
|
||||
{editingBanner ? "배너 수정" : "배너 등록"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-6 h-6 flex items-center justify-center cursor-pointer hover:opacity-80 shrink-0"
|
||||
aria-label="닫기"
|
||||
>
|
||||
<ModalCloseSvg />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="px-6 py-0">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 배너 제목 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
|
||||
배너 제목<span className="text-[var(--color-error)]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
if (errors.title) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.title;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="배너 제목을 입력해 주세요."
|
||||
className={`h-[40px] px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-normal text-[var(--color-text-title)] placeholder:text-[var(--color-text-placeholder-alt)] focus:outline-none ${
|
||||
errors.title
|
||||
? "border-[var(--color-error)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||
: "border-[var(--color-neutral-40)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||
}`}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 배너 설명 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
|
||||
배너 설명<span className="text-[var(--color-error)]">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
if (errors.description) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.description;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="배너 설명을 입력해 주세요."
|
||||
rows={4}
|
||||
className={`px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-normal text-[var(--color-text-title)] placeholder:text-[var(--color-text-placeholder-alt)] focus:outline-none resize-none ${
|
||||
errors.description
|
||||
? "border-[var(--color-error)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||
: "border-[var(--color-neutral-40)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||
}`}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 배너 이미지 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] whitespace-pre">
|
||||
배너 이미지
|
||||
</label>
|
||||
<span className="text-[13px] font-normal leading-[1.4] text-[var(--color-text-meta)]">
|
||||
30MB 미만의 PNG, JPG
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
aria-label="이미지 파일 선택"
|
||||
/>
|
||||
<div
|
||||
onClick={handleImageAreaClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`border border-dashed min-h-[192px] rounded-[8px] flex flex-col items-center justify-center gap-3 cursor-pointer transition-colors px-0 relative overflow-hidden ${
|
||||
isDragging
|
||||
? "bg-blue-50 border-blue-300"
|
||||
: previewUrl
|
||||
? "bg-white border-[var(--color-neutral-40)]"
|
||||
: "bg-gray-50 border-[var(--color-neutral-40)] hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative flex items-start">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="미리보기"
|
||||
className="h-[160px] w-auto object-contain"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveImage}
|
||||
className="w-[18px] h-[18px] flex items-center justify-center ml-1 z-10 hover:opacity-80 transition-opacity shrink-0"
|
||||
aria-label="이미지 삭제"
|
||||
>
|
||||
<CloseXOSvg width={18} height={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/0 hover:bg-black/20 transition-colors flex items-center justify-center my-4">
|
||||
<div className="text-center opacity-0 hover:opacity-100 transition-opacity">
|
||||
<p className="text-[14px] font-normal leading-normal text-white">
|
||||
클릭하여 이미지 변경
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 13.3333V26.6667M13.3333 20H26.6667"
|
||||
stroke="var(--color-text-meta)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[14px] font-normal leading-normal text-[var(--color-text-meta)] whitespace-pre">
|
||||
(클릭하여 이미지 업로드)
|
||||
<br aria-hidden="true" />
|
||||
미첨부 시 기본 이미지가 노출됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{errors.image && (
|
||||
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.image}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 표시 */}
|
||||
{errors.submit && (
|
||||
<div className="px-6 pb-2">
|
||||
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions Container */}
|
||||
<div className="flex flex-col gap-8 h-[96px] items-center p-6">
|
||||
<div className="flex items-center justify-center gap-3 w-full">
|
||||
{editingBanner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteClick}
|
||||
className="h-[48px] px-4 rounded-[10px] bg-[#fef2f2] text-[16px] font-semibold leading-normal text-[var(--color-error)] w-[136px] hover:bg-[#fae6e6] transition-colors cursor-pointer"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-bg-gray-light)] text-[16px] font-semibold leading-normal text-[var(--color-basic-text)] w-[136px] hover:bg-[var(--color-bg-gray-hover)] transition-colors cursor-pointer"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-active-button)] text-[16px] font-semibold leading-normal text-white w-[136px] hover:bg-[var(--color-active-button-hover)] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{isDeleteConfirmOpen && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={handleDeleteCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end">
|
||||
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||
<div className="flex gap-[8px] items-start w-full">
|
||||
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||
배너를 삭제하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-[8px] items-start w-full">
|
||||
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
|
||||
<p className="mb-0">삭제 버튼을 누르면 배너 정보가 삭제됩니다.</p>
|
||||
<p>정말 삭제하시겠습니까?</p>
|
||||
</div>
|
||||
</div>
|
||||
{errors.submit && (
|
||||
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteCancel}
|
||||
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors"
|
||||
>
|
||||
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
|
||||
취소
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
|
||||
{isDeleting ? '삭제 중...' : '삭제'}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
180
src/app/admin/banner/page.tsx
Normal file
180
src/app/admin/banner/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||
import BannerRegistrationModal, { type Banner } from "./BannerRegistrationModal";
|
||||
|
||||
export default function AdminBannerPage() {
|
||||
// TODO: 나중에 실제 데이터로 교체
|
||||
const [banners, setBanners] = useState<Banner[]>([
|
||||
{
|
||||
id: 1,
|
||||
order: 1,
|
||||
imageUrl: "http://localhost:3845/assets/43be88ae6f992fc221d0d9c29e82073e7b202f46.png",
|
||||
title: "XR 교육 플랫폼에 오신 것을 환영합니다",
|
||||
description: "다양한 강좌와 함께 성장하는 학습 경험을 시작하세요.",
|
||||
registeredDate: "2025-09-10",
|
||||
},
|
||||
]);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBanner, setEditingBanner] = useState<Banner | null>(null);
|
||||
|
||||
const handleRegister = () => {
|
||||
setEditingBanner(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingBanner(null);
|
||||
};
|
||||
|
||||
const handleSaveBanner = (title: string, description: string, imageKey?: string) => {
|
||||
// TODO: API가 추가되면 실제로 배너를 저장하고 리스트를 새로고침
|
||||
console.log('배너 저장:', { title, description, imageKey });
|
||||
setIsModalOpen(false);
|
||||
setEditingBanner(null);
|
||||
};
|
||||
|
||||
const handleDeleteBanner = () => {
|
||||
// TODO: API가 추가되면 실제로 배너를 삭제하고 리스트를 새로고침
|
||||
console.log('배너 삭제');
|
||||
setIsModalOpen(false);
|
||||
setEditingBanner(null);
|
||||
};
|
||||
|
||||
const handleRowClick = (banner: Banner) => {
|
||||
setEditingBanner(banner);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-white">
|
||||
{/* 메인 레이아웃 */}
|
||||
<div className="flex flex-1 min-h-0 justify-center">
|
||||
<div className="w-[1440px] flex min-h-0">
|
||||
{/* 사이드바 */}
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main className="w-[1120px] bg-white">
|
||||
<div className="h-full flex flex-col px-8">
|
||||
{/* 제목 영역 */}
|
||||
<div className="h-[100px] flex items-center">
|
||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||
배너 관리
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 영역 */}
|
||||
<div className="flex-1 pt-8 flex flex-col gap-4">
|
||||
{/* 상단 정보 및 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47]">
|
||||
총 {banners.length}건
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegister}
|
||||
className="bg-[#1f2b91] text-white text-[16px] font-semibold leading-[1.5] px-4 py-2 rounded-lg hover:bg-[#1a2478] transition-colors"
|
||||
>
|
||||
등록하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
{banners.length === 0 ? (
|
||||
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
||||
현재 관리할 수 있는 항목이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-[#dee1e6] rounded-lg overflow-hidden">
|
||||
<div className="flex flex-col">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="bg-gray-50 flex h-12">
|
||||
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[57px]">
|
||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||
순서
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 shrink-0 w-[240px]">
|
||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||
배너 이미지
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 flex-1 min-w-0">
|
||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||
배너 문구
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center px-4 py-3 shrink-0 w-[140px]">
|
||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||
등록일
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 바디 */}
|
||||
{banners.map((banner) => (
|
||||
<div
|
||||
key={banner.id}
|
||||
className="border-t border-[#dee1e6] flex cursor-pointer hover:bg-[#F5F7FF] transition-colors"
|
||||
onClick={() => handleRowClick(banner)}
|
||||
>
|
||||
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[57px]">
|
||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
|
||||
{banner.order}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[240px]">
|
||||
<div className="h-[120px] w-[208px] rounded overflow-hidden">
|
||||
<img
|
||||
src={banner.imageUrl}
|
||||
alt={banner.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 flex-1 min-w-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[16px] font-semibold leading-[1.5] text-[#1b2027]">
|
||||
{banner.title}
|
||||
</p>
|
||||
<p className="text-[14px] font-medium leading-[1.5] text-[#333c47]">
|
||||
{banner.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center px-4 py-3 shrink-0 w-[140px]">
|
||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
|
||||
{banner.registeredDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<BannerRegistrationModal
|
||||
open={isModalOpen}
|
||||
onClose={handleModalClose}
|
||||
onSave={handleSaveBanner}
|
||||
onDelete={handleDeleteBanner}
|
||||
editingBanner={editingBanner}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user