feat: Add SortGroupBar with Date/Title/Duration/Size/Status sort button…

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

GSD-Task: S04/T01
This commit is contained in:
jlightner 2026-04-03 06:49:38 +00:00
parent 9fc15a3ed0
commit 79b2a3a566
2 changed files with 189 additions and 2 deletions

View file

@ -0,0 +1,155 @@
import { ArrowDown, ArrowUp } from 'lucide-react';
export type SortKey = 'publishedAt' | 'title' | 'duration' | 'fileSize' | 'status';
export type GroupByKey = 'none' | 'playlist' | 'year' | 'type';
interface SortButton {
key: SortKey;
label: string;
}
const SORT_BUTTONS: SortButton[] = [
{ key: 'publishedAt', label: 'Date' },
{ key: 'title', label: 'Title' },
{ key: 'duration', label: 'Duration' },
{ key: 'fileSize', label: 'Size' },
{ key: 'status', label: 'Status' },
];
const GROUP_BY_OPTIONS: { value: GroupByKey; label: string; youtubeOnly?: boolean }[] = [
{ value: 'none', label: 'No Grouping' },
{ value: 'playlist', label: 'Playlist', youtubeOnly: true },
{ value: 'year', label: 'Year' },
{ value: 'type', label: 'Type' },
];
interface SortGroupBarProps {
sortKey: string | null;
sortDirection: 'asc' | 'desc';
onSort: (key: string, direction: 'asc' | 'desc') => void;
groupBy: GroupByKey;
onGroupByChange: (groupBy: GroupByKey) => void;
isYouTube: boolean;
}
export function SortGroupBar({
sortKey,
sortDirection,
onSort,
groupBy,
onGroupByChange,
isYouTube,
}: SortGroupBarProps) {
const handleSortClick = (key: SortKey) => {
if (sortKey === key) {
// Toggle direction
onSort(key, sortDirection === 'asc' ? 'desc' : 'asc');
} else {
// New sort key — default to descending
onSort(key, 'desc');
}
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-5)',
borderBottom: '1px solid var(--border)',
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 */}
{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) => onGroupByChange(e.target.value as GroupByKey)}
aria-label="Group 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)',
}}
>
{GROUP_BY_OPTIONS.filter(
(opt) => !opt.youtubeOnly || isYouTube,
).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}

View file

@ -36,6 +36,7 @@ import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
import { ContentCard } from '../components/ContentCard';
import { ContentListItem } from '../components/ContentListItem';
import { Pagination } from '../components/Pagination';
import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast';
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
@ -121,8 +122,23 @@ export function ChannelDetail() {
const [contentSearch, setContentSearch] = useState('');
const [contentStatusFilter, setContentStatusFilter] = useState('');
const [contentTypeFilter, setContentTypeFilter] = useState('');
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [sortKey, setSortKey] = useState<string | null>(() => {
try { return localStorage.getItem('tubearr-sort-key') || null; } catch { return null; }
});
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(() => {
try {
const stored = localStorage.getItem('tubearr-sort-dir');
if (stored === 'asc' || stored === 'desc') return stored;
return 'asc';
} catch { return 'asc'; }
});
const [groupBy, setGroupBy] = useState<GroupByKey>(() => {
try {
const stored = localStorage.getItem('tubearr-group-by');
if (stored === 'none' || stored === 'playlist' || stored === 'year' || stored === 'type') return stored;
return 'none';
} catch { return 'none'; }
});
const [viewMode, setViewMode] = useState<'table' | 'card' | 'list'>(() => {
try {
const stored = localStorage.getItem('tubearr-content-view');
@ -316,6 +332,13 @@ export function ChannelDetail() {
setSortKey(key);
setSortDirection(direction);
setContentPage(1);
try { localStorage.setItem('tubearr-sort-key', key); } catch { /* ignore */ }
try { localStorage.setItem('tubearr-sort-dir', direction); } catch { /* ignore */ }
}, []);
const handleGroupByChange = useCallback((value: GroupByKey) => {
setGroupBy(value);
try { localStorage.setItem('tubearr-group-by', value); } catch { /* ignore */ }
}, []);
const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => {
@ -1433,6 +1456,15 @@ export function ChannelDetail() {
</button>
</div>
</div>
{/* Sort & Group controls */}
<SortGroupBar
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
groupBy={groupBy}
onGroupByChange={handleGroupByChange}
isYouTube={isYouTube}
/>
{contentError ? (
<div
role="alert"