Add channels view toggle on the main page
New GET /api/channels/activity returns subscribed channels sorted by their latest video (no-video channels last) with the 3 newest videos per channel and cursor pagination. The feed page gains a 'Лента | Каналы' toggle (?view=channels) with compact video cards per channel row; category filters and the sidebar preserve the view.
This commit is contained in:
parent
0dc1336951
commit
d4950f6979
13 changed files with 721 additions and 16 deletions
|
|
@ -97,6 +97,27 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
|
|||
.download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); }
|
||||
.download-action-error { color: var(--error); font-size: 12px; }
|
||||
.load-more { display: flex; margin: 34px auto 0; }
|
||||
.view-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
|
||||
.view-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; }
|
||||
.view-toggle a:hover { color: var(--text); background: var(--hover); }
|
||||
.view-toggle a.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.channel-activity-list { list-style: none; display: grid; gap: 25px; padding: 0; margin: 0; }
|
||||
.channel-activity-row { display: grid; grid-template-columns: 228px minmax(0, 1fr); gap: 18px; align-items: start; min-width: 0; padding-bottom: 25px; border-bottom: 1px solid var(--border); }
|
||||
.channel-activity-identity { display: flex; align-items: center; gap: 12px; min-width: 0; padding-top: 2px; color: var(--text); text-decoration: none; }
|
||||
.channel-activity-identity img, .channel-activity-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; object-fit: cover; }
|
||||
.channel-activity-avatar { display: grid; place-items: center; background: #344557; color: var(--text); font-size: 17px; font-weight: 700; }
|
||||
.channel-activity-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 650; }
|
||||
.channel-activity-identity:hover .channel-activity-name { color: var(--accent); }
|
||||
.channel-activity-videos { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; min-width: 0; }
|
||||
.channel-activity-empty-videos { margin: 0; align-self: center; color: var(--subtle); font-size: 12px; }
|
||||
.compact-video-card { min-width: 0; display: flex; flex-direction: column; }
|
||||
.compact-thumbnail { position: relative; display: block; overflow: hidden; width: 100%; aspect-ratio: 16 / 9; border-radius: 9px; background: var(--surface-raised); }
|
||||
.compact-thumbnail img, .compact-thumbnail .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.compact-video-title { margin-top: 7px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--text); text-decoration: none; font-size: 13px; font-weight: 600; line-height: 1.42; }
|
||||
.compact-video-title:hover { color: var(--accent); }
|
||||
.compact-video-time { margin-top: 4px; color: var(--muted); font-size: 11px; }
|
||||
.skeleton-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; background: linear-gradient(90deg, #222 25%, #303030 50%, #222 75%); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; }
|
||||
.channel-activity-skeleton .skeleton-line { margin: 0; }
|
||||
.empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); }
|
||||
.empty-state h2 { margin: 0 0 9px; font-size: 20px; }
|
||||
.empty-state p { font-size: 14px; line-height: 1.55; margin-bottom: 20px; }
|
||||
|
|
@ -233,6 +254,8 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
|
|||
.mobile-category-nav { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -5px -28px 25px; padding: 0 28px 5px; }
|
||||
.mobile-category-nav a { flex: none; min-height: 35px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 0 14px; background: var(--surface); color: var(--muted); font-size: 12px; text-decoration: none; }
|
||||
.mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); }
|
||||
.channel-activity-row { grid-template-columns: 1fr; gap: 12px; padding-bottom: 21px; }
|
||||
.channel-activity-identity { padding-top: 0; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.topbar { height: 112px; display: grid; grid-template-columns: 44px 1fr auto; grid-template-rows: 55px 49px; column-gap: 7px; row-gap: 0; padding: 0 15px 8px; }
|
||||
|
|
@ -247,7 +270,9 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
|
|||
.page { padding: 24px 16px 58px; }
|
||||
.page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
|
||||
.page-heading h1 { font-size: 27px; }
|
||||
.view-toggle a { min-height: 38px; padding: 0 12px; }
|
||||
.mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; }
|
||||
.channel-activity-videos { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
|
||||
.video-thumbnail { border-radius: 9px; }
|
||||
.video-info { padding: 10px 2px 0; }
|
||||
|
|
@ -281,6 +306,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
|
|||
@media (pointer: coarse) {
|
||||
.video-actions .button-link, .video-actions .button-secondary, .download-badge, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; }
|
||||
.sidebar-badge { display: inline-flex; align-items: center; }
|
||||
.view-toggle a { min-height: 44px; }
|
||||
.reorder-buttons .icon-button { width: 40px; height: 40px; }
|
||||
.popover-create input, .popover-create button { height: 44px; }
|
||||
.popover-create button { width: 44px; }
|
||||
|
|
|
|||
|
|
@ -81,6 +81,23 @@ export function listChannels(
|
|||
return request<ChannelDto[]>(`/api/channels${suffix}`)
|
||||
}
|
||||
|
||||
export interface ChannelActivityItemDto {
|
||||
channel: ChannelDto
|
||||
videos: FeedVideoDto[]
|
||||
}
|
||||
|
||||
export function getChannelActivity(
|
||||
params: { categoryId?: number; uncategorized?: boolean; cursor?: string; limit?: number } = {},
|
||||
) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
|
||||
if (params.uncategorized) qs.set('uncategorized', 'true')
|
||||
if (params.cursor) qs.set('cursor', params.cursor)
|
||||
if (params.limit != null) qs.set('limit', String(params.limit))
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
||||
return request<{ items: ChannelActivityItemDto[]; next_cursor: string | null }>(`/api/channels/activity${suffix}`)
|
||||
}
|
||||
|
||||
export function setChannelCategories(channelId: number, categoryIds: number[]) {
|
||||
return request<ChannelDto>(`/api/channels/${channelId}/categories`, {
|
||||
method: 'PUT',
|
||||
|
|
|
|||
|
|
@ -63,8 +63,12 @@ function SyncIndicator() {
|
|||
function Sidebar({ close }: { close: () => void }) {
|
||||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||
const categories = categoriesQuery.data ?? []
|
||||
const location = useLocation()
|
||||
// Keep the current view mode ("Лента | Каналы") when switching between
|
||||
// "Все видео" and category links; badges (?new=1) are feed-specific.
|
||||
const viewQuery = new URLSearchParams(location.search).get('view') === 'channels' ? '?view=channels' : ''
|
||||
const links = [
|
||||
{ to: '/', icon: 'home' as const, text: 'Все видео', end: true },
|
||||
{ to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true },
|
||||
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
|
||||
]
|
||||
return <nav className="sidebar-nav" aria-label="Основная навигация">
|
||||
|
|
@ -74,7 +78,7 @@ function Sidebar({ close }: { close: () => void }) {
|
|||
<div className="sidebar-group sidebar-categories">
|
||||
<div className="sidebar-heading">Категории</div>
|
||||
{categories.map((category) => <div key={category.id} className="sidebar-category-row">
|
||||
<NavLink to={`/category/${category.id}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
|
||||
<NavLink to={`/category/${category.id}${viewQuery}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
|
||||
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
|
||||
</div>)}
|
||||
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}
|
||||
|
|
|
|||
81
frontend/src/components/ChannelActivityList.tsx
Normal file
81
frontend/src/components/ChannelActivityList.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { getChannelActivity } from '../api/client'
|
||||
import CompactVideoCard from './CompactVideoCard'
|
||||
import Icon from './Icon'
|
||||
|
||||
interface Props {
|
||||
categoryId?: number
|
||||
uncategorized?: boolean
|
||||
syncRunning: boolean
|
||||
}
|
||||
|
||||
function RowSkeleton() {
|
||||
return <li className="channel-activity-row channel-activity-skeleton" aria-hidden="true">
|
||||
<div className="channel-activity-identity"><span className="skeleton-avatar" /><span className="skeleton-line" /></div>
|
||||
<div className="channel-activity-videos">
|
||||
{Array.from({ length: 3 }, (_, index) => <div key={index} className="compact-video-card"><div className="skeleton-thumbnail" /><div className="skeleton-line short" /></div>)}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
function ChannelActivityList({ categoryId, uncategorized, syncRunning }: Props) {
|
||||
const location = useLocation()
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['channel-activity', location.pathname, categoryId ?? null, uncategorized],
|
||||
queryFn: ({ pageParam }) => getChannelActivity({ categoryId, uncategorized, cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
||||
})
|
||||
const items = query.data?.pages.flatMap((page) => page.items) ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (query.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)))
|
||||
}
|
||||
}, [query.isLoading, location.pathname, location.search])
|
||||
|
||||
if (query.isLoading) {
|
||||
return <ul className="channel-activity-list">{Array.from({ length: 5 }, (_, index) => <RowSkeleton key={index} />)}</ul>
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить каналы</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => query.refetch()}>Повторить</button></div>
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
const isAll = categoryId === undefined && !uncategorized
|
||||
return <div className="empty-state">
|
||||
<span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isAll ? 'users' : 'folder'} size={28} /></span>
|
||||
<h2>{syncRunning ? 'Импортируем каналы' : isAll ? 'Пока нет каналов' : uncategorized ? 'Все каналы распределены' : 'В этой категории нет каналов'}</h2>
|
||||
<p>{syncRunning ? 'Подписки и лента обновляются. Каналы появятся здесь автоматически.' : isAll ? 'Подпишись на интересные каналы, чтобы видеть их последние видео.' : uncategorized ? 'Каналы без категории появятся здесь, как только снимешь с них все темы.' : 'Добавь каналы в эту категорию, чтобы видеть их здесь.'}</p>
|
||||
{isAll && !syncRunning && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}
|
||||
</div>
|
||||
}
|
||||
|
||||
return <>
|
||||
<ul className="channel-activity-list">
|
||||
{items.map((item) => (
|
||||
<li className="channel-activity-row" key={item.channel.id}>
|
||||
<Link to={`/channels/${item.channel.id}/videos`} className="channel-activity-identity" aria-label={`Канал: ${item.channel.title}`}>
|
||||
{item.channel.thumbnail_url ? <img src={item.channel.thumbnail_url} alt="" loading="lazy" /> : <span className="channel-activity-avatar">{item.channel.title.charAt(0).toUpperCase()}</span>}
|
||||
<span className="channel-activity-name" title={item.channel.title}>{item.channel.title}</span>
|
||||
</Link>
|
||||
<div className="channel-activity-videos">
|
||||
{item.videos.length > 0
|
||||
? item.videos.map((video) => <CompactVideoCard key={video.youtube_video_id} video={video} />)
|
||||
: <p className="channel-activity-empty-videos">Нет видео</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{query.hasNextPage && <button className="button-secondary load-more" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>{query.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
|
||||
</>
|
||||
}
|
||||
|
||||
export default ChannelActivityList
|
||||
|
|
@ -46,6 +46,7 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
mutationFn: () => unsubscribeChannel(channel.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
34
frontend/src/components/CompactVideoCard.tsx
Normal file
34
frontend/src/components/CompactVideoCard.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { FeedVideoDto } from '../api/client'
|
||||
import { formatDuration, formatRelativeTime } from '../utils/format'
|
||||
|
||||
interface Props {
|
||||
video: FeedVideoDto
|
||||
}
|
||||
|
||||
function CompactVideoCard({ video }: Props) {
|
||||
const duration = formatDuration(video.duration_seconds)
|
||||
const href = `/video/${video.youtube_video_id}`
|
||||
const location = useLocation()
|
||||
const from = location.pathname + location.search
|
||||
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))
|
||||
|
||||
return (
|
||||
<div className="compact-video-card">
|
||||
<Link to={href} state={{ from }} onClick={rememberScroll} className="compact-thumbnail" aria-label={`Смотреть: ${video.title}`}>
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="video-thumbnail-placeholder" />
|
||||
)}
|
||||
{duration && <span className="duration-badge">{duration}</span>}
|
||||
</Link>
|
||||
<Link to={href} state={{ from }} onClick={rememberScroll} className="compact-video-title" title={video.title}>
|
||||
{video.title}
|
||||
</Link>
|
||||
<span className="compact-video-time">{formatRelativeTime(video.published_at)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompactVideoCard
|
||||
|
|
@ -13,7 +13,7 @@ function Categories() {
|
|||
const cancelDeleteRef = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => { if (deletingId !== null) cancelDeleteRef.current?.focus() }, [deletingId])
|
||||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) }
|
||||
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }); queryClient.invalidateQueries({ queryKey: ['channel-activity'] }) }
|
||||
const createMutation = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewName(''); invalidate() } })
|
||||
const updateMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), onSuccess: () => { setEditingId(null); invalidate() } })
|
||||
const deleteMutation = useMutation({ mutationFn: deleteCategory, onSuccess: () => { setDeletingId(null); invalidate() } })
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ function Channels() {
|
|||
const finishedAt = syncStatusQuery.data?.subscriptions.finished_at
|
||||
const previousFinished = useRef(finishedAt)
|
||||
useEffect(() => {
|
||||
if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
if (finishedAt && previousFinished.current !== finishedAt) {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
|
||||
}
|
||||
previousFinished.current = finishedAt
|
||||
}, [finishedAt, queryClient])
|
||||
const allChannels = allChannelsQuery.data ?? []
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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'
|
||||
|
||||
|
|
@ -17,6 +18,10 @@ function Feed() {
|
|||
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'
|
||||
const viewQuery = isChannelsView ? 'view=channels' : ''
|
||||
const withView = (path: string) => viewQuery ? `${path}?${viewQuery}` : path
|
||||
const newOnly = categoryNumber !== undefined && searchParams.get('new') === '1'
|
||||
const localCategoryRaw = isLocal ? searchParams.get('category') : null
|
||||
const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined
|
||||
|
|
@ -42,47 +47,58 @@ function Feed() {
|
|||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
||||
enabled: !categoryId || categoryNumber !== 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'] })
|
||||
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'] })
|
||||
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} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
|
||||
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 (feedQuery.isLoading) return
|
||||
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)))
|
||||
}
|
||||
}, [feedQuery.isLoading, location.pathname, location.search])
|
||||
}, [isChannelsView, feedQuery.isLoading, location.pathname, location.search])
|
||||
const hasInvalidCategory = Boolean(categoryId && !categoryNumber)
|
||||
|
||||
return <section className="page">
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={`/category/${categoryNumber}`}>Показать все</Link></>}</p></div>
|
||||
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра">
|
||||
<Link to={location.pathname} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link>
|
||||
<Link to={{ pathname: location.pathname, search: 'view=channels' }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link>
|
||||
</div>}
|
||||
</div>
|
||||
<nav className="mobile-category-nav" aria-label="Категории">
|
||||
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
||||
<Link to="/uncategorized" className={isUncategorized ? 'active' : ''}>Без категории</Link>
|
||||
<Link to={withView('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
||||
<Link to={withView('/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>)}
|
||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withView(`/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>
|
||||
|
|
@ -91,11 +107,11 @@ function Feed() {
|
|||
</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> : <>
|
||||
{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} />)}</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.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 ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncRunning && <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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue