myYouTube/frontend/src/pages/Feed.tsx
vrubelroman fb6e639309 Remove watch/download buttons from feed video cards
Cards stay fully clickable via thumbnail and title; downloads are
managed from the video page. On the Saved page the download button
remains (status/delete), and the dead .button-link styles are removed.
2026-09-22 07:53:19 +00:00

120 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
function VideoSkeleton() {
return <li className="video-card" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /><div className="skeleton-line short" /></div></li>
}
function Feed() {
const location = useLocation()
const { categoryId } = useParams<{ categoryId: string }>()
const [searchParams] = useSearchParams()
const search = location.pathname === '/search' ? searchParams.get('q')?.trim() ?? '' : ''
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
const localUncategorized = isLocal && searchParams.get('uncategorized') === 'true'
const queryClient = useQueryClient()
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const category = categoriesQuery.data?.find((item) => item.id === categoryNumber)
const savedCountsQuery = useQuery({ queryKey: ['feed', 'saved-counts'], queryFn: getSavedCounts, enabled: isLocal })
const countQuery = useQuery({
queryKey: ['channels', isUncategorized ? 'uncategorized-count' : 'all-for-counts'],
queryFn: () => listChannels({ subscribed: true, uncategorized: isUncategorized }),
enabled: isUncategorized || (!categoryNumber && !isLocal && !search),
})
const feedQuery = useInfiniteQuery({
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly],
queryFn: ({ pageParam }) => getFeed({
categoryId: isLocal ? localCategory : categoryNumber,
uncategorized: isUncategorized || localUncategorized,
downloaded: isLocal,
search,
newOnly,
cursor: pageParam,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? 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'] })
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'] })
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} каналов в категории` : isChannelsView ? 'Каналы, отсортированные по свежести видео' : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
useEffect(() => {
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)))
}
}, [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={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={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>
<Link to="/local?uncategorized=true" className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/local?category=${item.id}`} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)}
</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> : 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} showActions={isLocal} />)}</ul>}
{!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>
}
export default Feed