mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
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:
@@ -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>;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user