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 { 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)} />
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue