feat: 봇 관리 섹션별 다른 디자인 적용

- Meilisearch: 리스트형 (BotListItem) - 한 줄에 모든 정보
- YouTube: 미니 카드형 (BotMiniCard) - 호버시 액션 버튼
- X: 테이블형 (BotTableRow) - 표 형식으로 정보 비교

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-02-06 18:04:16 +09:00
parent dbfee503d5
commit b5118f2dea
3 changed files with 274 additions and 129 deletions

View file

@ -3,7 +3,7 @@
*/ */
import { memo } from 'react'; import { memo } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Youtube, Play, Square, RefreshCw, Download } from 'lucide-react'; import { Play, Square, RefreshCw, Download } from 'lucide-react';
// X // X
export const XIcon = ({ size = 20, fill = 'currentColor' }) => ( export const XIcon = ({ size = 20, fill = 'currentColor' }) => (
@ -16,72 +16,29 @@ export const XIcon = ({ size = 20, fill = 'currentColor' }) => (
export const MeilisearchIcon = ({ size = 20 }) => ( export const MeilisearchIcon = ({ size = 20 }) => (
<svg width={size} height={size} viewBox="0 108.4 512 295.2"> <svg width={size} height={size} viewBox="0 108.4 512 295.2">
<defs> <defs>
<linearGradient <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">
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="0" stopColor="#ff5caa" />
<stop offset="1" stopColor="#ff4e62" /> <stop offset="1" stopColor="#ff4e62" />
</linearGradient> </linearGradient>
<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">
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="0" stopColor="#ff5caa" />
<stop offset="1" stopColor="#ff4e62" /> <stop offset="1" stopColor="#ff4e62" />
</linearGradient> </linearGradient>
<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">
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="0" stopColor="#ff5caa" />
<stop offset="1" stopColor="#ff4e62" /> <stop offset="1" stopColor="#ff4e62" />
</linearGradient> </linearGradient>
</defs> </defs>
<path <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)" />
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" <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)" />
fill="url(#meili-a)" <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)" />
/>
<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> </svg>
); );
/** /**
* @param {Object} props * 리스트형 (Meilisearch용) - 줄에 모든 정보
* @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({ export const BotListItem = memo(function BotListItem({
bot, bot,
index, index,
isInitialLoad, isInitialLoad,
@ -95,101 +52,246 @@ const BotCard = memo(function BotCard({
}) { }) {
return ( return (
<motion.div <motion.div
initial={isInitialLoad ? { opacity: 0, y: 10 } : false} initial={isInitialLoad ? { opacity: 0, x: -10 } : false}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, x: 0 }}
transition={isInitialLoad ? { delay: index * 0.05, duration: 0.2 } : { duration: 0.15 }} transition={isInitialLoad ? { delay: index * 0.05, duration: 0.2 } : { duration: 0.15 }}
onAnimationComplete={onAnimationComplete} onAnimationComplete={onAnimationComplete}
className="group relative bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden hover:shadow-md transition-all" className="flex items-center gap-4 p-4 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
> >
{/* 상단: 이름 + 상태 */} {/* 상태 표시 */}
<div className="p-4"> <div className={`w-2 h-2 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`} />
<div className="flex items-start justify-between mb-3">
{/* 이름 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{bot.name}</h3> <h3 className="font-medium text-gray-900 truncate">{bot.name}</h3>
<p className="text-xs text-gray-400 mt-0.5">
{bot.last_check_at ? formatTime(bot.last_check_at) : '대기 중'}
</p>
</div>
<span
className={`flex-shrink-0 flex items-center gap-1.5 px-2 py-0.5 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' : ''}`}
/>
{statusInfo.text}
</span>
</div> </div>
{/* 통계 */} {/* 통계 */}
<div className="flex items-center gap-3 mt-3 p-2.5 bg-gray-50 rounded-lg text-sm"> <div className="hidden sm:flex items-center gap-6 text-sm text-gray-500">
<div className="flex-1 text-center"> <div className="text-center">
<div className="font-semibold text-gray-900">{bot.schedules_added || 0}</div> <span className="font-semibold text-gray-900">{bot.schedules_added || 0}</span>
<div className="text-xs text-gray-400"> 추가</div> <span className="ml-1">추가</span>
</div>
<div className="w-px h-8 bg-gray-200" />
<div className="flex-1 text-center">
<div className={`font-semibold ${bot.last_added_count > 0 ? 'text-green-600' : 'text-gray-400'}`}>
+{bot.last_added_count || 0}
</div>
<div className="text-xs text-gray-400">최근</div>
</div>
<div className="w-px h-8 bg-gray-200" />
<div className="flex-1 text-center">
<div className="font-semibold text-gray-900">{formatInterval(bot.check_interval)}</div>
<div className="text-xs text-gray-400">간격</div>
</div> </div>
<div className="text-center">
<span className="text-xs">{bot.last_check_at ? formatTime(bot.last_check_at) : '-'}</span>
</div> </div>
</div> </div>
{/* 오류 메시지 */} {/* 버튼 */}
{bot.status === 'error' && bot.error_message && ( <div className="flex items-center gap-2">
<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="flex border-t border-gray-100">
<button <button
onClick={() => onSync(bot.id)} onClick={() => onSync(bot.id)}
disabled={syncing === bot.id} disabled={syncing === bot.id}
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2.5 text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-50 border-r border-gray-100" className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors disabled:opacity-50"
title="전체 동기화"
> >
{syncing === bot.id ? ( {syncing === bot.id ? (
<> <RefreshCw size={18} className="animate-spin" />
<RefreshCw size={14} className="animate-spin" />
<span>동기화 </span>
</>
) : ( ) : (
<> <Download size={18} />
<Download size={14} />
<span>전체 동기화</span>
</>
)} )}
</button> </button>
<button <button
onClick={() => onToggle(bot.id, bot.status, bot.name)} onClick={() => onToggle(bot.id, bot.status, bot.name)}
className={`flex items-center justify-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors ${ className={`p-2 rounded-lg transition-colors ${
bot.status === 'running' bot.status === 'running'
? 'text-gray-600 hover:bg-gray-50' ? 'text-gray-500 hover:text-red-600 hover:bg-red-50'
: 'text-green-600 hover:bg-green-50' : 'text-gray-500 hover:text-green-600 hover:bg-green-50'
}`} }`}
title={bot.status === 'running' ? '정지' : '시작'}
> >
{bot.status === 'running' ? ( {bot.status === 'running' ? <Square size={18} /> : <Play size={18} />}
<>
<Square size={14} />
<span>정지</span>
</>
) : (
<>
<Play size={14} />
<span>시작</span>
</>
)}
</button> </button>
</div> </div>
</motion.div> </motion.div>
); );
}); });
/**
* 미니 카드형 (YouTube용) - 컴팩트한 카드
*/
export const BotMiniCard = memo(function BotMiniCard({
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.2 } : { duration: 0.15 }}
onAnimationComplete={onAnimationComplete}
className="group relative bg-white border border-gray-200 rounded-xl overflow-hidden hover:shadow-md transition-all"
>
{/* 메인 영역 */}
<div className="p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900 truncate flex-1">{bot.name}</h3>
<span
className={`ml-2 flex items-center gap-1.5 px-2 py-0.5 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' : ''}`} />
{statusInfo.text}
</span>
</div>
{/* 간단 통계 */}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
<span> <strong className="text-gray-900">{bot.schedules_added || 0}</strong></span>
<span></span>
<span>최근 <strong className={bot.last_added_count > 0 ? 'text-green-600' : 'text-gray-400'}>+{bot.last_added_count || 0}</strong></span>
<span></span>
<span>{formatInterval(bot.check_interval)}</span>
</div>
{/* 마지막 업데이트 */}
<p className="mt-1 text-xs text-gray-400">
{bot.last_check_at ? formatTime(bot.last_check_at) : '대기 중'}
</p>
</div>
{/* 오류 메시지 */}
{bot.status === 'error' && bot.error_message && (
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs">
{bot.error_message}
</div>
)}
{/* 호버시 나타나는 액션 버튼 */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
<button
onClick={() => onSync(bot.id)}
disabled={syncing === bot.id}
className="flex items-center gap-1.5 px-3 py-2 bg-white text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-100 transition-colors disabled:opacity-50"
>
{syncing === bot.id ? (
<RefreshCw size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
동기화
</button>
<button
onClick={() => onToggle(bot.id, bot.status, bot.name)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
bot.status === 'running'
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-green-500 text-white hover:bg-green-600'
}`}
>
{bot.status === 'running' ? <Square size={14} /> : <Play size={14} />}
{bot.status === 'running' ? '정지' : '시작'}
</button>
</div>
</motion.div>
);
});
/**
* 테이블 (X용)
*/
export const BotTableRow = memo(function BotTableRow({
bot,
index,
isInitialLoad,
syncing,
statusInfo,
onSync,
onToggle,
onAnimationComplete,
formatTime,
formatInterval,
}) {
return (
<motion.tr
initial={isInitialLoad ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={isInitialLoad ? { delay: index * 0.05, duration: 0.2 } : { duration: 0.15 }}
onAnimationComplete={onAnimationComplete}
className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`} />
<span className="font-medium text-gray-900">{bot.name}</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusInfo.bg} ${statusInfo.color}`}>
{statusInfo.text}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-900 font-medium">{bot.schedules_added || 0}</td>
<td className="px-4 py-3 text-sm">
<span className={bot.last_added_count > 0 ? 'text-green-600 font-medium' : 'text-gray-400'}>
+{bot.last_added_count || 0}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500">{formatInterval(bot.check_interval)}</td>
<td className="px-4 py-3 text-xs text-gray-400">
{bot.last_check_at ? formatTime(bot.last_check_at) : '-'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={() => onSync(bot.id)}
disabled={syncing === bot.id}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors disabled:opacity-50"
title="전체 동기화"
>
{syncing === bot.id ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
</button>
<button
onClick={() => onToggle(bot.id, bot.status, bot.name)}
className={`p-1.5 rounded transition-colors ${
bot.status === 'running'
? 'text-gray-400 hover:text-red-600 hover:bg-red-50'
: 'text-gray-400 hover:text-green-600 hover:bg-green-50'
}`}
title={bot.status === 'running' ? '정지' : '시작'}
>
{bot.status === 'running' ? <Square size={16} /> : <Play size={16} />}
</button>
</div>
</td>
</motion.tr>
);
});
/**
* 테이블 래퍼 (X용)
*/
export const BotTable = ({ children }) => (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3">이름</th>
<th className="px-4 py-3">상태</th>
<th className="px-4 py-3"> 추가</th>
<th className="px-4 py-3">최근</th>
<th className="px-4 py-3">간격</th>
<th className="px-4 py-3">마지막 업데이트</th>
<th className="px-4 py-3">액션</th>
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
// ( )
const BotCard = BotMiniCard;
export default BotCard; export default BotCard;

View file

@ -1 +1 @@
export { default as BotCard, XIcon, MeilisearchIcon } from './BotCard'; export { default as BotCard, XIcon, MeilisearchIcon, BotListItem, BotMiniCard, BotTableRow, BotTable } from './BotCard';

View file

@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Database, Youtube } from 'lucide-react'; import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Database, Youtube } from 'lucide-react';
import { Toast, Tooltip, AnimatedNumber } from '@/components/common'; import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
import { AdminLayout, BotCard, XIcon } from '@/components/pc/admin'; import { AdminLayout, BotCard, XIcon, BotListItem, BotMiniCard, BotTableRow, BotTable } from '@/components/pc/admin';
import { useAdminAuth } from '@/hooks/pc/admin'; 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';
@ -379,7 +379,7 @@ function ScheduleBots() {
</div> </div>
</div> </div>
{/* 봇 카드 목록 */} {/* 봇 목록 - 타입별 다른 스타일 */}
{sectionBots.length === 0 ? ( {sectionBots.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<Bot size={36} className="mx-auto mb-3 opacity-30" /> <Bot size={36} className="mx-auto mb-3 opacity-30" />
@ -388,10 +388,53 @@ function ScheduleBots() {
<p className="text-xs mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p> <p className="text-xs mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p>
)} )}
</div> </div>
) : ( ) : type === 'meilisearch' ? (
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-4"> /* Meilisearch: 리스트형 */
<div className="p-4 space-y-2">
{sectionBots.map((bot, index) => ( {sectionBots.map((bot, index) => (
<BotCard <BotListItem
key={bot.id}
bot={bot}
index={index}
isInitialLoad={isInitialLoad}
syncing={syncing}
statusInfo={getStatusInfo(bot.status)}
onSync={handleSyncAllVideos}
onToggle={toggleBot}
onAnimationComplete={() =>
isInitialLoad && index === sectionBots.length - 1 && setIsInitialLoad(false)
}
formatTime={formatTime}
formatInterval={formatInterval}
/>
))}
</div>
) : type === 'x' ? (
/* X: 테이블형 */
<BotTable>
{sectionBots.map((bot, index) => (
<BotTableRow
key={bot.id}
bot={bot}
index={index}
isInitialLoad={isInitialLoad}
syncing={syncing}
statusInfo={getStatusInfo(bot.status)}
onSync={handleSyncAllVideos}
onToggle={toggleBot}
onAnimationComplete={() =>
isInitialLoad && index === sectionBots.length - 1 && setIsInitialLoad(false)
}
formatTime={formatTime}
formatInterval={formatInterval}
/>
))}
</BotTable>
) : (
/* YouTube: 미니 카드형 */
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{sectionBots.map((bot, index) => (
<BotMiniCard
key={bot.id} key={bot.id}
bot={bot} bot={bot}
index={index} index={index}