Redesign responsive MyTube interface
This commit is contained in:
parent
90eb012e0d
commit
c4a772b1e3
23 changed files with 787 additions and 1119 deletions
|
|
@ -1,116 +1,108 @@
|
|||
import { useMutation, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getFeed, getSyncStatus, listCategories, listChannels, logout, syncVideos } from '../api/client'
|
||||
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
|
||||
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'
|
||||
import VideoCard from '../components/VideoCard'
|
||||
import { useState } from 'react'
|
||||
|
||||
function VideoSkeleton() {
|
||||
return <li className="video-card skeleton-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 [filter, setFilter] = useState<CategoryFilter>('all')
|
||||
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 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 categories = categoriesQuery.data ?? []
|
||||
|
||||
const allChannelsQuery = useQuery({
|
||||
queryKey: ['channels', 'all-for-counts'],
|
||||
queryFn: () => listChannels({ subscribed: true }),
|
||||
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 allChannels = allChannelsQuery.data ?? []
|
||||
const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length
|
||||
|
||||
const feedQuery = useInfiniteQuery({
|
||||
queryKey: ['feed', filter],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getFeed({
|
||||
categoryId: typeof filter === 'number' ? filter : undefined,
|
||||
uncategorized: filter === 'uncategorized',
|
||||
cursor: pageParam,
|
||||
}),
|
||||
queryKey: ['feed', location.pathname, search],
|
||||
queryFn: ({ pageParam }) => getFeed({
|
||||
categoryId: isLocal ? localCategory : categoryNumber,
|
||||
uncategorized: isUncategorized || localUncategorized,
|
||||
downloaded: isLocal,
|
||||
search,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
||||
enabled: !categoryId || categoryNumber !== undefined,
|
||||
})
|
||||
|
||||
const syncStatusQuery = useQuery({
|
||||
queryKey: ['sync-status'],
|
||||
queryFn: getSyncStatus,
|
||||
refetchInterval: (query) => (query.state.data?.videos.running ? 1500 : false),
|
||||
queryKey: ['sync-status'], queryFn: getSyncStatus,
|
||||
refetchInterval: (query) => query.state.data?.videos.running ? 2000 : 60_000,
|
||||
})
|
||||
|
||||
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])
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: syncVideos,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
|
||||
},
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) },
|
||||
})
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: logout,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
|
||||
},
|
||||
})
|
||||
|
||||
const running = syncStatusQuery.data?.videos.running
|
||||
const title = search ? `Поиск: ${search}` : isLocal ? 'На сервере' : isUncategorized ? 'Без категории' : categoryId ? category?.name ?? 'Категория' : 'Все видео'
|
||||
const subtitle = search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
|
||||
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
||||
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)
|
||||
|
||||
return (
|
||||
<div className="feed-screen">
|
||||
<header>
|
||||
<h1>Лента</h1>
|
||||
<div className="actions">
|
||||
<Link to="/saved">Сохранённые</Link>
|
||||
<Link to="/channels">Каналы</Link>
|
||||
<Link to="/categories">Категории</Link>
|
||||
<button onClick={() => syncMutation.mutate()} disabled={running}>
|
||||
{running ? 'Синхронизация...' : 'Обновить сейчас'}
|
||||
</button>
|
||||
<button onClick={() => logoutMutation.mutate()}>Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>}
|
||||
{syncStatusQuery.data?.videos.status === 'failed' && (
|
||||
<p className="error">Последняя синхронизация видео завершилась ошибкой: {syncStatusQuery.data.videos.error}</p>
|
||||
)}
|
||||
|
||||
<div className="layout">
|
||||
<CategoryNav
|
||||
categories={categories}
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
allCount={allChannels.length}
|
||||
uncategorizedCount={uncategorizedCount}
|
||||
/>
|
||||
|
||||
<div className="content">
|
||||
{feedQuery.isLoading && <p>Загрузка...</p>}
|
||||
{feedQuery.isError && <p className="error">Не удалось загрузить ленту</p>}
|
||||
|
||||
<ul className="video-grid">
|
||||
{items.map((video) => (
|
||||
<VideoCard key={video.youtube_video_id} video={video} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!feedQuery.isLoading && items.length === 0 && (
|
||||
<p>Видео пока нет. Нажми "Обновить сейчас" или проверь, есть ли каналы в этой категории.</p>
|
||||
)}
|
||||
|
||||
{feedQuery.hasNextPage && (
|
||||
<button
|
||||
className="load-more"
|
||||
onClick={() => feedQuery.fetchNextPage()}
|
||||
disabled={feedQuery.isFetchingNextPage}
|
||||
>
|
||||
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
return <section className="page feed-screen">
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}</p></div>
|
||||
{!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>}
|
||||
</div>
|
||||
)
|
||||
<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>
|
||||
}
|
||||
|
||||
export default Feed
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue