- Detect shorts by duration (SHORTS_MAX_DURATION_SECONDS, default 180)
and expose is_short in feed, video and channel-activity DTOs.
- Feed gains type=all|long|short and anchor; lists show a two-mode
'Обычные | Shorts' filter (no 'Все') persisted in the URL.
- Clicking a short opens /shorts/🆔 a vertical scroll-snap feed with
autoplay for the active slide, context-aware endpoints and infinite
loading.
- Keep the sound choice across swipes, syncing with the player's own
mute control and guarding against the widget's stale isMuted() reads.
182 lines
15 KiB
TypeScript
182 lines
15 KiB
TypeScript
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'
|
||
|
||
// The UI exposes exactly two mutually exclusive modes. `long` is the default
|
||
// and has no URL parameter; `short` is `?type=short`. The REST endpoint still
|
||
// accepts the unfiltered value for future mobile clients, but the UI never
|
||
// sends it.
|
||
type TypeFilter = 'long' | 'short'
|
||
|
||
const TYPE_OPTIONS: { value: TypeFilter; label: string }[] = [
|
||
{ value: 'long', label: 'Обычные' },
|
||
{ value: 'short', label: 'Shorts' },
|
||
]
|
||
|
||
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'
|
||
// Any value other than `short` (missing, `all`, `long`, garbage) is
|
||
// normalized to the default `long` mode.
|
||
const videoType: TypeFilter = searchParams.get('type') === 'short' ? 'short' : 'long'
|
||
// Query params carried across sidebar/mobile-category links: the view mode
|
||
// and the video-type filter. Only `?type=short` is ever written; `long` is
|
||
// the default and has no parameter. `?new=1` stays feed-specific.
|
||
const preservedParams = new URLSearchParams()
|
||
if (isChannelsView) preservedParams.set('view', 'channels')
|
||
if (videoType === 'short') preservedParams.set('type', 'short')
|
||
const preservedQuery = preservedParams.toString()
|
||
const withPreserved = (path: string) => (preservedQuery ? `${path}${path.includes('?') ? '&' : '?'}${preservedQuery}` : path)
|
||
// "На сервере" has no channels view, so the view mode must not leak into its
|
||
// URL; keep only the type filter.
|
||
const localNavPath = `/local${videoType === 'short' ? '?type=short' : ''}`
|
||
const typeLink = (value: TypeFilter) => {
|
||
const params = new URLSearchParams(location.search)
|
||
if (value === 'short') params.set('type', 'short')
|
||
else params.delete('type')
|
||
const qs = params.toString()
|
||
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
|
||
}
|
||
const feedViewLink = () => {
|
||
const params = new URLSearchParams()
|
||
if (videoType === 'short') params.set('type', 'short')
|
||
const qs = params.toString()
|
||
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
|
||
}
|
||
const channelsViewSearch = new URLSearchParams({ view: 'channels' })
|
||
// The filter is not applied in the channels view; carrying `?type=short`
|
||
// only restores the user's choice when they switch back to the feed.
|
||
if (videoType === 'short') channelsViewSearch.set('type', 'short')
|
||
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, videoType],
|
||
queryFn: ({ pageParam }) => getFeed({
|
||
categoryId: isLocal ? localCategory : categoryNumber,
|
||
uncategorized: isUncategorized || localUncategorized,
|
||
downloaded: isLocal,
|
||
search,
|
||
newOnly,
|
||
type: videoType,
|
||
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)
|
||
// Empty-state content is derived from the active type filter together with
|
||
// the context, so the heading and the hint never contradict each other.
|
||
const typeEmptyTitle = videoType === 'short' ? 'Здесь пока нет Shorts' : 'Здесь пока нет обычных видео'
|
||
const typeEmptyText = videoType === 'short' ? 'Короткие ролики появятся здесь, когда попадут в ленту.' : 'Обычные ролики появятся здесь, когда попадут в ленту.'
|
||
// Offer switching to the other type when this context does have content,
|
||
// just not of the selected type.
|
||
const otherTypeLink = videoType === 'short' ? typeLink('long') : typeLink('short')
|
||
const otherTypeLabel = videoType === 'short' ? 'Смотреть обычные' : 'Смотреть Shorts'
|
||
// `/local`: tell "nothing saved at all" apart from "nothing of this type".
|
||
const noSavedVideos = savedCountsQuery.data?.all === 0
|
||
// `/uncategorized`: tell "all channels already categorized" apart from
|
||
// "uncategorized channels exist but have no videos of this type".
|
||
const noUncategorizedChannels = countQuery.data !== undefined && countQuery.data.length === 0
|
||
// Root feed with zero subscriptions: onboarding takes priority over the
|
||
// type-specific empty-state.
|
||
const noSubscriptions = location.pathname === '/' && countQuery.data !== undefined && countQuery.data.length === 0
|
||
|
||
return <section className="page">
|
||
<div className="page-heading">
|
||
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={withPreserved(`/category/${categoryNumber}`)}>Показать все</Link></>}</p></div>
|
||
<div className="page-controls">
|
||
{!isChannelsView && <div className="type-toggle" role="group" aria-label="Тип видео">
|
||
{TYPE_OPTIONS.map((option) => <Link key={option.value} to={typeLink(option.value)} className={videoType === option.value ? 'active' : ''} aria-current={videoType === option.value ? 'page' : undefined}>{option.label}</Link>)}
|
||
</div>}
|
||
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра">
|
||
<Link to={feedViewLink()} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link>
|
||
<Link to={{ pathname: location.pathname, search: `?${channelsViewSearch.toString()}` }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link>
|
||
</div>}
|
||
</div>
|
||
</div>
|
||
<nav className="mobile-category-nav" aria-label="Категории">
|
||
<Link to={withPreserved('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
||
<Link to={withPreserved('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link>
|
||
<Link to={localNavPath} className={isLocal ? 'active' : ''}>На сервере</Link>
|
||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withPreserved(`/category/${item.id}`)} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
|
||
</nav>
|
||
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
|
||
<Link to={withPreserved('/local')} className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
|
||
<Link to={withPreserved('/local?uncategorized=true')} className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
|
||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withPreserved(`/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 ? (noSavedVideos ? 'Пока нет сохранённых видео' : typeEmptyTitle) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы распределены' : typeEmptyTitle) : noSubscriptions ? 'Здесь пока нет видео' : typeEmptyTitle}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? (noSavedVideos ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : typeEmptyText) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы уже распределены по категориям.' : typeEmptyText) : noSubscriptions ? 'Добавь каналы в категорию или обнови ленту.' : typeEmptyText}</p>{!search && !syncRunning && (isLocal ? (!noSavedVideos && <Link className="button-primary" to={otherTypeLink}>{otherTypeLabel}</Link>) : noSubscriptions ? <Link className="button-primary" to="/channels">Перейти к каналам</Link> : isUncategorized && noUncategorizedChannels ? null : <Link className="button-primary" to={otherTypeLink}>{otherTypeLabel}</Link>)}</div>}
|
||
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
|
||
</>}
|
||
</section>
|
||
}
|
||
|
||
export default Feed
|