feat: Added Videos/Streams/Audio tab bar with count badges to channel d…
- "src/frontend/src/pages/ChannelDetail.tsx" - "src/frontend/src/api/hooks/useContent.ts" GSD-Task: S07/T02
This commit is contained in:
parent
69ec5841e7
commit
bd9e07f878
2 changed files with 196 additions and 23 deletions
|
|
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tansta
|
||||||
import { apiClient } from '../client';
|
import { apiClient } from '../client';
|
||||||
import { queueKeys } from './useQueue';
|
import { queueKeys } from './useQueue';
|
||||||
import type { ContentItem } from '@shared/types/index';
|
import type { ContentItem } from '@shared/types/index';
|
||||||
import type { ApiResponse, PaginatedResponse } from '@shared/types/api';
|
import type { ApiResponse, PaginatedResponse, ContentTypeCounts } from '@shared/types/api';
|
||||||
|
|
||||||
// ── Collect Types ──
|
// ── Collect Types ──
|
||||||
|
|
||||||
|
|
@ -31,6 +31,8 @@ export const contentKeys = {
|
||||||
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
|
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
|
||||||
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
|
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
|
||||||
['content', 'channel', channelId, 'paginated', filters] as const,
|
['content', 'channel', channelId, 'paginated', filters] as const,
|
||||||
|
countsByType: (channelId: number) =>
|
||||||
|
['content', 'channel', channelId, 'counts-by-type'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
|
|
@ -59,6 +61,20 @@ export function useChannelContentPaginated(channelId: number, filters: ChannelCo
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch content counts grouped by content type for a channel. */
|
||||||
|
export function useContentTypeCounts(channelId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: contentKeys.countsByType(channelId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<ApiResponse<ContentTypeCounts>>(
|
||||||
|
`/api/v1/channel/${channelId}/content-counts`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: channelId > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
|
|
||||||
/** Enqueue a content item for download. Returns 202 with queue item. */
|
/** Enqueue a content item for download. Returns 202 with queue item. */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
import { usePersistedState } from '../hooks/usePersistedState';
|
import { usePersistedState } from '../hooks/usePersistedState';
|
||||||
import { useBulkSelection } from '../hooks/useBulkSelection';
|
import { useBulkSelection } from '../hooks/useBulkSelection';
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,7 +27,7 @@ import {
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCancelScan, useSetMonitoringMode } from '../api/hooks/useChannels';
|
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCancelScan, useSetMonitoringMode } from '../api/hooks/useChannels';
|
||||||
import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, useUpdateContentRating, type ChannelContentFilters } from '../api/hooks/useContent';
|
import { useChannelContentPaginated, useContentTypeCounts, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, useUpdateContentRating, type ChannelContentFilters } from '../api/hooks/useContent';
|
||||||
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
|
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
|
||||||
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
|
|
@ -72,23 +72,44 @@ const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [
|
||||||
export function ChannelDetail() {
|
export function ChannelDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const channelId = parseInt(id ?? '0', 10);
|
const channelId = parseInt(id ?? '0', 10);
|
||||||
|
|
||||||
// ── Data hooks ──
|
// ── Data hooks ──
|
||||||
const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId);
|
const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId);
|
||||||
const { data: formatProfiles } = useFormatProfiles();
|
const { data: formatProfiles } = useFormatProfiles();
|
||||||
const { data: playlistData } = useChannelPlaylists(channelId);
|
const { data: playlistData } = useChannelPlaylists(channelId);
|
||||||
|
const { data: contentTypeCounts } = useContentTypeCounts(channelId);
|
||||||
|
|
||||||
|
// ── Content type tab (URL-driven) ──
|
||||||
|
const activeTab = searchParams.get('tab') ?? 'all';
|
||||||
|
|
||||||
// ── Content pagination state ──
|
// ── Content pagination state ──
|
||||||
const [contentPage, setContentPage] = useState(1);
|
const [contentPage, setContentPage] = useState(1);
|
||||||
|
|
||||||
|
const setActiveTab = useCallback((tab: string) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (tab === 'all') {
|
||||||
|
next.delete('tab');
|
||||||
|
} else {
|
||||||
|
next.set('tab', tab);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
setContentPage(1);
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const [contentSearch, setContentSearch] = useState('');
|
const [contentSearch, setContentSearch] = useState('');
|
||||||
const [contentStatusFilter, setContentStatusFilter] = useState('');
|
const [contentStatusFilter, setContentStatusFilter] = useState('');
|
||||||
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
|
||||||
const [sortKey, setSortKey] = usePersistedState<string | null>('tubearr-sort-key', null);
|
const [sortKey, setSortKey] = usePersistedState<string | null>('tubearr-sort-key', null);
|
||||||
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc');
|
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc');
|
||||||
const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none');
|
const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none');
|
||||||
const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table');
|
const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table');
|
||||||
|
|
||||||
|
// Derive contentType filter from active tab
|
||||||
|
const contentTypeFilter = activeTab === 'all' ? '' : activeTab;
|
||||||
|
|
||||||
const contentFilters: ChannelContentFilters = useMemo(() => ({
|
const contentFilters: ChannelContentFilters = useMemo(() => ({
|
||||||
page: contentPage,
|
page: contentPage,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
|
|
@ -1508,11 +1529,163 @@ export function ChannelDetail() {
|
||||||
padding: 'var(--space-4) var(--space-5)',
|
padding: 'var(--space-4) var(--space-5)',
|
||||||
borderBottom: '1px solid var(--border)',
|
borderBottom: '1px solid var(--border)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'column',
|
||||||
gap: 'var(--space-3)',
|
gap: 'var(--space-3)',
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Content type tabs */}
|
||||||
|
{contentTypeCounts && (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Content type filter"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
paddingBottom: 'var(--space-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* All tab */}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'all'}
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: activeTab === 'all' ? 600 : 400,
|
||||||
|
backgroundColor: activeTab === 'all' ? 'var(--accent)' : 'transparent',
|
||||||
|
color: activeTab === 'all' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: activeTab === 'all' ? 'none' : '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
<span style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: activeTab === 'all' ? 'rgba(255,255,255,0.2)' : 'var(--bg-hover)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{contentTypeCounts.video + contentTypeCounts.audio + contentTypeCounts.livestream}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{contentTypeCounts.video > 0 && (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'video'}
|
||||||
|
onClick={() => setActiveTab('video')}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: activeTab === 'video' ? 600 : 400,
|
||||||
|
backgroundColor: activeTab === 'video' ? 'var(--accent)' : 'transparent',
|
||||||
|
color: activeTab === 'video' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: activeTab === 'video' ? 'none' : '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Film size={14} />
|
||||||
|
Videos
|
||||||
|
<span style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: activeTab === 'video' ? 'rgba(255,255,255,0.2)' : 'var(--bg-hover)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{contentTypeCounts.video}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{contentTypeCounts.livestream > 0 && (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'livestream'}
|
||||||
|
onClick={() => setActiveTab('livestream')}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: activeTab === 'livestream' ? 600 : 400,
|
||||||
|
backgroundColor: activeTab === 'livestream' ? 'var(--accent)' : 'transparent',
|
||||||
|
color: activeTab === 'livestream' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: activeTab === 'livestream' ? 'none' : '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg>
|
||||||
|
Streams
|
||||||
|
<span style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: activeTab === 'livestream' ? 'rgba(255,255,255,0.2)' : 'var(--bg-hover)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{contentTypeCounts.livestream}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{contentTypeCounts.audio > 0 && (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'audio'}
|
||||||
|
onClick={() => setActiveTab('audio')}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: activeTab === 'audio' ? 600 : 400,
|
||||||
|
backgroundColor: activeTab === 'audio' ? 'var(--accent)' : 'transparent',
|
||||||
|
color: activeTab === 'audio' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: activeTab === 'audio' ? 'none' : '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Music size={14} />
|
||||||
|
Audio
|
||||||
|
<span style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: activeTab === 'audio' ? 'rgba(255,255,255,0.2)' : 'var(--bg-hover)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{contentTypeCounts.audio}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Toolbar row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h2
|
<h2
|
||||||
style={{
|
style={{
|
||||||
fontSize: 'var(--font-size-md)',
|
fontSize: 'var(--font-size-md)',
|
||||||
|
|
@ -1565,23 +1738,6 @@ export function ChannelDetail() {
|
||||||
<option value="failed">Failed</option>
|
<option value="failed">Failed</option>
|
||||||
<option value="ignored">Ignored</option>
|
<option value="ignored">Ignored</option>
|
||||||
</select>
|
</select>
|
||||||
{/* Type filter */}
|
|
||||||
<select
|
|
||||||
value={contentTypeFilter}
|
|
||||||
onChange={(e) => { setContentTypeFilter(e.target.value); setContentPage(1); }}
|
|
||||||
aria-label="Filter by type"
|
|
||||||
style={{
|
|
||||||
padding: 'var(--space-2) var(--space-3)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
minWidth: 90,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">All Types</option>
|
|
||||||
<option value="video">Video</option>
|
|
||||||
<option value="audio">Audio</option>
|
|
||||||
<option value="livestream">Livestream</option>
|
|
||||||
</select>
|
|
||||||
{/* View mode segmented control */}
|
{/* View mode segmented control */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1652,6 +1808,7 @@ export function ChannelDetail() {
|
||||||
<LayoutList size={16} />
|
<LayoutList size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Sort & Group controls */}
|
{/* Sort & Group controls */}
|
||||||
<SortGroupBar
|
<SortGroupBar
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue