mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
feat(stats): collapsible series groups in library tab
This commit is contained in:
56
stats/src/components/library/LibraryTab.test.tsx
Normal file
56
stats/src/components/library/LibraryTab.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
||||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||||
import {
|
import {
|
||||||
@@ -13,10 +13,47 @@ interface LibraryTabProps {
|
|||||||
onNavigateToSession: (sessionId: number) => void;
|
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) {
|
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||||
const { media, loading, error, refresh } = 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);
|
||||||
|
const [collapsedOverrides, setCollapsedOverrides] = useState<Map<string, boolean>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search.trim()) return media;
|
if (!search.trim()) return media;
|
||||||
@@ -35,6 +72,10 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
|||||||
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
|
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
|
||||||
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
|
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
|
||||||
|
|
||||||
|
const toggleGroup = useCallback((group: CollapsibleGroup) => {
|
||||||
|
setCollapsedOverrides((prev) => toggleLibraryGroupCollapse(prev, group));
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (selectedVideoId !== null) {
|
if (selectedVideoId !== null) {
|
||||||
return (
|
return (
|
||||||
<MediaDetailView
|
<MediaDetailView
|
||||||
@@ -70,12 +111,39 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
|||||||
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => {
|
||||||
|
const isSingleVideo = group.items.length === 1;
|
||||||
|
const isCollapsed = isLibraryGroupCollapsed(group, collapsedOverrides);
|
||||||
|
const bodyId = `library-group-body-${group.key}`;
|
||||||
|
return (
|
||||||
<section
|
<section
|
||||||
key={group.key}
|
key={group.key}
|
||||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
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">
|
<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
|
<CoverImage
|
||||||
videoId={group.items[0]!.videoId}
|
videoId={group.items[0]!.videoId}
|
||||||
title={group.title}
|
title={group.title}
|
||||||
@@ -84,31 +152,24 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
|||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<h3 className="text-base font-semibold text-ctp-text truncate">
|
||||||
{group.title}
|
{group.title}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{group.subtitle ? (
|
{group.subtitle ? (
|
||||||
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
|
<div className="text-xs text-ctp-overlay1 truncate mt-1">
|
||||||
|
{group.subtitle}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="text-xs text-ctp-overlay2 mt-2">
|
<div className="text-xs text-ctp-overlay2 mt-2">
|
||||||
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
|
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
|
||||||
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
|
{formatDuration(group.totalActiveMs)} ·{' '}
|
||||||
|
{formatNumber(group.totalCards)} cards
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="p-4">
|
{!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">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<MediaCard
|
<MediaCard
|
||||||
@@ -119,8 +180,10 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user