import { useRef, useState, useEffect } from 'react'; import { useWordDetail } from '../../hooks/useWordDetail'; import { apiClient } from '../../lib/api-client'; import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters'; import { fullReading } from '../../lib/reading-utils'; import type { VocabularyOccurrenceEntry } from '../../types/stats'; import { PosBadge } from './pos-helpers'; const INITIAL_PAGE_SIZE = 5; const LOAD_MORE_SIZE = 10; type MineStatus = { loading?: boolean; success?: boolean; error?: string }; interface WordDetailPanelProps { wordId: number | null; onClose: () => void; onSelectWord?: (wordId: number) => void; onNavigateToAnime?: (animeId: number) => void; isExcluded?: (w: { headword: string; word: string; reading: string }) => boolean; onToggleExclusion?: (w: { headword: string; word: string; reading: string }) => void; } function highlightWord(text: string, words: string[]): React.ReactNode { const needles = words.filter(Boolean); if (needles.length === 0) return text; const escaped = needles.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const pattern = new RegExp(`(${escaped.join('|')})`, 'g'); const parts = text.split(pattern); const needleSet = new Set(needles); return parts.map((part, i) => needleSet.has(part) ? ( {part} ) : ( part ), ); } 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')}`; } export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAnime, isExcluded, onToggleExclusion, }: WordDetailPanelProps) { const { data, loading, error } = useWordDetail(wordId); const [occurrences, setOccurrences] = useState([]); const [occLoading, setOccLoading] = useState(false); const [occLoadingMore, setOccLoadingMore] = useState(false); const [occError, setOccError] = useState(null); const [hasMore, setHasMore] = useState(false); const [occLoaded, setOccLoaded] = useState(false); const [mineStatus, setMineStatus] = useState>({}); const requestIdRef = useRef(0); useEffect(() => { setOccurrences([]); setOccLoaded(false); setOccLoading(false); setOccLoadingMore(false); setOccError(null); setHasMore(false); setMineStatus({}); requestIdRef.current++; }, [wordId]); if (wordId === null) return null; const loadOccurrences = async ( detail: NonNullable['detail'], offset: number, limit: number, append: boolean, ) => { const reqId = ++requestIdRef.current; if (append) { setOccLoadingMore(true); } else { setOccLoading(true); setOccError(null); } try { const rows = await apiClient.getWordOccurrences( detail.headword, detail.word, detail.reading, limit, offset, ); if (reqId !== requestIdRef.current) return; setOccurrences((prev) => (append ? [...prev, ...rows] : rows)); setHasMore(rows.length === limit); } catch (err) { if (reqId !== requestIdRef.current) return; setOccError(err instanceof Error ? err.message : String(err)); if (!append) { setOccurrences([]); setHasMore(false); } } finally { if (reqId !== requestIdRef.current) return; setOccLoading(false); setOccLoadingMore(false); setOccLoaded(true); } }; const handleShowOccurrences = () => { if (!data) return; void loadOccurrences(data.detail, 0, INITIAL_PAGE_SIZE, false); }; const handleLoadMore = () => { if (!data || occLoadingMore || !hasMore) return; void loadOccurrences(data.detail, occurrences.length, LOAD_MORE_SIZE, true); }; const handleMine = async ( occ: VocabularyOccurrenceEntry, mode: 'word' | 'sentence' | 'audio', ) => { if (!occ.sourcePath || occ.segmentStartMs == null || occ.segmentEndMs == null) { return; } const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`; setMineStatus((prev) => ({ ...prev, [key]: { loading: true } })); try { const result = await apiClient.mineCard({ sourcePath: occ.sourcePath!, startMs: occ.segmentStartMs!, endMs: occ.segmentEndMs!, sentence: occ.text, word: data!.detail.headword, secondaryText: occ.secondaryText, videoTitle: occ.videoTitle, mode, }); if (result.error) { setMineStatus((prev) => ({ ...prev, [key]: { error: result.error } })); } else { setMineStatus((prev) => ({ ...prev, [key]: { success: true } })); const label = mode === 'audio' ? 'Audio card' : mode === 'word' ? data!.detail.headword : occ.text.slice(0, 30); if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' }); } else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') { Notification.requestPermission().then((p) => { if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` }); }); } } } catch (err) { setMineStatus((prev) => ({ ...prev, [key]: { error: err instanceof Error ? err.message : String(err) }, })); } }; return (
)}
{data && ( <>
{formatNumber(data.detail.frequency)}
Frequency
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
First Seen
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
Last Seen
{data.animeAppearances.length > 0 && (

Anime Appearances

{data.animeAppearances.map((a) => ( ))}
)} {data.similarWords.length > 0 && (

Similar Words

{data.similarWords.map((sw) => ( ))}
)}

Example Lines

{!occLoaded && !occLoading && ( )} {occLoading && (
Loading occurrences...
)} {occError &&
Error: {occError}
} {occLoaded && !occLoading && occurrences.length === 0 && (
No example lines tracked yet. Lines are stored for sessions recorded after the subtitle tracking update.
)} {occurrences.length > 0 && (
{occurrences.map((occ, idx) => (
{occ.animeTitle ?? occ.videoTitle}
{occ.videoTitle} · line {occ.lineIndex}
{formatNumber(occ.occurrenceCount)} in line
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '} · session {occ.sessionId} {(() => { const canMine = !!occ.sourcePath && occ.segmentStartMs != null && occ.segmentEndMs != null; const unavailableReason = canMine ? null : occ.sourcePath ? 'This line is missing segment timing.' : 'This source has no local file path.'; const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; const wordStatus = mineStatus[`${baseKey}-word`]; const sentenceStatus = mineStatus[`${baseKey}-sentence`]; const audioStatus = mineStatus[`${baseKey}-audio`]; return ( <> ); })()}
{(() => { const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; const errors = ['word', 'sentence', 'audio'] .map((m) => mineStatus[`${baseKey}-${m}`]?.error) .filter(Boolean); return errors.length > 0 ? (
{errors[0]}
) : null; })()}

{highlightWord(occ.text, [data!.detail.headword, data!.detail.word])}

))} {hasMore && ( )}
)}
)}
); }