feat(stats): delete episode from library detail view

Add Delete Episode button to MediaDetailView/MediaHeader; extract
buildDeleteEpisodeHandler for testability. Add refresh() to
useMediaLibrary (version-bump pattern) and call it in LibraryTab's
onBack so the list reloads after a delete.
This commit is contained in:
2026-04-09 01:01:13 -07:00
parent 20976d63f0
commit 8e25e19cac
6 changed files with 160 additions and 10 deletions

View File

@@ -14,7 +14,7 @@ interface LibraryTabProps {
} }
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) { export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
const { media, loading, error } = useMediaLibrary(); const { media, loading, error, refresh } = useMediaLibrary();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null); const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
@@ -36,7 +36,15 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]); const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
if (selectedVideoId !== null) { if (selectedVideoId !== null) {
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />; return (
<MediaDetailView
videoId={selectedVideoId}
onBack={() => {
setSelectedVideoId(null);
refresh();
}}
/>
);
} }
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;

View File

@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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', () => { test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
assert.equal( assert.equal(
@@ -41,3 +43,85 @@ test('getRelatedCollectionLabel returns View Anime for non-youtube media', () =>
'View Anime', '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<void> },
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<void> },
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<void> },
confirmFn: fakeConfirm,
onBack: () => {},
setDeleteError: (msg) => {
capturedError = msg;
},
});
await handler();
assert.equal(capturedError, 'Network failure');
});

View File

@@ -1,12 +1,34 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMediaDetail } from '../../hooks/useMediaDetail'; import { useMediaDetail } from '../../hooks/useMediaDetail';
import { apiClient } from '../../lib/api-client'; 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 { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { MediaHeader } from './MediaHeader'; import { MediaHeader } from './MediaHeader';
import { MediaSessionList } from './MediaSessionList'; import { MediaSessionList } from './MediaSessionList';
import type { MediaDetailData, SessionSummary } from '../../types/stats'; import type { MediaDetailData, SessionSummary } from '../../types/stats';
interface DeleteEpisodeHandlerOptions {
videoId: number;
title: string;
apiClient: { deleteVideo: (id: number) => Promise<void> };
confirmFn: (title: string) => boolean;
onBack: () => void;
setDeleteError: (msg: string | null) => void;
}
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
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 { export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
if (detail?.channelName?.trim()) { if (detail?.channelName?.trim()) {
return 'View Channel'; return 'View Channel';
@@ -79,6 +101,15 @@ export function MediaDetailView({
} }
}; };
const handleDeleteEpisode = buildDeleteEpisodeHandler({
videoId,
title: detail.canonicalTitle,
apiClient,
confirmFn: confirmEpisodeDelete,
onBack,
setDeleteError,
});
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -99,7 +130,7 @@ export function MediaDetailView({
</button> </button>
) : null} ) : null}
</div> </div>
<MediaHeader detail={detail} /> <MediaHeader detail={detail} onDeleteEpisode={handleDeleteEpisode} />
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null} {deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<MediaSessionList <MediaSessionList
sessions={sessions} sessions={sessions}

View File

@@ -12,9 +12,14 @@ interface MediaHeaderProps {
totalUniqueWords: number; totalUniqueWords: number;
knownWordCount: number; knownWordCount: number;
} | null; } | null;
onDeleteEpisode?: () => void;
} }
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) { export function MediaHeader({
detail,
initialKnownWordsSummary = null,
onDeleteEpisode,
}: MediaHeaderProps) {
const knownTokenRate = const knownTokenRate =
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null; detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
const avgSessionMs = const avgSessionMs =
@@ -50,7 +55,18 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
className="w-32 h-44 rounded-lg shrink-0" className="w-32 h-44 rounded-lg shrink-0"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2> <div className="flex items-start justify-between gap-2">
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
{onDeleteEpisode != null ? (
<button
type="button"
onClick={onDeleteEpisode}
className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity"
>
Delete Episode
</button>
) : null}
</div>
{detail.channelName ? ( {detail.channelName ? (
<div className="mt-1 text-sm text-ctp-subtext1 truncate"> <div className="mt-1 text-sm text-ctp-subtext1 truncate">
{detail.channelUrl ? ( {detail.channelUrl ? (

View File

@@ -55,3 +55,11 @@ test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => {
false, 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');
});

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getStatsClient } from './useStatsApi'; import { getStatsClient } from './useStatsApi';
import type { MediaLibraryItem } from '../types/stats'; import type { MediaLibraryItem } from '../types/stats';
@@ -18,6 +18,9 @@ export function useMediaLibrary() {
const [media, setMedia] = useState<MediaLibraryItem[]>([]); const [media, setMedia] = useState<MediaLibraryItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0);
const refresh = useCallback(() => setVersion((v) => v + 1), []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -59,7 +62,7 @@ export function useMediaLibrary() {
clearTimeout(retryTimer); clearTimeout(retryTimer);
} }
}; };
}, []); }, [version]);
return { media, loading, error }; return { media, loading, error, refresh };
} }