2026-09-16 21:40:32 +00:00
import { useEffect , useRef } from 'react'
2026-09-18 09:39:18 +00:00
import { useInfiniteQuery , useQuery , useQueryClient } from '@tanstack/react-query'
2026-09-16 21:40:32 +00:00
import { Link , useLocation , useParams , useSearchParams } from 'react-router-dom'
2026-09-18 09:39:18 +00:00
import { getFeed , getSavedCounts , getSyncStatus , listCategories , listChannels } from '../api/client'
2026-09-18 12:13:32 +00:00
import ChannelActivityList from '../components/ChannelActivityList'
2026-09-16 21:40:32 +00:00
import Icon from '../components/Icon'
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
import VideoCard from '../components/VideoCard'
2026-09-16 21:40:32 +00:00
2026-09-28 09:33:35 +00:00
// 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' } ,
]
2026-09-16 21:40:32 +00:00
function VideoSkeleton() {
2026-09-17 17:22:22 +00:00
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 >
2026-09-16 21:40:32 +00:00
}
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
function Feed() {
2026-09-16 21:40:32 +00:00
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'
2026-09-18 12:13:32 +00:00
const supportsViewToggle = ! isLocal && location . pathname !== '/search'
const isChannelsView = supportsViewToggle && searchParams . get ( 'view' ) === 'channels'
2026-09-28 09:33:35 +00:00
// 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' )
2026-09-17 21:07:33 +00:00
const newOnly = categoryNumber !== undefined && searchParams . get ( 'new' ) === '1'
2026-09-16 21:40:32 +00:00
const localCategoryRaw = isLocal ? searchParams . get ( 'category' ) : null
const localCategory = localCategoryRaw && /^\d+$/ . test ( localCategoryRaw ) ? Number ( localCategoryRaw ) : undefined
const localUncategorized = isLocal && searchParams . get ( 'uncategorized' ) === 'true'
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
const queryClient = useQueryClient ( )
const categoriesQuery = useQuery ( { queryKey : [ 'categories' ] , queryFn : listCategories } )
2026-09-16 21:40:32 +00:00
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 ) ,
2026-09-16 20:38:06 +00:00
} )
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
const feedQuery = useInfiniteQuery ( {
2026-09-28 09:33:35 +00:00
queryKey : [ 'feed' , location . pathname , search , localCategory , localUncategorized , newOnly , videoType ] ,
2026-09-16 21:40:32 +00:00
queryFn : ( { pageParam } ) = > getFeed ( {
categoryId : isLocal ? localCategory : categoryNumber ,
uncategorized : isUncategorized || localUncategorized ,
downloaded : isLocal ,
search ,
2026-09-17 21:07:33 +00:00
newOnly ,
2026-09-28 09:33:35 +00:00
type : videoType ,
2026-09-16 21:40:32 +00:00
cursor : pageParam ,
} ) ,
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
initialPageParam : undefined as string | undefined ,
getNextPageParam : ( lastPage ) = > lastPage . next_cursor ? ? undefined ,
2026-09-18 12:13:32 +00:00
enabled : ( ! categoryId || categoryNumber !== undefined ) && ! isChannelsView ,
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
} )
const syncStatusQuery = useQuery ( {
2026-09-16 21:40:32 +00:00
queryKey : [ 'sync-status' ] , queryFn : getSyncStatus ,
refetchInterval : ( query ) = > query . state . data ? . videos . running ? 2000 : 60_000 ,
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
} )
2026-09-18 12:13:32 +00:00
const syncRunning = Boolean ( syncStatusQuery . data ? . subscriptions . running || syncStatusQuery . data ? . videos . running )
2026-09-16 21:40:32 +00:00
const finishedAt = syncStatusQuery . data ? . videos . finished_at
const previousFinished = useRef ( finishedAt )
useEffect ( ( ) = > {
2026-09-18 12:13:32 +00:00
if ( finishedAt && previousFinished . current !== finishedAt ) {
queryClient . invalidateQueries ( { queryKey : [ 'feed' ] } )
queryClient . invalidateQueries ( { queryKey : [ 'channel-activity' ] } )
}
2026-09-16 21:40:32 +00:00
previousFinished . current = finishedAt
} , [ finishedAt , queryClient ] )
const subscriptionsFinished = syncStatusQuery . data ? . subscriptions . finished_at
const previousSubscriptionsFinished = useRef ( subscriptionsFinished )
useEffect ( ( ) = > {
2026-09-18 12:13:32 +00:00
if ( subscriptionsFinished && previousSubscriptionsFinished . current !== subscriptionsFinished ) {
queryClient . invalidateQueries ( { queryKey : [ 'channels' ] } )
queryClient . invalidateQueries ( { queryKey : [ 'channel-activity' ] } )
}
2026-09-16 21:40:32 +00:00
previousSubscriptionsFinished . current = subscriptionsFinished
} , [ subscriptionsFinished , queryClient ] )
const title = search ? ` Поиск: ${ search } ` : isLocal ? 'Н а сервере' : isUncategorized ? 'Без категории' : categoryId ? category ? . name ? ? 'Категория' : 'В с е видео'
2026-09-18 12:13:32 +00:00
const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? ` ${ countQuery . data ? . length ? ? '…' } каналов пока без категории ` : category ? ` ${ category . channel_count } каналов в категории ` : isChannelsView ? 'Каналы, отсортированные по свежести видео' : ` ${ countQuery . data ? . length ? ? '…' } подписок в вашей ленте `
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
const items = feedQuery . data ? . pages . flatMap ( ( page ) = > page . items ) ? ? [ ]
2026-09-16 21:40:32 +00:00
useEffect ( ( ) = > {
2026-09-18 12:13:32 +00:00
if ( isChannelsView || feedQuery . isLoading ) return
2026-09-16 21:40:32 +00:00
const key = ` feed-scroll: ${ location . pathname + location . search } `
const saved = sessionStorage . getItem ( key )
if ( saved ) {
sessionStorage . removeItem ( key )
requestAnimationFrame ( ( ) = > window . scrollTo ( 0 , Number ( saved ) ) )
}
2026-09-18 12:13:32 +00:00
} , [ isChannelsView , feedQuery . isLoading , location . pathname , location . search ] )
2026-09-16 21:40:32 +00:00
const hasInvalidCategory = Boolean ( categoryId && ! categoryNumber )
2026-09-28 09:33:35 +00:00
// 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
2026-09-16 21:40:32 +00:00
2026-09-17 17:22:22 +00:00
return < section className = "page" >
2026-09-16 21:40:32 +00:00
< div className = "page-heading" >
2026-09-28 09:33:35 +00:00
< 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 >
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
< / div >
2026-09-16 21:40:32 +00:00
< nav className = "mobile-category-nav" aria-label = "Категории" >
2026-09-28 09:33:35 +00:00
< 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 > ) }
2026-09-16 21:40:32 +00:00
< / nav >
{ isLocal && < nav className = "local-filter-nav" aria-label = "Фильтр сохранённых видео" >
2026-09-28 09:33:35 +00:00
< 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 > ) }
2026-09-16 21:40:32 +00:00
< / 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 > }
2026-09-18 12:13:32 +00:00
{ 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 } / > : < >
2026-09-16 21:40:32 +00:00
{ 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 > }
2026-09-22 07:53:19 +00:00
{ ! 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 > }
2026-09-28 09:33:35 +00:00
{ ! 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 > }
2026-09-16 21:40:32 +00:00
{ feedQuery . hasNextPage && < button className = "button-secondary load-more" onClick = { ( ) = > feedQuery . fetchNextPage ( ) } disabled = { feedQuery . isFetchingNextPage } > { feedQuery . isFetchingNextPage ? 'Загрузка…' : 'Показать ещё' } < / button > }
< / > }
< / section >
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
}
export default Feed