feat(stats): collapsible series groups in library tab

This commit is contained in:
2026-04-09 01:07:25 -07:00
parent 8e25e19cac
commit cfb2396791
2 changed files with 166 additions and 47 deletions

View 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);
});

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
import { formatDuration, formatNumber } from '../../lib/formatters';
import {
@@ -13,10 +13,47 @@ 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;
@@ -35,6 +72,10 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
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
@@ -70,57 +111,79 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
<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>
) : (
{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>
{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
</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>
</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>
))}
)}
</section>
);
})}
</div>
)}
</div>