refactor: ConfirmDialog 공통 컴포넌트 생성 및 적용
새로 생성된 파일: - components/admin/ConfirmDialog.jsx (109줄) - 삭제 확인 등 위험한 작업을 위한 공통 다이얼로그 - Props: isOpen, onClose, onConfirm, title, message, loading, variant 등 수정된 파일: - pages/pc/admin/AdminAlbums.jsx (60줄 → 15줄) - pages/pc/admin/AdminSchedule.jsx (70줄 → 17줄) 총 약 100줄의 중복 코드 제거
This commit is contained in:
parent
5c812b7ac5
commit
d52b43397c
3 changed files with 149 additions and 130 deletions
115
frontend/src/components/admin/ConfirmDialog.jsx
Normal file
115
frontend/src/components/admin/ConfirmDialog.jsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* ConfirmDialog 컴포넌트
|
||||||
|
* 삭제 등 위험한 작업의 확인을 위한 공통 다이얼로그
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - isOpen: 다이얼로그 표시 여부
|
||||||
|
* - onClose: 닫기 콜백
|
||||||
|
* - onConfirm: 확인 콜백
|
||||||
|
* - title: 제목 (예: "앨범 삭제")
|
||||||
|
* - message: 메시지 내용 (ReactNode 가능)
|
||||||
|
* - confirmText: 확인 버튼 텍스트 (기본: "삭제")
|
||||||
|
* - cancelText: 취소 버튼 텍스트 (기본: "취소")
|
||||||
|
* - loading: 로딩 상태
|
||||||
|
* - loadingText: 로딩 중 텍스트 (기본: "삭제 중...")
|
||||||
|
* - variant: 버튼 색상 (기본: "danger", "primary" 가능)
|
||||||
|
*/
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
function ConfirmDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '삭제',
|
||||||
|
cancelText = '취소',
|
||||||
|
loading = false,
|
||||||
|
loadingText = '삭제 중...',
|
||||||
|
variant = 'danger',
|
||||||
|
icon: Icon = AlertTriangle
|
||||||
|
}) {
|
||||||
|
// 버튼 색상 설정
|
||||||
|
const buttonColors = {
|
||||||
|
danger: 'bg-red-500 hover:bg-red-600',
|
||||||
|
primary: 'bg-primary hover:bg-primary-dark'
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconBgColors = {
|
||||||
|
danger: 'bg-red-100',
|
||||||
|
primary: 'bg-primary/10'
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconColors = {
|
||||||
|
danger: 'text-red-500',
|
||||||
|
primary: 'text-primary'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={() => !loading && onClose()}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className={`w-10 h-10 rounded-full ${iconBgColors[variant]} flex items-center justify-center`}>
|
||||||
|
<Icon className={iconColors[variant]} size={20} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메시지 */}
|
||||||
|
<div className="text-gray-600 mb-6">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
className={`px-4 py-2 ${buttonColors[variant]} text-white rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
{loadingText}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
{confirmText}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
|
|
@ -3,11 +3,12 @@ import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Plus, Search, Edit2, Trash2, Image, Music,
|
Plus, Search, Edit2, Trash2, Image, Music,
|
||||||
Home, ChevronRight, Calendar, AlertTriangle, X
|
Home, ChevronRight, Calendar, X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Toast from '../../../components/Toast';
|
import Toast from '../../../components/Toast';
|
||||||
import Tooltip from '../../../components/Tooltip';
|
import Tooltip from '../../../components/Tooltip';
|
||||||
import AdminHeader from '../../../components/admin/AdminHeader';
|
import AdminHeader from '../../../components/admin/AdminHeader';
|
||||||
|
import ConfirmDialog from '../../../components/admin/ConfirmDialog';
|
||||||
import useToast from '../../../hooks/useToast';
|
import useToast from '../../../hooks/useToast';
|
||||||
import * as authApi from '../../../api/admin/auth';
|
import * as authApi from '../../../api/admin/auth';
|
||||||
import { getAlbums } from '../../../api/public/albums';
|
import { getAlbums } from '../../../api/public/albums';
|
||||||
|
|
@ -80,65 +81,20 @@ function AdminAlbums() {
|
||||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<AnimatePresence>
|
<ConfirmDialog
|
||||||
{deleteDialog.show && (
|
isOpen={deleteDialog.show}
|
||||||
<motion.div
|
onClose={() => setDeleteDialog({ show: false, album: null })}
|
||||||
initial={{ opacity: 0 }}
|
onConfirm={handleDelete}
|
||||||
animate={{ opacity: 1 }}
|
title="앨범 삭제"
|
||||||
exit={{ opacity: 0 }}
|
message={
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
<>
|
||||||
onClick={() => !deleting && setDeleteDialog({ show: false, album: null })}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0.9, opacity: 0 }}
|
|
||||||
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
|
||||||
<AlertTriangle className="text-red-500" size={20} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-bold text-gray-900">앨범 삭제</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
<span className="font-medium text-gray-900">"{deleteDialog.album?.title}"</span> 앨범을 삭제하시겠습니까?
|
<span className="font-medium text-gray-900">"{deleteDialog.album?.title}"</span> 앨범을 삭제하시겠습니까?
|
||||||
<br />
|
<br />
|
||||||
<span className="text-sm text-red-500">이 작업은 되돌릴 수 없으며, 모든 트랙과 커버 이미지가 함께 삭제됩니다.</span>
|
<span className="text-sm text-red-500">이 작업은 되돌릴 수 없으며, 모든 트랙과 커버 이미지가 함께 삭제됩니다.</span>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteDialog({ show: false, album: null })}
|
|
||||||
disabled={deleting}
|
|
||||||
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{deleting ? (
|
|
||||||
<>
|
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
삭제 중...
|
|
||||||
</>
|
</>
|
||||||
) : (
|
}
|
||||||
<>
|
loading={deleting}
|
||||||
<Trash2 size={16} />
|
/>
|
||||||
삭제
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<AdminHeader user={user} />
|
<AdminHeader user={user} />
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
|
Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
|
||||||
ChevronLeft, Search, ChevronDown, AlertTriangle, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2
|
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
@ -11,6 +11,7 @@ import { useInView } from 'react-intersection-observer';
|
||||||
import Toast from '../../../components/Toast';
|
import Toast from '../../../components/Toast';
|
||||||
import Tooltip from '../../../components/Tooltip';
|
import Tooltip from '../../../components/Tooltip';
|
||||||
import AdminHeader from '../../../components/admin/AdminHeader';
|
import AdminHeader from '../../../components/admin/AdminHeader';
|
||||||
|
import ConfirmDialog from '../../../components/admin/ConfirmDialog';
|
||||||
import useScheduleStore from '../../../stores/useScheduleStore';
|
import useScheduleStore from '../../../stores/useScheduleStore';
|
||||||
import useToast from '../../../hooks/useToast';
|
import useToast from '../../../hooks/useToast';
|
||||||
import { getTodayKST, formatDate } from '../../../utils/date';
|
import { getTodayKST, formatDate } from '../../../utils/date';
|
||||||
|
|
@ -567,75 +568,22 @@ function AdminSchedule() {
|
||||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<AnimatePresence>
|
<ConfirmDialog
|
||||||
{deleteDialogOpen && (
|
isOpen={deleteDialogOpen}
|
||||||
<motion.div
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
initial={{ opacity: 0 }}
|
onConfirm={handleDelete}
|
||||||
animate={{ opacity: 1 }}
|
title="일정 삭제"
|
||||||
exit={{ opacity: 0 }}
|
message={
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
<>
|
||||||
onClick={() => !deleting && setDeleteDialogOpen(false)}
|
<p className="text-gray-600 mb-2">다음 일정을 삭제하시겠습니까?</p>
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0.9, opacity: 0 }}
|
|
||||||
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
|
||||||
<AlertTriangle className="text-red-500" size={20} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-bold text-gray-900">일정 삭제</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-600 mb-2">
|
|
||||||
다음 일정을 삭제하시겠습니까?
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-900 font-medium mb-4 p-3 bg-gray-50 rounded-lg">
|
<p className="text-gray-900 font-medium mb-4 p-3 bg-gray-50 rounded-lg">
|
||||||
{scheduleToDelete?.title}
|
{scheduleToDelete?.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-red-500 mb-6">
|
<p className="text-sm text-red-500">이 작업은 되돌릴 수 없습니다.</p>
|
||||||
이 작업은 되돌릴 수 없습니다.
|
</>
|
||||||
</p>
|
}
|
||||||
|
loading={deleting}
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
|
||||||
disabled={deleting}
|
|
||||||
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{deleting ? (
|
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
|
||||||
className="w-4 h-4 border-2 border-white border-t-transparent rounded-full"
|
|
||||||
/>
|
/>
|
||||||
삭제 중...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
삭제
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<AdminHeader user={user} />
|
<AdminHeader user={user} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue