diff --git a/src/frontend/src/pages/Channels.tsx b/src/frontend/src/pages/Channels.tsx index 5562965..f1b18dc 100644 --- a/src/frontend/src/pages/Channels.tsx +++ b/src/frontend/src/pages/Channels.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Plus, Loader, RefreshCw, Download, Link2 } from 'lucide-react'; +import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Plus, Loader, RefreshCw, Download, Link2, Search } from 'lucide-react'; import { useChannels, useScanAllChannels } from '../api/hooks/useChannels'; import { useCollectAllMonitored } from '../api/hooks/useContent'; import { Table, type Column } from '../components/Table'; @@ -14,12 +14,39 @@ import { useToast } from '../components/Toast'; import { formatRelativeTime } from '../utils/format'; import type { ChannelWithCounts } from '@shared/types/api'; +// ── Channel list sort/group types ── + +type ChannelSortKey = 'name' | 'platform' | 'lastCheckedAt' | 'contentCount'; +type ChannelGroupBy = 'none' | 'platform'; + +interface ChannelSortButton { + key: ChannelSortKey; + label: string; +} + +const CHANNEL_SORT_BUTTONS: ChannelSortButton[] = [ + { key: 'name', label: 'Name' }, + { key: 'platform', label: 'Platform' }, + { key: 'lastCheckedAt', label: 'Last Checked' }, + { key: 'contentCount', label: 'Content Count' }, +]; + +const CHANNEL_GROUP_OPTIONS: { value: ChannelGroupBy; label: string }[] = [ + { value: 'none', label: 'No Grouping' }, + { value: 'platform', label: 'Platform' }, +]; + // ── Component ── export function Channels() { const navigate = useNavigate(); const [showAddModal, setShowAddModal] = useState(false); const [showAddUrl, setShowAddUrl] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [sortKey, setSortKey] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [groupBy, setGroupBy] = useState('none'); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); const { toast } = useToast(); const { data: channels, isLoading, error, refetch } = useChannels(); @@ -64,6 +91,97 @@ export function Channels() { [navigate], ); + // ── Sort handler ── + const handleSortClick = useCallback((key: ChannelSortKey) => { + setSortKey((prev) => { + if (prev === key) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')); + return key; + } + setSortDirection('asc'); + return key; + }); + }, []); + + // ── Toggle group expand/collapse ── + const toggleGroup = useCallback((id: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + // ── Filtered, sorted, grouped channels ── + const filteredChannels = useMemo(() => { + let result = channels ?? []; + + // Text search — case-insensitive on channel name + if (searchQuery.trim()) { + const q = searchQuery.trim().toLowerCase(); + result = result.filter((c) => c.name.toLowerCase().includes(q)); + } + + // Sort + if (sortKey) { + result = [...result].sort((a, b) => { + let cmp = 0; + switch (sortKey) { + case 'name': + cmp = a.name.localeCompare(b.name); + break; + case 'platform': + cmp = a.platform.localeCompare(b.platform); + break; + case 'lastCheckedAt': { + const aTime = a.lastCheckedAt ? new Date(a.lastCheckedAt).getTime() : 0; + const bTime = b.lastCheckedAt ? new Date(b.lastCheckedAt).getTime() : 0; + cmp = aTime - bTime; + break; + } + case 'contentCount': { + const aCount = a.contentCounts?.total ?? 0; + const bCount = b.contentCounts?.total ?? 0; + cmp = aCount - bCount; + break; + } + } + return sortDirection === 'asc' ? cmp : -cmp; + }); + } + + return result; + }, [channels, searchQuery, sortKey, sortDirection]); + + const groupedChannels = useMemo<{ id: string; title: string; platform?: string; items: ChannelWithCounts[] }[] | null>(() => { + if (groupBy === 'none') return null; + + // Group by platform + const platformMap = new Map(); + for (const ch of filteredChannels) { + const key = ch.platform; + const arr = platformMap.get(key); + if (arr) { + arr.push(ch); + } else { + platformMap.set(key, [ch]); + } + } + + return Array.from(platformMap.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([platform, items]) => ({ + id: platform, + title: platform.charAt(0).toUpperCase() + platform.slice(1), + platform, + items, + })); + }, [groupBy, filteredChannels]); + const columns = useMemo[]>( () => [ { @@ -268,24 +386,236 @@ export function Channels() { - {/* Channel table */} + {/* Filter bar */}
- c.id} - onRowClick={handleRowClick} - emptyMessage="No channels added yet. Add a YouTube channel or SoundCloud artist to get started." - /> + {/* Search row */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search channels..." + aria-label="Search channels by name" + style={{ + flex: 1, + padding: 'var(--space-1) var(--space-2)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--border)', + backgroundColor: 'var(--bg-input)', + color: 'var(--text-primary)', + fontSize: 'var(--font-size-sm)', + outline: 'none', + }} + /> +
+ + {/* Sort + Group row */} +
+ {/* Sort label */} + + Sort + + + {/* Sort buttons */} + {CHANNEL_SORT_BUTTONS.map((btn) => { + const isActive = sortKey === btn.key; + return ( + + ); + })} + + {/* Spacer */} +
+ + {/* Group by */} + + Group + + +
+ {/* Channel table / grouped view */} + {groupedChannels ? ( +
+ {groupedChannels.length === 0 ? ( +
+ No channels match your search. +
+ ) : ( + groupedChannels.map((group) => { + const isExpanded = expandedGroups.has(group.id); + return ( +
+ + {isExpanded && ( +
c.id} + onRowClick={handleRowClick} + emptyMessage="No channels in this group." + /> + )} + + ); + }) + )} + + ) : ( +
+
c.id} + onRowClick={handleRowClick} + emptyMessage={searchQuery.trim() ? 'No channels match your search.' : 'No channels added yet. Add a YouTube channel or SoundCloud artist to get started.'} + /> + + )} + {/* Add Channel modal */} setShowAddModal(false)} />