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:
parent
9fc15a3ed0
commit
79b2a3a566
2 changed files with 189 additions and 2 deletions
155
src/frontend/src/components/SortGroupBar.tsx
Normal file
155
src/frontend/src/components/SortGroupBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue