import { useState, useEffect, useRef } from 'react'; import { useAnimeDetail } from '../../hooks/useAnimeDetail'; import { getStatsClient } from '../../hooks/useStatsApi'; import { confirmAnimeDelete } from '../../lib/delete-confirm'; 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; /** Called after the whole library entry is deleted, so the caller can refresh. */ onAnimeDeleted?: () => void; /** * Called after the AniList link changes. The library list caches the old * anilistId (and with it the cover URL), so it has to refetch or the grid * keeps showing the previous title's art. */ onAnilistRelinked?: () => void; /** Called after an episode is reassigned to another entry. */ onEpisodeMoved?: () => 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([]); const [range, setRange] = useState(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(); 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 (

Watch Time

{ranges.map((r) => ( ))}
); } 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, onAnimeDeleted, onAnilistRelinked, onEpisodeMoved, }: AnimeDetailViewProps) { const { data, loading, error, reload } = useAnimeDetail(animeId); const [showAnilistSelector, setShowAnilistSelector] = useState(false); const [coverRetryToken, setCoverRetryToken] = useState(0); const [isDeletingAnime, setIsDeletingAnime] = useState(false); const [deleteError, setDeleteError] = useState(null); const isDeletingAnimeRef = useRef(false); const knownWordsSummary = useAnimeKnownWords(animeId); useEffect(() => { setCoverRetryToken(0); }, [animeId]); if (loading) return
Loading...
; if (error) return
Error: {error}
; if (!data?.detail) return
Anime not found
; const { detail, episodes, anilistEntries } = data; const handleDeleteAnime = async () => { if (isDeletingAnimeRef.current) return; isDeletingAnimeRef.current = true; // Cleared up front so cancelling a retry doesn't leave the previous // attempt's failure on screen. setDeleteError(null); let confirmed = false; try { confirmed = await confirmAnimeDelete(detail.canonicalTitle, detail.episodeCount); } catch (err) { setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.'); isDeletingAnimeRef.current = false; return; } if (!confirmed) { isDeletingAnimeRef.current = false; return; } setDeleteError(null); setIsDeletingAnime(true); try { await getStatsClient().deleteAnime(animeId); onAnimeDeleted?.(); onBack(); } catch (err) { setDeleteError(err instanceof Error ? err.message : 'Failed to delete this title.'); setIsDeletingAnime(false); } finally { isDeletingAnimeRef.current = false; } }; return (
setShowAnilistSelector(true)} onDeleteAnime={() => void handleDeleteAnime()} isDeletingAnime={isDeletingAnime} /> {deleteError ?
{deleteError}
: null} { onEpisodeMoved?.(); // The last episode taking the entry with it leaves nothing to show. if (removedPreviousAnime) onBack(); else reload(); }} onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined} /> {showAnilistSelector && ( setShowAnilistSelector(false)} onLinked={() => { setShowAnilistSelector(false); setCoverRetryToken((value) => value + 1); reload(); onAnilistRelinked?.(); }} /> )}
); }