feat: Added groupedContent useMemo and renderGroupedContent to unify pl…

- "src/frontend/src/pages/ChannelDetail.tsx"

GSD-Task: S04/T02
This commit is contained in:
jlightner 2026-04-03 06:51:51 +00:00
parent 79b2a3a566
commit 1711389d9c

View file

@ -177,7 +177,7 @@ export function ChannelDetail() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showFullDescription, setShowFullDescription] = useState(false);
const [scanInProgress, setScanInProgress] = useState(false);
const [expandedPlaylists, setExpandedPlaylists] = useState<Set<number | 'uncategorized'>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
const [checkIntervalSaved, setCheckIntervalSaved] = useState(false);
@ -341,13 +341,18 @@ export function ChannelDetail() {
try { localStorage.setItem('tubearr-group-by', value); } catch { /* ignore */ }
}, []);
// Reset expanded groups when groupBy changes
useEffect(() => {
setExpandedGroups(new Set());
}, [groupBy]);
const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => {
setViewMode(mode);
try { localStorage.setItem('tubearr-content-view', mode); } catch { /* ignore */ }
}, []);
const togglePlaylist = useCallback((id: number | 'uncategorized') => {
setExpandedPlaylists((prev) => {
const toggleGroup = useCallback((id: string | number) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
@ -450,6 +455,68 @@ export function ChannelDetail() {
return groups.length > 0 ? groups : null;
}, [channel, playlistData, content]);
// ── Unified group-by logic ──
const groupedContent = useMemo<{ id: string | number; title: string; items: ContentItem[] }[] | null>(() => {
if (groupBy === 'none') return null;
if (groupBy === 'playlist') {
// Delegate to existing playlist grouping for YouTube; null for others
return playlistGroups;
}
if (groupBy === 'year') {
const yearMap = new Map<string, ContentItem[]>();
for (const item of content) {
const year = item.publishedAt ? new Date(item.publishedAt).getFullYear().toString() : 'Unknown';
const arr = yearMap.get(year);
if (arr) {
arr.push(item);
} else {
yearMap.set(year, [item]);
}
}
// Sort groups by year descending, 'Unknown' last
const groups = Array.from(yearMap.entries())
.sort((a, b) => {
if (a[0] === 'Unknown') return 1;
if (b[0] === 'Unknown') return -1;
return Number(b[0]) - Number(a[0]);
})
.map(([year, items]) => ({ id: year, title: year, items }));
return groups.length > 0 ? groups : null;
}
if (groupBy === 'type') {
const shorts: ContentItem[] = [];
const longform: ContentItem[] = [];
const livestream: ContentItem[] = [];
const audio: ContentItem[] = [];
for (const item of content) {
if (item.contentType === 'livestream') {
livestream.push(item);
} else if (item.contentType === 'audio') {
audio.push(item);
} else if (item.contentType === 'video' && item.duration != null && item.duration <= 60) {
shorts.push(item);
} else {
// video with duration > 60, null duration, or any other video
longform.push(item);
}
}
const groups: { id: string; title: string; items: ContentItem[] }[] = [];
if (shorts.length > 0) groups.push({ id: 'short', title: 'Short', items: shorts });
if (longform.length > 0) groups.push({ id: 'longform', title: 'Longform', items: longform });
if (livestream.length > 0) groups.push({ id: 'livestream', title: 'Livestream', items: livestream });
if (audio.length > 0) groups.push({ id: 'audio', title: 'Audio', items: audio });
return groups.length > 0 ? groups : null;
}
return null;
}, [groupBy, content, playlistGroups]);
// ── Content table columns ──
const contentColumns = useMemo<Column<ContentItem>[]>(
@ -742,15 +809,15 @@ export function ChannelDetail() {
[selectedIds, toggleSelect, toggleMonitored, downloadContent],
);
const renderPlaylistGroups = useCallback(
(groups: { id: number | 'uncategorized'; title: string; items: ContentItem[] }[]) => (
const renderGroupedContent = useCallback(
(groups: { id: string | number; title: string; items: ContentItem[] }[]) => (
<div>
{groups.map((group) => {
const isExpanded = expandedPlaylists.has(group.id);
const isExpanded = expandedGroups.has(group.id);
return (
<div key={group.id} style={{ borderBottom: '1px solid var(--border)' }}>
<button
onClick={() => togglePlaylist(group.id)}
onClick={() => toggleGroup(group.id)}
aria-expanded={isExpanded}
style={{
display: 'flex',
@ -793,7 +860,7 @@ export function ChannelDetail() {
})}
</div>
),
[expandedPlaylists, togglePlaylist, renderTable, renderCardGrid, renderListView, viewMode],
[expandedGroups, toggleGroup, renderTable, renderCardGrid, renderListView, viewMode],
);
// ── Loading / Error states ──
@ -844,7 +911,6 @@ export function ChannelDetail() {
}
const isYouTube = channel.platform === 'youtube';
const hasPlaylistGroups = isYouTube && playlistGroups !== null;
return (
<div>
@ -1498,8 +1564,8 @@ export function ChannelDetail() {
) : null}
{contentLoading ? (
<SkeletonTable rows={8} columns={8} />
) : hasPlaylistGroups ? (
renderPlaylistGroups(playlistGroups!)
) : groupedContent ? (
renderGroupedContent(groupedContent)
) : viewMode === 'card' ? (
renderCardGrid(content)
) : viewMode === 'list' ? (