import { useRef, useState } from 'react'; import { useWordDetail } from '../../hooks/useWordDetail'; import { apiClient } from '../../lib/api-client'; import { formatNumber, formatRelativeDate } from '../../lib/formatters'; import type { VocabularyOccurrenceEntry } from '../../types/stats'; import { PosBadge } from './pos-helpers'; const OCCURRENCES_PAGE_SIZE = 50; interface WordDetailPanelProps { wordId: number | null; onClose: () => void; onSelectWord?: (wordId: number) => void; onNavigateToAnime?: (animeId: number) => void; } 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 }: 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 requestIdRef = useRef(0); if (wordId === null) return null; const loadOccurrences = async (detail: NonNullable['detail'], offset: 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, OCCURRENCES_PAGE_SIZE, offset, ); if (reqId !== requestIdRef.current) return; setOccurrences(prev => append ? [...prev, ...rows] : rows); setHasMore(rows.length === OCCURRENCES_PAGE_SIZE); } 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, false); }; const handleLoadMore = () => { if (!data || occLoadingMore || !hasMore) return; void loadOccurrences(data.detail, occurrences.length, true); }; return (
{data && ( <>
{formatNumber(data.detail.frequency)}
Frequency
{formatRelativeDate(data.detail.firstSeen)}
First Seen
{formatRelativeDate(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 occurrences tracked yet.
)} {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}

{occ.text}

))}
)}
)}
{occLoaded && !occLoading && !occError && hasMore && (
)} ); }