2026-09-16 21:40:32 +00:00
import { useEffect , useRef } from 'react'
import { useInfiniteQuery , useMutation , useQuery , useQueryClient } from '@tanstack/react-query'
import { Link , useLocation , useParams , useSearchParams } from 'react-router-dom'
import { getFeed , getSavedCounts , getSyncStatus , listCategories , listChannels , syncVideos } from '../api/client'
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
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-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-17 21:07:33 +00:00
queryKey : [ 'feed' , location . pathname , search , localCategory , localUncategorized , newOnly ] ,
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-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-16 21:40:32 +00:00
enabled : ! categoryId || categoryNumber !== undefined ,
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-16 21:40:32 +00:00
const finishedAt = syncStatusQuery . data ? . videos . finished_at
const previousFinished = useRef ( finishedAt )
useEffect ( ( ) = > {
if ( finishedAt && previousFinished . current !== finishedAt ) queryClient . invalidateQueries ( { queryKey : [ 'feed' ] } )
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' ] } )
previousSubscriptionsFinished . current = subscriptionsFinished
} , [ subscriptionsFinished , queryClient ] )
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 syncMutation = useMutation ( {
mutationFn : syncVideos ,
2026-09-16 21:40:32 +00:00
onSuccess : ( ) = > { queryClient . invalidateQueries ( { queryKey : [ 'sync-status' ] } ) ; queryClient . invalidateQueries ( { queryKey : [ 'feed' ] } ) } ,
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-16 21:40:32 +00:00
const title = search ? ` Поиск: ${ search } ` : isLocal ? 'Н а сервере' : isUncategorized ? 'Без категории' : categoryId ? category ? . name ? ? 'Категория' : 'В с е видео'
2026-09-17 21:07:33 +00:00
const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? ` ${ countQuery . data ? . length ? ? '…' } каналов пока без категории ` : category ? ` ${ category . channel_count } каналов в категории ` : ` ${ 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 ( ( ) = > {
if ( 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 ] )
const hasInvalidCategory = Boolean ( categoryId && ! categoryNumber )
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-17 21:07:33 +00:00
< div > < p className = "eyebrow" > М о я л е н т а < / p > < h1 > { title } < / h1 > < p className = "page-subtitle" > { subtitle } { newOnly && < > · < Link to = { ` /category/ ${ categoryNumber } ` } > П о к а з а т ь в с е < / Link > < / > } < / p > < / div >
2026-09-16 21:40:32 +00:00
{ ! isLocal && ! search && < button type = "button" className = "button-secondary refresh-button" onClick = { ( ) = > syncMutation . mutate ( ) } disabled = { syncMutation . isPending || syncStatusQuery . data ? . videos . running } > < Icon name = "refresh" size = { 17 } className = { syncStatusQuery . data ? . videos . running ? 'spin' : '' } / > { syncStatusQuery . data ? . videos . running ? 'Обновляется' : 'Обновить' } < / button > }
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 = "Категории" >
< Link to = "/" className = { location . pathname === '/' ? 'active' : '' } > В с е < / Link >
< Link to = "/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 > ) }
< / 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 > }
{ syncMutation . isError && < div className = "notice error-notice" role = "alert" > < Icon name = "alert" size = { 18 } / > Н е у д а л о с ь з а п у с т и т ь о б н о в л е н и е в и д е о . < / div > }
{ 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 > : < >
{ 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 . 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