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 { LibraryEntryPicker } from './LibraryEntryPicker'; import type { AnimeEpisode } from '../../types/stats'; /** * Row actions that only appear on hover. Keyboard focus and pointers with no * hover (touch) reveal them too, otherwise those users cannot reach the button * at all. */ const HOVER_REVEALED = 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100'; interface EpisodeListProps { episodes: AnimeEpisode[]; isYoutube?: boolean; /** Entry these episodes currently belong to; excluded from the move picker. */ animeId?: number; onEpisodeDeleted?: () => void; /** Fires after an episode is reassigned, so the caller can refetch. */ onEpisodeMoved?: (removedPreviousAnime: boolean) => void; onOpenDetail?: (videoId: number) => void; } export function EpisodeList({ episodes: initialEpisodes, isYoutube = false, animeId, onEpisodeDeleted, onEpisodeMoved, onOpenDetail, }: EpisodeListProps) { const [expandedVideoId, setExpandedVideoId] = useState(null); const [episodes, setEpisodes] = useState(initialEpisodes); const [movingEpisode, setMovingEpisode] = useState(null); const [moveTargetId, setMoveTargetId] = useState(null); const [moveError, setMoveError] = useState(null); 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 (!(await confirmEpisodeDelete(title))) return; await apiClient.deleteVideo(videoId); setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId)); if (expandedVideoId === videoId) setExpandedVideoId(null); onEpisodeDeleted?.(); }; const handleMoveEpisode = async (videoId: number, targetAnimeId: number) => { setMoveTargetId(targetAnimeId); setMoveError(null); try { const result = await apiClient.moveVideoToAnime(videoId, targetAnimeId); setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId)); if (expandedVideoId === videoId) setExpandedVideoId(null); setMovingEpisode(null); onEpisodeMoved?.(result.removedPreviousAnime); } catch (err) { setMoveError(err instanceof Error ? err.message : 'Failed to move this episode.'); } finally { setMoveTargetId(null); } }; const watchedCount = episodes.filter((ep) => ep.watched).length; return (

{isYoutube ? 'Videos' : 'Episodes'}

{watchedCount}/{episodes.length} watched
{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 ( 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" > {expandedVideoId === ep.videoId && ( )} ); })}
# Title Progress Watch Time Cards Lookup Rate Last Watched
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'} {ep.episode ?? idx + 1} {ep.canonicalTitle} {progressPct != null ? ( = 85 ? 'text-ctp-green' : progressPct >= 50 ? 'text-ctp-peach' : 'text-ctp-overlay2' } > {progressPct}% ) : ( {'\u2014'} )} {formatDuration(ep.totalActiveMs)} {formatNumber(ep.totalCards)}
{lookupRate?.shortValue ?? '\u2014'}
{lookupRate?.longValue ?? 'lookup rate'}
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
{onOpenDetail ? ( ) : null}
{movingEpisode && ( void handleMoveEpisode(movingEpisode.videoId, entry.animeId)} onClose={() => { setMovingEpisode(null); setMoveError(null); }} /> )}
); }