mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-12 04:19:25 -07:00
feat(stats): dashboard updates (#50)
This commit is contained in:
@@ -1,120 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import {
|
||||
groupMediaLibraryItems,
|
||||
summarizeMediaLibraryGroups,
|
||||
} from '../../lib/media-library-grouping';
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaDetailView } from './MediaDetailView';
|
||||
|
||||
interface LibraryTabProps {
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
}
|
||||
|
||||
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||
const { media, loading, error } = useMediaLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return media;
|
||||
const q = search.toLowerCase();
|
||||
return media.filter((m) => {
|
||||
const haystacks = [
|
||||
m.canonicalTitle,
|
||||
m.videoTitle,
|
||||
m.channelName,
|
||||
m.uploaderId,
|
||||
m.channelId,
|
||||
].filter(Boolean);
|
||||
return haystacks.some((value) => value!.toLowerCase().includes(q));
|
||||
});
|
||||
}, [media, search]);
|
||||
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
|
||||
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
|
||||
|
||||
if (selectedVideoId !== null) {
|
||||
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search titles..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{grouped.length} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video
|
||||
{summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<section
|
||||
key={group.key}
|
||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40">
|
||||
<CoverImage
|
||||
videoId={group.items[0]!.videoId}
|
||||
title={group.title}
|
||||
src={group.imageUrl}
|
||||
className="w-16 h-16 rounded-2xl shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{group.channelUrl ? (
|
||||
<a
|
||||
href={group.channelUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-base font-semibold text-ctp-text truncate hover:text-ctp-blue transition-colors"
|
||||
>
|
||||
{group.title}
|
||||
</a>
|
||||
) : (
|
||||
<h3 className="text-base font-semibold text-ctp-text truncate">
|
||||
{group.title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
{group.subtitle ? (
|
||||
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
|
||||
) : null}
|
||||
<div className="text-xs text-ctp-overlay2 mt-2">
|
||||
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{group.items.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<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,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, 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<void> };
|
||||
confirmFn: (title: string) => boolean;
|
||||
onBack: () => void;
|
||||
setDeleteError: (msg: string | null) => void;
|
||||
/**
|
||||
* Ref used to guard against reentrant delete calls synchronously. When set,
|
||||
* a subsequent invocation while the previous request is still pending is
|
||||
* ignored so clicks during the await window can't trigger duplicate deletes.
|
||||
*/
|
||||
isDeletingRef?: { current: boolean };
|
||||
/** Optional React state setter so the UI can reflect the pending state. */
|
||||
setIsDeleting?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
||||
return async () => {
|
||||
if (opts.isDeletingRef?.current) return;
|
||||
if (!opts.confirmFn(opts.title)) return;
|
||||
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
|
||||
opts.setIsDeleting?.(true);
|
||||
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.');
|
||||
} finally {
|
||||
if (opts.isDeletingRef) opts.isDeletingRef.current = false;
|
||||
opts.setIsDeleting?.(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
|
||||
if (detail?.channelName?.trim()) {
|
||||
return 'View Channel';
|
||||
@@ -35,6 +71,8 @@ export function MediaDetailView({
|
||||
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
|
||||
const isDeletingEpisodeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSessions(data?.sessions ?? null);
|
||||
@@ -79,6 +117,17 @@ export function MediaDetailView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEpisode = buildDeleteEpisodeHandler({
|
||||
videoId,
|
||||
title: detail.canonicalTitle,
|
||||
apiClient,
|
||||
confirmFn: confirmEpisodeDelete,
|
||||
onBack,
|
||||
setDeleteError,
|
||||
isDeletingRef: isDeletingEpisodeRef,
|
||||
setIsDeleting: setIsDeletingEpisode,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -99,7 +148,11 @@ export function MediaDetailView({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<MediaHeader detail={detail} />
|
||||
<MediaHeader
|
||||
detail={detail}
|
||||
onDeleteEpisode={handleDeleteEpisode}
|
||||
isDeletingEpisode={isDeletingEpisode}
|
||||
/>
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
<MediaSessionList
|
||||
sessions={sessions}
|
||||
|
||||
@@ -12,9 +12,16 @@ interface MediaHeaderProps {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null;
|
||||
onDeleteEpisode?: () => void;
|
||||
isDeletingEpisode?: boolean;
|
||||
}
|
||||
|
||||
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
|
||||
export function MediaHeader({
|
||||
detail,
|
||||
initialKnownWordsSummary = null,
|
||||
onDeleteEpisode,
|
||||
isDeletingEpisode = false,
|
||||
}: MediaHeaderProps) {
|
||||
const knownTokenRate =
|
||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
||||
const avgSessionMs =
|
||||
@@ -50,7 +57,21 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
|
||||
className="w-32 h-44 rounded-lg shrink-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="min-w-0 flex-1 text-lg font-bold text-ctp-text truncate">
|
||||
{detail.canonicalTitle}
|
||||
</h2>
|
||||
{onDeleteEpisode != null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteEpisode}
|
||||
disabled={isDeletingEpisode}
|
||||
className="shrink-0 text-xs text-ctp-red hover:opacity-75 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeletingEpisode ? 'Deleting...' : 'Delete Episode'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{detail.channelName ? (
|
||||
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
|
||||
{detail.channelUrl ? (
|
||||
|
||||
Reference in New Issue
Block a user