import { useEffect, useRef, useState } from 'react'; import { apiClient } from '../../lib/api-client'; import { getSentenceSearchMineAvailability, renderSentenceWithMatches, } from '../../lib/sentence-search'; import type { SentenceSearchResult } from '../../types/stats'; const SEARCH_LIMIT = 50; const SEARCH_DEBOUNCE_MS = 160; type MineMode = 'word' | 'sentence' | 'audio'; type MineStatus = { loading?: boolean; success?: boolean; error?: string }; function formatSegment(ms: number | null): string { if (ms == null || !Number.isFinite(ms)) return '--:--'; const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${String(seconds).padStart(2, '0')}`; } function resultKey(result: SentenceSearchResult, index: number): string { return `${result.sessionId}-${result.lineIndex}-${result.segmentStartMs ?? index}`; } function statusKey(result: SentenceSearchResult, index: number, mode: MineMode): string { return `${resultKey(result, index)}-${mode}`; } function buttonLabel( mode: MineMode, status: MineStatus | undefined, disabledLabel: string, ): string { if (status?.loading) return 'Mining...'; if (status?.success) return 'Mined!'; if (disabledLabel) return disabledLabel; if (mode === 'word') return 'Word'; if (mode === 'audio') return 'Audio'; return 'Sentence'; } export function formatSentenceSearchMatchCountLabel(count: number): string { return count === 1 ? 'Match' : 'Matches'; } export function SearchTab() { const [query, setQuery] = useState(''); const [searchByHeadword, setSearchByHeadword] = useState(true); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [mineStatus, setMineStatus] = useState>({}); const requestRef = useRef(0); useEffect(() => { const trimmed = query.trim(); const requestId = ++requestRef.current; setMineStatus({}); if (!trimmed) { setResults([]); setLoading(false); setError(null); return; } setLoading(true); setError(null); const timer = window.setTimeout(() => { apiClient .searchSentences(trimmed, SEARCH_LIMIT, searchByHeadword) .then((nextResults) => { if (requestId !== requestRef.current) return; setResults(nextResults); }) .catch((err: Error) => { if (requestId !== requestRef.current) return; setError(err.message); setResults([]); }) .finally(() => { if (requestId !== requestRef.current) return; setLoading(false); }); }, SEARCH_DEBOUNCE_MS); return () => { window.clearTimeout(timer); }; }, [query, searchByHeadword]); const handleMine = async ( result: SentenceSearchResult, index: number, mode: MineMode, ): Promise => { const availability = getSentenceSearchMineAvailability(result, query); if (mode === 'sentence' ? !availability.canMineSentence : !availability.canMineWordAudio) { return; } if (!result.sourcePath || result.segmentStartMs == null || result.segmentEndMs == null) { return; } const key = statusKey(result, index, mode); setMineStatus((prev) => ({ ...prev, [key]: { loading: true } })); try { const searchedWord = availability.exactMatch ? query.trim() : ''; const response = await apiClient.mineCard({ sourcePath: result.sourcePath, startMs: result.segmentStartMs, endMs: result.segmentEndMs, sentence: result.text, word: searchedWord, secondaryText: result.secondaryText, videoTitle: result.videoTitle, mode, }); if (response.error) { setMineStatus((prev) => ({ ...prev, [key]: { error: response.error } })); return; } setMineStatus((prev) => ({ ...prev, [key]: { success: true } })); } catch (err) { setMineStatus((prev) => ({ ...prev, [key]: { error: err instanceof Error ? err.message : String(err) }, })); } }; const trimmedQuery = query.trim(); return (
Sentence Search
setQuery(event.target.value)} placeholder="Search sentence text or media..." className="min-w-0 flex-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 text-base text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-yellow focus:outline-none" autoComplete="off" aria-label="Sentence search" />
{loading ? '...' : results.length}
{loading ? 'Matches' : formatSentenceSearchMatchCountLabel(results.length)}
{error && (
Error: {error}
)} {!trimmedQuery && (
Search your tracked subtitle lines.
)} {trimmedQuery && !loading && !error && results.length === 0 && (
No sentence matches.
)} {results.length > 0 && (
{results.map((result, index) => { const availability = getSentenceSearchMineAvailability(result, trimmedQuery); const wordStatus = mineStatus[statusKey(result, index, 'word')]; const sentenceStatus = mineStatus[statusKey(result, index, 'sentence')]; const audioStatus = mineStatus[statusKey(result, index, 'audio')]; const wordAudioDisabledReason = availability.unavailableReason ?? (availability.exactMatch ? '' : 'Exact searched word not found in sentence.'); const sentenceDisabledReason = availability.unavailableReason ?? ''; const errors = [wordStatus?.error, sentenceStatus?.error, audioStatus?.error].filter( Boolean, ); return (
{result.animeTitle ?? result.videoTitle}
{result.videoTitle} line {result.lineIndex} session {result.sessionId} {formatSegment(result.segmentStartMs)}-{formatSegment(result.segmentEndMs)}
{availability.exactMatch && ( )} {availability.exactMatch && ( )}

{renderSentenceWithMatches(result.text, trimmedQuery)}

{errors.length > 0 &&
{errors[0]}
}
); })}
)}
); }