mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-22 00:11:27 -07:00
feat(stats): build anime-centric stats dashboard frontend
5-tab React dashboard with Catppuccin Mocha theme: - Overview: hero stats, streak calendar, watch time chart, recent sessions - Anime: grid with cover art, episode list with completion %, detail view - Trends: 15 charts across Activity, Efficiency, Anime, and Patterns - Vocabulary: POS-filtered word/kanji lists with detail panels - Sessions: expandable session history with event timeline Features: - Cross-tab navigation (anime <-> vocabulary) - Global word detail panel overlay - Expandable episode detail with Anki card links (Expression field) - Per-anime multi-line trend charts - Watch time by day-of-week and hour-of-day - Collapsible sections with accessibility (aria-expanded) - Card size selector for anime grid - Cover art caching via AniList - HTTP API client with file:// protocol fallback for Electron overlay
This commit is contained in:
46
stats/src/components/vocabulary/KanjiBreakdown.tsx
Normal file
46
stats/src/components/vocabulary/KanjiBreakdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
stats/src/components/vocabulary/KanjiDetailPanel.tsx
Normal file
232
stats/src/components/vocabulary/KanjiDetailPanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
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<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);
|
||||
|
||||
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(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(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>
|
||||
);
|
||||
}
|
||||
149
stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx
Normal file
149
stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
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 };
|
||||
109
stats/src/components/vocabulary/VocabularyTab.tsx
Normal file
109
stats/src/components/vocabulary/VocabularyTab.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useMemo, useState } 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 { formatNumber } from '../../lib/formatters';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { buildVocabularySummary } from '../../lib/dashboard-data';
|
||||
import { isFilterable } from './pos-helpers';
|
||||
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
|
||||
|
||||
interface VocabularyTabProps {
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
onOpenWordDetail?: (wordId: number) => void;
|
||||
}
|
||||
|
||||
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) {
|
||||
const { words, kanji, loading, error } = useVocabulary();
|
||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||
const [hideParticles, setHideParticles] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredWords = useMemo(
|
||||
() => hideParticles ? words.filter(w => !isFilterable(w)) : words,
|
||||
[words, hideParticles],
|
||||
);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
const summary = buildVocabularySummary(filteredWords, kanji);
|
||||
|
||||
const handleSelectWord = (entry: VocabularyEntry): void => {
|
||||
onOpenWordDetail?.(entry.wordId);
|
||||
};
|
||||
|
||||
const openKanjiDetail = (entry: KanjiEntry): void => {
|
||||
setSelectedKanjiId(entry.kanjiId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<StatCard label="Unique Words" value={formatNumber(summary.uniqueWords)} color="text-ctp-blue" />
|
||||
<StatCard label="Unique Kanji" value={formatNumber(summary.uniqueKanji)} color="text-ctp-green" />
|
||||
<StatCard
|
||||
label="New This Week"
|
||||
value={`+${formatNumber(summary.newThisWeek)}`}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-ctp-subtext0 select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideParticles}
|
||||
onChange={(e) => setHideParticles(e.target.checked)}
|
||||
className="rounded border-ctp-surface2 bg-ctp-surface1 text-ctp-blue focus:ring-ctp-blue"
|
||||
/>
|
||||
Hide particles & single kana
|
||||
</label>
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
<TrendChart
|
||||
title="New Words by Day"
|
||||
data={summary.newWordsTimeline}
|
||||
color="#c6a0f6"
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
stats/src/components/vocabulary/WordDetailPanel.tsx
Normal file
246
stats/src/components/vocabulary/WordDetailPanel.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useWordDetail } from '../../hooks/useWordDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
||||
import { PosBadge } from './pos-helpers';
|
||||
|
||||
const OCCURRENCES_PAGE_SIZE = 50;
|
||||
|
||||
interface WordDetailPanelProps {
|
||||
wordId: 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 WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAnime }: 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 requestIdRef = useRef(0);
|
||||
|
||||
if (wordId === null) return null;
|
||||
|
||||
const loadOccurrences = async (detail: NonNullable<typeof data>['detail'], offset: 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,
|
||||
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, 0, false);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!data || occLoadingMore || !hasMore) return;
|
||||
void loadOccurrences(data.detail, occurrences.length, true);
|
||||
};
|
||||
|
||||
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">{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>
|
||||
<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-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(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(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 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-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
|
||||
{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-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={handleLoadMore}
|
||||
disabled={occLoadingMore}
|
||||
>
|
||||
{occLoadingMore ? 'Loading more...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
stats/src/components/vocabulary/WordList.tsx
Normal file
126
stats/src/components/vocabulary/WordList.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
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 };
|
||||
37
stats/src/components/vocabulary/pos-helpers.tsx
Normal file
37
stats/src/components/vocabulary/pos-helpers.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user