Add channels view toggle on the main page

New GET /api/channels/activity returns subscribed channels sorted by
their latest video (no-video channels last) with the 3 newest videos
per channel and cursor pagination. The feed page gains a 'Лента |
Каналы' toggle (?view=channels) with compact video cards per channel
row; category filters and the sidebar preserve the view.
This commit is contained in:
vrubelroman 2026-09-18 12:13:32 +00:00
parent 0dc1336951
commit d4950f6979
13 changed files with 721 additions and 16 deletions

View file

@ -97,6 +97,27 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
.download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); }
.download-action-error { color: var(--error); font-size: 12px; }
.load-more { display: flex; margin: 34px auto 0; }
.view-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
.view-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; }
.view-toggle a:hover { color: var(--text); background: var(--hover); }
.view-toggle a.active { color: var(--accent); background: var(--accent-soft); }
.channel-activity-list { list-style: none; display: grid; gap: 25px; padding: 0; margin: 0; }
.channel-activity-row { display: grid; grid-template-columns: 228px minmax(0, 1fr); gap: 18px; align-items: start; min-width: 0; padding-bottom: 25px; border-bottom: 1px solid var(--border); }
.channel-activity-identity { display: flex; align-items: center; gap: 12px; min-width: 0; padding-top: 2px; color: var(--text); text-decoration: none; }
.channel-activity-identity img, .channel-activity-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; object-fit: cover; }
.channel-activity-avatar { display: grid; place-items: center; background: #344557; color: var(--text); font-size: 17px; font-weight: 700; }
.channel-activity-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 650; }
.channel-activity-identity:hover .channel-activity-name { color: var(--accent); }
.channel-activity-videos { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; min-width: 0; }
.channel-activity-empty-videos { margin: 0; align-self: center; color: var(--subtle); font-size: 12px; }
.compact-video-card { min-width: 0; display: flex; flex-direction: column; }
.compact-thumbnail { position: relative; display: block; overflow: hidden; width: 100%; aspect-ratio: 16 / 9; border-radius: 9px; background: var(--surface-raised); }
.compact-thumbnail img, .compact-thumbnail .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; }
.compact-video-title { margin-top: 7px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--text); text-decoration: none; font-size: 13px; font-weight: 600; line-height: 1.42; }
.compact-video-title:hover { color: var(--accent); }
.compact-video-time { margin-top: 4px; color: var(--muted); font-size: 11px; }
.skeleton-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; background: linear-gradient(90deg, #222 25%, #303030 50%, #222 75%); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; }
.channel-activity-skeleton .skeleton-line { margin: 0; }
.empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); }
.empty-state h2 { margin: 0 0 9px; font-size: 20px; }
.empty-state p { font-size: 14px; line-height: 1.55; margin-bottom: 20px; }
@ -233,6 +254,8 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
.mobile-category-nav { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -5px -28px 25px; padding: 0 28px 5px; }
.mobile-category-nav a { flex: none; min-height: 35px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 0 14px; background: var(--surface); color: var(--muted); font-size: 12px; text-decoration: none; }
.mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); }
.channel-activity-row { grid-template-columns: 1fr; gap: 12px; padding-bottom: 21px; }
.channel-activity-identity { padding-top: 0; }
}
@media (max-width: 620px) {
.topbar { height: 112px; display: grid; grid-template-columns: 44px 1fr auto; grid-template-rows: 55px 49px; column-gap: 7px; row-gap: 0; padding: 0 15px 8px; }
@ -247,7 +270,9 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
.page { padding: 24px 16px 58px; }
.page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
.page-heading h1 { font-size: 27px; }
.view-toggle a { min-height: 38px; padding: 0 12px; }
.mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; }
.channel-activity-videos { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
.video-thumbnail { border-radius: 9px; }
.video-info { padding: 10px 2px 0; }
@ -281,6 +306,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
@media (pointer: coarse) {
.video-actions .button-link, .video-actions .button-secondary, .download-badge, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; }
.sidebar-badge { display: inline-flex; align-items: center; }
.view-toggle a { min-height: 44px; }
.reorder-buttons .icon-button { width: 40px; height: 40px; }
.popover-create input, .popover-create button { height: 44px; }
.popover-create button { width: 44px; }

View file

@ -81,6 +81,23 @@ export function listChannels(
return request<ChannelDto[]>(`/api/channels${suffix}`)
}
export interface ChannelActivityItemDto {
channel: ChannelDto
videos: FeedVideoDto[]
}
export function getChannelActivity(
params: { categoryId?: number; uncategorized?: boolean; cursor?: string; limit?: number } = {},
) {
const qs = new URLSearchParams()
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.cursor) qs.set('cursor', params.cursor)
if (params.limit != null) qs.set('limit', String(params.limit))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return request<{ items: ChannelActivityItemDto[]; next_cursor: string | null }>(`/api/channels/activity${suffix}`)
}
export function setChannelCategories(channelId: number, categoryIds: number[]) {
return request<ChannelDto>(`/api/channels/${channelId}/categories`, {
method: 'PUT',

View file

@ -63,8 +63,12 @@ function SyncIndicator() {
function Sidebar({ close }: { close: () => void }) {
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? []
const location = useLocation()
// Keep the current view mode ("Лента | Каналы") when switching between
// "Все видео" and category links; badges (?new=1) are feed-specific.
const viewQuery = new URLSearchParams(location.search).get('view') === 'channels' ? '?view=channels' : ''
const links = [
{ to: '/', icon: 'home' as const, text: 'Все видео', end: true },
{ to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true },
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
]
return <nav className="sidebar-nav" aria-label="Основная навигация">
@ -74,7 +78,7 @@ function Sidebar({ close }: { close: () => void }) {
<div className="sidebar-group sidebar-categories">
<div className="sidebar-heading">Категории</div>
{categories.map((category) => <div key={category.id} className="sidebar-category-row">
<NavLink to={`/category/${category.id}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
<NavLink to={`/category/${category.id}${viewQuery}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
</div>)}
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}

View file

@ -0,0 +1,81 @@
import { useEffect } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { Link, useLocation } from 'react-router-dom'
import { getChannelActivity } from '../api/client'
import CompactVideoCard from './CompactVideoCard'
import Icon from './Icon'
interface Props {
categoryId?: number
uncategorized?: boolean
syncRunning: boolean
}
function RowSkeleton() {
return <li className="channel-activity-row channel-activity-skeleton" aria-hidden="true">
<div className="channel-activity-identity"><span className="skeleton-avatar" /><span className="skeleton-line" /></div>
<div className="channel-activity-videos">
{Array.from({ length: 3 }, (_, index) => <div key={index} className="compact-video-card"><div className="skeleton-thumbnail" /><div className="skeleton-line short" /></div>)}
</div>
</li>
}
function ChannelActivityList({ categoryId, uncategorized, syncRunning }: Props) {
const location = useLocation()
const query = useInfiniteQuery({
queryKey: ['channel-activity', location.pathname, categoryId ?? null, uncategorized],
queryFn: ({ pageParam }) => getChannelActivity({ categoryId, uncategorized, cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
})
const items = query.data?.pages.flatMap((page) => page.items) ?? []
useEffect(() => {
if (query.isLoading) return
const key = `feed-scroll:${location.pathname + location.search}`
const saved = sessionStorage.getItem(key)
if (saved) {
sessionStorage.removeItem(key)
requestAnimationFrame(() => window.scrollTo(0, Number(saved)))
}
}, [query.isLoading, location.pathname, location.search])
if (query.isLoading) {
return <ul className="channel-activity-list">{Array.from({ length: 5 }, (_, index) => <RowSkeleton key={index} />)}</ul>
}
if (query.isError) {
return <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить каналы</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => query.refetch()}>Повторить</button></div>
}
if (items.length === 0) {
const isAll = categoryId === undefined && !uncategorized
return <div className="empty-state">
<span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isAll ? 'users' : 'folder'} size={28} /></span>
<h2>{syncRunning ? 'Импортируем каналы' : isAll ? 'Пока нет каналов' : uncategorized ? 'Все каналы распределены' : 'В этой категории нет каналов'}</h2>
<p>{syncRunning ? 'Подписки и лента обновляются. Каналы появятся здесь автоматически.' : isAll ? 'Подпишись на интересные каналы, чтобы видеть их последние видео.' : uncategorized ? 'Каналы без категории появятся здесь, как только снимешь с них все темы.' : 'Добавь каналы в эту категорию, чтобы видеть их здесь.'}</p>
{isAll && !syncRunning && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}
</div>
}
return <>
<ul className="channel-activity-list">
{items.map((item) => (
<li className="channel-activity-row" key={item.channel.id}>
<Link to={`/channels/${item.channel.id}/videos`} className="channel-activity-identity" aria-label={`Канал: ${item.channel.title}`}>
{item.channel.thumbnail_url ? <img src={item.channel.thumbnail_url} alt="" loading="lazy" /> : <span className="channel-activity-avatar">{item.channel.title.charAt(0).toUpperCase()}</span>}
<span className="channel-activity-name" title={item.channel.title}>{item.channel.title}</span>
</Link>
<div className="channel-activity-videos">
{item.videos.length > 0
? item.videos.map((video) => <CompactVideoCard key={video.youtube_video_id} video={video} />)
: <p className="channel-activity-empty-videos">Нет видео</p>}
</div>
</li>
))}
</ul>
{query.hasNextPage && <button className="button-secondary load-more" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>{query.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</>
}
export default ChannelActivityList

View file

@ -46,6 +46,7 @@ function ChannelCard({ channel, categories }: Props) {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['categories'] })
queryClient.invalidateQueries({ queryKey: ['feed'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
},
})
@ -53,6 +54,7 @@ function ChannelCard({ channel, categories }: Props) {
mutationFn: () => unsubscribeChannel(channel.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
},
})

View file

@ -0,0 +1,34 @@
import { Link, useLocation } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format'
interface Props {
video: FeedVideoDto
}
function CompactVideoCard({ video }: Props) {
const duration = formatDuration(video.duration_seconds)
const href = `/video/${video.youtube_video_id}`
const location = useLocation()
const from = location.pathname + location.search
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))
return (
<div className="compact-video-card">
<Link to={href} state={{ from }} onClick={rememberScroll} className="compact-thumbnail" aria-label={`Смотреть: ${video.title}`}>
{video.thumbnail_url ? (
<img src={video.thumbnail_url} alt="" loading="lazy" />
) : (
<div className="video-thumbnail-placeholder" />
)}
{duration && <span className="duration-badge">{duration}</span>}
</Link>
<Link to={href} state={{ from }} onClick={rememberScroll} className="compact-video-title" title={video.title}>
{video.title}
</Link>
<span className="compact-video-time">{formatRelativeTime(video.published_at)}</span>
</div>
)
}
export default CompactVideoCard

View file

@ -13,7 +13,7 @@ function Categories() {
const cancelDeleteRef = useRef<HTMLButtonElement>(null)
useEffect(() => { if (deletingId !== null) cancelDeleteRef.current?.focus() }, [deletingId])
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) }
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }); queryClient.invalidateQueries({ queryKey: ['channel-activity'] }) }
const createMutation = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewName(''); invalidate() } })
const updateMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), onSuccess: () => { setEditingId(null); invalidate() } })
const deleteMutation = useMutation({ mutationFn: deleteCategory, onSuccess: () => { setDeletingId(null); invalidate() } })

