// 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 */}
{/* 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 });