feat: Wired ContentListItem into ChannelDetail with three-button segmen…
- "src/frontend/src/pages/ChannelDetail.tsx" GSD-Task: S03/T02
This commit is contained in:
parent
592c8b1317
commit
9fc15a3ed0
1 changed files with 113 additions and 24 deletions
|
|
@ -12,6 +12,7 @@ import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Film,
|
Film,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
|
LayoutList,
|
||||||
List,
|
List,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
Loader,
|
Loader,
|
||||||
|
|
@ -33,6 +34,7 @@ import { QualityLabel } from '../components/QualityLabel';
|
||||||
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||||
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
||||||
import { ContentCard } from '../components/ContentCard';
|
import { ContentCard } from '../components/ContentCard';
|
||||||
|
import { ContentListItem } from '../components/ContentListItem';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
|
|
@ -121,8 +123,12 @@ export function ChannelDetail() {
|
||||||
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [viewMode, setViewMode] = useState<'table' | 'card'>(() => {
|
const [viewMode, setViewMode] = useState<'table' | 'card' | 'list'>(() => {
|
||||||
try { return (localStorage.getItem('tubearr-content-view') as 'table' | 'card') || 'table'; }
|
try {
|
||||||
|
const stored = localStorage.getItem('tubearr-content-view');
|
||||||
|
if (stored === 'table' || stored === 'card' || stored === 'list') return stored;
|
||||||
|
return 'table';
|
||||||
|
}
|
||||||
catch { return 'table'; }
|
catch { return 'table'; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -312,12 +318,9 @@ export function ChannelDetail() {
|
||||||
setContentPage(1);
|
setContentPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleViewToggle = useCallback(() => {
|
const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => {
|
||||||
setViewMode((prev) => {
|
setViewMode(mode);
|
||||||
const next = prev === 'table' ? 'card' : 'table';
|
try { localStorage.setItem('tubearr-content-view', mode); } catch { /* ignore */ }
|
||||||
try { localStorage.setItem('tubearr-content-view', next); } catch { /* ignore */ }
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const togglePlaylist = useCallback((id: number | 'uncategorized') => {
|
const togglePlaylist = useCallback((id: number | 'uncategorized') => {
|
||||||
|
|
@ -685,6 +688,37 @@ export function ChannelDetail() {
|
||||||
[selectedIds, toggleSelect, toggleMonitored, downloadContent],
|
[selectedIds, toggleSelect, toggleMonitored, downloadContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderListView = useCallback(
|
||||||
|
(items: ContentItem[]) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
No content found for this channel.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<ContentListItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
selected={selectedIds.has(item.id)}
|
||||||
|
onSelect={toggleSelect}
|
||||||
|
onToggleMonitored={(id, monitored) => toggleMonitored.mutate({ contentId: id, monitored })}
|
||||||
|
onDownload={(id) => downloadContent.mutate(id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[selectedIds, toggleSelect, toggleMonitored, downloadContent],
|
||||||
|
);
|
||||||
|
|
||||||
const renderPlaylistGroups = useCallback(
|
const renderPlaylistGroups = useCallback(
|
||||||
(groups: { id: number | 'uncategorized'; title: string; items: ContentItem[] }[]) => (
|
(groups: { id: number | 'uncategorized'; title: string; items: ContentItem[] }[]) => (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -726,13 +760,17 @@ export function ChannelDetail() {
|
||||||
{group.items.length}
|
{group.items.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{isExpanded ? renderTable(group.items) : null}
|
{isExpanded ? (
|
||||||
|
viewMode === 'card' ? renderCardGrid(group.items) :
|
||||||
|
viewMode === 'list' ? renderListView(group.items) :
|
||||||
|
renderTable(group.items)
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
[expandedPlaylists, togglePlaylist, renderTable],
|
[expandedPlaylists, togglePlaylist, renderTable, renderCardGrid, renderListView, viewMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Loading / Error states ──
|
// ── Loading / Error states ──
|
||||||
|
|
@ -1324,27 +1362,76 @@ export function ChannelDetail() {
|
||||||
<option value="audio">Audio</option>
|
<option value="audio">Audio</option>
|
||||||
<option value="livestream">Livestream</option>
|
<option value="livestream">Livestream</option>
|
||||||
</select>
|
</select>
|
||||||
{/* View toggle */}
|
{/* View mode segmented control */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={handleViewToggle}
|
onClick={() => handleSetViewMode('table')}
|
||||||
title={viewMode === 'table' ? 'Switch to card view' : 'Switch to table view'}
|
title="Table view"
|
||||||
aria-label={viewMode === 'table' ? 'Switch to card view' : 'Switch to table view'}
|
aria-label="Table view"
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
width: 34,
|
width: 34,
|
||||||
height: 34,
|
height: 34,
|
||||||
borderRadius: 'var(--radius-md)',
|
backgroundColor: viewMode === 'table' ? 'var(--accent)' : 'var(--bg-input)',
|
||||||
backgroundColor: 'var(--bg-input)',
|
color: viewMode === 'table' ? '#fff' : 'var(--text-secondary)',
|
||||||
border: '1px solid var(--border-light)',
|
border: 'none',
|
||||||
color: 'var(--text-secondary)',
|
cursor: 'pointer',
|
||||||
transition: 'all var(--transition-fast)',
|
transition: 'all var(--transition-fast)',
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{viewMode === 'table' ? <Grid3X3 size={16} /> : <List size={16} />}
|
<List size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetViewMode('card')}
|
||||||
|
title="Card view"
|
||||||
|
aria-label="Card view"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
backgroundColor: viewMode === 'card' ? 'var(--accent)' : 'var(--bg-input)',
|
||||||
|
color: viewMode === 'card' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: 'none',
|
||||||
|
borderLeft: '1px solid var(--border)',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid3X3 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetViewMode('list')}
|
||||||
|
title="List view"
|
||||||
|
aria-label="List view"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
backgroundColor: viewMode === 'list' ? 'var(--accent)' : 'var(--bg-input)',
|
||||||
|
color: viewMode === 'list' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutList size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{contentError ? (
|
{contentError ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1383,6 +1470,8 @@ export function ChannelDetail() {
|
||||||
renderPlaylistGroups(playlistGroups!)
|
renderPlaylistGroups(playlistGroups!)
|
||||||
) : viewMode === 'card' ? (
|
) : viewMode === 'card' ? (
|
||||||
renderCardGrid(content)
|
renderCardGrid(content)
|
||||||
|
) : viewMode === 'list' ? (
|
||||||
|
renderListView(content)
|
||||||
) : (
|
) : (
|
||||||
renderTable(content)
|
renderTable(content)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue