From 218b825878112dd5e398f7fec25ed29c16a54f79 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 23 Jan 2026 10:21:06 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(Phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/frontend-improvement.md | 54 ++-- .../src/components/common/AnimatedNumber.jsx | 32 +++ frontend-temp/src/components/common/index.js | 1 + .../src/components/pc/admin/bot/BotCard.jsx | 233 +++++++++++++++ .../src/components/pc/admin/bot/index.js | 1 + .../src/components/pc/admin/index.js | 3 + .../pc/admin/schedule/CategoryFormModal.jsx | 195 +++++++++++++ .../src/components/pc/admin/schedule/index.js | 1 + .../pages/pc/admin/schedules/ScheduleBots.jsx | 271 ++---------------- .../pc/admin/schedules/ScheduleCategory.jsx | 178 +----------- 10 files changed, 535 insertions(+), 434 deletions(-) create mode 100644 frontend-temp/src/components/common/AnimatedNumber.jsx create mode 100644 frontend-temp/src/components/pc/admin/bot/BotCard.jsx create mode 100644 frontend-temp/src/components/pc/admin/bot/index.js create mode 100644 frontend-temp/src/components/pc/admin/schedule/CategoryFormModal.jsx diff --git a/docs/frontend-improvement.md b/docs/frontend-improvement.md index 930b47f..69318b1 100644 --- a/docs/frontend-improvement.md +++ b/docs/frontend-improvement.md @@ -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줄 | 카테고리 폼 모달 | diff --git a/frontend-temp/src/components/common/AnimatedNumber.jsx b/frontend-temp/src/components/common/AnimatedNumber.jsx new file mode 100644 index 0000000..b58ec78 --- /dev/null +++ b/frontend-temp/src/components/common/AnimatedNumber.jsx @@ -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 ( + + {chars.map((char, i) => ( + + + {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => ( + + {n} + + ))} + + + ))} + + ); +}); + +export default AnimatedNumber; diff --git a/frontend-temp/src/components/common/index.js b/frontend-temp/src/components/common/index.js index 273a39a..e3db5c6 100644 --- a/frontend-temp/src/components/common/index.js +++ b/frontend-temp/src/components/common/index.js @@ -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'; diff --git a/frontend-temp/src/components/pc/admin/bot/BotCard.jsx b/frontend-temp/src/components/pc/admin/bot/BotCard.jsx new file mode 100644 index 0000000..4df22f4 --- /dev/null +++ b/frontend-temp/src/components/pc/admin/bot/BotCard.jsx @@ -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' }) => ( + + + +); + +// Meilisearch 아이콘 컴포넌트 +export const MeilisearchIcon = ({ size = 20 }) => ( + + + + + + + + + + + + + + + + + + + +); + +/** + * @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 ( + + {/* 상단 헤더 */} +
+
+
+ {bot.type === 'x' ? ( + + ) : bot.type === 'meilisearch' ? ( + + ) : ( + + )} +
+
+

{bot.name}

+

+ {bot.last_check_at + ? `${formatTime(bot.last_check_at)}에 업데이트됨` + : '아직 업데이트 없음'} +