View file

@ -20,7 +20,10 @@ function Channels() {
const finishedAt = syncStatusQuery.data?.subscriptions.finished_at
const previousFinished = useRef(finishedAt)
useEffect(() => {
if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['channels'] })
if (finishedAt && previousFinished.current !== finishedAt) {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
}
previousFinished.current = finishedAt
}, [finishedAt, queryClient])
const allChannels = allChannelsQuery.data ?? []

View file

@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels } from '../api/client'
import ChannelActivityList from '../components/ChannelActivityList'
import Icon from '../components/Icon'
import VideoCard from '../components/VideoCard'
@ -17,6 +18,10 @@ function Feed() {
const categoryNumber = categoryId && /^\d+$/.test(categoryId) ? Number(categoryId) : undefined
const isLocal = location.pathname === '/local'
const isUncategorized = location.pathname === '/uncategorized'
const supportsViewToggle = !isLocal && location.pathname !== '/search'
const isChannelsView = supportsViewToggle && searchParams.get('view') === 'channels'
const viewQuery = isChannelsView ? 'view=channels' : ''
const withView = (path: string) => viewQuery ? `${path}?${viewQuery}` : path
const newOnly = categoryNumber !== undefined && searchParams.get('new') === '1'
const localCategoryRaw = isLocal ? searchParams.get('category') : null
const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined
@ -42,47 +47,58 @@ function Feed() {
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
enabled: !categoryId || categoryNumber !== undefined,
enabled: (!categoryId || categoryNumber !== undefined) && !isChannelsView,
})
const syncStatusQuery = useQuery({
queryKey: ['sync-status'], queryFn: getSyncStatus,
refetchInterval: (query) => query.state.data?.videos.running ? 2000 : 60_000,
})
const syncRunning = Boolean(syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running)
const finishedAt = syncStatusQuery.data?.videos.finished_at
const previousFinished = useRef(finishedAt)
useEffect(() => {
if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['feed'] })
if (finishedAt && previousFinished.current !== finishedAt) {
queryClient.invalidateQueries({ queryKey: ['feed'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
}
previousFinished.current = finishedAt
}, [finishedAt, queryClient])
const subscriptionsFinished = syncStatusQuery.data?.subscriptions.finished_at
const previousSubscriptionsFinished = useRef(subscriptionsFinished)
useEffect(() => {
if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) queryClient.invalidateQueries({ queryKey: ['channels'] })
if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
}
previousSubscriptionsFinished.current = subscriptionsFinished
}, [subscriptionsFinished, queryClient])
const title = search ? `Поиск: ${search}` : isLocal ? 'На сервере' : isUncategorized ? 'Без категории' : categoryId ? category?.name ?? 'Категория' : 'Все видео'
const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : isChannelsView ? 'Каналы, отсортированные по свежести видео' : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
useEffect(() => {
if (feedQuery.isLoading) return
if (isChannelsView || feedQuery.isLoading) return
const key = `feed-scroll:${location.pathname + location.search}`
const saved = sessionStorage.getItem(key)
if (saved) {
sessionStorage.removeItem(key)
requestAnimationFrame(() => window.scrollTo(0, Number(saved)))
}
}, [feedQuery.isLoading, location.pathname, location.search])
}, [isChannelsView, feedQuery.isLoading, location.pathname, location.search])
const hasInvalidCategory = Boolean(categoryId && !categoryNumber)
return <section className="page">
<div className="page-heading">
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={`/category/${categoryNumber}`}>Показать все</Link></>}</p></div>
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра">
<Link to={location.pathname} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link>
<Link to={{ pathname: location.pathname, search: 'view=channels' }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link>
</div>}
</div>
<nav className="mobile-category-nav" aria-label="Категории">
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link>
<Link to="/uncategorized" className={isUncategorized ? 'active' : ''}>Без категории</Link>
<Link to={withView('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
<Link to={withView('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link>
<Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link>
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/category/${item.id}`} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withView(`/category/${item.id}`)} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
</nav>
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
<Link to="/local" className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
@ -91,11 +107,11 @@ function Feed() {
</nav>}
{syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>}
{location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 && <div className="notice onboarding-notice"><Icon name="folder" size={19} /><span>Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.</span><Link to="/channels">Настроить категории <Icon name="chevron" size={15} /></Link></div>}
{hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? <div className="empty-state"><h2>Категория не найдена</h2><Link className="button-primary" to="/">Ко всем видео</Link></div> : <>
{hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? <div className="empty-state"><h2>Категория не найдена</h2><Link className="button-primary" to="/">Ко всем видео</Link></div> : isChannelsView ? <ChannelActivityList categoryId={categoryNumber} uncategorized={isUncategorized} syncRunning={syncRunning} /> : <>
{feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>}
{feedQuery.isError && <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить видео</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => feedQuery.refetch()}>Повторить</button></div>}
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncStatusQuery.data?.subscriptions.running && !syncStatusQuery.data?.videos.running && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>}
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncRunning && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>}
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</>}
</section>