diff --git a/src/frontend/src/api/hooks/useContent.ts b/src/frontend/src/api/hooks/useContent.ts index d6a692a..3bdbf33 100644 --- a/src/frontend/src/api/hooks/useContent.ts +++ b/src/frontend/src/api/hooks/useContent.ts @@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tansta import { apiClient } from '../client'; import { queueKeys } from './useQueue'; 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 ── @@ -31,6 +31,8 @@ export const contentKeys = { byChannel: (channelId: number) => ['content', 'channel', channelId] as const, byChannelPaginated: (channelId: number, filters: ChannelContentFilters) => ['content', 'channel', channelId, 'paginated', filters] as const, + countsByType: (channelId: number) => + ['content', 'channel', channelId, 'counts-by-type'] as const, }; // ── 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>( + `/api/v1/channel/${channelId}/content-counts`, + ); + return response.data; + }, + enabled: channelId > 0, + }); +} + // ── Mutations ── /** Enqueue a content item for download. Returns 202 with queue item. */ diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 65222c2..ee167c2 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -1,5 +1,5 @@ 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 { useBulkSelection } from '../hooks/useBulkSelection'; import { @@ -27,7 +27,7 @@ import { Users, } from 'lucide-react'; 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 { useFormatProfiles } from '../api/hooks/useFormatProfiles'; import { Table, type Column } from '../components/Table'; @@ -72,23 +72,44 @@ const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [ export function ChannelDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const channelId = parseInt(id ?? '0', 10); // ── Data hooks ── const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId); const { data: formatProfiles } = useFormatProfiles(); const { data: playlistData } = useChannelPlaylists(channelId); + const { data: contentTypeCounts } = useContentTypeCounts(channelId); + + // ── Content type tab (URL-driven) ── + const activeTab = searchParams.get('tab') ?? 'all'; // ── Content pagination state ── 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 [contentStatusFilter, setContentStatusFilter] = useState(''); - const [contentTypeFilter, setContentTypeFilter] = useState(''); const [sortKey, setSortKey] = usePersistedState('tubearr-sort-key', null); const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc'); const [groupBy, setGroupBy] = usePersistedState('tubearr-group-by', 'none'); 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(() => ({ page: contentPage, pageSize: 50, @@ -1508,11 +1529,163 @@ export function ChannelDetail() { padding: 'var(--space-4) var(--space-5)', borderBottom: '1px solid var(--border)', display: 'flex', - alignItems: 'center', + flexDirection: 'column', gap: 'var(--space-3)', - flexWrap: 'wrap', }} > + {/* Content type tabs */} + {contentTypeCounts && ( +
+ {/* All tab */} + + {contentTypeCounts.video > 0 && ( + + )} + {contentTypeCounts.livestream > 0 && ( + + )} + {contentTypeCounts.audio > 0 && ( + + )} +
+ )} + {/* Toolbar row */} +

Failed - {/* Type filter */} - {/* View mode segmented control */}
+

{/* Sort & Group controls */}