// LandingPage — public-facing root for unauthenticated visitors. Once /me // resolves to a user the App swaps in PodcastSelect; this component never // renders for logged-in users. const LandingPage = ({ lang, onSearch, onSelectShow }) => { const t = lang === 'zh'; const { isMobile } = useViewport(); const [shows, setShows] = React.useState(null); const [showsError, setShowsError] = React.useState(null); const [query, setQuery] = React.useState(''); React.useEffect(() => { let cancelled = false; (async () => { try { const res = await apiFetch(`/shows`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (!cancelled) setShows(data); } catch (err) { if (!cancelled) setShowsError(err.message); } })(); return () => { cancelled = true; }; }, []); const showsRef = React.useRef(null); const handleSubmit = (e) => { if (e && e.preventDefault) e.preventDefault(); onSearch && onSearch(query.trim()); // Scroll to the show cards so anonymous visitors can pick a show to // search inside. Per LandingPage spec the typed query is preserved // (stashed in App state) for QueryPage pre-fill. if (showsRef.current) { showsRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }; const truncate = (str, max) => { if (!str) return ''; const clean = str.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); return clean.length > max ? clean.slice(0, max) + '…' : clean; }; const totalEpisodes = (shows || []).reduce( (acc, s) => acc + (s.episode_count || 0), 0, ); const totalTranscribed = (shows || []).reduce( (acc, s) => acc + (s.transcribed_count || 0), 0, ); return (
{/* ─── Hero ─── */}

{t ? <>「那個來賓說過什麼?」
別再瘋狂快轉了。 : <>"What did that guest say?"
Stop fast-forwarding through podcasts.}

{t ? '忘記在哪一集沒關係。直接問,從節目片段中找回那道遺忘的靈光一閃,瞬間解開你的疑惑。' : 'Forgotten which episode it was in? Just ask. Find the moment of insight you remember, instantly.'}

setQuery(e.target.value)} placeholder={t ? '例如:在 這又沒有很屌 查詢「歌單」' : 'Example: search "playlist" in 這又沒有很屌'} style={{ flex: 1, padding: '14px 18px', fontSize: 15, borderRadius: 10, border: `1px solid ${TOKEN.surfaceBorder}`, background: TOKEN.surfaceRaised, color: TOKEN.text, }} /> {t ? '找回靈光一閃' : 'Bring back the insight'}
{shows && shows.length > 0 && (

{t ? `已索引 ${shows.length} 個節目、${totalEpisodes} 集、${totalTranscribed} 集已轉錄` : `${shows.length} shows indexed · ${totalEpisodes} episodes · ${totalTranscribed} transcribed`}

)}
{/* ─── Collected Shows ─── */}
{t ? '收錄節目' : 'Collected Shows'}
{showsError && (

{t ? '載入節目失敗:' : 'Failed to load shows: '}{showsError}

)} {!shows && !showsError && (

{t ? '載入中…' : 'Loading…'}

)} {shows && shows.length === 0 && (

{t ? '目前還沒有可瀏覽的節目' : 'No shows available yet'}

)} {shows && shows.length > 0 && (
{shows.map(show => { const epCount = show.episode_count || 0; const transcribed = show.transcribed_count || 0; const ratio = epCount > 0 ? Math.round((transcribed / epCount) * 100) : 0; return (
onSelectShow && onSelectShow(show)} style={{ background: TOKEN.surface, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 12, padding: 20, cursor: 'pointer', transition: 'border-color 120ms, transform 120ms', display: 'flex', flexDirection: 'column', minHeight: 220, }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = TOKEN.accent; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = TOKEN.surfaceBorder; }} >

🎧 {show.title}

{truncate(show.description, t ? 60 : 100) || (t ? '(無描述)' : '(no description)')}

{epCount} {t ? '集' : 'eps'} {ratio === 100 ? (t ? '✓ 已轉錄全部' : '✓ Fully transcribed') : `${ratio}% ${t ? '已轉錄' : 'transcribed'}`}
{ e.stopPropagation(); onSelectShow && onSelectShow(show); }}> {t ? '瀏覽集數 →' : 'Browse episodes →'}
); })}
)}
{/* ─── Paywall band ─── */}
💎

{t ? '登入解鎖:30 次 AI 統整回答(一次性免費額度,用完可申請補充)' : 'Log in to unlock: 30 free AI summary answers (one-time, request more once depleted)'}

{t ? '瀏覽逐字稿、看相關段落都不用登入。只有「請 AI 統整回答」會用到 quota。' : 'Browsing transcripts and seeing matched segments stays free. Only AI-generated summaries use your quota.'}

{ window.location.href = googleLoginUrl(); }}> {t ? '以 Google 登入 →' : 'Sign in with Google →'}
); }; Object.assign(window, { LandingPage });