feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions

View File

@@ -0,0 +1,168 @@
import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers';
import { fullReading } from '../../lib/reading-utils';
import type { VocabularyEntry } from '../../types/stats';
interface CrossAnimeWordsTableProps {
words: VocabularyEntry[];
knownWords: Set<string>;
onSelectWord?: (word: VocabularyEntry) => void;
}
const PAGE_SIZE = 25;
export function CrossAnimeWordsTable({
words,
knownWords,
onSelectWord,
}: CrossAnimeWordsTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const ranked = useMemo(() => {
let filtered = words.filter((w) => w.animeCount >= 2);
if (hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !knownWords.has(w.headword) && !knownWords.has(w.word));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (
w.frequencyRank != null &&
(existing.frequencyRank == null || w.frequencyRank < existing.frequencyRank)
) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) existing.reading = w.reading;
if (!existing.partOfSpeech && w.partOfSpeech) existing.partOfSpeech = w.partOfSpeech;
}
}
return [...byHeadword.values()].sort((a, b) => {
if (b.animeCount !== a.animeCount) return b.animeCount - a.animeCount;
return b.frequency - a.frequency;
});
}, [words, knownWords, hideKnown, hasKnownData]);
const hasMultiAnimeWords = words.some((w) => w.animeCount >= 2);
if (!hasMultiAnimeWords) return null;
const totalPages = Math.ceil(ranked.length / PAGE_SIZE);
const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
>
<span
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
>
{'\u25B6'}
</span>
Words In Multiple Anime
</button>
<div className="flex items-center gap-3">
{hasKnownData && (
<button
type="button"
onClick={() => {
setHideKnown(!hideKnown);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKnown
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Known
</button>
)}
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div>
</div>
{collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown
? 'All multi-anime words are already known!'
: 'No words found across multiple anime.'}
</div>
) : (
<>
<div className="overflow-x-auto mt-3">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="text-left py-2 pr-3 font-medium">Word</th>
<th className="text-left py-2 pr-3 font-medium">Reading</th>
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
<th className="text-right py-2 pr-3 font-medium w-16">Anime</th>
<th className="text-right py-2 font-medium w-16">Seen</th>
</tr>
</thead>
<tbody>
{paged.map((w) => (
<tr
key={w.wordId}
onClick={() => onSelectWord?.(w)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
<td className="py-1.5 pr-3 text-ctp-subtext0">
{fullReading(w.headword, w.reading) || w.headword}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 pr-3 text-right font-mono tabular-nums text-ctp-green text-xs">
{w.animeCount}
</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 mt-3 text-xs">
<button
type="button"
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Prev
</button>
<span className="text-ctp-overlay2">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import type { ExcludedWord } from '../../hooks/useExcludedWords';
interface ExclusionManagerProps {
excluded: ExcludedWord[];
onRemove: (w: ExcludedWord) => void;
onClearAll: () => void;
onClose: () => void;
}
export function ExclusionManager({
excluded,
onRemove,
onClearAll,
onClose,
}: ExclusionManagerProps) {
return (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close exclusion manager"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<div className="absolute inset-x-0 top-1/2 mx-auto max-w-lg -translate-y-1/2 rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex items-center justify-between border-b border-ctp-surface1 px-5 py-4">
<h2 className="text-sm font-semibold text-ctp-text">
Excluded Words
<span className="ml-2 text-ctp-overlay1 font-normal">({excluded.length})</span>
</h2>
<div className="flex items-center gap-2">
{excluded.length > 0 && (
<button
type="button"
className="rounded-md border border-ctp-red/30 px-3 py-1.5 text-xs font-medium text-ctp-red transition hover:bg-ctp-red/10"
onClick={onClearAll}
>
Clear All
</button>
)}
<button
type="button"
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onClose}
>
Close
</button>
</div>
</div>
<div className="max-h-80 overflow-y-auto px-5 py-3">
{excluded.length === 0 ? (
<div className="py-6 text-center text-sm text-ctp-overlay2">
No excluded words yet. Use the Exclude button on a word's detail panel to hide it from
stats.
</div>
) : (
<div className="space-y-1.5">
{excluded.map((w) => (
<div
key={`${w.headword}\0${w.word}\0${w.reading}`}
className="flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2"
>
<div className="min-w-0">
<span className="text-sm font-medium text-ctp-text">{w.headword}</span>
{w.reading && w.reading !== w.headword && (
<span className="ml-2 text-xs text-ctp-subtext0">{w.reading}</span>
)}
</div>
<button
type="button"
className="shrink-0 rounded-md border border-ctp-surface2 px-2 py-1 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={() => onRemove(w)}
>
Restore
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers';
import { fullReading } from '../../lib/reading-utils';
import type { VocabularyEntry } from '../../types/stats';
interface FrequencyRankTableProps {
words: VocabularyEntry[];
knownWords: Set<string>;
onSelectWord?: (word: VocabularyEntry) => void;
}
const PAGE_SIZE = 25;
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const isWordKnown = (w: VocabularyEntry): boolean => {
return knownWords.has(w.headword) || knownWords.has(w.word);
};
const ranked = useMemo(() => {
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
if (hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !isWordKnown(w));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (w.frequencyRank! < existing.frequencyRank!) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) {
existing.reading = w.reading;
}
if (!existing.partOfSpeech && w.partOfSpeech) {
existing.partOfSpeech = w.partOfSpeech;
}
}
}
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
}, [words, knownWords, hideKnown, hasKnownData]);
if (words.every((w) => w.frequencyRank == null)) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-2">Most Common Words Seen</h3>
<div className="text-xs text-ctp-overlay2">
No frequency rank data available. Run the frequency backfill script or install a frequency
dictionary.
</div>
</div>
);
}
const totalPages = Math.ceil(ranked.length / PAGE_SIZE);
const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
>
<span
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
>
{'\u25B6'}
</span>
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
</button>
<div className="flex items-center gap-3">
{hasKnownData && (
<button
type="button"
onClick={() => {
setHideKnown(!hideKnown);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKnown
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Known
</button>
)}
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div>
</div>
{collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
</div>
) : (
<>
<div className="overflow-x-auto mt-3">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
<th className="text-left py-2 pr-3 font-medium">Word</th>
<th className="text-left py-2 pr-3 font-medium">Reading</th>
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
<th className="text-right py-2 font-medium w-20">Seen</th>
</tr>
</thead>
<tbody>
{paged.map((w) => (
<tr
key={w.wordId}
onClick={() => onSelectWord?.(w)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
#{w.frequencyRank!.toLocaleString()}
</td>
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
<td className="py-1.5 pr-3 text-ctp-subtext0">
{fullReading(w.headword, w.reading) || w.headword}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 mt-3 text-xs">
<button
type="button"
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Prev
</button>
<span className="text-ctp-overlay2">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { KanjiEntry } from '../../types/stats';
interface KanjiBreakdownProps {
kanji: KanjiEntry[];
selectedKanjiId?: number | null;
onSelectKanji?: (entry: KanjiEntry) => void;
}
export function KanjiBreakdown({
kanji,
selectedKanjiId = null,
onSelectKanji,
}: KanjiBreakdownProps) {
if (kanji.length === 0) return null;
const maxFreq = kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Kanji Encountered</h3>
<div className="flex flex-wrap gap-1">
{kanji.map((k) => {
const ratio = k.frequency / maxFreq;
const opacity = Math.max(0.3, ratio);
return (
<button
type="button"
key={k.kanji}
className={`cursor-pointer rounded px-1 text-lg text-ctp-teal transition ${
selectedKanjiId === k.kanjiId
? 'bg-ctp-teal/10 ring-1 ring-ctp-teal'
: 'hover:bg-ctp-surface1/80'
}`}
style={{ opacity }}
title={`${k.kanji} — seen ${k.frequency}x`}
aria-label={`${k.kanji} — seen ${k.frequency} times`}
onClick={() => onSelectKanji?.(k)}
>
{k.kanji}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,267 @@
import { useRef, useState, useEffect } from 'react';
import { useKanjiDetail } from '../../hooks/useKanjiDetail';
import { apiClient } from '../../lib/api-client';
import { epochMsFromDbTimestamp, 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<VocabularyOccurrenceEntry[]>([]);
const [occLoading, setOccLoading] = useState(false);
const [occLoadingMore, setOccLoadingMore] = useState(false);
const [occError, setOccError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [occLoaded, setOccLoaded] = useState(false);
const requestIdRef = useRef(0);
useEffect(() => {
setOccurrences([]);
setOccLoaded(false);
setOccLoading(false);
setOccLoadingMore(false);
setOccError(null);
setHasMore(false);
requestIdRef.current++;
}, [kanjiId]);
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 (
<div className="fixed inset-0 z-40">
<button
type="button"
aria-label="Close kanji detail panel"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
Kanji Detail
</div>
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
{data && (
<>
<h2 className="mt-1 text-5xl font-semibold text-ctp-teal">{data.detail.kanji}</h2>
<div className="mt-2 text-sm text-ctp-subtext0">
{formatNumber(data.detail.frequency)} total occurrences
</div>
</>
)}
</div>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onClose}
>
Close
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{data && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-lg font-bold text-ctp-teal">
{formatNumber(data.detail.frequency)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div>
</div>
{data.animeAppearances.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Anime Appearances
</h3>
<div className="space-y-1.5">
{data.animeAppearances.map((a) => (
<button
key={a.animeId}
type="button"
onClick={() => {
onClose();
onNavigateToAnime?.(a.animeId);
}}
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-teal hover:ring-1 hover:ring-ctp-teal text-left"
>
<span className="truncate text-ctp-text">{a.animeTitle}</span>
<span className="ml-2 shrink-0 rounded-full bg-ctp-teal/10 px-2 py-0.5 text-[11px] font-medium text-ctp-teal">
{formatNumber(a.occurrenceCount)}
</span>
</button>
))}
</div>
</section>
)}
{data.words.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Words Using This Kanji
</h3>
<div className="flex flex-wrap gap-1.5">
{data.words.map((w) => (
<button
key={w.wordId}
type="button"
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-ctp-blue bg-ctp-blue/10 transition hover:ring-1 hover:ring-ctp-blue"
onClick={() => onSelectWord?.(w.wordId)}
>
{w.headword}
<span className="opacity-60">({formatNumber(w.frequency)})</span>
</button>
))}
</div>
</section>
)}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Example Lines
</h3>
{!occLoaded && !occLoading && (
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal"
onClick={handleShowOccurrences}
>
Load example lines
</button>
)}
{occLoading && (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
)}
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
{occLoaded && !occLoading && occurrences.length === 0 && (
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
)}
{occurrences.length > 0 && (
<div className="space-y-3">
{occurrences.map((occ, idx) => (
<article
key={`${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs ?? idx}`}
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-ctp-text">
{occ.animeTitle ?? occ.videoTitle}
</div>
<div className="truncate text-xs text-ctp-subtext0">
{occ.videoTitle} · line {occ.lineIndex}
</div>
</div>
<div className="rounded-full bg-ctp-teal/10 px-2 py-1 text-[11px] font-medium text-ctp-teal">
{formatNumber(occ.occurrenceCount)} in line
</div>
</div>
<div className="mt-3 text-xs text-ctp-overlay1">
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} ·
session {occ.sessionId}
</div>
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{occ.text}
</p>
</article>
))}
</div>
)}
</section>
</>
)}
</div>
{occLoaded && !occLoading && !occError && hasMore && (
<div className="border-t border-ctp-surface1 px-4 py-4">
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal disabled:cursor-not-allowed disabled:opacity-60"
onClick={handleLoadMore}
disabled={occLoadingMore}
>
{occLoadingMore ? 'Loading more...' : 'Load more'}
</button>
</div>
)}
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import type { KanjiEntry, VocabularyEntry, VocabularyOccurrenceEntry } from '../../types/stats';
import { formatNumber } from '../../lib/formatters';
type VocabularyDrawerTarget =
| {
kind: 'word';
entry: VocabularyEntry;
}
| {
kind: 'kanji';
entry: KanjiEntry;
};
interface VocabularyOccurrencesDrawerProps {
target: VocabularyDrawerTarget | null;
occurrences: VocabularyOccurrenceEntry[];
loading: boolean;
loadingMore: boolean;
error: string | null;
hasMore: boolean;
onClose: () => void;
onLoadMore: () => 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')}`;
}
function renderTitle(target: VocabularyDrawerTarget): string {
return target.kind === 'word' ? target.entry.headword : target.entry.kanji;
}
function renderSubtitle(target: VocabularyDrawerTarget): string {
if (target.kind === 'word') {
return target.entry.reading || target.entry.word;
}
return `${formatNumber(target.entry.frequency)} seen`;
}
function renderFrequency(target: VocabularyDrawerTarget): string {
return `${formatNumber(target.entry.frequency)} total`;
}
export function VocabularyOccurrencesDrawer({
target,
occurrences,
loading,
loadingMore,
error,
hasMore,
onClose,
onLoadMore,
}: VocabularyOccurrencesDrawerProps) {
if (!target) return null;
return (
<div className="fixed inset-0 z-40">
<button
type="button"
aria-label="Close occurrence drawer"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
{target.kind === 'word' ? 'Word Occurrences' : 'Kanji Occurrences'}
</div>
<h2 className="mt-1 truncate text-2xl font-semibold text-ctp-text">
{renderTitle(target)}
</h2>
<div className="mt-1 text-sm text-ctp-subtext0">{renderSubtitle(target)}</div>
<div className="mt-2 text-xs text-ctp-overlay1">
{renderFrequency(target)} · {formatNumber(occurrences.length)} loaded
</div>
</div>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onClose}
>
Close
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{loading ? (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
) : null}
{!loading && error ? <div className="text-sm text-ctp-red">Error: {error}</div> : null}
{!loading && !error && occurrences.length === 0 ? (
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
) : null}
{!loading && !error ? (
<div className="space-y-3">
{occurrences.map((occurrence, index) => (
<article
key={`${occurrence.sessionId}-${occurrence.lineIndex}-${occurrence.segmentStartMs ?? index}`}
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-ctp-text">
{occurrence.animeTitle ?? occurrence.videoTitle}
</div>
<div className="truncate text-xs text-ctp-subtext0">
{occurrence.videoTitle} · line {occurrence.lineIndex}
</div>
</div>
<div className="rounded-full bg-ctp-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
{formatNumber(occurrence.occurrenceCount)} in line
</div>
</div>
<div className="mt-3 text-xs text-ctp-overlay1">
{formatSegment(occurrence.segmentStartMs)}-
{formatSegment(occurrence.segmentEndMs)} · session {occurrence.sessionId}
</div>
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{occurrence.text}
</p>
</article>
))}
</div>
) : null}
</div>
{!loading && !error && hasMore ? (
<div className="border-t border-ctp-surface1 px-4 py-4">
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
onClick={onLoadMore}
disabled={loadingMore}
>
{loadingMore ? 'Loading more...' : 'Load more'}
</button>
</div>
) : null}
</div>
</aside>
</div>
);
}
export type { VocabularyDrawerTarget };

View File

@@ -0,0 +1,211 @@
import { useState, useMemo } from 'react';
import { useVocabulary } from '../../hooks/useVocabulary';
import { StatCard } from '../layout/StatCard';
import { WordList } from './WordList';
import { KanjiBreakdown } from './KanjiBreakdown';
import { KanjiDetailPanel } from './KanjiDetailPanel';
import { ExclusionManager } from './ExclusionManager';
import { formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart';
import { FrequencyRankTable } from './FrequencyRankTable';
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import { buildVocabularySummary } from '../../lib/dashboard-data';
import type { ExcludedWord } from '../../hooks/useExcludedWords';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
interface VocabularyTabProps {
onNavigateToAnime?: (animeId: number) => void;
onOpenWordDetail?: (wordId: number) => void;
excluded: ExcludedWord[];
isExcluded: (w: { headword: string; word: string; reading: string }) => boolean;
onRemoveExclusion: (w: ExcludedWord) => void;
onClearExclusions: () => void;
}
function isProperNoun(w: VocabularyEntry): boolean {
return w.pos2 === '固有名詞';
}
export function VocabularyTab({
onNavigateToAnime,
onOpenWordDetail,
excluded,
isExcluded,
onRemoveExclusion,
onClearExclusions,
}: VocabularyTabProps) {
const { words, kanji, knownWords, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [hideNames, setHideNames] = useState(false);
const [showExclusionManager, setShowExclusionManager] = useState(false);
const hasNames = useMemo(() => words.some(isProperNoun), [words]);
const filteredWords = useMemo(() => {
let result = words;
if (hideNames) result = result.filter((w) => !isProperNoun(w));
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
return result;
}, [words, hideNames, excluded, isExcluded]);
const summary = useMemo(
() => buildVocabularySummary(filteredWords, kanji),
[filteredWords, kanji],
);
const knownWordCount = useMemo(() => {
if (knownWords.size === 0) return 0;
let count = 0;
for (const w of filteredWords) {
if (knownWords.has(w.headword)) count += 1;
}
return count;
}, [filteredWords, knownWords]);
if (loading) {
return (
<div className="text-ctp-overlay2 p-4" role="status" aria-live="polite">
Loading...
</div>
);
}
if (error) {
return (
<div className="text-ctp-red p-4" role="alert" aria-live="assertive">
Error: {error}
</div>
);
}
const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId);
};
const handleBarClick = (headword: string): void => {
const match = filteredWords.find((w) => w.headword === headword);
if (match) onOpenWordDetail?.(match.wordId);
};
const openKanjiDetail = (entry: KanjiEntry): void => {
setSelectedKanjiId(entry.kanjiId);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
<StatCard
label="Unique Words"
value={formatNumber(summary.uniqueWords)}
color="text-ctp-blue"
/>
{knownWords.size > 0 && (
<StatCard
label="Known Words"
value={`${formatNumber(knownWordCount)} (${summary.uniqueWords > 0 ? Math.round((knownWordCount / summary.uniqueWords) * 100) : 0}%)`}
color="text-ctp-green"
/>
)}
<StatCard
label="Unique Kanji"
value={formatNumber(summary.uniqueKanji)}
color="text-ctp-teal"
/>
<StatCard
label="New This Week"
value={`+${formatNumber(summary.newThisWeek)}`}
color="text-ctp-mauve"
/>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search words..."
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{hasNames && (
<button
type="button"
onClick={() => setHideNames(!hideNames)}
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
hideNames
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Names
</button>
)}
<button
type="button"
onClick={() => setShowExclusionManager(true)}
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
excluded.length > 0
? 'bg-ctp-surface2 text-ctp-text border-ctp-red/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Exclusions{excluded.length > 0 && ` (${excluded.length})`}
</button>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<TrendChart
title="Top Repeated Words"
data={summary.topWords}
color="#8aadf4"
type="bar"
onBarClick={handleBarClick}
/>
<TrendChart
title="New Words by Day"
data={summary.newWordsTimeline}
color="#c6a0f6"
type="line"
/>
</div>
<FrequencyRankTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
/>
<CrossAnimeWordsTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
/>
<WordList
words={filteredWords}
selectedKey={null}
onSelectWord={handleSelectWord}
search={search}
/>
<KanjiBreakdown
kanji={kanji}
selectedKanjiId={selectedKanjiId}
onSelectKanji={openKanjiDetail}
/>
<KanjiDetailPanel
kanjiId={selectedKanjiId}
onClose={() => setSelectedKanjiId(null)}
onSelectWord={onOpenWordDetail}
onNavigateToAnime={onNavigateToAnime}
/>
{showExclusionManager && (
<ExclusionManager
excluded={excluded}
onRemove={onRemoveExclusion}
onClearAll={onClearExclusions}
onClose={() => setShowExclusionManager(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,471 @@
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) ? (
<mark
key={i}
className="bg-transparent text-ctp-blue underline decoration-ctp-blue/40 underline-offset-2"
>
{part}
</mark>
) : (
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<VocabularyOccurrenceEntry[]>([]);
const [occLoading, setOccLoading] = useState(false);
const [occLoadingMore, setOccLoadingMore] = useState(false);
const [occError, setOccError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [occLoaded, setOccLoaded] = useState(false);
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
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<typeof data>['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 (
<div className="fixed inset-0 z-40">
<button
type="button"
aria-label="Close word detail panel"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
Word Detail
</div>
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
{data && (
<>
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">
{data.detail.headword}
</h2>
<div className="mt-1 text-sm text-ctp-subtext0">
{fullReading(data.detail.headword, data.detail.reading) || data.detail.word}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />}
{data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos1}
</span>
)}
{data.detail.pos2 && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos2}
</span>
)}
{data.detail.pos3 && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos3}
</span>
)}
</div>
</>
)}
</div>
<div className="flex items-center gap-2">
{data && onToggleExclusion && (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs font-medium transition ${
isExcluded?.(data.detail)
? 'border-ctp-red/50 bg-ctp-red/10 text-ctp-red hover:bg-ctp-red/20'
: 'border-ctp-surface2 text-ctp-subtext0 hover:border-ctp-red hover:text-ctp-red'
}`}
onClick={() => onToggleExclusion(data.detail)}
>
{isExcluded?.(data.detail) ? 'Excluded' : 'Exclude'}
</button>
)}
<button
type="button"
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onClose}
>
Close
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{data && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-lg font-bold text-ctp-blue">
{formatNumber(data.detail.frequency)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div>
</div>
{data.animeAppearances.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Anime Appearances
</h3>
<div className="space-y-1.5">
{data.animeAppearances.map((a) => (
<button
key={a.animeId}
type="button"
onClick={() => {
onClose();
onNavigateToAnime?.(a.animeId);
}}
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-blue hover:ring-1 hover:ring-ctp-blue text-left"
>
<span className="truncate text-ctp-text">{a.animeTitle}</span>
<span className="ml-2 shrink-0 rounded-full bg-ctp-blue/10 px-2 py-0.5 text-[11px] font-medium text-ctp-blue">
{formatNumber(a.occurrenceCount)}
</span>
</button>
))}
</div>
</section>
)}
{data.similarWords.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Similar Words
</h3>
<div className="flex flex-wrap gap-1.5">
{data.similarWords.map((sw) => (
<button
key={sw.wordId}
type="button"
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-ctp-blue bg-ctp-blue/10 transition hover:ring-1 hover:ring-ctp-blue"
onClick={() => onSelectWord?.(sw.wordId)}
>
{sw.headword}
<span className="opacity-60">({formatNumber(sw.frequency)})</span>
</button>
))}
</div>
</section>
)}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Example Lines
</h3>
{!occLoaded && !occLoading && (
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={handleShowOccurrences}
>
Load example lines
</button>
)}
{occLoading && (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
)}
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
{occLoaded && !occLoading && occurrences.length === 0 && (
<div className="text-sm text-ctp-overlay2">
No example lines tracked yet. Lines are stored for sessions recorded after the
subtitle tracking update.
</div>
)}
{occurrences.length > 0 && (
<div className="space-y-3">
{occurrences.map((occ, idx) => (
<article
key={`${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs ?? idx}`}
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-ctp-text">
{occ.animeTitle ?? occ.videoTitle}
</div>
<div className="truncate text-xs text-ctp-subtext0">
{occ.videoTitle} · line {occ.lineIndex}
</div>
</div>
<div className="rounded-full bg-ctp-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
{formatNumber(occ.occurrenceCount)} in line
</div>
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
<span>
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
· session {occ.sessionId}
</span>
{(() => {
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 (
<>
<button
type="button"
title={unavailableReason ?? 'Mine this word from video clip'}
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
disabled={wordStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'word')}
>
{wordStatus?.loading
? 'Mining...'
: wordStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Word'}
</button>
<button
type="button"
title={
unavailableReason ?? 'Mine this sentence from video clip'
}
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
disabled={sentenceStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'sentence')}
>
{sentenceStatus?.loading
? 'Mining...'
: sentenceStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Sentence'}
</button>
<button
type="button"
title={unavailableReason ?? 'Mine this line as audio-only card'}
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
disabled={audioStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'audio')}
>
{audioStatus?.loading
? 'Mining...'
: audioStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Audio'}
</button>
</>
);
})()}
</div>
{(() => {
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 ? (
<div className="mt-1 text-[10px] text-ctp-red">{errors[0]}</div>
) : null;
})()}
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{highlightWord(occ.text, [data!.detail.headword, data!.detail.word])}
</p>
</article>
))}
{hasMore && (
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
onClick={handleLoadMore}
disabled={occLoadingMore}
>
{occLoadingMore ? 'Loading more...' : 'Load more'}
</button>
)}
</div>
)}
</section>
</>
)}
</div>
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useMemo, useState } from 'react';
import type { VocabularyEntry } from '../../types/stats';
import { PosBadge } from './pos-helpers';
interface WordListProps {
words: VocabularyEntry[];
selectedKey?: string | null;
onSelectWord?: (word: VocabularyEntry) => void;
search?: string;
}
type SortKey = 'frequency' | 'lastSeen' | 'firstSeen';
function toWordKey(word: VocabularyEntry): string {
return `${word.headword}\u0000${word.word}\u0000${word.reading}`;
}
const PAGE_SIZE = 100;
export function WordList({ words, selectedKey = null, onSelectWord, search = '' }: WordListProps) {
const [sortBy, setSortBy] = useState<SortKey>('frequency');
const [page, setPage] = useState(0);
const titleBySort: Record<SortKey, string> = {
frequency: 'Most Seen Words',
lastSeen: 'Recently Seen Words',
firstSeen: 'First Seen Words',
};
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return words;
return words.filter(
(w) =>
w.headword.toLowerCase().includes(needle) ||
w.word.toLowerCase().includes(needle) ||
w.reading.toLowerCase().includes(needle),
);
}, [words, search]);
const sorted = useMemo(() => {
const copy = [...filtered];
if (sortBy === 'frequency') copy.sort((a, b) => b.frequency - a.frequency);
else if (sortBy === 'lastSeen') copy.sort((a, b) => b.lastSeen - a.lastSeen);
else copy.sort((a, b) => b.firstSeen - a.firstSeen);
return copy;
}, [filtered, sortBy]);
const totalPages = Math.ceil(sorted.length / PAGE_SIZE);
const paged = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const maxFreq = words.reduce((max, word) => Math.max(max, word.frequency), 1);
const getFrequencyColor = (freq: number) => {
const ratio = freq / maxFreq;
if (ratio > 0.5) return 'text-ctp-blue bg-ctp-blue/10';
if (ratio > 0.2) return 'text-ctp-green bg-ctp-green/10';
return 'text-ctp-mauve bg-ctp-mauve/10';
};
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">
{titleBySort[sortBy]}
{search && (
<span className="ml-2 text-ctp-overlay1 font-normal">({filtered.length} matches)</span>
)}
</h3>
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value as SortKey);
setPage(0);
}}
className="text-xs bg-ctp-surface1 text-ctp-subtext0 border border-ctp-surface2 rounded px-2 py-1"
>
<option value="frequency">Frequency</option>
<option value="lastSeen">Last Seen</option>
<option value="firstSeen">First Seen</option>
</select>
</div>
<div className="flex flex-wrap gap-1.5">
{paged.map((w) => (
<button
type="button"
key={toWordKey(w)}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition ${getFrequencyColor(
w.frequency,
)} ${
selectedKey === toWordKey(w)
? 'ring-1 ring-ctp-blue ring-offset-1 ring-offset-ctp-surface0'
: 'hover:ring-1 hover:ring-ctp-surface2'
}`}
title={`${w.word} (${w.reading}) — seen ${w.frequency}x`}
onClick={() => onSelectWord?.(w)}
>
{w.headword}
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
<span className="opacity-60">({w.frequency})</span>
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-3">
<button
type="button"
disabled={page === 0}
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPage((p) => p - 1)}
>
Prev
</button>
<span className="text-xs text-ctp-overlay1">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</div>
);
}
export { toWordKey };

View File

@@ -0,0 +1,38 @@
import type { VocabularyEntry } from '../../types/stats';
const POS_COLORS: Record<string, string> = {
noun: 'bg-ctp-blue/15 text-ctp-blue',
verb: 'bg-ctp-green/15 text-ctp-green',
adjective: 'bg-ctp-mauve/15 text-ctp-mauve',
adverb: 'bg-ctp-peach/15 text-ctp-peach',
particle: 'bg-ctp-overlay0/15 text-ctp-overlay0',
auxiliary_verb: 'bg-ctp-overlay0/15 text-ctp-overlay0',
conjunction: 'bg-ctp-overlay0/15 text-ctp-overlay0',
prenominal: 'bg-ctp-yellow/15 text-ctp-yellow',
suffix: 'bg-ctp-flamingo/15 text-ctp-flamingo',
prefix: 'bg-ctp-flamingo/15 text-ctp-flamingo',
interjection: 'bg-ctp-rosewater/15 text-ctp-rosewater',
};
const DEFAULT_POS_COLOR = 'bg-ctp-surface1 text-ctp-subtext0';
export function posColor(pos: string): string {
return POS_COLORS[pos] ?? DEFAULT_POS_COLOR;
}
export function PosBadge({ pos }: { pos: string }) {
return (
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${posColor(pos)}`}>
{pos.replace(/_/g, ' ')}
</span>
);
}
const PARTICLE_POS = new Set(['particle', 'auxiliary_verb', 'conjunction']);
export function isFilterable(entry: VocabularyEntry): boolean {
if (PARTICLE_POS.has(entry.partOfSpeech ?? '')) return true;
if (entry.headword.length === 1 && /[\u3040-\u309F\u30A0-\u30FF]/.test(entry.headword))
return true;
return false;
}