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 { ContentCard } from '../components/ContentCard';
|
||||||
import { ContentListItem } from '../components/ContentListItem';
|
import { ContentListItem } from '../components/ContentListItem';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
|
import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||||
|
|
@ -121,8 +122,23 @@ export function ChannelDetail() {
|
||||||
const [contentSearch, setContentSearch] = useState('');
|
const [contentSearch, setContentSearch] = useState('');
|
||||||
const [contentStatusFilter, setContentStatusFilter] = useState('');
|
const [contentStatusFilter, setContentStatusFilter] = useState('');
|
||||||
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
const [sortKey, setSortKey] = useState<string | null>(() => {
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
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'>(() => {
|
const [viewMode, setViewMode] = useState<'table' | 'card' | 'list'>(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('tubearr-content-view');
|
const stored = localStorage.getItem('tubearr-content-view');
|
||||||
|
|
@ -316,6 +332,13 @@ export function ChannelDetail() {
|
||||||
setSortKey(key);
|
setSortKey(key);
|
||||||
setSortDirection(direction);
|
setSortDirection(direction);
|
||||||
setContentPage(1);
|
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') => {
|
const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => {
|
||||||
|
|
@ -1433,6 +1456,15 @@ export function ChannelDetail() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Sort & Group controls */}
|
||||||
|
<SortGroupBar
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
groupBy={groupBy}
|
||||||
|
onGroupByChange={handleGroupByChange}
|
||||||
|
isYouTube={isYouTube}
|
||||||
|
/>
|
||||||
{contentError ? (
|
{contentError ? (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue