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 } 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

Моя лента

{title}

{subtitle}{newOnly && <> · Показать все}

{!isChannelsView &&
{TYPE_OPTIONS.map((option) => {option.label})}
} {supportsViewToggle &&
Лента Каналы
}
{isLocal && } {syncStatusQuery.data?.videos.status === 'failed' &&
Последнее обновление видео завершилось ошибкой. Попробуйте позже.
} {location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 &&
Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.Настроить категории
} {hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ?

Категория не найдена

Ко всем видео
: isChannelsView ? : <> {feedQuery.isLoading && } {feedQuery.isError &&

Не удалось загрузить видео

Проверь соединение и попробуй ещё раз.

} {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && } {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 &&

{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? (noSavedVideos ? 'Пока нет сохранённых видео' : typeEmptyTitle) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы распределены' : typeEmptyTitle) : noSubscriptions ? 'Здесь пока нет видео' : typeEmptyTitle}

{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? (noSavedVideos ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : typeEmptyText) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы уже распределены по категориям.' : typeEmptyText) : noSubscriptions ? 'Добавь каналы в категорию или обнови ленту.' : typeEmptyText}

{!search && !syncRunning && (isLocal ? (!noSavedVideos && {otherTypeLabel}) : noSubscriptions ? Перейти к каналам : isUncategorized && noUncategorizedChannels ? null : {otherTypeLabel})}
} {feedQuery.hasNextPage && } }
} export default Feed