+
+
+ + + {statusInfo.text} + +
+ + {/* 통계 정보 */} +
+ {bot.type === 'meilisearch' ? ( + <> +
+
{bot.schedules_added || 0}
+
동기화 수
+
+
+
+ {bot.last_added_count ? `${(bot.last_added_count / 1000 || 0).toFixed(1)}초` : '-'} +
+
소요 시간
+
+ + ) : ( + <> +
+
{bot.schedules_added}
+
총 추가
+
+
+
0 ? 'text-green-500' : 'text-gray-400'}`} + > + +{bot.last_added_count || 0} +
+
마지막
+
+ + )} +
+
{formatInterval(bot.check_interval)}
+
업데이트 간격
+
+
+ + {/* 오류 메시지 */} + {bot.status === 'error' && bot.error_message && ( +
+ {bot.error_message} +
+ )} + + {/* 액션 버튼 */} +
+
+ + +
+
+
+ ); +}); + +export default BotCard; diff --git a/frontend-temp/src/components/pc/admin/bot/index.js b/frontend-temp/src/components/pc/admin/bot/index.js new file mode 100644 index 0000000..e95a011 --- /dev/null +++ b/frontend-temp/src/components/pc/admin/bot/index.js @@ -0,0 +1 @@ +export { default as BotCard, XIcon, MeilisearchIcon } from './BotCard'; diff --git a/frontend-temp/src/components/pc/admin/index.js b/frontend-temp/src/components/pc/admin/index.js index 2453081..47cf754 100644 --- a/frontend-temp/src/components/pc/admin/index.js +++ b/frontend-temp/src/components/pc/admin/index.js @@ -9,3 +9,6 @@ export * from './schedule'; // 앨범 관련 export * from './album'; + +// 봇 관련 +export * from './bot'; diff --git a/frontend-temp/src/components/pc/admin/schedule/CategoryFormModal.jsx b/frontend-temp/src/components/pc/admin/schedule/CategoryFormModal.jsx new file mode 100644 index 0000000..6ba1ead --- /dev/null +++ b/frontend-temp/src/components/pc/admin/schedule/CategoryFormModal.jsx @@ -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 ( + + {isOpen && ( + + e.stopPropagation()} + > +

+ {editingCategory ? '카테고리 수정' : '카테고리 추가'} +

+ + {/* 카테고리 이름 */} +
+ + 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" + /> +
+ + {/* 색상 선택 */} +
+ +
+ {COLOR_OPTIONS.map((color) => ( + + +
+ + + )} + +
+ + + + {/* 버튼 */} +
+ + +
+
+
+ )} +
+ ); +}); + +export default CategoryFormModal; diff --git a/frontend-temp/src/components/pc/admin/schedule/index.js b/frontend-temp/src/components/pc/admin/schedule/index.js index 3151963..c9f2176 100644 --- a/frontend-temp/src/components/pc/admin/schedule/index.js +++ b/frontend-temp/src/components/pc/admin/schedule/index.js @@ -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'; diff --git a/frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx b/frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx index 74670c3..9271797 100644 --- a/frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx +++ b/frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx @@ -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 ( - - {chars.map((char, i) => ( - - - {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => ( - - {n} - - ))} - - - ))} - - ); -} - -// X 아이콘 컴포넌트 -const XIcon = ({ size = 20, fill = 'currentColor' }) => ( - - - -); - -// Meilisearch 아이콘 컴포넌트 -const MeilisearchIcon = ({ size = 20 }) => ( - - - - - - - - - - - - - - - - - - - -); - function ScheduleBots() { const queryClient = useQueryClient(); const { user, isAuthenticated } = useAdminAuth(); @@ -411,154 +311,23 @@ function ScheduleBots() { ) : (
- {bots.map((bot, index) => { - const statusInfo = getStatusInfo(bot.status); - - return ( - - 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" - > - {/* 상단 헤더 */} -
-
-
- {bot.type === 'x' ? ( - - ) : bot.type === 'meilisearch' ? ( - - ) : ( - - )} -
-
-

{bot.name}

-

- {bot.last_check_at - ? `${formatTime(bot.last_check_at)}에 업데이트됨` - : '아직 업데이트 없음'} -

-
-
- - - {statusInfo.text} - -
- - {/* 통계 정보 */} -
- {bot.type === 'meilisearch' ? ( - <> -
-
- {bot.schedules_added || 0} -
-
동기화 수
-
-
-
- {bot.last_added_count - ? `${(bot.last_added_count / 1000 || 0).toFixed(1)}초` - : '-'} -
-
소요 시간
-
- - ) : ( - <> -
-
{bot.schedules_added}
-
총 추가
-
-
-
0 ? 'text-green-500' : 'text-gray-400'}`} - > - +{bot.last_added_count || 0} -
-
마지막
-
- - )} -
-
- {formatInterval(bot.check_interval)} -
-
업데이트 간격
-
-
- - {/* 오류 메시지 */} - {bot.status === 'error' && bot.error_message && ( -
- {bot.error_message} -
- )} - - {/* 액션 버튼 */} -
-
- - -
-
-
- ); - })} + {bots.map((bot, index) => ( + + isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false) + } + formatTime={formatTime} + formatInterval={formatInterval} + /> + ))}
)} diff --git a/frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx b/frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx index fb666f2..6c684b8 100644 --- a/frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx +++ b/frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx @@ -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() { {/* 추가/수정 모달 */} - - {modalOpen && ( - setModalOpen(false)} - > - e.stopPropagation()} - > -

- {editingCategory ? '카테고리 수정' : '카테고리 추가'} -

- - {/* 카테고리 이름 */} -
- - 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" - /> -
- - {/* 색상 선택 */} -
- -
- {COLOR_OPTIONS.map((color) => ( - - -
- - - )} - -
- - - - {/* 버튼 */} -
- - -
-
-
- )} -
+ setModalOpen(false)} + editingCategory={editingCategory} + formData={formData} + setFormData={setFormData} + colorPickerOpen={colorPickerOpen} + setColorPickerOpen={setColorPickerOpen} + onSave={handleSave} + /> {/* 삭제 확인 다이얼로그 */}