refactor: CATEGORY_ID 하드코딩 제거 및 봇 관리 페이지 애니메이션 추가

- constants/index.js에서 CATEGORY_ID 상수 삭제
- 카테고리 API 데이터의 name으로 비교하도록 변경 (유튜브, X)
- 봇 관리 페이지에 stagger 애니메이션 및 AnimatedNumber 추가
- admin/schedules API 경로 수정 (/admin/schedules/:id → /schedules/:id)
- authApi export 누락 수정
- 문서 업데이트 (Phase 1 완료, 관리자 에러 페이지 추가 예정)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-22 22:04:37 +09:00
parent 362182fb53
commit 6a96b8a5f9
10 changed files with 155 additions and 91 deletions

View file

@ -147,10 +147,20 @@ pages/pc/admin/schedules/
## 3. 작업 순서 ## 3. 작업 순서
### Phase 1: 구조 정리 (비파괴적) ### Phase 1: 구조 정리 (비파괴적) ✅ 완료
1. [ ] API 구조 개선 (폴더 재배치, transforms.js 생성) 1. [x] API 구조 개선 (폴더 재배치)
2. [ ] 중복 유틸 함수 제거 (Schedules.jsx 등) - `api/pc/`, `api/common/``api/public/`, `api/admin/`
3. [ ] 컴포넌트 폴더 구조화 2. [x] 미사용 코드 제거
- utils/format.js, utils/date.js 미사용 함수
- constants/index.js 미사용 상수
- 미사용 hooks, stores
3. [x] 컴포넌트 폴더 구조화 (layout/, common/, schedule/ 등)
4. [x] CATEGORY_ID 하드코딩 제거
- constants에서 삭제
- 카테고리 API 데이터의 `name`으로 비교하도록 변경
5. [x] 봇 관리 페이지 애니메이션 추가
- 페이지 stagger 애니메이션
- 통계 카드 AnimatedNumber
### Phase 2: 대형 파일 분리 ### Phase 2: 대형 파일 분리
1. [ ] Schedules.jsx 분리 1. [ ] Schedules.jsx 분리
@ -159,21 +169,32 @@ pages/pc/admin/schedules/
4. [ ] ScheduleDict.jsx 분리 4. [ ] ScheduleDict.jsx 분리
5. [ ] AlbumForm.jsx 분리 5. [ ] AlbumForm.jsx 분리
### Phase 3: 검증 ### Phase 3: 추가 개선
1. [ ] 모든 페이지 동작 확인 1. [ ] 관리자 페이지용 에러 페이지 추가 (404, 500 등)
2. [ ] 빌드 확인 2. [ ] 모든 페이지 동작 확인
3. [ ] 빌드 확인
--- ---
## 4. 삭제 항목 ## 4. 삭제 항목
### constants/index.js에서 삭제됨 ### constants/index.js
- `CATEGORY_ID` (하드코딩 → API name 비교로 변경)
- `CATEGORY_NAMES` (미사용) - `CATEGORY_NAMES` (미사용)
- `ALBUM_TYPES` (미사용) - `ALBUM_TYPES` (미사용)
- `SOCIAL_LINKS.tiktok` (불필요) - `SOCIAL_LINKS.tiktok` (불필요)
- `SOCIAL_LINKS.fancafe` (불필요) - `SOCIAL_LINKS.fancafe` (불필요)
### 삭제 예정 ### API 구조
- `api/pc/` 폴더 구조 (→ `api/public/`, `api/admin/`으로 변경) - `api/pc/` 폴더 → `api/public/`, `api/admin/`으로 변경 완료
- `api/pc/common/` (→ `api/public/`으로 통합) - `api/common/``api/client.js`로 통합 완료
- 각 파일의 중복 유틸 함수
### utils
- `format.js`: formatNumber, formatViewCount, formatFileSize, formatDuration, truncateText
- `date.js`: nowKST, parseDateKST, isPast, isFuture
### hooks
- useCalendar, useLightbox, useMediaQuery, useScheduleFiltering, useScheduleSearch
### stores
- useUIStore

View file

