// 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 ( ); }; // --- 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 && ( )} {/* Logo */}
PodcastRAG {!isMobile && beta}
{/* Desktop: main nav items inline */} {!isMobile && ( )} {/* Right: lang toggle + user / sign-in (desktop only — mobile lang is in dropdown) */} {!isMobile && (
{user ? ( ) : ( )}
)} {isMobile && user && ( )} {isMobile && !user && ( )}
{/* 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 ( ); })}
)} {/* 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 (
{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}
)}
); }; const TopNavItem = ({ icon, label, active, onClick, secondary, mobile }) => { const [hovered, setHovered] = React.useState(false); return ( ); }; // --- 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 (
{open && (
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'transparent' }} />
{items.map((it, idx) => ( ))}
)}
); }; // --- 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 });