import { useRef, useState } from 'react'; import { useKanjiDetail } from '../../hooks/useKanjiDetail'; import { apiClient } from '../../lib/api-client'; import { formatNumber, formatRelativeDate } from '../../lib/formatters'; import type { VocabularyOccurrenceEntry } from '../../types/stats'; const OCCURRENCES_PAGE_SIZE = 50; interface KanjiDetailPanelProps { kanjiId: 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 KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToAnime }: KanjiDetailPanelProps) { const { data, loading, error } = useKanjiDetail(kanjiId); 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 (kanjiId === null) return null; const loadOccurrences = async (kanji: string, offset: number, append: boolean) => { const reqId = ++requestIdRef.current; if (append) { setOccLoadingMore(true); } else { setOccLoading(true); setOccError(null); } try { const rows = await apiClient.getKanjiOccurrences(kanji, 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.kanji, 0, false); }; const handleLoadMore = () => { if (!data || occLoadingMore || !hasMore) return; void loadOccurrences(data.detail.kanji, 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.words.length > 0 && (

Words Using This Kanji

{data.words.map(w => ( ))}
)}

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 && (
)} ); }