// Shared components: tokens, icons, Nav, Layout // API_BASE is sourced from config.js (window.__API_BASE__) so each environment // can point the frontend at its own backend without rebuilding the JSX. const API_BASE = (typeof window !== 'undefined' && window.__API_BASE__) || 'http://localhost:8000'; const TOKEN = { bg: '#0b1120', surface: '#131c2e', surfaceRaised: '#1a2540', surfaceBorder: '#243050', accent: '#6366f1', accentHover: '#818cf8', accentDim: 'rgba(99,102,241,0.15)', text: '#e2e8f0', textSecondary: '#7c8fad', textMuted: '#4a5a78', success: '#22c55e', warning: '#f59e0b', danger: '#ef4444', }; // --- Mini icon set (SVG) --- const Icon = ({ name, size = 18, color = 'currentColor', style = {} }) => { const s = { width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }; const paths = { mic: , search: , rss: , settings: , key: , brain: , database: , calendar: , chevronRight: , chevronLeft: , play: , send: , check: , clock: , refresh: , eye: , eyeOff: , plus: , trash: , globe: , arrowLeft: , fileText: , zap: , podcast: , }; return paths[name] || ; }; // --- Badge --- const Badge = ({ children, variant = 'default' }) => { const colors = { default: { bg: TOKEN.accentDim, color: TOKEN.accentHover }, success: { bg: 'rgba(34,197,94,0.12)', color: '#4ade80' }, warning: { bg: 'rgba(245,158,11,0.12)', color: '#fbbf24' }, danger: { bg: 'rgba(239,68,68,0.12)', color: '#f87171' }, muted: { bg: TOKEN.surfaceBorder, color: TOKEN.textSecondary }, }; const c = colors[variant] || colors.default; return ( {children} ); }; // --- Button --- const Btn = ({ children, onClick, variant = 'primary', size = 'md', disabled, style: extraStyle = {}, icon }) => { const [hovered, setHovered] = React.useState(false); const base = { display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 8, cursor: disabled ? 'not-allowed' : 'pointer', border: 'none', fontWeight: 500, transition: 'all 0.15s', opacity: disabled ? 0.5 : 1, fontFamily: 'inherit', }; const sizes = { sm: { padding: '5px 12px', fontSize: 13 }, md: { padding: '8px 16px', fontSize: 14 }, lg: { padding: '11px 22px', fontSize: 15 } }; const variants = { primary: { background: hovered ? TOKEN.accentHover : TOKEN.accent, color: '#fff' }, secondary: { background: hovered ? TOKEN.surfaceRaised : TOKEN.surfaceBorder, color: TOKEN.text, border: `1px solid ${TOKEN.surfaceBorder}` }, ghost: { background: hovered ? TOKEN.surfaceRaised : 'transparent', color: hovered ? TOKEN.text : TOKEN.textSecondary }, danger: { background: hovered ? '#dc2626' : TOKEN.danger, color: '#fff' }, }; return ( ); }; // --- Input --- const Input = ({ value, onChange, placeholder, type = 'text', icon, style: extraStyle = {} }) => { const [focused, setFocused] = React.useState(false); return (
{icon && } setFocused(true)} onBlur={() => setFocused(false)} style={{ width: '100%', boxSizing: 'border-box', background: TOKEN.surfaceRaised, border: `1px solid ${focused ? TOKEN.accent : TOKEN.surfaceBorder}`, borderRadius: 8, padding: icon ? '9px 12px 9px 36px' : '9px 12px', color: TOKEN.text, fontSize: 14, outline: 'none', fontFamily: 'inherit', transition: 'border 0.15s', ...extraStyle }} />
); }; // --- Top Nav --- const TopNav = ({ lang, page, setPage, onToggleLang, onAdminClick }) => { const t = lang === 'zh'; const isAdmin = page && page.startsWith('admin'); const mainItems = [ { id: 'select', icon: 'podcast', label: t ? '節目選擇' : 'Shows' }, { id: 'admin', icon: 'settings', label: t ? '後台管理' : 'Admin' }, ]; const adminItems = [ { id: 'admin-api', icon: 'key', label: t ? 'API 金鑰' : 'API Keys' }, { id: 'admin-llm', icon: 'brain', label: t ? 'LLM 模型' : 'LLM Models' }, { id: 'admin-rag', icon: 'database', label: t ? 'RAG 設定' : 'RAG Config' }, { id: 'admin-schedule', icon: 'calendar', label: t ? '轉錄排程' : 'Transcription' }, ]; return (
{/* Primary bar */}
{/* Logo */}
PodcastRAG beta
{/* Main nav items */} {/* Right: lang toggle */}
{/* Admin secondary bar */} {isAdmin && (
{adminItems.map(item => ( setPage(item.id)} secondary /> ))}
)}
); }; const TopNavItem = ({ icon, label, active, onClick, secondary }) => { const [hovered, setHovered] = React.useState(false); return ( ); }; // --- Confirm Modal (for destructive actions) --- const ConfirmModal = ({ open, title, message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', danger = false, onConfirm, onCancel }) => { if (!open) return null; return (
e.stopPropagation()} style={{ background: TOKEN.surface, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 14, padding: '22px 26px', minWidth: 380, maxWidth: 480, boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}>
{title}
{message}
{cancelLabel} {confirmLabel}
); }; Object.assign(window, { API_BASE, TOKEN, Icon, Badge, Btn, Input, TopNav, ConfirmModal });