2026-01-23 10:21:06 +09:00
|
|
|
/**
|
|
|
|
|
* 봇 카드 컴포넌트
|
|
|
|
|
*/
|
|
|
|
|
import { memo } from 'react';
|
|
|
|
|
import { motion } from 'framer-motion';
|
2026-02-06 18:16:12 +09:00
|
|
|
import { Play, Square, RefreshCw, RotateCcw, Pencil, Trash2 } from 'lucide-react';
|
|
|
|
|
import { Tooltip } from '@/components/common';
|
2026-01-23 10:21:06 +09:00
|
|
|
|
|
|
|
|
// 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>
|
2026-02-06 18:04:16 +09:00
|
|
|
<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">
|
2026-01-23 10:21:06 +09:00
|
|
|
<stop offset="0" stopColor="#ff5caa" />
|
|
|
|
|
<stop offset="1" stopColor="#ff4e62" />
|
|
|
|
|
</linearGradient>
|
2026-02-06 18:04:16 +09:00
|
|
|
<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">
|
2026-01-23 10:21:06 +09:00
|
|
|
<stop offset="0" stopColor="#ff5caa" />
|
|
|
|
|
<stop offset="1" stopColor="#ff4e62" />
|
|
|
|
|
</linearGradient>
|
2026-02-06 18:04:16 +09:00
|
|
|
<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">
|
2026-01-23 10:21:06 +09:00
|
|
|
<stop offset="0" stopColor="#ff5caa" />
|
|
|
|
|
<stop offset="1" stopColor="#ff4e62" />
|
|
|
|
|
</linearGradient>
|
|
|
|
|
</defs>
|
2026-02-06 18:04:16 +09:00
|
|
|
<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)" />
|
2026-01-23 10:21:06 +09:00
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-06 18:04:16 +09:00
|
|
|
* 리스트형 봇 (Meilisearch용) - 한 줄에 모든 정보
|
2026-01-23 10:21:06 +09:00
|
|
|
*/
|
2026-02-06 18:04:16 +09:00
|
|
|
export const BotListItem = memo(function BotListItem({
|
2026-01-23 10:21:06 +09:00
|
|
|
bot,
|
|
|
|
|
index,
|
|
|
|
|
isInitialLoad,
|
|
|
|
|
syncing,
|
|
|
|
|
statusInfo,
|
|
|
|
|
onSync,
|
|
|
|
|
onToggle,
|
|
|
|
|
onAnimationComplete,
|
|
|
|
|
formatTime,
|
|
|
|
|
formatInterval,
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<motion.div
|
2026-02-06 18:04:16 +09:00
|
|
|
initial={isInitialLoad ? { opacity: 0, x: -10 } : false}
|
|
|
|
|
animate={{ opacity: 1, x: 0 }}
|
2026-02-06 18:01:15 +09:00
|
|
|
transition={isInitialLoad ? { delay: index * 0.05, duration: 0.2 } : { duration: 0.15 }}
|
2026-01-23 10:21:06 +09:00
|
|
|
onAnimationComplete={onAnimationComplete}
|
2026-02-06 18:04:16 +09:00
|
|
|
className="flex items-center gap-4 p-4 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
|
2026-01-23 10:21:06 +09:00
|
|
|
>
|
2026-02-06 18:04:16 +09:00
|
|
|
{/* 상태 표시 */}
|
|
|
|
|
<div className={`w-2 h-2 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`} />
|
|
|
|
|
|
|
|
|
|
{/* 이름 */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<h3 className="font-medium text-gray-900 truncate">{bot.name}</h3>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 통계 */}
|
|
|
|
|
<div className="hidden sm:flex items-center gap-6 text-sm text-gray-500">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<span className="font-semibold text-gray-900">{bot.schedules_added || 0}</span>
|
|
|
|
|
<span className="ml-1">추가</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<span className="text-xs">{bot.last_check_at ? formatTime(bot.last_check_at) : '-'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 버튼 */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onSync(bot.id)}
|
|
|
|
|
disabled={syncing === bot.id}
|
|
|
|
|
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 ? (
|
|
|
|
|
<RefreshCw size={18} className="animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Download size={18} />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onToggle(bot.id, bot.status, bot.name)}
|
|
|
|
|
className={`p-2 rounded-lg transition-colors ${
|
|
|
|
|
bot.status === 'running'
|
|
|
|
|
? 'text-gray-500 hover:text-red-600 hover:bg-red-50'
|
|
|
|
|
: 'text-gray-500 hover:text-green-600 hover:bg-green-50'
|
|
|
|
|
}`}
|
|
|
|
|
title={bot.status === 'running' ? '정지' : '시작'}
|
|
|
|
|
>
|
|
|
|
|
{bot.status === 'running' ? <Square size={18} /> : <Play size={18} />}
|
|
|
|
|
</button>
|
|
|
|
|
</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"
|
|
|
|
|
>
|
|
|
|
|
{/* 메인 영역 */}
|
2026-02-06 18:01:15 +09:00
|
|
|
<div className="p-4">
|
2026-02-06 18:04:16 +09:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<h3 className="font-semibold text-gray-900 truncate flex-1">{bot.name}</h3>
|
2026-01-23 10:21:06 +09:00
|
|
|
<span
|
2026-02-06 18:04:16 +09:00
|
|
|
className={`ml-2 flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.color}`}
|
2026-01-27 11:59:18 +09:00
|
|
|
>
|
2026-02-06 18:04:16 +09:00
|
|
|
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`} />
|
2026-02-06 18:01:15 +09:00
|
|
|
{statusInfo.text}
|
|
|
|
|
</span>
|
2026-01-27 11:59:18 +09:00
|
|
|
</div>
|
2026-02-06 18:01:15 +09:00
|
|
|
|
2026-02-06 18:04:16 +09:00
|
|
|
{/* 간단 통계 */}
|
|
|
|
|
<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>
|
2026-01-27 11:59:18 +09:00
|
|
|
</div>
|
2026-02-06 18:04:16 +09:00
|
|
|
|
|
|
|
|
{/* 마지막 업데이트 */}
|
|
|
|
|
<p className="mt-1 text-xs text-gray-400">
|
|
|
|
|
{bot.last_check_at ? formatTime(bot.last_check_at) : '대기 중'}
|
|
|
|
|
</p>
|
2026-01-23 10:21:06 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 오류 메시지 */}
|
|
|
|
|
{bot.status === 'error' && bot.error_message && (
|
2026-02-06 18:04:16 +09:00
|
|
|
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs">
|
2026-01-23 10:21:06 +09:00
|
|
|
{bot.error_message}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-06 18:04:16 +09:00
|
|
|
{/* 호버시 나타나는 액션 버튼 */}
|
|
|
|
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
2026-02-06 18:01:15 +09:00
|
|
|
<button
|
|
|
|
|
onClick={() => onSync(bot.id)}
|
|
|
|
|
disabled={syncing === bot.id}
|
2026-02-06 18:04:16 +09:00
|
|
|
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"
|
2026-02-06 18:01:15 +09:00
|
|
|
>
|
|
|
|
|
{syncing === bot.id ? (
|
2026-02-06 18:04:16 +09:00
|
|
|
<RefreshCw size={14} className="animate-spin" />
|
2026-02-06 18:01:15 +09:00
|
|
|
) : (
|
2026-02-06 18:04:16 +09:00
|
|
|
<Download size={14} />
|
2026-02-06 18:01:15 +09:00
|
|
|
)}
|
2026-02-06 18:04:16 +09:00
|
|
|
동기화
|
2026-02-06 18:01:15 +09:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onToggle(bot.id, bot.status, bot.name)}
|
2026-02-06 18:04:16 +09:00
|
|
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
2026-02-06 18:01:15 +09:00
|
|
|
bot.status === 'running'
|
2026-02-06 18:04:16 +09:00
|
|
|
? 'bg-red-500 text-white hover:bg-red-600'
|
|
|
|
|
: 'bg-green-500 text-white hover:bg-green-600'
|
2026-02-06 18:01:15 +09:00
|
|
|
}`}
|
|
|
|
|
>
|
2026-02-06 18:04:16 +09:00
|
|
|
{bot.status === 'running' ? <Square size={14} /> : <Play size={14} />}
|
|
|
|
|
{bot.status === 'running' ? '정지' : '시작'}
|
2026-02-06 18:01:15 +09:00
|
|
|
</button>
|
2026-01-23 10:21:06 +09:00
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-06 18:04:16 +09:00
|
|
|
/**
|
2026-02-06 18:12:22 +09:00
|
|
|
* 테이블 행 봇
|
2026-02-06 18:04:16 +09:00
|
|
|
*/
|
|
|
|
|
export const BotTableRow = memo(function BotTableRow({
|
|
|
|
|
bot,
|
|
|
|
|
index,
|
|
|
|
|
isInitialLoad,
|
|
|
|
|
syncing,
|
|
|
|
|
statusInfo,
|
|
|
|
|
onSync,
|
|
|
|
|
onToggle,
|
2026-02-06 18:12:22 +09:00
|
|
|
onEdit,
|
2026-02-06 18:16:12 +09:00
|
|
|
onDelete,
|
2026-02-06 18:04:16 +09:00
|
|
|
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' : ''}`} />
|
2026-02-06 18:12:22 +09:00
|
|
|
<span className="font-medium text-gray-900 truncate">{bot.name}</span>
|
2026-02-06 18:04:16 +09:00
|
|
|
</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">
|
2026-02-06 18:16:12 +09:00
|
|
|
{/* 전체 동기화 */}
|
|
|
|
|
<Tooltip text="전체 동기화">
|
|
|
|
|
<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"
|
|
|
|
|
>
|
|
|
|
|
{syncing === bot.id ? (
|
|
|
|
|
<RefreshCw size={16} className="animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<RotateCcw size={16} />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
{/* 시작/정지 */}
|
|
|
|
|
<Tooltip text={bot.status === 'running' ? '정지' : '시작'}>
|
2026-02-06 18:12:22 +09:00
|
|
|
<button
|
2026-02-06 18:16:12 +09:00
|
|
|
onClick={() => onToggle(bot.id, bot.status, bot.name)}
|
|
|
|
|
className={`p-1.5 rounded transition-colors ${
|
|
|
|
|
bot.status === 'running'
|
|
|
|
|
? 'text-gray-400 hover:text-orange-600 hover:bg-orange-50'
|
|
|
|
|
: 'text-gray-400 hover:text-green-600 hover:bg-green-50'
|
|
|
|
|
}`}
|
2026-02-06 18:12:22 +09:00
|
|
|
>
|
2026-02-06 18:16:12 +09:00
|
|
|
{bot.status === 'running' ? <Square size={16} /> : <Play size={16} />}
|
2026-02-06 18:12:22 +09:00
|
|
|
</button>
|
2026-02-06 18:16:12 +09:00
|
|
|
</Tooltip>
|
|
|
|
|
{/* 수정 (YouTube만) */}
|
|
|
|
|
{bot.type === 'youtube' && onEdit && (
|
|
|
|
|
<Tooltip text="수정">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onEdit(bot)}
|
|
|
|
|
className="p-1.5 text-gray-400 hover:text-amber-600 hover:bg-amber-50 rounded transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Pencil size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
|
|
|
|
{/* 삭제 (YouTube만) */}
|
|
|
|
|
{bot.type === 'youtube' && onDelete && (
|
|
|
|
|
<Tooltip text="삭제">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onDelete(bot)}
|
|
|
|
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
2026-02-06 18:12:22 +09:00
|
|
|
)}
|
2026-02-06 18:04:16 +09:00
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</motion.tr>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-06 18:09:02 +09:00
|
|
|
* 테이블 래퍼
|
2026-02-06 18:04:16 +09:00
|
|
|
*/
|
|
|
|
|
export const BotTable = ({ children }) => (
|
|
|
|
|
<div className="overflow-x-auto">
|
2026-02-06 18:09:02 +09:00
|
|
|
<table className="w-full table-fixed">
|
2026-02-06 18:04:16 +09:00
|
|
|
<thead>
|
|
|
|
|
<tr className="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
2026-02-06 18:16:48 +09:00
|
|
|
<th className="px-4 py-3 w-[24%]">이름</th>
|
|
|
|
|
<th className="px-4 py-3 w-[9%]">상태</th>
|
|
|
|
|
<th className="px-4 py-3 w-[9%]">총 추가</th>
|
|
|
|
|
<th className="px-4 py-3 w-[9%]">최근</th>
|
|
|
|
|
<th className="px-4 py-3 w-[9%]">간격</th>
|
2026-02-06 18:09:02 +09:00
|
|
|
<th className="px-4 py-3 w-[20%]">마지막 업데이트</th>
|
2026-02-06 18:16:48 +09:00
|
|
|
<th className="px-4 py-3 w-[20%]">액션</th>
|
2026-02-06 18:04:16 +09:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>{children}</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 기본 카드 (호환성 유지)
|
|
|
|
|
const BotCard = BotMiniCard;
|
|
|
|
|
|
2026-01-23 10:21:06 +09:00
|
|
|
export default BotCard;
|