refactor: 일정 관리 컴포넌트 분리 (Phase 3)
- AnimatedNumber 공통 컴포넌트 추출 (32줄) - BotCard 컴포넌트 분리 + XIcon, MeilisearchIcon 포함 (233줄) - CategoryFormModal 컴포넌트 분리 (195줄) - ScheduleBots.jsx: 570줄 → 339줄 (231줄 감소) - ScheduleCategory.jsx: 441줄 → 289줄 (152줄 감소) - 문서 업데이트: 개선 결과 테이블 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
08d704da5c
commit
218b825878
10 changed files with 535 additions and 434 deletions
|
|
@ -225,19 +225,26 @@ function CategoryFormModal({ isOpen, onClose, category, onSave }) {
|
|||
|
||||
## 3. 개선 우선순위
|
||||
|
||||
### Phase 1: 중복 코드 제거 (빠른 효과)
|
||||
1. [ ] `utils/color.js` 생성 - colorMap, getColorStyle, COLOR_OPTIONS 통합
|
||||
2. [ ] 3개 파일에서 import로 교체
|
||||
### Phase 1: 중복 코드 제거 (빠른 효과) ✅ 완료
|
||||
1. [x] `utils/color.js` 생성 - COLOR_MAP, COLOR_OPTIONS, getColorStyle 통합
|
||||
2. [x] 3개 파일에서 import로 교체
|
||||
- Schedules.jsx: 1159줄 → 1139줄 (20줄 감소)
|
||||
- ScheduleForm.jsx: 765줄 → 743줄 (22줄 감소)
|
||||
- ScheduleCategory.jsx: 466줄 → 441줄 (25줄 감소)
|
||||
|
||||
### Phase 2: 커스텀 훅 분리 (복잡도 감소)
|
||||
1. [ ] `useScheduleSearch` 훅 생성 - Schedules.jsx 검색 로직 분리
|
||||
2. [ ] 달력 관련 로직 정리
|
||||
### Phase 2: 커스텀 훅 분리 (복잡도 감소) ✅ 완료
|
||||
1. [x] `useScheduleSearch` 훅 생성 - Schedules.jsx 검색 로직 분리
|
||||
- 검색어 자동완성, 무한 스크롤, 키보드 네비게이션 캡슐화
|
||||
- Schedules.jsx: 1139줄 → 1009줄 (130줄 감소)
|
||||
2. [ ] 달력 관련 로직 정리 (선택사항, 현재 규모 적절)
|
||||
|
||||
### Phase 3: 컴포넌트 분리 (재사용성)
|
||||
1. [ ] `AnimatedNumber` 공통 컴포넌트화
|
||||
2. [ ] `BotCard` 컴포넌트 분리
|
||||
3. [ ] `CategoryFormModal` 컴포넌트 분리
|
||||
4. [ ] SVG 아이콘 분리 (XIcon, MeilisearchIcon)
|
||||
### Phase 3: 컴포넌트 분리 (재사용성) ✅ 완료
|
||||
1. [x] `AnimatedNumber` 공통 컴포넌트화 → components/common/AnimatedNumber.jsx (32줄)
|
||||
2. [x] `BotCard` 컴포넌트 분리 → components/pc/admin/bot/BotCard.jsx (233줄)
|
||||
3. [x] `CategoryFormModal` 컴포넌트 분리 → components/pc/admin/schedule/CategoryFormModal.jsx (195줄)
|
||||
4. [x] SVG 아이콘 분리 (XIcon, MeilisearchIcon) → BotCard.jsx에 포함
|
||||
- ScheduleBots.jsx: 570줄 → 339줄 (231줄 감소)
|
||||
- ScheduleCategory.jsx: 441줄 → 289줄 (152줄 감소)
|
||||
|
||||
### Phase 4: 코드 정리
|
||||
1. [ ] ScheduleForm.jsx - fetchSchedule 중복 제거
|
||||
|
|
@ -245,11 +252,22 @@ function CategoryFormModal({ isOpen, onClose, category, onSave }) {
|
|||
|
||||
---
|
||||
|
||||
## 4. 예상 효과
|
||||
## 4. 개선 결과
|
||||
|
||||
| 항목 | 현재 | 개선 후 |
|
||||
|------|------|---------|
|
||||
| colorMap/getColorStyle 중복 | 3곳 | 1곳 (utils) |
|
||||
| Schedules.jsx 상태 수 | ~20개 | ~12개 (훅 분리) |
|
||||
| ScheduleBots.jsx | 570줄 | ~400줄 (컴포넌트 분리) |
|
||||
| ScheduleCategory.jsx | 466줄 | ~300줄 (모달 분리) |
|
||||
| 파일 | 개선 전 | 개선 후 | 감소 |
|
||||
|------|---------|---------|------|
|
||||
| Schedules.jsx | 1159줄 | 1009줄 | 150줄 |
|
||||
| ScheduleForm.jsx | 765줄 | 743줄 | 22줄 |
|
||||
| ScheduleDict.jsx | 572줄 | 572줄 | - |
|
||||
| ScheduleBots.jsx | 570줄 | 339줄 | 231줄 |
|
||||
| ScheduleCategory.jsx | 466줄 | 289줄 | 177줄 |
|
||||
| **합계** | **3532줄** | **2952줄** | **580줄** |
|
||||
|
||||
### 새로 생성된 파일
|
||||
| 파일 | 라인 수 | 역할 |
|
||||
|------|---------|------|
|
||||
| utils/color.js | 35줄 | 색상 상수/유틸 |
|
||||
| hooks/pc/admin/useScheduleSearch.js | 217줄 | 검색 로직 훅 |
|
||||
| components/common/AnimatedNumber.jsx | 32줄 | 숫자 애니메이션 |
|
||||
| components/pc/admin/bot/BotCard.jsx | 233줄 | 봇 카드 |
|
||||
| components/pc/admin/schedule/CategoryFormModal.jsx | 195줄 | 카테고리 폼 모달 |
|
||||
|
|
|
|||
32
frontend-temp/src/components/common/AnimatedNumber.jsx
Normal file
32
frontend-temp/src/components/common/AnimatedNumber.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* 슬롯머신 스타일 롤링 숫자 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const AnimatedNumber = memo(function AnimatedNumber({ value, className = '' }) {
|
||||
const chars = String(value).split('');
|
||||
|
||||
return (
|
||||
<span className={`inline-flex overflow-hidden ${className}`}>
|
||||
{chars.map((char, i) => (
|
||||
<span key={i} className="relative h-[1.2em] overflow-hidden">
|
||||
<motion.span
|
||||
className="flex flex-col"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: `-${parseInt(char) * 10}%` }}
|
||||
transition={{ type: 'tween', ease: 'easeOut', duration: 0.8, delay: i * 0.1 }}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<span key={n} className="h-[1.2em] flex items-center justify-center">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnimatedNumber;
|
||||
|
|
@ -6,3 +6,4 @@ export { default as ScrollToTop } from './ScrollToTop';
|
|||
export { default as Lightbox } from './Lightbox';
|
||||
export { default as MobileLightbox } from './MobileLightbox';
|
||||
export { default as LightboxIndicator } from './LightboxIndicator';
|
||||
export { default as AnimatedNumber } from './AnimatedNumber';
|
||||
|
|
|
|||
233
frontend-temp/src/components/pc/admin/bot/BotCard.jsx
Normal file
233
frontend-temp/src/components/pc/admin/bot/BotCard.jsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* 봇 카드 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Youtube, Play, Square, RefreshCw, Download } from 'lucide-react';
|
||||
|
||||
// X 아이콘 컴포넌트
|
||||
export const XIcon = ({ size = 20, fill = 'currentColor' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Meilisearch 아이콘 컴포넌트
|
||||
export const MeilisearchIcon = ({ size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 108.4 512 295.2">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="meili-a"
|
||||
x1="488.157"
|
||||
x2="-21.055"
|
||||
y1="469.917"
|
||||
y2="179.001"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="meili-b"
|
||||
x1="522.305"
|
||||
x2="13.094"
|
||||
y1="410.144"
|
||||
y2="119.228"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="meili-c"
|
||||
x1="556.456"
|
||||
x2="47.244"
|
||||
y1="350.368"
|
||||
y2="59.452"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="m0 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-a)"
|
||||
/>
|
||||
<path
|
||||
d="m138.8 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-b)"
|
||||
/>
|
||||
<path
|
||||
d="m277.6 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-c)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.bot - 봇 데이터
|
||||
* @param {number} props.index - 인덱스 (애니메이션용)
|
||||
* @param {boolean} props.isInitialLoad - 첫 로드 여부
|
||||
* @param {string|null} props.syncing - 동기화 중인 봇 ID
|
||||
* @param {Object} props.statusInfo - 상태 정보 (text, color, bg, dot)
|
||||
* @param {Function} props.onSync - 동기화 핸들러
|
||||
* @param {Function} props.onToggle - 토글 핸들러
|
||||
* @param {Function} props.onAnimationComplete - 애니메이션 완료 핸들러
|
||||
* @param {Function} props.formatTime - 시간 포맷 함수
|
||||
* @param {Function} props.formatInterval - 간격 포맷 함수
|
||||
*/
|
||||
const BotCard = memo(function BotCard({
|
||||
bot,
|
||||
index,
|
||||
isInitialLoad,
|
||||
syncing,
|
||||
statusInfo,
|
||||
onSync,
|
||||
onToggle,
|
||||
onAnimationComplete,
|
||||
formatTime,
|
||||
formatInterval,
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={isInitialLoad ? { opacity: 0, scale: 0.95 } : false}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={isInitialLoad ? { delay: index * 0.05 } : { duration: 0.15 }}
|
||||
onAnimationComplete={onAnimationComplete}
|
||||
className="relative bg-gradient-to-br from-gray-50 to-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
||||
>
|
||||
{/* 상단 헤더 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
bot.type === 'x'
|
||||
? 'bg-black'
|
||||
: bot.type === 'meilisearch'
|
||||
? 'bg-[#ddf1fd]'
|
||||
: 'bg-red-50'
|
||||
}`}
|
||||
>
|
||||
{bot.type === 'x' ? (
|
||||
<XIcon size={20} fill="white" />
|
||||
) : bot.type === 'meilisearch' ? (
|
||||
<MeilisearchIcon size={20} />
|
||||
) : (
|
||||
<Youtube size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{bot.name}</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
{bot.last_check_at
|
||||
? `${formatTime(bot.last_check_at)}에 업데이트됨`
|
||||
: '아직 업데이트 없음'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.color}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`}
|
||||
></span>
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
|
||||
{bot.type === 'meilisearch' ? (
|
||||
<>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{bot.schedules_added || 0}</div>
|
||||
<div className="text-xs text-gray-400">동기화 수</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{bot.last_added_count ? `${(bot.last_added_count / 1000 || 0).toFixed(1)}초` : '-'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">소요 시간</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{bot.schedules_added}</div>
|
||||
<div className="text-xs text-gray-400">총 추가</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div
|
||||
className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}
|
||||
>
|
||||
+{bot.last_added_count || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">마지막</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</div>
|
||||
<div className="text-xs text-gray-400">업데이트 간격</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{bot.status === 'error' && bot.error_message && (
|
||||
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs border-t border-red-100">
|
||||
{bot.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onSync(bot.id)}
|
||||
disabled={syncing === bot.id}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{syncing === bot.id ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
<span>동기화 중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} />
|
||||
<span>전체 동기화</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggle(bot.id, bot.status, bot.name)}
|
||||
className={`flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
|
||||
bot.status === 'running'
|
||||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
: 'bg-green-500 text-white hover:bg-green-600'
|
||||
}`}
|
||||
>
|
||||
{bot.status === 'running' ? (
|
||||
<>
|
||||
<Square size={16} />
|
||||
<span>정지</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
<span>시작</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default BotCard;
|
||||
1
frontend-temp/src/components/pc/admin/bot/index.js
Normal file
1
frontend-temp/src/components/pc/admin/bot/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as BotCard, XIcon, MeilisearchIcon } from './BotCard';
|
||||
|
|
@ -9,3 +9,6 @@ export * from './schedule';
|
|||
|
||||
// 앨범 관련
|
||||
export * from './album';
|
||||
|
||||
// 봇 관련
|
||||
export * from './bot';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* 카테고리 추가/수정 모달 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { COLOR_OPTIONS } from '@/utils/color';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 모달 열림 여부
|
||||
* @param {Function} props.onClose - 모달 닫기 핸들러
|
||||
* @param {Object|null} props.editingCategory - 수정 중인 카테고리 (null이면 추가 모드)
|
||||
* @param {Object} props.formData - 폼 데이터 { name, color }
|
||||
* @param {Function} props.setFormData - 폼 데이터 업데이트 함수
|
||||
* @param {boolean} props.colorPickerOpen - 컬러 피커 열림 여부
|
||||
* @param {Function} props.setColorPickerOpen - 컬러 피커 상태 변경 함수
|
||||
* @param {Function} props.onSave - 저장 핸들러
|
||||
*/
|
||||
const CategoryFormModal = memo(function CategoryFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingCategory,
|
||||
formData,
|
||||
setFormData,
|
||||
colorPickerOpen,
|
||||
setColorPickerOpen,
|
||||
onSave,
|
||||
}) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={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 w-full mx-4 shadow-xl"
|
||||
style={{ maxWidth: '452px', minWidth: '452px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">
|
||||
{editingCategory ? '카테고리 수정' : '카테고리 추가'}
|
||||
</h3>
|
||||
|
||||
{/* 카테고리 이름 */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">카테고리 이름 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="예: 방송, 이벤트"
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 색상 선택 */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">색상 선택 *</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color: color.id })}
|
||||
className={`w-10 h-10 rounded-full ${color.bg} transition-all ${
|
||||
formData.color === color.id
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
{/* 커스텀 색상 - 무지개 그라디언트 버튼 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setColorPickerOpen(!colorPickerOpen);
|
||||
}}
|
||||
className={`w-10 h-10 rounded-full transition-all ${
|
||||
formData.color?.startsWith('#')
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
style={{
|
||||
background: formData.color?.startsWith('#')
|
||||
? formData.color
|
||||
: 'conic-gradient(from 0deg, #ff0000, #ff8000, #ffff00, #00ff00, #00ffff, #0000ff, #8000ff, #ff0080, #ff0000)',
|
||||
}}
|
||||
title="커스텀 색상"
|
||||
/>
|
||||
{/* 색상 선택 팝업 */}
|
||||
<AnimatePresence>
|
||||
{colorPickerOpen && (
|
||||
<>
|
||||
{/* 바깥 영역 클릭시 컬러피커만 닫기 */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setColorPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
className="absolute top-12 left-0 z-50 p-4 bg-white rounded-2xl shadow-xl border border-gray-100"
|
||||
style={{ width: '240px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<HexColorPicker
|
||||
color={formData.color?.startsWith('#') ? formData.color : '#6b7280'}
|
||||
onChange={(color) => setFormData({ ...formData, color })}
|
||||
style={{ width: '100%', height: '180px' }}
|
||||
/>
|
||||
<div className="mt-4 flex items-center">
|
||||
<span className="px-3 py-2 text-sm bg-gray-100 border border-r-0 border-gray-200 rounded-l-lg text-gray-500">
|
||||
#
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color?.startsWith('#') ? formData.color.slice(1) : ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').slice(0, 6);
|
||||
if (val) {
|
||||
setFormData({ ...formData, color: '#' + val });
|
||||
}
|
||||
}}
|
||||
placeholder="FFFFFF"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, color: 'blue' });
|
||||
setColorPickerOpen(false);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColorPickerOpen(false)}
|
||||
className="px-3 py-1.5 text-sm bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
{editingCategory ? '수정' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default CategoryFormModal;
|
||||
|
|
@ -5,3 +5,4 @@ export { default as LocationSearchDialog } from './LocationSearchDialog';
|
|||
export { default as MemberSelector } from './MemberSelector';
|
||||
export { default as ImageUploader } from './ImageUploader';
|
||||
export { default as WordItem, POS_TAGS } from './WordItem';
|
||||
export { default as CategoryFormModal } from './CategoryFormModal';
|
||||
|
|
|
|||
|
|
@ -2,20 +2,9 @@ import { useState, useEffect } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Home,
|
||||
ChevronRight,
|
||||
Bot,
|
||||
Play,
|
||||
Square,
|
||||
Youtube,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import { Toast, Tooltip } from '@/components/common';
|
||||
import { AdminLayout } from '@/components/pc/admin';
|
||||
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw } from 'lucide-react';
|
||||
import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
|
||||
import { AdminLayout, BotCard } from '@/components/pc/admin';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { useToast } from '@/hooks/common';
|
||||
import * as botsApi from '@/api/admin/bots';
|
||||
|
|
@ -38,95 +27,6 @@ const itemVariants = {
|
|||
},
|
||||
};
|
||||
|
||||
// 슬롯머신 스타일 롤링 숫자 컴포넌트
|
||||
function AnimatedNumber({ value, className = '' }) {
|
||||
const chars = String(value).split('');
|
||||
|
||||
return (
|
||||
<span className={`inline-flex overflow-hidden ${className}`}>
|
||||
{chars.map((char, i) => (
|
||||
<span key={i} className="relative h-[1.2em] overflow-hidden">
|
||||
<motion.span
|
||||
className="flex flex-col"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: `-${parseInt(char) * 10}%` }}
|
||||
transition={{ type: 'tween', ease: 'easeOut', duration: 0.8, delay: i * 0.1 }}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<span key={n} className="h-[1.2em] flex items-center justify-center">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// X 아이콘 컴포넌트
|
||||
const XIcon = ({ size = 20, fill = 'currentColor' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Meilisearch 아이콘 컴포넌트
|
||||
const MeilisearchIcon = ({ size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 108.4 512 295.2">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="meili-a"
|
||||
x1="488.157"
|
||||
x2="-21.055"
|
||||
y1="469.917"
|
||||
y2="179.001"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="meili-b"
|
||||
x1="522.305"
|
||||
x2="13.094"
|
||||
y1="410.144"
|
||||
y2="119.228"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="meili-c"
|
||||
x1="556.456"
|
||||
x2="47.244"
|
||||
y1="350.368"
|
||||
y2="59.452"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="m0 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-a)"
|
||||
/>
|
||||
<path
|
||||
d="m138.8 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-b)"
|
||||
/>
|
||||
<path
|
||||
d="m277.6 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-c)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function ScheduleBots() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
|
@ -411,154 +311,23 @@ function ScheduleBots() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{bots.map((bot, index) => {
|
||||
const statusInfo = getStatusInfo(bot.status);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={bot.id}
|
||||
initial={isInitialLoad ? { opacity: 0, scale: 0.95 } : false}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={isInitialLoad ? { delay: index * 0.05 } : { duration: 0.15 }}
|
||||
onAnimationComplete={() =>
|
||||
isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false)
|
||||
}
|
||||
className="relative bg-gradient-to-br from-gray-50 to-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
||||
>
|
||||
{/* 상단 헤더 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
bot.type === 'x'
|
||||
? 'bg-black'
|
||||
: bot.type === 'meilisearch'
|
||||
? 'bg-[#ddf1fd]'
|
||||
: 'bg-red-50'
|
||||
}`}
|
||||
>
|
||||
{bot.type === 'x' ? (
|
||||
<XIcon size={20} fill="white" />
|
||||
) : bot.type === 'meilisearch' ? (
|
||||
<MeilisearchIcon size={20} />
|
||||
) : (
|
||||
<Youtube size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{bot.name}</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
{bot.last_check_at
|
||||
? `${formatTime(bot.last_check_at)}에 업데이트됨`
|
||||
: '아직 업데이트 없음'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.color}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`}
|
||||
></span>
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
|
||||
{bot.type === 'meilisearch' ? (
|
||||
<>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{bot.schedules_added || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">동기화 수</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{bot.last_added_count
|
||||
? `${(bot.last_added_count / 1000 || 0).toFixed(1)}초`
|
||||
: '-'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">소요 시간</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{bot.schedules_added}</div>
|
||||
<div className="text-xs text-gray-400">총 추가</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div
|
||||
className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}
|
||||
>
|
||||
+{bot.last_added_count || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">마지막</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatInterval(bot.check_interval)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">업데이트 간격</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{bot.status === 'error' && bot.error_message && (
|
||||
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs border-t border-red-100">
|
||||
{bot.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSyncAllVideos(bot.id)}
|
||||
disabled={syncing === bot.id}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{syncing === bot.id ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
<span>동기화 중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} />
|
||||
<span>전체 동기화</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleBot(bot.id, bot.status, bot.name)}
|
||||
className={`flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
|
||||
bot.status === 'running'
|
||||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
: 'bg-green-500 text-white hover:bg-green-600'
|
||||
}`}
|
||||
>
|
||||
{bot.status === 'running' ? (
|
||||
<>
|
||||
<Square size={16} />
|
||||
<span>정지</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
<span>시작</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
{bots.map((bot, index) => (
|
||||
<BotCard
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
index={index}
|
||||
isInitialLoad={isInitialLoad}
|
||||
syncing={syncing}
|
||||
statusInfo={getStatusInfo(bot.status)}
|
||||
onSync={handleSyncAllVideos}
|
||||
onToggle={toggleBot}
|
||||
onAnimationComplete={() =>
|
||||
isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false)
|
||||
}
|
||||
formatTime={formatTime}
|
||||
formatInterval={formatInterval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
||||
import { Reorder } from 'framer-motion';
|
||||
import { Home, ChevronRight, Plus, Edit3, Trash2, GripVertical } from 'lucide-react';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { Toast } from '@/components/common';
|
||||
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
|
||||
import { AdminLayout, ConfirmDialog, CategoryFormModal } from '@/components/pc/admin';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { useToast } from '@/hooks/common';
|
||||
import * as categoriesApi from '@/api/admin/categories';
|
||||
import { COLOR_OPTIONS, getColorStyle } from '@/utils/color';
|
||||
import { getColorStyle } from '@/utils/color';
|
||||
|
||||
function ScheduleCategory() {
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
|
@ -257,167 +256,16 @@ function ScheduleCategory() {
|
|||
</div>
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<AnimatePresence>
|
||||
{modalOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
<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 w-full mx-4 shadow-xl"
|
||||
style={{ maxWidth: '452px', minWidth: '452px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">
|
||||
{editingCategory ? '카테고리 수정' : '카테고리 추가'}
|
||||
</h3>
|
||||
|
||||
{/* 카테고리 이름 */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">카테고리 이름 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="예: 방송, 이벤트"
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 색상 선택 */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">색상 선택 *</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color: color.id })}
|
||||
className={`w-10 h-10 rounded-full ${color.bg} transition-all ${
|
||||
formData.color === color.id
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
{/* 커스텀 색상 - 무지개 그라디언트 버튼 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setColorPickerOpen(!colorPickerOpen);
|
||||
}}
|
||||
className={`w-10 h-10 rounded-full transition-all ${
|
||||
formData.color?.startsWith('#')
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
style={{
|
||||
background: formData.color?.startsWith('#')
|
||||
? formData.color
|
||||
: 'conic-gradient(from 0deg, #ff0000, #ff8000, #ffff00, #00ff00, #00ffff, #0000ff, #8000ff, #ff0080, #ff0000)',
|
||||
}}
|
||||
title="커스텀 색상"
|
||||
/>
|
||||
{/* 색상 선택 팝업 */}
|
||||
<AnimatePresence>
|
||||
{colorPickerOpen && (
|
||||
<>
|
||||
{/* 바깥 영역 클릭시 컬러피커만 닫기 */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setColorPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
className="absolute top-12 left-0 z-50 p-4 bg-white rounded-2xl shadow-xl border border-gray-100"
|
||||
style={{ width: '240px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<HexColorPicker
|
||||
color={formData.color?.startsWith('#') ? formData.color : '#6b7280'}
|
||||
onChange={(color) => setFormData({ ...formData, color })}
|
||||
style={{ width: '100%', height: '180px' }}
|
||||
/>
|
||||
<div className="mt-4 flex items-center">
|
||||
<span className="px-3 py-2 text-sm bg-gray-100 border border-r-0 border-gray-200 rounded-l-lg text-gray-500">
|
||||
#
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color?.startsWith('#') ? formData.color.slice(1) : ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').slice(0, 6);
|
||||
if (val) {
|
||||
setFormData({ ...formData, color: '#' + val });
|
||||
}
|
||||
}}
|
||||
placeholder="FFFFFF"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, color: 'blue' });
|
||||
setColorPickerOpen(false);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColorPickerOpen(false)}
|
||||
className="px-3 py-1.5 text-sm bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
{editingCategory ? '수정' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<CategoryFormModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
editingCategory={editingCategory}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
colorPickerOpen={colorPickerOpen}
|
||||
setColorPickerOpen={setColorPickerOpen}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue