mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
refactor(stats): drop unused LibraryTab and useMediaLibrary
The live "Library" tab renders AnimeTab via App.tsx; LibraryTab and its useMediaLibrary hook were never wired in. Remove them so the dead code doesn't mislead readers, and drop the collapsible-library-group item from the feedback-pass changelog since it targeted dead code.
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
type: changed
|
type: changed
|
||||||
area: stats
|
area: stats
|
||||||
|
|
||||||
- Library groups now collapse multi-video series behind a per-group dropdown so the grid stays browsable.
|
|
||||||
- Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
|
- Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
|
||||||
- Trends add a 365-day range next to the existing 7d/30d/90d/all options.
|
- Trends add a 365-day range next to the existing 7d/30d/90d/all options.
|
||||||
- Library detail view gets a delete-episode action that removes the video and all its sessions.
|
- Library detail view gets a delete-episode action that removes the video and all its sessions.
|
||||||
- Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
|
- Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
|
||||||
- Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
|
- Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
|
||||||
- Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
|
- Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
|
||||||
|
- Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import {
|
|
||||||
isLibraryGroupCollapsed,
|
|
||||||
toggleLibraryGroupCollapse,
|
|
||||||
} from './LibraryTab';
|
|
||||||
|
|
||||||
interface FakeGroup {
|
|
||||||
key: string;
|
|
||||||
items: { videoId: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const multiVideoGroup: FakeGroup = {
|
|
||||||
key: 'series-a',
|
|
||||||
items: [{ videoId: 1 }, { videoId: 2 }, { videoId: 3 }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const singletonGroup: FakeGroup = {
|
|
||||||
key: 'video-b',
|
|
||||||
items: [{ videoId: 99 }],
|
|
||||||
};
|
|
||||||
|
|
||||||
test('isLibraryGroupCollapsed defaults to collapsed for multi-video groups', () => {
|
|
||||||
assert.equal(isLibraryGroupCollapsed(multiVideoGroup, new Map()), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isLibraryGroupCollapsed defaults to expanded for singleton groups', () => {
|
|
||||||
assert.equal(isLibraryGroupCollapsed(singletonGroup, new Map()), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isLibraryGroupCollapsed honors an explicit user override', () => {
|
|
||||||
const overrides = new Map<string, boolean>([
|
|
||||||
['series-a', false],
|
|
||||||
['video-b', true],
|
|
||||||
]);
|
|
||||||
assert.equal(isLibraryGroupCollapsed(multiVideoGroup, overrides), false);
|
|
||||||
assert.equal(isLibraryGroupCollapsed(singletonGroup, overrides), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toggleLibraryGroupCollapse flips a multi-video group from collapsed to expanded', () => {
|
|
||||||
const next = toggleLibraryGroupCollapse(new Map(), multiVideoGroup);
|
|
||||||
assert.equal(next.get('series-a'), false);
|
|
||||||
assert.equal(isLibraryGroupCollapsed(multiVideoGroup, next), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toggleLibraryGroupCollapse flips a singleton group from expanded to collapsed', () => {
|
|
||||||
const next = toggleLibraryGroupCollapse(new Map(), singletonGroup);
|
|
||||||
assert.equal(next.get('video-b'), true);
|
|
||||||
assert.equal(isLibraryGroupCollapsed(singletonGroup, next), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toggleLibraryGroupCollapse toggles back when called twice', () => {
|
|
||||||
const once = toggleLibraryGroupCollapse(new Map(), multiVideoGroup);
|
|
||||||
const twice = toggleLibraryGroupCollapse(once, multiVideoGroup);
|
|
||||||
assert.equal(isLibraryGroupCollapsed(multiVideoGroup, twice), true);
|
|
||||||
});
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollapsibleGroup {
|
|
||||||
key: string;
|
|
||||||
items: { videoId: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute whether a library group should render collapsed.
|
|
||||||
*
|
|
||||||
* Default behavior: multi-video groups (series) start collapsed so the library
|
|
||||||
* is browsable; singletons stay expanded since collapsing them is just noise.
|
|
||||||
* Once the user clicks a group header we record an explicit override in the
|
|
||||||
* Map and respect it from then on.
|
|
||||||
*/
|
|
||||||
export function isLibraryGroupCollapsed(
|
|
||||||
group: CollapsibleGroup,
|
|
||||||
overrides: Map<string, boolean>,
|
|
||||||
): boolean {
|
|
||||||
const override = overrides.get(group.key);
|
|
||||||
if (override !== undefined) return override;
|
|
||||||
return group.items.length > 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a new override map with `group`'s collapsed state flipped.
|
|
||||||
*/
|
|
||||||
export function toggleLibraryGroupCollapse(
|
|
||||||
overrides: Map<string, boolean>,
|
|
||||||
group: CollapsibleGroup,
|
|
||||||
): Map<string, boolean> {
|
|
||||||
const next = new Map(overrides);
|
|
||||||
next.set(group.key, !isLibraryGroupCollapsed(group, overrides));
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
|
||||||
const { media, loading, error, refresh } = useMediaLibrary();
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
|
||||||
const [collapsedOverrides, setCollapsedOverrides] = useState<Map<string, boolean>>(
|
|
||||||
() => new Map(),
|
|
||||||
);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const toggleGroup = useCallback((group: CollapsibleGroup) => {
|
|
||||||
setCollapsedOverrides((prev) => toggleLibraryGroupCollapse(prev, group));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (selectedVideoId !== null) {
|
|
||||||
return (
|
|
||||||
<MediaDetailView
|
|
||||||
videoId={selectedVideoId}
|
|
||||||
onBack={() => {
|
|
||||||
setSelectedVideoId(null);
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
const isSingleVideo = group.items.length === 1;
|
|
||||||
const isCollapsed = isLibraryGroupCollapsed(group, collapsedOverrides);
|
|
||||||
const bodyId = `library-group-body-${group.key}`;
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={group.key}
|
|
||||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!isSingleVideo) toggleGroup(group);
|
|
||||||
}}
|
|
||||||
aria-expanded={!isCollapsed}
|
|
||||||
aria-controls={bodyId}
|
|
||||||
disabled={isSingleVideo}
|
|
||||||
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
|
|
||||||
isSingleVideo
|
|
||||||
? 'cursor-default'
|
|
||||||
: 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{!isSingleVideo && (
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
|
|
||||||
isCollapsed ? '' : 'rotate-90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{'\u25B6'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div id={bodyId} 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,65 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import type { MediaLibraryItem } from '../types/stats';
|
|
||||||
import { shouldRefreshMediaLibraryRows } from './useMediaLibrary';
|
|
||||||
|
|
||||||
const baseItem: MediaLibraryItem = {
|
|
||||||
videoId: 1,
|
|
||||||
canonicalTitle: 'watch?v=abc123',
|
|
||||||
totalSessions: 1,
|
|
||||||
totalActiveMs: 60_000,
|
|
||||||
totalCards: 0,
|
|
||||||
totalTokensSeen: 10,
|
|
||||||
lastWatchedMs: 1_000,
|
|
||||||
hasCoverArt: 0,
|
|
||||||
youtubeVideoId: 'abc123',
|
|
||||||
videoUrl: 'https://www.youtube.com/watch?v=abc123',
|
|
||||||
videoTitle: null,
|
|
||||||
videoThumbnailUrl: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
|
||||||
channelId: null,
|
|
||||||
channelName: null,
|
|
||||||
channelUrl: null,
|
|
||||||
channelThumbnailUrl: null,
|
|
||||||
uploaderId: null,
|
|
||||||
uploaderUrl: null,
|
|
||||||
description: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
test('shouldRefreshMediaLibraryRows requests a follow-up fetch for incomplete youtube metadata', () => {
|
|
||||||
assert.equal(shouldRefreshMediaLibraryRows([baseItem]), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldRefreshMediaLibraryRows skips follow-up fetch when youtube metadata is complete', () => {
|
|
||||||
assert.equal(
|
|
||||||
shouldRefreshMediaLibraryRows([
|
|
||||||
{
|
|
||||||
...baseItem,
|
|
||||||
videoTitle: 'Video Name',
|
|
||||||
channelName: 'Creator Name',
|
|
||||||
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => {
|
|
||||||
assert.equal(
|
|
||||||
shouldRefreshMediaLibraryRows([
|
|
||||||
{
|
|
||||||
...baseItem,
|
|
||||||
youtubeVideoId: null,
|
|
||||||
videoUrl: null,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
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,68 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { getStatsClient } from './useStatsApi';
|
|
||||||
import type { MediaLibraryItem } from '../types/stats';
|
|
||||||
|
|
||||||
const MEDIA_LIBRARY_REFRESH_DELAY_MS = 1_500;
|
|
||||||
const MEDIA_LIBRARY_MAX_RETRIES = 3;
|
|
||||||
|
|
||||||
export function shouldRefreshMediaLibraryRows(rows: MediaLibraryItem[]): boolean {
|
|
||||||
return rows.some((row) => {
|
|
||||||
if (!row.youtubeVideoId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !row.videoTitle?.trim() || !row.channelName?.trim() || !row.channelThumbnailUrl?.trim();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMediaLibrary() {
|
|
||||||
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [version, setVersion] = useState(0);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => setVersion((v) => v + 1), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
let retryCount = 0;
|
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const load = (isInitial = false) => {
|
|
||||||
if (isInitial) {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
getStatsClient()
|
|
||||||
.getMediaLibrary()
|
|
||||||
.then((rows) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setMedia(rows);
|
|
||||||
if (shouldRefreshMediaLibraryRows(rows) && retryCount < MEDIA_LIBRARY_MAX_RETRIES) {
|
|
||||||
retryCount += 1;
|
|
||||||
retryTimer = setTimeout(() => {
|
|
||||||
retryTimer = null;
|
|
||||||
load(false);
|
|
||||||
}, MEDIA_LIBRARY_REFRESH_DELAY_MS);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err: Error) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setError(err.message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (cancelled || !isInitial) return;
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
load(true);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (retryTimer) {
|
|
||||||
clearTimeout(retryTimer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [version]);
|
|
||||||
|
|
||||||
return { media, loading, error, refresh };
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user