mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 12:11:28 -07:00
feat: improve stats dashboard and annotation settings
This commit is contained in:
139
stats/src/components/vocabulary/FrequencyRankTable.tsx
Normal file
139
stats/src/components/vocabulary/FrequencyRankTable.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { PosBadge } from './pos-helpers';
|
||||
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 hasKnownData = knownWords.size > 0;
|
||||
|
||||
const isWordKnown = (w: VocabularyEntry): boolean => {
|
||||
return knownWords.has(w.headword) || knownWords.has(w.word) || knownWords.has(w.reading);
|
||||
};
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
|
||||
if (hideKnown && hasKnownData) {
|
||||
filtered = filtered.filter((w) => !isWordKnown(w));
|
||||
}
|
||||
return filtered.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 mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">
|
||||
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
|
||||
</h3>
|
||||
<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>
|
||||
{ranked.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
{w.reading !== w.headword ? w.reading : ''}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useVocabulary } from '../../hooks/useVocabulary';
|
||||
import { StatCard } from '../layout/StatCard';
|
||||
import { WordList } from './WordList';
|
||||
@@ -6,6 +6,7 @@ import { KanjiBreakdown } from './KanjiBreakdown';
|
||||
import { KanjiDetailPanel } from './KanjiDetailPanel';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { FrequencyRankTable } from './FrequencyRankTable';
|
||||
import { buildVocabularySummary } from '../../lib/dashboard-data';
|
||||
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
|
||||
|
||||
@@ -14,10 +15,21 @@ interface VocabularyTabProps {
|
||||
onOpenWordDetail?: (wordId: number) => void;
|
||||
}
|
||||
|
||||
function isProperNoun(w: VocabularyEntry): boolean {
|
||||
return w.pos2 === '固有名詞';
|
||||
}
|
||||
|
||||
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) {
|
||||
const { words, kanji, loading, error } = useVocabulary();
|
||||
const { words, kanji, knownWords, loading, error } = useVocabulary();
|
||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [hideNames, setHideNames] = useState(false);
|
||||
|
||||
const hasNames = useMemo(() => words.some(isProperNoun), [words]);
|
||||
const filteredWords = useMemo(
|
||||
() => hideNames ? words.filter((w) => !isProperNoun(w)) : words,
|
||||
[words, hideNames],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -34,7 +46,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
|
||||
);
|
||||
}
|
||||
|
||||
const summary = buildVocabularySummary(words, kanji);
|
||||
const summary = buildVocabularySummary(filteredWords, kanji);
|
||||
|
||||
const handleSelectWord = (entry: VocabularyEntry): void => {
|
||||
onOpenWordDetail?.(entry.wordId);
|
||||
@@ -56,14 +68,27 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search words..."
|
||||
className="rounded border border-ctp-surface2 bg-ctp-surface1 px-3 py-1 text-xs text-ctp-text placeholder:text-ctp-overlay0 focus:border-ctp-blue focus:outline-none focus:ring-1 focus:ring-ctp-blue"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
@@ -81,8 +106,10 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
|
||||
|
||||
<WordList
|
||||
words={words}
|
||||
words={filteredWords}
|
||||
selectedKey={null}
|
||||
onSelectWord={handleSelectWord}
|
||||
search={search}
|
||||
|
||||
Reference in New Issue
Block a user