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:
parent
362182fb53
commit
6a96b8a5f9
10 changed files with 155 additions and 91 deletions
|
|
@ -147,10 +147,20 @@ pages/pc/admin/schedules/
|
|||
|
||||
## 3. 작업 순서
|
||||
|
||||
### Phase 1: 구조 정리 (비파괴적)
|
||||
1. [ ] API 구조 개선 (폴더 재배치, transforms.js 생성)
|
||||
2. [ ] 중복 유틸 함수 제거 (Schedules.jsx 등)
|
||||
3. [ ] 컴포넌트 폴더 구조화
|
||||
### Phase 1: 구조 정리 (비파괴적) ✅ 완료
|
||||
1. [x] API 구조 개선 (폴더 재배치)
|
||||
- `api/pc/`, `api/common/` → `api/public/`, `api/admin/`
|
||||
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: 대형 파일 분리
|
||||
1. [ ] Schedules.jsx 분리
|
||||
|
|
@ -159,21 +169,32 @@ pages/pc/admin/schedules/
|
|||
4. [ ] ScheduleDict.jsx 분리
|
||||
5. [ ] AlbumForm.jsx 분리
|
||||
|
||||
### Phase 3: 검증
|
||||
1. [ ] 모든 페이지 동작 확인
|
||||
2. [ ] 빌드 확인
|
||||
### Phase 3: 추가 개선
|
||||
1. [ ] 관리자 페이지용 에러 페이지 추가 (404, 500 등)
|
||||
2. [ ] 모든 페이지 동작 확인
|
||||
3. [ ] 빌드 확인
|
||||
|
||||
---
|
||||
|
||||
## 4. 삭제할 항목
|
||||
## 4. 삭제된 항목
|
||||
|
||||
### constants/index.js에서 삭제됨
|
||||
### constants/index.js
|
||||
- `CATEGORY_ID` (하드코딩 → API name 비교로 변경)
|
||||
- `CATEGORY_NAMES` (미사용)
|
||||
- `ALBUM_TYPES` (미사용)
|
||||
- `SOCIAL_LINKS.tiktok` (불필요)
|
||||
- `SOCIAL_LINKS.fancafe` (불필요)
|
||||
|
||||
### 삭제 예정
|
||||
- `api/pc/` 폴더 구조 (→ `api/public/`, `api/admin/`으로 변경)
|
||||
- `api/pc/common/` (→ `api/public/`으로 통합)
|
||||
- 각 파일의 중복 유틸 함수
|
||||
### API 구조
|
||||
- `api/pc/` 폴더 → `api/public/`, `api/admin/`으로 변경 완료
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
|||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getSchedule(id) {
|
||||
return fetchAuthApi(`/admin/schedules/${id}`);
|
||||
return fetchAuthApi(`/schedules/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ export * as memberApi from './public/members';
|
|||
|
||||
// 관리자 API
|
||||
export * from './admin';
|
||||
export * as authApi from './admin/auth';
|
||||
|
|
|
|||
|
|
@ -2,13 +2,6 @@
|
|||
* 상수 정의
|
||||
*/
|
||||
|
||||
/** 카테고리 ID */
|
||||
export const CATEGORY_ID = {
|
||||
DEFAULT: 1,
|
||||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
};
|
||||
|
||||
/** 공식 SNS 링크 */
|
||||
export const SOCIAL_LINKS = {
|
||||
youtube: 'https://www.youtube.com/@fromis9_official',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
|
||||
import Linkify from 'react-linkify';
|
||||
import { getSchedule } from '@/api';
|
||||
import { CATEGORY_ID } from '@/constants';
|
||||
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 = () => {
|
||||
switch (categoryId) {
|
||||
case CATEGORY_ID.YOUTUBE:
|
||||
switch (categoryName) {
|
||||
case '유튜브':
|
||||
return <MobileYoutubeSection schedule={schedule} />;
|
||||
case CATEGORY_ID.X:
|
||||
case 'X':
|
||||
return <MobileXSection schedule={schedule} />;
|
||||
default:
|
||||
return <MobileDefaultSection schedule={schedule} />;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Home,
|
||||
ChevronRight,
|
||||
|
|
@ -20,6 +20,50 @@ import { useAdminAuth } from '@/hooks/pc/admin';
|
|||
import { useToast } from '@/hooks/common';
|
||||
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 아이콘 컴포넌트
|
||||
const XIcon = ({ size = 20, fill = 'currentColor' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
|
||||
|
|
@ -246,14 +290,24 @@ function ScheduleBots() {
|
|||
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 (
|
||||
<AdminLayout user={user}>
|
||||
<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">
|
||||
<Home size={16} />
|
||||
</Link>
|
||||
|
|
@ -263,63 +317,72 @@ function ScheduleBots() {
|
|||
</Link>
|
||||
<ChevronRight size={14} />
|
||||
<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>
|
||||
<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="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 className="bg-white rounded-xl p-5 border border-gray-100">
|
||||
<div className="text-sm text-gray-500 mb-1">실행 중</div>
|
||||
<div className="text-2xl font-bold text-green-500">
|
||||
{bots.filter((b) => b.status === 'running').length}
|
||||
<AnimatedNumber value={runningCount} />
|
||||
</div>
|
||||
</div>
|
||||
<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-2xl font-bold text-gray-400">
|
||||
{bots.filter((b) => b.status === 'stopped').length}
|
||||
<AnimatedNumber value={stoppedCount} />
|
||||
</div>
|
||||
</div>
|
||||
<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-2xl font-bold text-red-500">
|
||||
{bots.filter((b) => b.status === 'error').length}
|
||||
<AnimatedNumber value={errorCount} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* API 할당량 경고 배너 */}
|
||||
{quotaWarning && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-8 flex items-start justify-between">
|
||||
<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">
|
||||
<XCircle size={18} className="text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-red-700">YouTube API 할당량 경고</h3>
|
||||
<p className="text-sm text-red-600 mt-0.5">{quotaWarning.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismissQuotaWarning}
|
||||
className="text-red-400 hover:text-red-600 transition-colors text-sm px-2 py-1"
|
||||
<AnimatePresence>
|
||||
{quotaWarning && (
|
||||
<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"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
<XCircle size={18} className="text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-red-700">YouTube API 할당량 경고</h3>
|
||||
<p className="text-sm text-red-600 mt-0.5">{quotaWarning.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismissQuotaWarning}
|
||||
className="text-red-400 hover:text-red-600 transition-colors text-sm px-2 py-1"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</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">
|
||||
<h2 className="font-bold text-gray-900">봇 목록</h2>
|
||||
<Tooltip text="새로고침">
|
||||
|
|
@ -498,8 +561,8 @@ function ScheduleBots() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,18 +118,12 @@ const getCategoryInfo = (schedule, categories) => {
|
|||
return found || { id: catId, name: '미분류', color: '#6b7280' };
|
||||
};
|
||||
|
||||
// 카테고리 ID 상수
|
||||
const CATEGORY_IDS = {
|
||||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
};
|
||||
|
||||
// 카테고리별 수정 경로 반환
|
||||
const getEditPath = (scheduleId, categoryId) => {
|
||||
switch (categoryId) {
|
||||
case CATEGORY_IDS.YOUTUBE:
|
||||
const getEditPath = (scheduleId, categoryName) => {
|
||||
switch (categoryName) {
|
||||
case '유튜브':
|
||||
return `/admin/schedule/${scheduleId}/edit/youtube`;
|
||||
case CATEGORY_IDS.X:
|
||||
case 'X':
|
||||
return `/admin/schedule/${scheduleId}/edit/x`;
|
||||
default:
|
||||
return `/admin/schedule/${scheduleId}/edit`;
|
||||
|
|
@ -230,7 +224,7 @@ const ScheduleItem = memo(function ScheduleItem({
|
|||
</a>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
|
|
@ -1408,7 +1402,7 @@ function Schedules() {
|
|||
</a>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
|
|
|
|||
|
|
@ -29,12 +29,6 @@ const itemVariants = {
|
|||
},
|
||||
};
|
||||
|
||||
// 카테고리 ID 상수
|
||||
const CATEGORY_IDS = {
|
||||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* 일정 추가 페이지 (카테고리별 폼 분기)
|
||||
*/
|
||||
|
|
@ -71,11 +65,13 @@ function ScheduleFormPage() {
|
|||
|
||||
// 카테고리에 따른 폼 렌더링
|
||||
const renderForm = () => {
|
||||
switch (selectedCategory) {
|
||||
case CATEGORY_IDS.YOUTUBE:
|
||||
const selectedCategoryName = categories.find(c => c.id === selectedCategory)?.name;
|
||||
|
||||
switch (selectedCategoryName) {
|
||||
case '유튜브':
|
||||
return <YouTubeForm />;
|
||||
|
||||
case CATEGORY_IDS.X:
|
||||
case 'X':
|
||||
return <XForm />;
|
||||
|
||||
// 다른 카테고리는 기존 폼으로 리다이렉트
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
|
|||
import { getSchedule } from '@/api';
|
||||
|
||||
// 섹션 컴포넌트들
|
||||
import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from './sections';
|
||||
import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||
|
||||
/**
|
||||
* PC 일정 상세 페이지
|
||||
|
|
@ -112,20 +112,20 @@ function PCScheduleDetail() {
|
|||
}
|
||||
|
||||
// 카테고리별 섹션 렌더링
|
||||
const categoryId = schedule.category?.id;
|
||||
const categoryName = schedule.category?.name;
|
||||
const renderCategorySection = () => {
|
||||
switch (categoryId) {
|
||||
case CATEGORY_ID.YOUTUBE:
|
||||
switch (categoryName) {
|
||||
case '유튜브':
|
||||
return <YoutubeSection schedule={schedule} />;
|
||||
case CATEGORY_ID.X:
|
||||
case 'X':
|
||||
return <XSection schedule={schedule} />;
|
||||
default:
|
||||
return <DefaultSection schedule={schedule} />;
|
||||
}
|
||||
};
|
||||
|
||||
const isYoutube = categoryId === CATEGORY_ID.YOUTUBE;
|
||||
const isX = categoryId === CATEGORY_ID.X;
|
||||
const isYoutube = categoryName === '유튜브';
|
||||
const isX = categoryName === 'X';
|
||||
const hasCustomLayout = isYoutube || isX;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
/**
|
||||
* 스케줄 상세 페이지 상수 및 re-export
|
||||
* 스케줄 상세 페이지 유틸리티 re-export
|
||||
*/
|
||||
|
||||
// @/utils에서 re-export
|
||||
export { decodeHtmlEntities, formatTime } from '@/utils';
|
||||
export { formatFullDate, formatXDateTime } from '@/utils';
|
||||
|
||||
// @/constants에서 re-export
|
||||
export { CATEGORY_ID } from '@/constants';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue