// Query Page — Layer 2: Search/Chat + Resizable/Collapsible Episode Panel
const MOCK_EPISODES = [
{ id: 'ep142', num: 142, title: '輝達 CUDA 護城河與台積電先進封裝的共生關係', titleEn: 'NVIDIA\'s CUDA Moat and TSMC Advanced Packaging Symbiosis', date: '2026-04-18', duration: '58:24', summary: '本集深度解析輝達 CUDA 生態系統如何形成技術護城河,以及台積電 CoWoS 先進封裝技術在 AI 晶片供應鏈中的不可替代性。專訪台積電前研發副總裁,揭露 2nm 製程量產挑戰。', summaryEn: 'Deep analysis of how NVIDIA\'s CUDA ecosystem forms a technological moat, and the indispensability of TSMC\'s CoWoS advanced packaging in the AI chip supply chain.', transcribed: true },
{ id: 'ep141', num: 141, title: '英特爾重組後的殘局:晶圓代工業務存活機率評估', titleEn: 'Intel Post-Restructuring: Assessing Foundry Survival Odds', date: '2026-04-11', duration: '64:10', summary: '英特爾宣布將晶圓代工業務分拆為獨立子公司後,業界對其存活率看法分歧。本集邀請三位半導體分析師進行辯論,並提出關鍵觀察指標。', summaryEn: 'Intel announced the spin-off of its foundry business. Three semiconductor analysts debate its survival odds.', transcribed: true },
{ id: 'ep140', num: 140, title: '日本半導體復興計畫:熊本廠之後的下一步', titleEn: 'Japan\'s Semiconductor Revival: What Comes After Kumamoto', date: '2026-04-04', duration: '51:33', summary: '熊本廠量產後,日本政府持續推動半導體自主化。本集分析 Rapidus 2nm 計畫的可行性,以及日本吸引 TSMC 第三廠的籌碼。', summaryEn: 'After the Kumamoto plant, Japan continues its semiconductor independence push.', transcribed: true },
{ id: 'ep139', num: 139, title: '亞利桑那廠良率困境與美國供應鏈在地化的現實', titleEn: 'Arizona Yield Struggles and the Reality of US Supply Chain Localization', date: '2026-03-28', duration: '55:48', summary: '台積電亞利桑那廠量產後良率低於台灣廠的問題持續受關注。本集訪談在地工程師,分析人才、文化與製程轉移的多重挑戰。', summaryEn: 'TSMC Arizona yield rates remain below Taiwan fabs post-production ramp.', transcribed: true },
{ id: 'ep138', num: 138, title: '中國半導體自主化進展:突破與限制的真實現況', titleEn: 'China Chip Autonomy Progress: Reality Behind the Breakthroughs', date: '2026-03-21', duration: '61:07', summary: '中國 7nm 晶片量產消息持續發酵,但與世界先進水準的差距究竟有多大?本集拆解各方說法,提供有數據支撐的客觀評估。', summaryEn: 'China\'s 7nm chip production news continues to stir debate.', transcribed: true },
{ id: 'ep137', num: 137, title: '量子電腦商業化時間表:Google Willow 之後的賽局', titleEn: 'Quantum Computing Timeline: The Race After Google Willow', date: '2026-03-14', duration: '49:22', summary: 'Google Willow 晶片突破後,各大科技公司紛紛加速量子路線圖。', summaryEn: 'After Google Willow\'s breakthrough, major tech companies accelerate quantum roadmaps.', transcribed: false },
];
const MOCK_CHAT = [];
const formatTimestamp = (seconds) => {
if (seconds == null || Number.isNaN(seconds)) return '--:--';
const total = Math.floor(seconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
};
// ── Resizable Episode Panel ──
const ResizableLayout = ({ lang, leftContent, rightHeader, rightContent, epCount, epTotal }) => {
const t = lang === 'zh';
const containerRef = React.useRef(null);
const [panelWidth, setPanelWidth] = React.useState(340);
const [collapsed, setCollapsed] = React.useState(false);
const [dragging, setDragging] = React.useState(false);
const MIN_WIDTH = 200;
const MAX_RATIO = 0.45; // max 45% of container
const onDragStart = (e) => {
e.preventDefault();
setDragging(true);
const startX = e.clientX;
const startW = panelWidth;
const move = (ev) => {
if (!containerRef.current) return;
const maxW = containerRef.current.offsetWidth * MAX_RATIO;
const delta = startX - ev.clientX; // dragging handle leftward = wider panel
const newW = Math.min(maxW, Math.max(MIN_WIDTH, startW + delta));
setPanelWidth(newW);
};
const up = () => { setDragging(false); window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
return (
{/* Left: Query panel */}
{leftContent}
{/* Drag handle */}
{ if (!dragging) e.currentTarget.style.background = TOKEN.accent + '88'; }}
onMouseLeave={e => { if (!dragging) e.currentTarget.style.background = TOKEN.surfaceBorder; }}>
{/* Dots on handle */}
{/* Right: Episode panel (collapsible) */}
{/* Panel header with collapse toggle */}
{!collapsed && (
<>
{t ? '已轉錄集數' : 'Transcribed Episodes'}
{epCount} / {epTotal} {t ? '集' : 'eps'}
>
)}
{/* Episode list (hidden when collapsed) */}
{!collapsed && (
{rightContent}
)}
{/* Collapsed: rotated label */}
{collapsed && (
{t ? '集數列表' : 'Episodes'}
)}
);
};
// ── Main QueryPage ──
const QueryPage = ({ lang, show, onBack, onOpenEpisode, queryMode }) => {
const t = lang === 'zh';
const [activeTab, setActiveTab] = React.useState('chat');
const [chatInput, setChatInput] = React.useState('');
const [messages, setMessages] = React.useState(MOCK_CHAT);
const [searchQ, setSearchQ] = React.useState('');
const [searching, setSearching] = React.useState(false);
const [searchResults, setSearchResults] = React.useState(null);
const [selectedEp, setSelectedEp] = React.useState(null);
const [sending, setSending] = React.useState(false);
const [episodes, setEpisodes] = React.useState(null);
const [epError, setEpError] = React.useState(null);
const chatEndRef = React.useRef(null);
React.useEffect(() => {
if (chatEndRef.current) chatEndRef.current.scrollTop = chatEndRef.current.scrollHeight;
}, [messages]);
React.useEffect(() => {
let cancelled = false;
setEpisodes(null);
setEpError(null);
(async () => {
try {
const res = await fetch(`${API_BASE}/shows/${show.id}/episodes?limit=200`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!cancelled) setEpisodes(data);
} catch (err) {
if (!cancelled) setEpError(err.message);
}
})();
return () => { cancelled = true; };
}, [show.id]);
const handleSend = async () => {
const question = chatInput.trim();
if (!question || sending) return;
const nextHistory = [...messages, { role: 'user', text: question }];
setMessages(nextHistory);
setChatInput('');
setSending(true);
try {
const history = nextHistory
.slice(0, -1)
.slice(-10)
.map(m => ({ role: m.role, content: m.text }));
const res = await fetch(`${API_BASE}/shows/${show.id}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'chat', question, messages: history }),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(detail || `HTTP ${res.status}`);
}
const data = await res.json();
setMessages(m => [...m, { role: 'assistant', text: data.answer, citations: data.citations || [] }]);
} catch (err) {
const msg = t ? `查詢失敗:${err.message}` : `Query failed: ${err.message}`;
setMessages(m => [...m, { role: 'assistant', text: msg, citations: [] }]);
} finally {
setSending(false);
}
};
const handleSearch = async () => {
const question = searchQ.trim();
if (!question || searching) return;
setSearching(true);
setSearchResults(null);
try {
const res = await fetch(`${API_BASE}/shows/${show.id}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'search', question }),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(detail || `HTTP ${res.status}`);
}
const data = await res.json();
const mapped = (data.results || []).map(r => ({
epId: r.episode_id,
epTitle: r.episode_title,
startTime: r.start_time,
timestamp: formatTimestamp(r.start_time),
text: r.text,
}));
setSearchResults(mapped);
} catch (err) {
setSearchResults({ error: err.message });
} finally {
setSearching(false);
}
};
const showChat = queryMode !== 2;
const showSearch = queryMode !== 1;
const effectiveTabs = [showChat && 'chat', showSearch && 'search'].filter(Boolean);
const curTab = effectiveTabs.includes(activeTab) ? activeTab : effectiveTabs[0];
const showName = show.title;
const transcribedCount = show.transcribed_count || 0;
const showColor = deriveColor(show.id);
const epCount = episodes
? episodes.filter(e => e.transcript_status === 'completed').length
: transcribedCount;
const onCitationClick = (citation) => {
onOpenEpisode({ id: citation.episode_id, title: citation.episode_title }, citation.start_time);
};
const leftContent = (
{/* Tab bar */}
{effectiveTabs.length > 1 && (
{effectiveTabs.map(tab => (
))}
)}
{/* Chat */}
{curTab === 'chat' && (
{messages.map((msg, i) => )}
{sending && }
)}
{/* Search */}
{curTab === 'search' && (
{searching &&
{t ? '搜尋中...' : 'Searching...'}
}
{searchResults && searchResults.error && (
{t ? `搜尋失敗:${searchResults.error}` : `Search failed: ${searchResults.error}`}
)}
{Array.isArray(searchResults) && (
{t ? `找到 ${searchResults.length} 個相關片段` : `Found ${searchResults.length} segments`}
{searchResults.map((r, i) => (
))}
)}
{!searching && !searchResults && (
{t ? '輸入關鍵字開始搜尋' : 'Enter a keyword to search'}
)}
)}
);
const rightContent = (() => {
if (epError) return (
{lang === 'zh' ? `載入失敗:${epError}` : `Load error: ${epError}`}
);
if (episodes === null) return (
{lang === 'zh' ? '載入中...' : 'Loading...'}
);
return episodes.map(ep => (
ep.transcript_status === 'completed' && (setSelectedEp(ep.id), onOpenEpisode(ep, null))} />
));
})();
return (
{/* Top bar */}
{t ? '返回' : 'Back'}
{showName}
{transcribedCount} {t ? '集已轉錄' : 'transcribed'}
);
};
// ── Sub-components ──
const ChatBubble = ({ msg, lang, onCitationClick }) => {
const isUser = msg.role === 'user';
const citations = msg.citations || [];
return (
{isUser ? 'U' : 'AI'}
{msg.text}
{citations.length > 0 && (
{citations.map((c, i) => (
onCitationClick && onCitationClick(c)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', background: TOKEN.bg, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 6, color: TOKEN.accent, fontSize: 12, fontFamily: 'inherit', cursor: onCitationClick ? 'pointer' : 'default', transition: 'border-color 0.15s' }}
onMouseEnter={e => onCitationClick && (e.currentTarget.style.borderColor = TOKEN.accent)}
onMouseLeave={e => onCitationClick && (e.currentTarget.style.borderColor = TOKEN.surfaceBorder)}>
{(c.episode_title || '').slice(0, 24) || (lang === 'zh' ? '片段' : 'Clip')} @ {formatTimestamp(c.start_time)}
))}
)}
);
};
const TypingIndicator = () => (
);
const SearchResultCard = ({ result, lang, query }) => {
const hi = text => {
if (!query) return text;
const parts = text.split(new RegExp(`(${query})`, 'gi'));
return parts.map((p, i) => p.toLowerCase() === query.toLowerCase()
? {p}
: p);
};
return (
{result.epTitle}
{result.timestamp}
{hi(result.text)}
);
};
const EpisodeCard = ({ ep, lang, selected, onClick }) => {
const t = lang === 'zh';
const [hovered, setHovered] = React.useState(false);
const done = ep.transcript_status === 'completed';
const dateStr = ep.published_at ? ep.published_at.slice(0, 10) : '';
const durStr = ep.duration_seconds != null ? formatTimestamp(ep.duration_seconds) : '--:--';
return (
setHovered(true)} onMouseLeave={() => setHovered(false)}
style={{ background: selected ? TOKEN.accentDim : hovered && done ? TOKEN.surfaceRaised : 'transparent', border: `1px solid ${selected ? TOKEN.accent + '55' : hovered && done ? TOKEN.surfaceBorder : 'transparent'}`, borderRadius: 10, padding: '10px 12px', cursor: done ? 'pointer' : 'default', opacity: done ? 1 : 0.45, transition: 'all 0.12s', marginBottom: 6 }}>
{ep.title}
{dateStr}
{durStr}
{done ? {t ? '已轉錄' : 'Done'} : {t ? '待轉錄' : 'Pending'}}
{done &&
}
);
};
Object.assign(window, { QueryPage, MOCK_EPISODES });