feat: Added search bar, sort pill buttons (Name/Platform/Last Checked/C…

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

GSD-Task: S06/T01
This commit is contained in:
jlightner 2026-04-04 10:16:27 +00:00
parent 8cf998a697
commit 794181580f

View file

@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
import { useCollectAllMonitored } from '../api/hooks/useContent'; import { useCollectAllMonitored } from '../api/hooks/useContent';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
@ -14,12 +14,39 @@ import { useToast } from '../components/Toast';
import { formatRelativeTime } from '../utils/format'; import { formatRelativeTime } from '../utils/format';
import type { ChannelWithCounts } from '@shared/types/api'; 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 ── // ── Component ──
export function Channels() { export function Channels() {
const navigate = useNavigate(); const navigate = useNavigate();
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [showAddUrl, setShowAddUrl] = useState(false); const [showAddUrl, setShowAddUrl] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortKey, setSortKey] = useState<ChannelSortKey | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [groupBy, setGroupBy] = useState<ChannelGroupBy>('none');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const { toast } = useToast(); const { toast } = useToast();
const { data: channels, isLoading, error, refetch } = useChannels(); const { data: channels, isLoading, error, refetch } = useChannels();
@ -64,6 +91,97 @@ export function Channels() {
[navigate], [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<string, ChannelWithCounts[]>();
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<Column<ChannelWithCounts>[]>( const columns = useMemo<Column<ChannelWithCounts>[]>(
() => [ () => [
{ {
@ -268,24 +386,236 @@ export function Channels() {
</div> </div>
</div> </div>
{/* Channel table */} {/* Filter bar */}
<div <div
style={{ style={{
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
marginBottom: 'var(--space-4)',
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<Table {/* Search row */}
columns={columns} <div
data={channels ?? []} style={{
keyExtractor={(c) => c.id} display: 'flex',
onRowClick={handleRowClick} alignItems: 'center',
emptyMessage="No channels added yet. Add a YouTube channel or SoundCloud artist to get started." gap: 'var(--space-3)',
/> padding: 'var(--space-3) var(--space-5)',
borderBottom: '1px solid var(--border)',
}}
>
<Search size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<input
type="text"
value={searchQuery}
onChange={(e) => 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',
}}
/>
</div>
{/* Sort + Group row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-5)',
flexWrap: 'wrap',
}}
>
{/* Sort label */}
<span
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginRight: 'var(--space-1)',
flexShrink: 0,
}}
>
Sort
</span>
{/* Sort buttons */}
{CHANNEL_SORT_BUTTONS.map((btn) => {
const isActive = sortKey === btn.key;
return (
<button
key={btn.key}
onClick={() => handleSortClick(btn.key)}
aria-label={`Sort by ${btn.label}${isActive ? `, currently ${sortDirection === 'asc' ? 'ascending' : 'descending'}` : ''}`}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: 'var(--space-1) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
fontWeight: isActive ? 600 : 400,
backgroundColor: isActive ? 'var(--accent)' : 'transparent',
color: isActive ? '#fff' : 'var(--text-secondary)',
border: isActive ? 'none' : '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
lineHeight: 1.4,
}}
>
{btn.label}
{isActive && (
sortDirection === 'asc'
? <ArrowUp size={12} />
: <ArrowDown size={12} />
)}
</button>
);
})}
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Group by */}
<span
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginRight: 'var(--space-1)',
flexShrink: 0,
}}
>
Group
</span>
<select
value={groupBy}
onChange={(e) => {
setGroupBy(e.target.value as ChannelGroupBy);
setExpandedGroups(new Set());
}}
aria-label="Group channels by"
style={{
padding: 'var(--space-1) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
minWidth: 110,
backgroundColor: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
>
{CHANNEL_GROUP_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div> </div>
{/* Channel table / grouped view */}
{groupedChannels ? (
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
>
{groupedChannels.length === 0 ? (
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--text-muted)' }}>
No channels match your search.
</div>
) : (
groupedChannels.map((group) => {
const isExpanded = expandedGroups.has(group.id);
return (
<div key={group.id} style={{ borderBottom: '1px solid var(--border)' }}>
<button
onClick={() => toggleGroup(group.id)}
aria-expanded={isExpanded}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
width: '100%',
padding: 'var(--space-3) var(--space-5)',
backgroundColor: 'var(--bg-hover)',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
transition: 'background-color var(--transition-fast)',
}}
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{group.platform && <PlatformBadge platform={group.platform as ChannelWithCounts['platform']} />}
<span style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: 'var(--font-size-sm)' }}>
{group.title}
</span>
<span
style={{
padding: '1px 8px',
borderRadius: 'var(--radius-sm)',
backgroundColor: 'var(--bg-card)',
color: 'var(--text-secondary)',
fontSize: 'var(--font-size-xs)',
fontWeight: 500,
border: '1px solid var(--border)',
}}
>
{group.items.length}
</span>
</button>
{isExpanded && (
<Table
columns={columns}
data={group.items}
keyExtractor={(c) => c.id}
onRowClick={handleRowClick}
emptyMessage="No channels in this group."
/>
)}
</div>
);
})
)}
</div>
) : (
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
>
<Table
columns={columns}
data={filteredChannels}
keyExtractor={(c) => 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.'}
/>
</div>
)}
{/* Add Channel modal */} {/* Add Channel modal */}
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} /> <AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} />