// 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';
// --- useViewport: shared hook returning { isMobile } ---
// isMobile === window.innerWidth < 768. Initial value taken synchronously
// from window.innerWidth to avoid first-render flash. Resize listener wraps
// state updates in requestAnimationFrame to coalesce within a frame, and
// only triggers setState when isMobile actually changes (so resizes within
// the same band cause no extra renders).
const useViewport = () => {
const [isMobile, setIsMobile] = React.useState(() => (typeof window !== 'undefined' ? window.innerWidth < 768 : false));
React.useEffect(() => {
let rafId = 0;
const onResize = () => {
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
const next = window.innerWidth < 768;
setIsMobile(prev => (prev === next ? prev : next));
});
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
if (rafId) window.cancelAnimationFrame(rafId);
};
}, []);
return { isMobile };
};
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: ,
edit: ,
trash: ,
globe: ,
arrowLeft: ,
fileText: ,
zap: ,
podcast: ,
moreVertical: ,
list: ,
menu: ,
chevronUp: ,
chevronDown: ,
x: ,
users: ,
};
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 { isMobile } = useViewport();
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',
...(isMobile && { minHeight: 44, minWidth: 44, justifyContent: 'center' }),
};
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 (
setHovered(true)} onMouseLeave={() => setHovered(false)}
style={{ ...base, ...sizes[size], ...variants[variant], ...extraStyle }}>
{icon && }
{children}
);
};
// --- Input ---
const Input = ({ value, onChange, placeholder, type = 'text', icon, style: extraStyle = {}, ...rest }) => {
const [focused, setFocused] = React.useState(false);
return (
{icon && }
setFocused(true)} onBlur={() => setFocused(false)}
{...rest}
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, user, onSignIn, onLogout }) => {
const t = lang === 'zh';
const isAdmin = page && page.startsWith('admin');
const { isMobile } = useViewport();
const [menuOpen, setMenuOpen] = React.useState(false);
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
const mainItems = [
{ id: 'select', icon: 'podcast', label: t ? '節目選擇' : 'Shows' },
{ id: 'release-log', icon: 'fileText', label: t ? '更新日誌' : 'Change Log' },
{ 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' },
{ id: 'admin-queue', icon: 'list', label: t ? '轉錄序列' : 'Queue' },
{ id: 'admin-users', icon: 'users', label: t ? '使用者' : 'Users' },
{ id: 'admin-quota-requests', icon: 'fileText', label: t ? 'Quota 申請' : 'Quota Requests' },
{ id: 'admin-external-api', icon: 'globe', label: t ? '外部 API 狀態' : 'External API Status' },
];
const onMainClick = (id) => {
setMenuOpen(false);
if (id === 'admin') onAdminClick();
else setPage(id);
};
return (
{/* Primary bar */}
{isMobile && (
setMenuOpen(v => !v)} aria-label={t ? '主選單' : 'Main menu'}
style={{ width: 44, height: 44, borderRadius: 8, background: menuOpen ? TOKEN.accentDim : 'transparent', border: 'none', color: TOKEN.text, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 0, marginRight: 8 }}>
)}
{/* Logo */}
PodcastRAG
{!isMobile &&
beta }
{/* Desktop: main nav items inline */}
{!isMobile && (
{mainItems.map(item => {
const active = item.id === 'select'
? !isAdmin && (page === 'select' || page === 'query' || page === 'transcript')
: item.id === 'release-log'
? page === 'release-log'
: isAdmin;
return (
onMainClick(item.id)} />
);
})}
)}
{/* Right: lang toggle + user / sign-in (desktop only — mobile lang is in dropdown) */}
{!isMobile && (
{lang === 'zh' ? '中文' : 'EN'}
{user ? (
) : (
{t ? '使用 Google 登入' : 'Sign in with Google'}
)}
)}
{isMobile && user && (
)}
{isMobile && !user && (
{t ? '登入' : 'Sign in'}
)}
{/* Mobile hamburger dropdown */}
{isMobile && menuOpen && (
setMenuOpen(false)}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 998 }} />
{mainItems.map(item => {
const active = item.id === 'select'
? !isAdmin && (page === 'select' || page === 'query' || page === 'transcript')
: item.id === 'release-log'
? page === 'release-log'
: isAdmin;
return (
onMainClick(item.id)}
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '14px 20px', minHeight: 44, background: active ? TOKEN.accentDim : 'transparent', border: 'none', color: active ? TOKEN.accent : TOKEN.text, fontSize: 15, fontWeight: active ? 600 : 500, textAlign: 'left', cursor: 'pointer', fontFamily: 'inherit' }}>
{item.label}
);
})}
{ onToggleLang(); setMenuOpen(false); }}
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '14px 20px', minHeight: 44, background: 'transparent', border: 'none', color: TOKEN.textSecondary, fontSize: 14, textAlign: 'left', cursor: 'pointer', fontFamily: 'inherit' }}>
{lang === 'zh' ? '切換為 English' : '切換為中文'}
)}
{/* Admin secondary bar — desktop: flex; mobile: horizontal scroll */}
{isAdmin && (
{adminItems.map(item => (
setPage(item.id)} secondary mobile={isMobile} />
))}
)}
);
};
const UserMenu = ({ user, lang, open, setOpen, onLogout, compact }) => {
const t = lang === 'zh';
const ref = React.useRef(null);
const quotaLow = user.quota_remaining === 0;
React.useEffect(() => {
if (!open) return;
const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [open, setOpen]);
return (
setOpen(v => !v)}
title={user.email}
style={{ display: 'flex', alignItems: 'center', gap: 8, background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 99, padding: compact ? '3px 6px' : '4px 10px 4px 4px', color: TOKEN.text, cursor: 'pointer', fontSize: 12, fontFamily: 'inherit' }}>
{user.avatar_url
?
: {(user.name || user.email || '?').slice(0,1).toUpperCase()}
}
{!compact && (
{user.name || user.email.split('@')[0]}
{t ? '剩餘' : 'Remaining'} {user.quota_remaining}
)}
{open && (
{user.name || user.email.split('@')[0]}
{user.email}
{t ? '角色' : 'Role'} {user.role}
{t ? '剩餘額度' : 'Remaining quota'}
{user.quota_remaining}
{t ? '累計查詢' : 'Total queries'} {user.total_queries}
{ setOpen(false); onLogout && onLogout(); }}
style={{ width: '100%', marginTop: 12, background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8, padding: '8px 10px', color: TOKEN.danger, cursor: 'pointer', fontSize: 13, fontFamily: 'inherit' }}>
{t ? '登出' : 'Sign out'}
)}
);
};
const TopNavItem = ({ icon, label, active, onClick, secondary, mobile }) => {
const [hovered, setHovered] = React.useState(false);
return (
setHovered(true)} onMouseLeave={() => setHovered(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: secondary ? '0 14px' : '0 16px',
height: '100%', minHeight: mobile ? 44 : undefined,
flexShrink: 0,
background: 'none', border: 'none',
borderBottom: `2px solid ${active ? TOKEN.accent : 'transparent'}`,
color: active ? TOKEN.accent : hovered ? TOKEN.text : TOKEN.textSecondary,
cursor: 'pointer', fontSize: secondary ? 13 : 14,
fontWeight: active ? 600 : 400, fontFamily: 'inherit',
transition: 'all 0.12s', whiteSpace: 'nowrap',
}}>
{label}
);
};
// --- Confirm Modal (for destructive actions) ---
const ConfirmModal = ({ open, title, message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', danger = false, loading = false, onConfirm, onCancel }) => {
const { isMobile } = useViewport();
if (!open) return null;
return (
e.stopPropagation()}
style={{ background: TOKEN.surface, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 14, padding: isMobile ? '18px 18px' : '22px 26px', width: 'min(95vw, 520px)', boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}>
{title}
{message}
{cancelLabel}
{confirmLabel}
);
};
// --- Form Modal (for non-destructive form submissions) ---
const FormModal = ({ open, title, children, confirmLabel = 'Confirm', cancelLabel = 'Cancel', onConfirm, onCancel, submitDisabled = false }) => {
const { isMobile } = useViewport();
if (!open) return null;
return (
e.stopPropagation()}
style={{ background: TOKEN.surface, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 14, padding: isMobile ? '18px 18px' : '22px 26px', width: 'min(95vw, 520px)', maxHeight: isMobile ? '90vh' : 'none', overflowY: isMobile ? 'auto' : 'visible', boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}>
{title}
{children}
{cancelLabel}
{confirmLabel}
);
};
// --- OverflowMenu ---
// items: [{ label, icon?, onClick, disabled?, danger? }]
// Renders a "⋯" trigger button; menu opens on click, closes on backdrop click or ESC.
const OverflowMenu = ({ items, ariaLabel = 'More actions' }) => {
const [open, setOpen] = React.useState(false);
const { isMobile } = useViewport();
React.useEffect(() => {
if (!open) return undefined;
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
return (
{ e.stopPropagation(); setOpen(v => !v); }}
style={{ width: isMobile ? 44 : 30, height: isMobile ? 44 : 30, borderRadius: 8, border: `1px solid ${TOKEN.surfaceBorder}`, background: open ? TOKEN.accentDim : TOKEN.surfaceRaised, color: TOKEN.textSecondary, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
{open && (
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'transparent' }} />
{items.map((it, idx) => (
{ if (it.disabled) return; setOpen(false); it.onClick && it.onClick(); }}
style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderRadius: 6, border: 'none', background: 'transparent', color: it.danger ? '#f87171' : TOKEN.text, fontSize: 13, fontFamily: 'inherit', textAlign: 'left', cursor: it.disabled ? 'not-allowed' : 'pointer', opacity: it.disabled ? 0.45 : 1 }}
onMouseEnter={(e) => { if (!it.disabled) e.currentTarget.style.background = TOKEN.surfaceRaised; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
{it.icon && }
{it.label}
))}
)}
);
};
// --- ProgressCounts (transcription status row) ---
const ProgressCounts = ({ counts, lang }) => {
const t = lang === 'zh';
const labels = t
? { pending: '待處理', processing: '處理中', completed: '完成', failed: '失敗' }
: { pending: 'Pending', processing: 'Processing', completed: 'Completed', failed: 'Failed' };
const colors = {
pending: TOKEN.textSecondary,
processing: TOKEN.accent,
completed: TOKEN.success,
failed: TOKEN.danger,
};
return (
{['pending', 'processing', 'completed', 'failed'].map(k => (
{counts?.[k] ?? 0}
{labels[k]}
))}
);
};
// --- categoryToBadge: map api_health error_category to {variant, label} ---
// Single source of truth for category→colour mapping shared by ScheduleTab
// and ExternalApiStatusTab so one category yields one consistent colour.
const categoryToBadge = (category, lang) => {
const t = lang === 'zh';
const map = {
quota_exceeded: { variant: 'danger', label: t ? '額度不足' : 'Quota Exceeded' },
auth_error: { variant: 'danger', label: t ? '認證錯誤' : 'Auth Error' },
rate_limited: { variant: 'warning', label: t ? '速率受限' : 'Rate Limited' },
server_error: { variant: 'warning', label: t ? '伺服器錯誤' : 'Server Error' },
network_error: { variant: 'warning', label: t ? '網路錯誤' : 'Network Error' },
unknown: { variant: 'muted', label: t ? '未知錯誤' : 'Unknown Error' },
};
return map[category] || map.unknown;
};
// --- formatRelativeTime: ts_ms epoch → "2 分鐘前" / "2 minutes ago" ---
const formatRelativeTime = (tsMs, lang) => {
if (!tsMs) return '';
const t = lang === 'zh';
const diffSec = Math.max(0, Math.floor((Date.now() - tsMs) / 1000));
if (diffSec < 60) return t ? '剛剛' : 'just now';
const m = Math.floor(diffSec / 60);
if (m < 60) return t ? `${m} 分鐘前` : `${m} minute${m === 1 ? '' : 's'} ago`;
const h = Math.floor(m / 60);
if (h < 24) return t ? `${h} 小時前` : `${h} hour${h === 1 ? '' : 's'} ago`;
const d = Math.floor(h / 24);
return t ? `${d} 天前` : `${d} day${d === 1 ? '' : 's'} ago`;
};
// Episode blurb — AI summary preferred, fallback to RSS description.
// Failure (`pending` / `running` / `failed`) is invisible to users — they
// just see the original RSS description (D3 in episode-ai-summary design).
const EpisodeBlurb = ({ episode, lang, style }) => {
if (!episode) return null;
const useAi = episode.ai_summary_status === 'done' && episode.ai_summary;
const text = useAi ? episode.ai_summary : (episode.description || '');
if (!text) return null;
const baseStyle = {
color: TOKEN.textSecondary,
fontSize: 12,
lineHeight: 1.55,
margin: 0,
overflow: 'hidden',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 3,
...(style || {}),
};
// RSS description often contains HTML; AI summary is plain text.
if (useAi) {
return {text}
;
}
return
;
};
Object.assign(window, { API_BASE, TOKEN, Icon, Badge, Btn, Input, TopNav, ConfirmModal, FormModal, OverflowMenu, ProgressCounts, categoryToBadge, formatRelativeTime, useViewport, EpisodeBlurb });