banner page

This commit is contained in:
2025-11-30 00:25:16 +09:00
parent 109ca05e23
commit 54d763e008
7 changed files with 1899 additions and 35 deletions

View 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>
)}
</>
);
}

View 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>
);
}