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:
2026-03-16 01:42:40 -07:00
parent 92c1557e46
commit a3ed8dcf3d
4 changed files with 211 additions and 5 deletions

View 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 };
}