diff --git a/stats/src/components/library/LibraryTab.tsx b/stats/src/components/library/LibraryTab.tsx index e058062f..bc37605a 100644 --- a/stats/src/components/library/LibraryTab.tsx +++ b/stats/src/components/library/LibraryTab.tsx @@ -14,7 +14,7 @@ interface LibraryTabProps { } export function LibraryTab({ onNavigateToSession }: LibraryTabProps) { - const { media, loading, error } = useMediaLibrary(); + const { media, loading, error, refresh } = useMediaLibrary(); const [search, setSearch] = useState(''); const [selectedVideoId, setSelectedVideoId] = useState(null); @@ -36,7 +36,15 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) { const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]); if (selectedVideoId !== null) { - return setSelectedVideoId(null)} />; + return ( + { + setSelectedVideoId(null); + refresh(); + }} + /> + ); } if (loading) return
Loading...
; diff --git a/stats/src/components/library/MediaDetailView.test.tsx b/stats/src/components/library/MediaDetailView.test.tsx index accce1ed..6a8c47a9 100644 --- a/stats/src/components/library/MediaDetailView.test.tsx +++ b/stats/src/components/library/MediaDetailView.test.tsx @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { getRelatedCollectionLabel } from './MediaDetailView'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createElement } from 'react'; +import { getRelatedCollectionLabel, buildDeleteEpisodeHandler } from './MediaDetailView'; test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => { assert.equal( @@ -41,3 +43,85 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () => 'View Anime', ); }); + +test('buildDeleteEpisodeHandler calls deleteVideo then onBack when confirm returns true', async () => { + let deletedVideoId: number | null = null; + let onBackCalled = false; + + const fakeApiClient = { + deleteVideo: async (id: number) => { + deletedVideoId = id; + }, + }; + + const fakeConfirm = (_title: string) => true; + + const handler = buildDeleteEpisodeHandler({ + videoId: 42, + title: 'Test Episode', + apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise }, + confirmFn: fakeConfirm, + onBack: () => { + onBackCalled = true; + }, + setDeleteError: () => {}, + }); + + await handler(); + assert.equal(deletedVideoId, 42); + assert.equal(onBackCalled, true); +}); + +test('buildDeleteEpisodeHandler does nothing when confirm returns false', async () => { + let deletedVideoId: number | null = null; + let onBackCalled = false; + + const fakeApiClient = { + deleteVideo: async (id: number) => { + deletedVideoId = id; + }, + }; + + const fakeConfirm = (_title: string) => false; + + const handler = buildDeleteEpisodeHandler({ + videoId: 42, + title: 'Test Episode', + apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise }, + confirmFn: fakeConfirm, + onBack: () => { + onBackCalled = true; + }, + setDeleteError: () => {}, + }); + + await handler(); + assert.equal(deletedVideoId, null); + assert.equal(onBackCalled, false); +}); + +test('buildDeleteEpisodeHandler sets error when deleteVideo throws', async () => { + let capturedError: string | null = null; + + const fakeApiClient = { + deleteVideo: async (_id: number) => { + throw new Error('Network failure'); + }, + }; + + const fakeConfirm = (_title: string) => true; + + const handler = buildDeleteEpisodeHandler({ + videoId: 42, + title: 'Test Episode', + apiClient: fakeApiClient as { deleteVideo: (id: number) => Promise }, + confirmFn: fakeConfirm, + onBack: () => {}, + setDeleteError: (msg) => { + capturedError = msg; + }, + }); + + await handler(); + assert.equal(capturedError, 'Network failure'); +}); diff --git a/stats/src/components/library/MediaDetailView.tsx b/stats/src/components/library/MediaDetailView.tsx index 4f353c93..a2096001 100644 --- a/stats/src/components/library/MediaDetailView.tsx +++ b/stats/src/components/library/MediaDetailView.tsx @@ -1,12 +1,34 @@ import { useEffect, useState } from 'react'; import { useMediaDetail } from '../../hooks/useMediaDetail'; import { apiClient } from '../../lib/api-client'; -import { confirmSessionDelete } from '../../lib/delete-confirm'; +import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm'; import { getSessionDisplayWordCount } from '../../lib/session-word-count'; import { MediaHeader } from './MediaHeader'; import { MediaSessionList } from './MediaSessionList'; import type { MediaDetailData, SessionSummary } from '../../types/stats'; +interface DeleteEpisodeHandlerOptions { + videoId: number; + title: string; + apiClient: { deleteVideo: (id: number) => Promise }; + confirmFn: (title: string) => boolean; + onBack: () => void; + setDeleteError: (msg: string | null) => void; +} + +export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise { + return async () => { + if (!opts.confirmFn(opts.title)) return; + opts.setDeleteError(null); + try { + await opts.apiClient.deleteVideo(opts.videoId); + opts.onBack(); + } catch (err) { + opts.setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.'); + } + }; +} + export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string { if (detail?.channelName?.trim()) { return 'View Channel'; @@ -79,6 +101,15 @@ export function MediaDetailView({ } }; + const handleDeleteEpisode = buildDeleteEpisodeHandler({ + videoId, + title: detail.canonicalTitle, + apiClient, + confirmFn: confirmEpisodeDelete, + onBack, + setDeleteError, + }); + return (
@@ -99,7 +130,7 @@ export function MediaDetailView({ ) : null}
- + {deleteError ?
{deleteError}
: null} void; } -export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) { +export function MediaHeader({ + detail, + initialKnownWordsSummary = null, + onDeleteEpisode, +}: MediaHeaderProps) { const knownTokenRate = detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null; const avgSessionMs = @@ -50,7 +55,18 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe className="w-32 h-44 rounded-lg shrink-0" />
-

{detail.canonicalTitle}

+
+

{detail.canonicalTitle}

+ {onDeleteEpisode != null ? ( + + ) : null} +
{detail.channelName ? (
{detail.channelUrl ? ( diff --git a/stats/src/hooks/useMediaLibrary.test.ts b/stats/src/hooks/useMediaLibrary.test.ts index 39abbbea..8ae94e3a 100644 --- a/stats/src/hooks/useMediaLibrary.test.ts +++ b/stats/src/hooks/useMediaLibrary.test.ts @@ -55,3 +55,11 @@ test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => { false, ); }); + +test('useMediaLibrary is a function export', async () => { + // Verify the hook is exported as a function. The `refresh` return value + // is exercised at the component level; reactive re-fetch via version bump + // cannot be tested without a full React test environment (no @testing-library). + const mod = await import('./useMediaLibrary'); + assert.equal(typeof mod.useMediaLibrary, 'function'); +}); diff --git a/stats/src/hooks/useMediaLibrary.ts b/stats/src/hooks/useMediaLibrary.ts index a28b0c59..889fab2c 100644 --- a/stats/src/hooks/useMediaLibrary.ts +++ b/stats/src/hooks/useMediaLibrary.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { getStatsClient } from './useStatsApi'; import type { MediaLibraryItem } from '../types/stats'; @@ -18,6 +18,9 @@ export function useMediaLibrary() { const [media, setMedia] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [version, setVersion] = useState(0); + + const refresh = useCallback(() => setVersion((v) => v + 1), []); useEffect(() => { let cancelled = false; @@ -59,7 +62,7 @@ export function useMediaLibrary() { clearTimeout(retryTimer); } }; - }, []); + }, [version]); - return { media, loading, error }; + return { media, loading, error, refresh }; }