{ if (!dragging) e.currentTarget.style.background = TOKEN.accent + '88'; }}
onMouseLeave={e => { if (!dragging) e.currentTarget.style.background = TOKEN.surfaceBorder; }}>
{[0,1,2].map(i => )}
{/* Right: Episode panel (collapsible) */}
{!collapsed && (
<>
{t ? '已轉錄集數' : 'Transcribed Episodes'}
{epCount} / {epTotal} {t ? '集' : 'eps'}
>
)}
{!collapsed && (
{rightContent}
)}
{collapsed && (
{t ? '集數列表' : 'Episodes'}
)}
);
};
// ── Main QueryPage ──
// Locked card replacing the chat answer area for unauthenticated visitors.
// Height-capped so segment results below stay visible without forced scrolling.
const LockedAnswerCard = ({ lang }) => {
const t = lang === 'zh';
return (
{ window.location.href = googleLoginUrl(); }}>
{t ? '以 Google 登入解鎖' : 'Sign in with Google to unlock'}
{t ? '30 次免費' : '30 free uses'}
);
};
const QueryPage = ({ lang, show, onBack, onOpenEpisode, queryMode, user, onUserChange, initialQuery }) => {
const t = lang === 'zh';
const quotaExhausted = user && user.quota_remaining === 0;
const { isMobile } = useViewport();
const [drawerOpen, setDrawerOpen] = React.useState(false);
// Anonymous visitors land on the search tab so they can use the free
// segment search; chat tab shows a locked card for them.
const [activeTab, setActiveTab] = React.useState(user ? 'chat' : 'search');
const [chatInput, setChatInput] = React.useState('');
const [messages, setMessages] = React.useState(MOCK_CHAT);
const [searchQ, setSearchQ] = React.useState(initialQuery || '');
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 [quotaModalOpen, setQuotaModalOpen] = React.useState(false);
const chatEndRef = React.useRef(null);
// If we arrived from LandingPage with a query, fire the search once on mount
const didAutoSearchRef = React.useRef(false);
React.useEffect(() => {
if (didAutoSearchRef.current) return;
if (initialQuery && initialQuery.trim()) {
didAutoSearchRef.current = true;
// Defer one tick so handleSearch sees the populated state
setTimeout(() => handleSearch(initialQuery.trim()), 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialQuery]);
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 apiFetch(`/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 }));
let res;
try {
res = await apiFetch(`/shows/${show.id}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'chat', question, messages: history }),
});
} catch (netErr) {
throw new Error(networkErrorMessage(lang));
}
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(formatError(body, lang));
}
const data = await res.json();
setMessages(m => [...m, { role: 'assistant', text: data.answer, citations: data.citations || [] }]);
if (typeof data.quota_remaining === 'number' && onUserChange) onUserChange();
} catch (err) {
setMessages(m => [...m, { role: 'assistant', text: err.message, citations: [] }]);
} finally {
setSending(false);
}
};
const handleSearch = async (overrideQuestion) => {
const question = (overrideQuestion ?? searchQ).trim();
if (!question || searching) return;
setSearching(true);
setSearchResults(null);
try {
let res;
try {
// New public-search endpoint: works for anonymous (IP rate-limited)
// and authenticated users (no quota decrement). Keeps the chat
// endpoint as the only LLM-cost path.
res = await apiFetch(`/shows/${show.id}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
});
} catch (netErr) {
throw new Error(networkErrorMessage(lang));
}
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(formatError(body, lang));
}
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 = (
{/* Quota meter — authenticated only. Anon visitors see no meter (no
quota concept until they sign in). */}
{user && setQuotaModalOpen(true)} />}
{/* Tab bar */}
{effectiveTabs.length > 1 && (