mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(stats): add word exclusion list for vocabulary tab
- useExcludedWords hook manages excluded words in localStorage - ExclusionManager modal for viewing/removing excluded words - Exclusion button on WordDetailPanel header - Filtered words affect stat cards, charts, frequency table, and word list
This commit is contained in:
@@ -6,12 +6,14 @@ import { AnimeTab } from './components/anime/AnimeTab';
|
|||||||
import { VocabularyTab } from './components/vocabulary/VocabularyTab';
|
import { VocabularyTab } from './components/vocabulary/VocabularyTab';
|
||||||
import { SessionsTab } from './components/sessions/SessionsTab';
|
import { SessionsTab } from './components/sessions/SessionsTab';
|
||||||
import { WordDetailPanel } from './components/vocabulary/WordDetailPanel';
|
import { WordDetailPanel } from './components/vocabulary/WordDetailPanel';
|
||||||
|
import { useExcludedWords } from './hooks/useExcludedWords';
|
||||||
import type { TabId } from './components/layout/TabBar';
|
import type { TabId } from './components/layout/TabBar';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||||
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
||||||
const [globalWordId, setGlobalWordId] = useState<number | null>(null);
|
const [globalWordId, setGlobalWordId] = useState<number | null>(null);
|
||||||
|
const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords();
|
||||||
|
|
||||||
const navigateToAnime = useCallback((animeId: number) => {
|
const navigateToAnime = useCallback((animeId: number) => {
|
||||||
setActiveTab('anime');
|
setActiveTab('anime');
|
||||||
@@ -65,6 +67,10 @@ export function App() {
|
|||||||
<VocabularyTab
|
<VocabularyTab
|
||||||
onNavigateToAnime={navigateToAnime}
|
onNavigateToAnime={navigateToAnime}
|
||||||
onOpenWordDetail={openWordDetail}
|
onOpenWordDetail={openWordDetail}
|
||||||
|
excluded={excluded}
|
||||||
|
isExcluded={isExcluded}
|
||||||
|
onRemoveExclusion={removeExclusion}
|
||||||
|
onClearExclusions={clearAll}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -79,6 +85,8 @@ export function App() {
|
|||||||
onClose={() => setGlobalWordId(null)}
|
onClose={() => setGlobalWordId(null)}
|
||||||
onSelectWord={openWordDetail}
|
onSelectWord={openWordDetail}
|
||||||
onNavigateToAnime={navigateToAnime}
|
onNavigateToAnime={navigateToAnime}
|
||||||
|
isExcluded={isExcluded}
|
||||||
|
onToggleExclusion={toggleExclusion}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
77
stats/src/components/vocabulary/ExclusionManager.tsx
Normal file
77
stats/src/components/vocabulary/ExclusionManager.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,32 +4,42 @@ import { StatCard } from '../layout/StatCard';
|
|||||||
import { WordList } from './WordList';
|
import { WordList } from './WordList';
|
||||||
import { KanjiBreakdown } from './KanjiBreakdown';
|
import { KanjiBreakdown } from './KanjiBreakdown';
|
||||||
import { KanjiDetailPanel } from './KanjiDetailPanel';
|
import { KanjiDetailPanel } from './KanjiDetailPanel';
|
||||||
|
import { ExclusionManager } from './ExclusionManager';
|
||||||
import { formatNumber } from '../../lib/formatters';
|
import { formatNumber } from '../../lib/formatters';
|
||||||
import { TrendChart } from '../trends/TrendChart';
|
import { TrendChart } from '../trends/TrendChart';
|
||||||
import { FrequencyRankTable } from './FrequencyRankTable';
|
import { FrequencyRankTable } from './FrequencyRankTable';
|
||||||
|
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
|
||||||
import { buildVocabularySummary } from '../../lib/dashboard-data';
|
import { buildVocabularySummary } from '../../lib/dashboard-data';
|
||||||
|
import type { ExcludedWord } from '../../hooks/useExcludedWords';
|
||||||
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
|
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
|
||||||
|
|
||||||
interface VocabularyTabProps {
|
interface VocabularyTabProps {
|
||||||
onNavigateToAnime?: (animeId: number) => void;
|
onNavigateToAnime?: (animeId: number) => void;
|
||||||
onOpenWordDetail?: (wordId: 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 {
|
function isProperNoun(w: VocabularyEntry): boolean {
|
||||||
return w.pos2 === '固有名詞';
|
return w.pos2 === '固有名詞';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) {
|
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, isExcluded, onRemoveExclusion, onClearExclusions }: VocabularyTabProps) {
|
||||||
const { words, kanji, knownWords, loading, error } = useVocabulary();
|
const { words, kanji, knownWords, loading, error } = useVocabulary();
|
||||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [hideNames, setHideNames] = useState(false);
|
const [hideNames, setHideNames] = useState(false);
|
||||||
|
const [showExclusionManager, setShowExclusionManager] = useState(false);
|
||||||
|
|
||||||
const hasNames = useMemo(() => words.some(isProperNoun), [words]);
|
const hasNames = useMemo(() => words.some(isProperNoun), [words]);
|
||||||
const filteredWords = useMemo(
|
const filteredWords = useMemo(() => {
|
||||||
() => hideNames ? words.filter((w) => !isProperNoun(w)) : words,
|
let result = words;
|
||||||
[words, hideNames],
|
if (hideNames) result = result.filter((w) => !isProperNoun(w));
|
||||||
);
|
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
|
||||||
|
return result;
|
||||||
|
}, [words, hideNames, excluded, isExcluded]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -52,6 +62,11 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
|
|||||||
onOpenWordDetail?.(entry.wordId);
|
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 => {
|
const openKanjiDetail = (entry: KanjiEntry): void => {
|
||||||
setSelectedKanjiId(entry.kanjiId);
|
setSelectedKanjiId(entry.kanjiId);
|
||||||
};
|
};
|
||||||
@@ -89,6 +104,17 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
|
|||||||
Hide Names
|
Hide Names
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
@@ -97,6 +123,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
|
|||||||
data={summary.topWords}
|
data={summary.topWords}
|
||||||
color="#8aadf4"
|
color="#8aadf4"
|
||||||
type="bar"
|
type="bar"
|
||||||
|
onBarClick={handleBarClick}
|
||||||
/>
|
/>
|
||||||
<TrendChart
|
<TrendChart
|
||||||
title="New Words by Day"
|
title="New Words by Day"
|
||||||
@@ -108,6 +135,8 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
|
|||||||
|
|
||||||
<FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
|
<FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
|
||||||
|
|
||||||
|
<CrossAnimeWordsTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
|
||||||
|
|
||||||
<WordList
|
<WordList
|
||||||
words={filteredWords}
|
words={filteredWords}
|
||||||
selectedKey={null}
|
selectedKey={null}
|
||||||
@@ -127,6 +156,15 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
|
|||||||
onSelectWord={onOpenWordDetail}
|
onSelectWord={onOpenWordDetail}
|
||||||
onNavigateToAnime={onNavigateToAnime}
|
onNavigateToAnime={onNavigateToAnime}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showExclusionManager && (
|
||||||
|
<ExclusionManager
|
||||||
|
excluded={excluded}
|
||||||
|
onRemove={onRemoveExclusion}
|
||||||
|
onClearAll={onClearExclusions}
|
||||||
|
onClose={() => setShowExclusionManager(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
83
stats/src/hooks/useExcludedWords.ts
Normal file
83
stats/src/hooks/useExcludedWords.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
export interface ExcludedWord {
|
||||||
|
headword: string;
|
||||||
|
word: string;
|
||||||
|
reading: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'subminer-excluded-words';
|
||||||
|
|
||||||
|
function toKey(w: ExcludedWord): string {
|
||||||
|
return `${w.headword}\0${w.word}\0${w.reading}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: ExcludedWord[] | null = null;
|
||||||
|
let cachedKeys: Set<string> | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function load(): ExcludedWord[] {
|
||||||
|
if (cached) return cached;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
cached = raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
cached = [];
|
||||||
|
}
|
||||||
|
return cached!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKeySet(): Set<string> {
|
||||||
|
if (cachedKeys) return cachedKeys;
|
||||||
|
cachedKeys = new Set(load().map(toKey));
|
||||||
|
return cachedKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(words: ExcludedWord[]) {
|
||||||
|
cached = words;
|
||||||
|
cachedKeys = new Set(words.map(toKey));
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(words));
|
||||||
|
for (const fn of listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): ExcludedWord[] {
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExcludedWords() {
|
||||||
|
const excluded = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
|
const isExcluded = useCallback(
|
||||||
|
(w: { headword: string; word: string; reading: string }) => getKeySet().has(toKey(w)),
|
||||||
|
[excluded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleExclusion = useCallback(
|
||||||
|
(w: ExcludedWord) => {
|
||||||
|
const key = toKey(w);
|
||||||
|
const current = load();
|
||||||
|
if (getKeySet().has(key)) {
|
||||||
|
persist(current.filter(e => toKey(e) !== key));
|
||||||
|
} else {
|
||||||
|
persist([...current, w]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeExclusion = useCallback(
|
||||||
|
(w: ExcludedWord) => {
|
||||||
|
persist(load().filter(e => toKey(e) !== toKey(w)));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => persist([]), []);
|
||||||
|
|
||||||
|
return { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user