@ -70,7 +70,7 @@ export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
* @returns {Promise<object>} * @returns {Promise<object>}
*/ */
export async function getSchedule(id) { export async function getSchedule(id) {
return fetchAuthApi(`/admin/schedules/${id}`); return fetchAuthApi(`/schedules/${id}`);
} }
/** /**

View file

@ -13,3 +13,4 @@ export * as memberApi from './public/members';
// 관리자 API // 관리자 API
export * from './admin'; export * from './admin';
export * as authApi from './admin/auth';

View file

@ -2,13 +2,6 @@
* 상수 정의 * 상수 정의
*/ */
/** 카테고리 ID */
export const CATEGORY_ID = {
DEFAULT: 1,
YOUTUBE: 2,
X: 3,
};
/** 공식 SNS 링크 */ /** 공식 SNS 링크 */
export const SOCIAL_LINKS = { export const SOCIAL_LINKS = {
youtube: 'https://www.youtube.com/@fromis9_official', youtube: 'https://www.youtube.com/@fromis9_official',

View file

@ -5,7 +5,6 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react'; import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
import Linkify from 'react-linkify'; import Linkify from 'react-linkify';
import { getSchedule } from '@/api'; import { getSchedule } from '@/api';
import { CATEGORY_ID } from '@/constants';
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTime } from '@/utils'; import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTime } from '@/utils';
/** /**
@ -505,12 +504,12 @@ function MobileScheduleDetail() {
} }
// //
const categoryId = schedule.category?.id; const categoryName = schedule.category?.name;
const renderCategorySection = () => { const renderCategorySection = () => {
switch (categoryId) { switch (categoryName) {
case CATEGORY_ID.YOUTUBE: case '유튜브':
return <MobileYoutubeSection schedule={schedule} />; return <MobileYoutubeSection schedule={schedule} />;
case CATEGORY_ID.X: case 'X':
return <MobileXSection schedule={schedule} />; return <MobileXSection schedule={schedule} />;
default: default:
return <MobileDefaultSection schedule={schedule} />; return <MobileDefaultSection schedule={schedule} />;

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Home, Home,
ChevronRight, ChevronRight,
@ -20,6 +20,50 @@ import { useAdminAuth } from '@/hooks/pc/admin';
import { useToast } from '@/hooks/common'; import { useToast } from '@/hooks/common';
import * as botsApi from '@/api/admin/bots'; import * as botsApi from '@/api/admin/bots';
// variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: 'easeOut' },
},
};
//
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 // X
const XIcon = ({ size = 20, fill = 'currentColor' }) => ( const XIcon = ({ size = 20, fill = 'currentColor' }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}> <svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
@ -246,14 +290,24 @@ function ScheduleBots() {
return `${minutes}`; return `${minutes}`;
}; };
//
const runningCount = bots.filter((b) => b.status === 'running').length;
const stoppedCount = bots.filter((b) => b.status === 'stopped').length;
const errorCount = bots.filter((b) => b.status === 'error').length;
return ( return (
<AdminLayout user={user}> <AdminLayout user={user}>
<Toast toast={toast} onClose={() => setToast(null)} /> <Toast toast={toast} onClose={() => setToast(null)} />
{/* 메인 콘텐츠 */} {/* 메인 콘텐츠 */}
<div className="max-w-7xl mx-auto px-6 py-8"> <motion.div
className="max-w-7xl mx-auto px-6 py-8"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 브레드크럼 */} {/* 브레드크럼 */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8"> <motion.div variants={itemVariants} className="flex items-center gap-2 text-sm text-gray-400 mb-8">
<Link to="/admin/dashboard" className="hover:text-primary transition-colors"> <Link to="/admin/dashboard" className="hover:text-primary transition-colors">
<Home size={16} /> <Home size={16} />
</Link> </Link>
@ -263,43 +317,51 @@ function ScheduleBots() {
</Link> </Link>
<ChevronRight size={14} /> <ChevronRight size={14} />
<span className="text-gray-700"> 관리</span> <span className="text-gray-700"> 관리</span>
</div> </motion.div>
{/* 타이틀 */} {/* 타이틀 */}
<div className="mb-8"> <motion.div variants={itemVariants} className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2"> 관리</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2"> 관리</h1>
<p className="text-gray-500">일정 자동화 봇을 관리합니다</p> <p className="text-gray-500">일정 자동화 봇을 관리합니다</p>
</div> </motion.div>
{/* 봇 통계 */} {/* 봇 통계 */}
<div className="grid grid-cols-4 gap-4 mb-8"> <motion.div variants={itemVariants} className="grid grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-xl p-5 border border-gray-100"> <div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">전체 </div> <div className="text-sm text-gray-500 mb-1">전체 </div>
<div className="text-2xl font-bold text-gray-900">{bots.length}</div> <div className="text-2xl font-bold text-gray-900">
<AnimatedNumber value={bots.length} />
</div>
</div> </div>
<div className="bg-white rounded-xl p-5 border border-gray-100"> <div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">실행 </div> <div className="text-sm text-gray-500 mb-1">실행 </div>
<div className="text-2xl font-bold text-green-500"> <div className="text-2xl font-bold text-green-500">
{bots.filter((b) => b.status === 'running').length} <AnimatedNumber value={runningCount} />
</div> </div>
</div> </div>
<div className="bg-white rounded-xl p-5 border border-gray-100"> <div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">정지됨</div> <div className="text-sm text-gray-500 mb-1">정지됨</div>
<div className="text-2xl font-bold text-gray-400"> <div className="text-2xl font-bold text-gray-400">
{bots.filter((b) => b.status === 'stopped').length} <AnimatedNumber value={stoppedCount} />
</div> </div>
</div> </div>
<div className="bg-white rounded-xl p-5 border border-gray-100"> <div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">오류</div> <div className="text-sm text-gray-500 mb-1">오류</div>
<div className="text-2xl font-bold text-red-500"> <div className="text-2xl font-bold text-red-500">
{bots.filter((b) => b.status === 'error').length} <AnimatedNumber value={errorCount} />
</div>
</div> </div>
</div> </div>
</motion.div>
{/* API 할당량 경고 배너 */} {/* API 할당량 경고 배너 */}
<AnimatePresence>
{quotaWarning && ( {quotaWarning && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-8 flex items-start justify-between"> <motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="bg-red-50 border border-red-200 rounded-xl p-4 mb-8 flex items-start justify-between overflow-hidden"
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0"> <div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<XCircle size={18} className="text-red-500" /> <XCircle size={18} className="text-red-500" />
@ -315,11 +377,12 @@ function ScheduleBots() {
> >
닫기 닫기
</button> </button>
</div> </motion.div>
)} )}
</AnimatePresence>
{/* 봇 목록 */} {/* 봇 목록 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden"> <motion.div variants={itemVariants} className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between"> <div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="font-bold text-gray-900"> 목록</h2> <h2 className="font-bold text-gray-900"> 목록</h2>
<Tooltip text="새로고침"> <Tooltip text="새로고침">
@ -498,8 +561,8 @@ function ScheduleBots() {
})} })}
</div> </div>
)} )}
</div> </motion.div>
</div> </motion.div>
</AdminLayout> </AdminLayout>
); );
} }

View file

@ -118,18 +118,12 @@ const getCategoryInfo = (schedule, categories) => {
return found || { id: catId, name: '미분류', color: '#6b7280' }; return found || { id: catId, name: '미분류', color: '#6b7280' };
}; };
// ID
const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
};
// //
const getEditPath = (scheduleId, categoryId) => { const getEditPath = (scheduleId, categoryName) => {
switch (categoryId) { switch (categoryName) {
case CATEGORY_IDS.YOUTUBE: case '유튜브':
return `/admin/schedule/${scheduleId}/edit/youtube`; return `/admin/schedule/${scheduleId}/edit/youtube`;
case CATEGORY_IDS.X: case 'X':
return `/admin/schedule/${scheduleId}/edit/x`; return `/admin/schedule/${scheduleId}/edit/x`;
default: default:
return `/admin/schedule/${scheduleId}/edit`; return `/admin/schedule/${scheduleId}/edit`;
@ -230,7 +224,7 @@ const ScheduleItem = memo(function ScheduleItem({
</a> </a>
)} )}
<button <button
onClick={() => navigate(getEditPath(schedule.id, schedule.category_id))} onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500" className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
> >
<Edit2 size={18} /> <Edit2 size={18} />
@ -1408,7 +1402,7 @@ function Schedules() {
</a> </a>
)} )}
<button <button
onClick={() => navigate(getEditPath(schedule.id, catId))} onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500" className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
> >
<Edit2 size={18} /> <Edit2 size={18} />

View file

@ -29,12 +29,6 @@ const itemVariants = {
}, },
}; };
// ID
const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
};
/** /**
* 일정 추가 페이지 (카테고리별 분기) * 일정 추가 페이지 (카테고리별 분기)
*/ */
@ -71,11 +65,13 @@ function ScheduleFormPage() {
// //
const renderForm = () => { const renderForm = () => {
switch (selectedCategory) { const selectedCategoryName = categories.find(c => c.id === selectedCategory)?.name;
case CATEGORY_IDS.YOUTUBE:
switch (selectedCategoryName) {
case '유튜브':
return <YouTubeForm />; return <YouTubeForm />;
case CATEGORY_IDS.X: case 'X':
return <XForm />; return <XForm />;
// //

View file

@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
import { getSchedule } from '@/api'; import { getSchedule } from '@/api';
// //
import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from './sections'; import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections';
/** /**
* PC 일정 상세 페이지 * PC 일정 상세 페이지
@ -112,20 +112,20 @@ function PCScheduleDetail() {
} }
// //
const categoryId = schedule.category?.id; const categoryName = schedule.category?.name;
const renderCategorySection = () => { const renderCategorySection = () => {
switch (categoryId) { switch (categoryName) {
case CATEGORY_ID.YOUTUBE: case '유튜브':
return <YoutubeSection schedule={schedule} />; return <YoutubeSection schedule={schedule} />;
case CATEGORY_ID.X: case 'X':
return <XSection schedule={schedule} />; return <XSection schedule={schedule} />;
default: default:
return <DefaultSection schedule={schedule} />; return <DefaultSection schedule={schedule} />;
} }
}; };
const isYoutube = categoryId === CATEGORY_ID.YOUTUBE; const isYoutube = categoryName === '유튜브';
const isX = categoryId === CATEGORY_ID.X; const isX = categoryName === 'X';
const hasCustomLayout = isYoutube || isX; const hasCustomLayout = isYoutube || isX;
return ( return (

View file

@ -1,10 +1,7 @@
/** /**
* 스케줄 상세 페이지 상수 re-export * 스케줄 상세 페이지 유틸리티 re-export
*/ */
// @/utils에서 re-export // @/utils에서 re-export
export { decodeHtmlEntities, formatTime } from '@/utils'; export { decodeHtmlEntities, formatTime } from '@/utils';
export { formatFullDate, formatXDateTime } from '@/utils'; export { formatFullDate, formatXDateTime } from '@/utils';
// @/constants에서 re-export
export { CATEGORY_ID } from '@/constants';