mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 16:19:24 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
151
stats/src/components/anime/AnilistSelector.tsx
Normal file
151
stats/src/components/anime/AnilistSelector.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
|
||||
interface AnilistMedia {
|
||||
id: number;
|
||||
episodes: number | null;
|
||||
season: string | null;
|
||||
seasonYear: number | null;
|
||||
description: string | null;
|
||||
coverImage: { large: string | null; medium: string | null } | null;
|
||||
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||
}
|
||||
|
||||
interface AnilistSelectorProps {
|
||||
animeId: number;
|
||||
initialQuery: string;
|
||||
onClose: () => void;
|
||||
onLinked: () => void;
|
||||
}
|
||||
|
||||
export function AnilistSelector({
|
||||
animeId,
|
||||
initialQuery,
|
||||
onClose,
|
||||
onLinked,
|
||||
}: AnilistSelectorProps) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [results, setResults] = useState<AnilistMedia[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [linking, setLinking] = useState<number | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
if (initialQuery) doSearch(initialQuery);
|
||||
}, []);
|
||||
|
||||
const doSearch = async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiClient.searchAnilist(q.trim());
|
||||
setResults(data);
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setQuery(value);
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => doSearch(value), 400);
|
||||
};
|
||||
|
||||
const handleSelect = async (media: AnilistMedia) => {
|
||||
setLinking(media.id);
|
||||
try {
|
||||
await apiClient.reassignAnimeAnilist(animeId, {
|
||||
anilistId: media.id,
|
||||
titleRomaji: media.title?.romaji ?? null,
|
||||
titleEnglish: media.title?.english ?? null,
|
||||
titleNative: media.title?.native ?? null,
|
||||
episodesTotal: media.episodes ?? null,
|
||||
description: media.description ?? null,
|
||||
coverUrl: media.coverImage?.large ?? media.coverImage?.medium ?? null,
|
||||
});
|
||||
onLinked();
|
||||
} catch {
|
||||
setLinking(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
||||
<div
|
||||
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 border-b border-ctp-surface1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Select AniList Entry</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
placeholder="Search AniList..."
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading && <div className="text-xs text-ctp-overlay2 p-3">Searching...</div>}
|
||||
{!loading && results.length === 0 && query.trim() && (
|
||||
<div className="text-xs text-ctp-overlay2 p-3">No results</div>
|
||||
)}
|
||||
{results.map((media) => (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
disabled={linking !== null}
|
||||
onClick={() => void handleSelect(media)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
|
||||
>
|
||||
{media.coverImage?.medium ? (
|
||||
<img
|
||||
src={media.coverImage.medium}
|
||||
alt=""
|
||||
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface1"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-14 rounded bg-ctp-surface1 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ctp-text truncate">
|
||||
{media.title?.romaji ?? media.title?.english ?? 'Unknown'}
|
||||
</div>
|
||||
{media.title?.english && media.title.english !== media.title.romaji && (
|
||||
<div className="text-xs text-ctp-subtext0 truncate">{media.title.english}</div>
|
||||
)}
|
||||
<div className="text-xs text-ctp-overlay2 mt-0.5">
|
||||
{media.episodes ? `${media.episodes} eps` : 'Unknown eps'}
|
||||
{media.seasonYear ? ` · ${media.season ?? ''} ${media.seasonYear}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{linking === media.id ? (
|
||||
<span className="text-xs text-ctp-blue shrink-0">Linking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-ctp-overlay2 shrink-0">Select</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
stats/src/components/anime/AnimeCard.tsx
Normal file
35
stats/src/components/anime/AnimeCard.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
|
||||
interface AnimeCardProps {
|
||||
anime: AnimeLibraryItem;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AnimeCard({ anime, onClick }: AnimeCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-blue/50 hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full"
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<AnimeCoverImage
|
||||
animeId={anime.animeId}
|
||||
title={anime.canonicalTitle}
|
||||
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||
{anime.episodeCount} episode{anime.episodeCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatDuration(anime.totalActiveMs)} · {formatNumber(anime.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
74
stats/src/components/anime/AnimeCardsList.tsx
Normal file
74
stats/src/components/anime/AnimeCardsList.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import { EpisodeDetail } from './EpisodeDetail';
|
||||
import type { AnimeEpisode } from '../../types/stats';
|
||||
|
||||
interface AnimeCardsListProps {
|
||||
episodes: AnimeEpisode[];
|
||||
totalCards: number;
|
||||
}
|
||||
|
||||
export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
|
||||
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
|
||||
|
||||
if (totalCards === 0) {
|
||||
return (
|
||||
<CollapsibleSection title="Cards Mined (0)" defaultOpen={false}>
|
||||
<p className="text-sm text-ctp-overlay2">No cards mined from this anime yet.</p>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
const withCards = episodes.filter((ep) => ep.totalCards > 0);
|
||||
|
||||
return (
|
||||
<CollapsibleSection title={`Cards Mined (${formatNumber(totalCards)})`} defaultOpen={false}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
<th className="w-6 py-2 pr-1 font-medium" />
|
||||
<th className="text-left py-2 pr-3 font-medium">Episode</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Cards</th>
|
||||
<th className="text-right py-2 font-medium">Last Watched</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{withCards.map((ep) => (
|
||||
<Fragment key={ep.videoId}>
|
||||
<tr
|
||||
onClick={() =>
|
||||
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
|
||||
}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
|
||||
{expandedVideoId === ep.videoId ? '▼' : '▶'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ctp-text truncate max-w-[300px]">
|
||||
<span className="text-ctp-subtext0 mr-2">
|
||||
{ep.episode != null ? `#${ep.episode}` : ''}
|
||||
</span>
|
||||
{ep.canonicalTitle}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-cards-mined">
|
||||
{formatNumber(ep.totalCards)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-ctp-overlay2">
|
||||
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedVideoId === ep.videoId && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-2">
|
||||
<EpisodeDetail videoId={ep.videoId} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
35
stats/src/components/anime/AnimeCoverImage.tsx
Normal file
35
stats/src/components/anime/AnimeCoverImage.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
|
||||
interface AnimeCoverImageProps {
|
||||
animeId: number;
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
||||
>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src = getStatsClient().getAnimeCoverUrl(animeId);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
186
stats/src/components/anime/AnimeDetailView.tsx
Normal file
186
stats/src/components/anime/AnimeDetailView.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { AnimeHeader } from './AnimeHeader';
|
||||
import { EpisodeList } from './EpisodeList';
|
||||
import { AnimeWordList } from './AnimeWordList';
|
||||
import { AnilistSelector } from './AnilistSelector';
|
||||
import { AnimeOverviewStats } from './AnimeOverviewStats';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface AnimeDetailViewProps {
|
||||
animeId: number;
|
||||
onBack: () => void;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
onOpenEpisodeDetail?: (videoId: number) => void;
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
|
||||
function formatActiveMinutes(value: number | string) {
|
||||
const minutes = Number(value);
|
||||
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
|
||||
}
|
||||
|
||||
function AnimeWatchChart({ animeId }: { animeId: number }) {
|
||||
const [rollups, setRollups] = useState<DailyRollup[]>([]);
|
||||
const [range, setRange] = useState<Range>(30);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getAnimeRollups(animeId, 90)
|
||||
.then((data) => {
|
||||
if (!cancelled) setRollups(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRollups([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [animeId]);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
for (const r of rollups) {
|
||||
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
|
||||
}
|
||||
const chartData = Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([day, mins]) => ({
|
||||
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
minutes: Math.round(mins),
|
||||
}))
|
||||
.slice(-range);
|
||||
|
||||
const ranges: Range[] = [14, 30, 90];
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
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">Watch Time</h3>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAnimeKnownWords(animeId: number) {
|
||||
const [summary, setSummary] = useState<{
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getAnimeKnownWordsSummary(animeId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setSummary(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSummary(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [animeId]);
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function AnimeDetailView({
|
||||
animeId,
|
||||
onBack,
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
}: AnimeDetailViewProps) {
|
||||
const { data, loading, error, reload } = useAnimeDetail(animeId);
|
||||
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
|
||||
const knownWordsSummary = useAnimeKnownWords(animeId);
|
||||
|
||||
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>;
|
||||
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
|
||||
|
||||
const { detail, episodes, anilistEntries } = data;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
← Back to Library
|
||||
</button>
|
||||
<AnimeHeader
|
||||
detail={detail}
|
||||
anilistEntries={anilistEntries ?? []}
|
||||
onChangeAnilist={() => setShowAnilistSelector(true)}
|
||||
/>
|
||||
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
|
||||
<EpisodeList
|
||||
episodes={episodes}
|
||||
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
|
||||
/>
|
||||
<AnimeWatchChart animeId={animeId} />
|
||||
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
|
||||
{showAnilistSelector && (
|
||||
<AnilistSelector
|
||||
animeId={animeId}
|
||||
initialQuery={detail.canonicalTitle}
|
||||
onClose={() => setShowAnilistSelector(false)}
|
||||
onLinked={() => {
|
||||
setShowAnilistSelector(false);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
stats/src/components/anime/AnimeHeader.tsx
Normal file
99
stats/src/components/anime/AnimeHeader.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||
import type { AnimeDetailData, AnilistEntry } from '../../types/stats';
|
||||
|
||||
interface AnimeHeaderProps {
|
||||
detail: AnimeDetailData['detail'];
|
||||
anilistEntries: AnilistEntry[];
|
||||
onChangeAnilist?: () => void;
|
||||
}
|
||||
|
||||
function AnilistButton({ entry }: { entry: AnilistEntry }) {
|
||||
const label =
|
||||
entry.season != null
|
||||
? `Season ${entry.season}`
|
||||
: (entry.titleEnglish ?? entry.titleRomaji ?? 'AniList');
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${entry.anilistId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
{label}
|
||||
<span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) {
|
||||
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
|
||||
(t): t is string => t != null && t !== detail.canonicalTitle,
|
||||
);
|
||||
const uniqueAltTitles = [...new Set(altTitles)];
|
||||
|
||||
const hasMultipleEntries = anilistEntries.length > 1;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<AnimeCoverImage
|
||||
animeId={detail.animeId}
|
||||
title={detail.canonicalTitle}
|
||||
className="w-32 h-44 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||
{uniqueAltTitles.length > 0 && (
|
||||
<div className="text-xs text-ctp-overlay2 mt-0.5 truncate">
|
||||
{uniqueAltTitles.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-ctp-subtext0 mt-2">
|
||||
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{anilistEntries.length > 0 ? (
|
||||
hasMultipleEntries ? (
|
||||
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
|
||||
) : (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
View on AniList <span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
)
|
||||
) : detail.anilistId ? (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${detail.anilistId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
View on AniList <span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
) : null}
|
||||
{onChangeAnilist && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeAnilist}
|
||||
title="Search AniList and manually select the correct anime entry"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors"
|
||||
>
|
||||
{anilistEntries.length > 0 || detail.anilistId
|
||||
? 'Change AniList Entry'
|
||||
: 'Link to AniList'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{detail.description && (
|
||||
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
|
||||
{detail.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
stats/src/components/anime/AnimeOverviewStats.tsx
Normal file
125
stats/src/components/anime/AnimeOverviewStats.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||
import { Tooltip } from '../layout/Tooltip';
|
||||
import type { AnimeDetailData } from '../../types/stats';
|
||||
|
||||
interface AnimeOverviewStatsProps {
|
||||
detail: AnimeDetailData['detail'];
|
||||
knownWordsSummary: {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
color: string;
|
||||
tooltip: string;
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
function Metric({ label, value, unit, color, tooltip, sub }: MetricProps) {
|
||||
return (
|
||||
<Tooltip text={tooltip}>
|
||||
<div className="flex flex-col items-center gap-1 px-3 py-3 rounded-lg bg-ctp-surface1/40 hover:bg-ctp-surface1/70 transition-colors">
|
||||
<div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>
|
||||
{value}
|
||||
{unit && <span className="text-sm font-normal text-ctp-overlay2 ml-0.5">{unit}</span>}
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-wider text-ctp-overlay2 font-medium">
|
||||
{label}
|
||||
</div>
|
||||
{sub && <div className="text-[11px] text-ctp-overlay1">{sub}</div>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewStatsProps) {
|
||||
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalTokensSeen);
|
||||
|
||||
const knownPct =
|
||||
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
||||
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4 space-y-3">
|
||||
{/* Primary metrics - always 4 columns on sm+ */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<Metric
|
||||
label="Watch Time"
|
||||
value={formatDuration(detail.totalActiveMs)}
|
||||
color="text-ctp-blue"
|
||||
tooltip="Total active watch time for this anime"
|
||||
/>
|
||||
<Metric
|
||||
label="Sessions"
|
||||
value={String(detail.totalSessions)}
|
||||
color="text-ctp-peach"
|
||||
tooltip="Number of immersion sessions on this anime"
|
||||
/>
|
||||
<Metric
|
||||
label="Episodes"
|
||||
value={String(detail.episodeCount)}
|
||||
color="text-ctp-yellow"
|
||||
tooltip="Number of completed episodes for this anime"
|
||||
/>
|
||||
<Metric
|
||||
label="Words Seen"
|
||||
value={formatNumber(detail.totalTokensSeen)}
|
||||
color="text-ctp-mauve"
|
||||
tooltip="Total word occurrences across all sessions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary metrics - fills row evenly */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<Metric
|
||||
label="Cards Mined"
|
||||
value={formatNumber(detail.totalCards)}
|
||||
color="text-ctp-cards-mined"
|
||||
tooltip="Anki cards created from subtitle lines in this anime"
|
||||
/>
|
||||
<Metric
|
||||
label="Lookups"
|
||||
value={formatNumber(detail.totalYomitanLookupCount)}
|
||||
color="text-ctp-lavender"
|
||||
tooltip="Total Yomitan dictionary lookups during sessions"
|
||||
/>
|
||||
{lookupRate ? (
|
||||
<Metric
|
||||
label="Lookup Rate"
|
||||
value={lookupRate.shortValue}
|
||||
color="text-ctp-sapphire"
|
||||
tooltip="Yomitan lookups per 100 words seen"
|
||||
/>
|
||||
) : (
|
||||
<Metric
|
||||
label="Lookup Rate"
|
||||
value="—"
|
||||
color="text-ctp-overlay2"
|
||||
tooltip="No lookups recorded yet"
|
||||
/>
|
||||
)}
|
||||
{knownPct !== null ? (
|
||||
<Metric
|
||||
label="Known Words"
|
||||
value={`${knownPct}%`}
|
||||
color="text-ctp-green"
|
||||
tooltip={`${formatNumber(knownWordsSummary!.knownWordCount)} known out of ${formatNumber(knownWordsSummary!.totalUniqueWords)} unique words in this anime`}
|
||||
/>
|
||||
) : (
|
||||
<Metric
|
||||
label="Known Words"
|
||||
value="—"
|
||||
color="text-ctp-overlay2"
|
||||
tooltip="No word data available yet"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
stats/src/components/anime/AnimeTab.tsx
Normal file
147
stats/src/components/anime/AnimeTab.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { AnimeCard } from './AnimeCard';
|
||||
import { AnimeDetailView } from './AnimeDetailView';
|
||||
|
||||
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
||||
type CardSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
const GRID_CLASSES: Record<CardSize, string> = {
|
||||
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
|
||||
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
|
||||
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'lastWatched', label: 'Last Watched' },
|
||||
{ key: 'watchTime', label: 'Watch Time' },
|
||||
{ key: 'cards', label: 'Cards' },
|
||||
{ key: 'episodes', label: 'Episodes' },
|
||||
];
|
||||
|
||||
function sortAnime(list: ReturnType<typeof useAnimeLibrary>['anime'], key: SortKey) {
|
||||
return [...list].sort((a, b) => {
|
||||
switch (key) {
|
||||
case 'lastWatched':
|
||||
return b.lastWatchedMs - a.lastWatchedMs;
|
||||
case 'watchTime':
|
||||
return b.totalActiveMs - a.totalActiveMs;
|
||||
case 'cards':
|
||||
return b.totalCards - a.totalCards;
|
||||
case 'episodes':
|
||||
return b.episodeCount - a.episodeCount;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface AnimeTabProps {
|
||||
initialAnimeId?: number | null;
|
||||
onClearInitialAnime?: () => void;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
onOpenEpisodeDetail?: (animeId: number, videoId: number) => void;
|
||||
}
|
||||
|
||||
export function AnimeTab({
|
||||
initialAnimeId,
|
||||
onClearInitialAnime,
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
}: AnimeTabProps) {
|
||||
const { anime, loading, error } = useAnimeLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||
const [cardSize, setCardSize] = useState<CardSize>('md');
|
||||
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialAnimeId != null) {
|
||||
setSelectedAnimeId(initialAnimeId);
|
||||
onClearInitialAnime?.();
|
||||
}
|
||||
}, [initialAnimeId, onClearInitialAnime]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const base = search.trim()
|
||||
? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
|
||||
: anime;
|
||||
return sortAnime(base, sortKey);
|
||||
}, [anime, search, sortKey]);
|
||||
|
||||
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
||||
|
||||
if (selectedAnimeId !== null) {
|
||||
return (
|
||||
<AnimeDetailView
|
||||
animeId={selectedAnimeId}
|
||||
onBack={() => setSelectedAnimeId(null)}
|
||||
onNavigateToWord={onNavigateToWord}
|
||||
onOpenEpisodeDetail={
|
||||
onOpenEpisodeDetail
|
||||
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search anime..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.key} value={opt.key}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0">
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setCardSize(size)}
|
||||
className={`px-2 py-1 rounded-md text-xs transition-colors ${
|
||||
cardSize === size
|
||||
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{size === 'sm' ? '▪' : size === 'md' ? '◼' : '⬛'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} anime · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
|
||||
) : (
|
||||
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
||||
{filtered.map((item) => (
|
||||
<AnimeCard
|
||||
key={item.animeId}
|
||||
anime={item}
|
||||
onClick={() => setSelectedAnimeId(item.animeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
stats/src/components/anime/AnimeWordList.tsx
Normal file
65
stats/src/components/anime/AnimeWordList.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import type { AnimeWord } from '../../types/stats';
|
||||
|
||||
interface AnimeWordListProps {
|
||||
animeId: number;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
}
|
||||
|
||||
export function AnimeWordList({ animeId, onNavigateToWord }: AnimeWordListProps) {
|
||||
const [words, setWords] = useState<AnimeWord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getStatsClient()
|
||||
.getAnimeWords(animeId, 50)
|
||||
.then((data) => {
|
||||
if (!cancelled) setWords(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setWords([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [animeId]);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 text-sm p-4">Loading words...</div>;
|
||||
if (words.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CollapsibleSection title={`Top Words (${words.length})`} defaultOpen={false}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{words.map((w) => (
|
||||
<button
|
||||
key={w.wordId}
|
||||
type="button"
|
||||
onClick={() => onNavigateToWord?.(w.wordId)}
|
||||
className="bg-ctp-base border border-ctp-surface1 rounded-md p-2 hover:border-ctp-blue transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<div className="text-sm font-medium text-ctp-text">{w.headword}</div>
|
||||
{w.reading && w.reading !== w.headword && (
|
||||
<div className="text-xs text-ctp-overlay2">{w.reading}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{w.partOfSpeech && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-ctp-surface1 text-ctp-subtext0 rounded">
|
||||
{w.partOfSpeech}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-ctp-mauve ml-auto">{formatNumber(w.frequency)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
38
stats/src/components/anime/CollapsibleSection.tsx
Normal file
38
stats/src/components/anime/CollapsibleSection.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: CollapsibleSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const contentId = useId();
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-ctp-text">{title}</h3>
|
||||
<span className="text-ctp-overlay2 text-xs" aria-hidden="true">
|
||||
{open ? '▲' : '▼'}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div id={contentId} className="px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
stats/src/components/anime/EpisodeDetail.tsx
Normal file
155
stats/src/components/anime/EpisodeDetail.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import type { EpisodeDetailData } from '../../types/stats';
|
||||
|
||||
interface EpisodeDetailProps {
|
||||
videoId: number;
|
||||
onSessionDeleted?: () => void;
|
||||
}
|
||||
|
||||
interface NoteInfo {
|
||||
noteId: number;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
|
||||
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getStatsClient()
|
||||
.getEpisodeDetail(videoId)
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
setData(d);
|
||||
const allNoteIds = d.cardEvents.flatMap((ev) => ev.noteIds);
|
||||
if (allNoteIds.length > 0) {
|
||||
getStatsClient()
|
||||
.ankiNotesInfo(allNoteIds)
|
||||
.then((notes) => {
|
||||
if (cancelled) return;
|
||||
const map = new Map<number, NoteInfo>();
|
||||
for (const note of notes) {
|
||||
const expr = note.preview?.word ?? '';
|
||||
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
||||
}
|
||||
setNoteInfos(map);
|
||||
})
|
||||
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setData(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [videoId]);
|
||||
|
||||
const handleDeleteSession = async (sessionId: number) => {
|
||||
if (!confirmSessionDelete()) return;
|
||||
await apiClient.deleteSession(sessionId);
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, sessions: prev.sessions.filter((s) => s.sessionId !== sessionId) };
|
||||
});
|
||||
onSessionDeleted?.();
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 text-xs p-3">Loading...</div>;
|
||||
if (!data)
|
||||
return <div className="text-ctp-overlay2 text-xs p-3">Failed to load episode details.</div>;
|
||||
|
||||
const { sessions, cardEvents } = data;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
||||
{sessions.length > 0 && (
|
||||
<div className="p-3 border-b border-ctp-surface1">
|
||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Sessions</h4>
|
||||
<div className="space-y-1">
|
||||
{sessions.map((s) => (
|
||||
<div key={s.sessionId} className="flex items-center gap-3 text-xs group">
|
||||
<span className="text-ctp-overlay2">
|
||||
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
|
||||
</span>
|
||||
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
|
||||
<span className="text-ctp-cards-mined">{formatNumber(s.cardsMined)} cards</span>
|
||||
<span className="text-ctp-peach">
|
||||
{formatNumber(getSessionDisplayWordCount(s))} words
|
||||
</span>
|
||||
<span className="text-ctp-green">{formatNumber(s.knownWordsSeen)} known words</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteSession(s.sessionId);
|
||||
}}
|
||||
className="ml-auto opacity-0 group-hover:opacity-100 text-ctp-red/70 hover:text-ctp-red transition-opacity text-[10px] px-1.5 py-0.5 rounded hover:bg-ctp-red/10"
|
||||
title="Delete session"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cardEvents.length > 0 && (
|
||||
<div className="p-3 border-b border-ctp-surface1">
|
||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
||||
<div className="space-y-1.5">
|
||||
{cardEvents.map((ev) => (
|
||||
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
||||
{ev.noteIds.length > 0 ? (
|
||||
ev.noteIds.map((noteId) => {
|
||||
const info = noteInfos.get(noteId);
|
||||
return (
|
||||
<div key={noteId} className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{info?.expression && (
|
||||
<span className="text-ctp-text font-medium truncate">
|
||||
{info.expression}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
getStatsClient().ankiBrowse(noteId);
|
||||
}}
|
||||
className="px-1.5 py-0.5 bg-ctp-surface1 text-ctp-blue rounded text-[10px] hover:bg-ctp-surface2 transition-colors cursor-pointer shrink-0 ml-auto"
|
||||
>
|
||||
Open in Anki
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-ctp-cards-mined">
|
||||
+{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.length === 0 && cardEvents.length === 0 && (
|
||||
<div className="p-3 text-xs text-ctp-overlay2">No detailed data available.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
stats/src/components/anime/EpisodeList.tsx
Normal file
196
stats/src/components/anime/EpisodeList.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmEpisodeDelete } from '../../lib/delete-confirm';
|
||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||
import { EpisodeDetail } from './EpisodeDetail';
|
||||
import type { AnimeEpisode } from '../../types/stats';
|
||||
|
||||
interface EpisodeListProps {
|
||||
episodes: AnimeEpisode[];
|
||||
onEpisodeDeleted?: () => void;
|
||||
onOpenDetail?: (videoId: number) => void;
|
||||
}
|
||||
|
||||
export function EpisodeList({
|
||||
episodes: initialEpisodes,
|
||||
onEpisodeDeleted,
|
||||
onOpenDetail,
|
||||
}: EpisodeListProps) {
|
||||
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
|
||||
const [episodes, setEpisodes] = useState(initialEpisodes);
|
||||
|
||||
if (episodes.length === 0) return null;
|
||||
|
||||
const sorted = [...episodes].sort((a, b) => {
|
||||
if (a.episode != null && b.episode != null) return a.episode - b.episode;
|
||||
if (a.episode != null) return -1;
|
||||
if (b.episode != null) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const toggleWatched = async (videoId: number, currentWatched: number) => {
|
||||
const newWatched = currentWatched ? 0 : 1;
|
||||
setEpisodes((prev) =>
|
||||
prev.map((ep) => (ep.videoId === videoId ? { ...ep, watched: newWatched } : ep)),
|
||||
);
|
||||
try {
|
||||
await apiClient.setVideoWatched(videoId, newWatched === 1);
|
||||
} catch {
|
||||
setEpisodes((prev) =>
|
||||
prev.map((ep) => (ep.videoId === videoId ? { ...ep, watched: currentWatched } : ep)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEpisode = async (videoId: number, title: string) => {
|
||||
if (!confirmEpisodeDelete(title)) return;
|
||||
await apiClient.deleteVideo(videoId);
|
||||
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
|
||||
if (expandedVideoId === videoId) setExpandedVideoId(null);
|
||||
onEpisodeDeleted?.();
|
||||
};
|
||||
|
||||
const watchedCount = episodes.filter((ep) => ep.watched).length;
|
||||
|
||||
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">Episodes</h3>
|
||||
<span className="text-xs text-ctp-overlay2">
|
||||
{watchedCount}/{episodes.length} watched
|
||||
</span>
|
||||
</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="w-6 py-2 pr-1 font-medium" />
|
||||
<th className="text-left py-2 pr-3 font-medium">#</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Title</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Progress</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Watch Time</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Cards</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Lookup Rate</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Last Watched</th>
|
||||
<th className="w-28 py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((ep, idx) => {
|
||||
const lookupRate = buildLookupRateDisplay(
|
||||
ep.totalYomitanLookupCount,
|
||||
ep.totalTokensSeen,
|
||||
);
|
||||
const progressPct =
|
||||
ep.durationMs > 0 && ep.endedMediaMs != null
|
||||
? Math.min(100, Math.round((ep.endedMediaMs / ep.durationMs) * 100))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Fragment key={ep.videoId}>
|
||||
<tr
|
||||
onClick={() =>
|
||||
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
|
||||
}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group"
|
||||
>
|
||||
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
|
||||
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ctp-subtext0">{ep.episode ?? idx + 1}</td>
|
||||
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
|
||||
{ep.canonicalTitle}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
{progressPct != null ? (
|
||||
<span
|
||||
className={
|
||||
progressPct >= 85
|
||||
? 'text-ctp-green'
|
||||
: progressPct >= 50
|
||||
? 'text-ctp-peach'
|
||||
: 'text-ctp-overlay2'
|
||||
}
|
||||
>
|
||||
{progressPct}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ctp-overlay2">{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-blue">
|
||||
{formatDuration(ep.totalActiveMs)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-cards-mined">
|
||||
{formatNumber(ep.totalCards)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<div className="text-ctp-sapphire">{lookupRate?.shortValue ?? '\u2014'}</div>
|
||||
<div className="text-[11px] text-ctp-overlay2">
|
||||
{lookupRate?.longValue ?? 'lookup rate'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-overlay2">
|
||||
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
|
||||
</td>
|
||||
<td className="py-2 text-center w-28">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{onOpenDetail ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenDetail(ep.videoId);
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-ctp-surface2 text-[11px] text-ctp-blue hover:border-ctp-blue/50 hover:bg-ctp-blue/10 transition-colors"
|
||||
title="Open episode details"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleWatched(ep.videoId, ep.watched);
|
||||
}}
|
||||
className={`w-5 h-5 rounded border transition-colors ${
|
||||
ep.watched
|
||||
? 'bg-ctp-green border-ctp-green text-ctp-base'
|
||||
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0'
|
||||
}`}
|
||||
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'}
|
||||
>
|
||||
{'\u2713'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
|
||||
}}
|
||||
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
|
||||
title="Delete episode"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedVideoId === ep.videoId && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-2">
|
||||
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user