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:
parent
8cf998a697
commit
794181580f
1 changed files with 339 additions and 9 deletions
|
|
@ -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<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 { 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<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>[]>(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -268,24 +386,236 @@ export function Channels() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel table */}
|
||||
{/* Filter bar */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
marginBottom: 'var(--space-4)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={channels ?? []}
|
||||
keyExtractor={(c) => c.id}
|
||||
onRowClick={handleRowClick}
|
||||
emptyMessage="No channels added yet. Add a YouTube channel or SoundCloud artist to get started."
|
||||
/>
|
||||
{/* Search row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} />
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue