Channel features: subscribers, new-videos badges, activity-driven sync
- Store subscriber count from YouTube statistics and show it on channel page - Sync 50 videos per channel with playlistItems pagination support - Show per-channel and per-category new-videos counters (2-day window) - Replace hourly videos sync with activity trigger (2h idle) and incremental backfill (hard cap 200 per channel) - Clicking the sidebar new-videos count filters the category feed to recent videos only (new_only) - Update agent-team docs: deploy after green checks
This commit is contained in:
parent
fde9a439df
commit
e10df8dcbd
27 changed files with 1420 additions and 107 deletions
|
|
@ -33,7 +33,12 @@
|
|||
.category-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.category-link { gap: 15px; padding-left: 18px; }
|
||||
.category-link.active .category-dot { background: var(--accent); }
|
||||
.sidebar-count { margin-left: auto; font-size: 11px; color: var(--subtle); }
|
||||
.sidebar-category-row { display: flex; align-items: center; gap: 4px; }
|
||||
.sidebar-category-row .sidebar-link { flex: 1; min-width: 0; }
|
||||
.sidebar-badge { margin-left: auto; flex: none; padding: 1px 8px; border-radius: 999px; background: rgba(255,107,104,.13); color: var(--error); font-size: 11px; font-weight: 650; line-height: 1.5; white-space: nowrap; }
|
||||
.sidebar-badge-link { cursor: pointer; text-decoration: none; transition: background .16s; }
|
||||
.sidebar-badge-link:hover { background: rgba(255,107,104,.26); }
|
||||
.sidebar-badge-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.sidebar-add { color: var(--accent); font-size: 13px; margin-top: 4px; }
|
||||
.sidebar-hint { margin: 0; padding: 5px 13px 9px; color: var(--subtle); font-size: 12px; }
|
||||
.sidebar-bottom { margin-top: auto; padding-bottom: 0; }
|
||||
|
|
@ -46,6 +51,8 @@ h1, h2, h3, p { margin-top: 0; }
|
|||
h1 { color: var(--text); font-size: clamp(25px, 2.25vw, 34px); line-height: 1.18; letter-spacing: -.035em; font-weight: 700; margin-bottom: 8px; }
|
||||
h2 { color: var(--text); font-size: 18px; line-height: 1.3; font-weight: 650; }
|
||||
.page-subtitle { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
|
||||
.page-subtitle a { color: var(--accent); text-decoration: none; font-weight: 650; }
|
||||
.page-subtitle a:hover { text-decoration: underline; }
|
||||
button, .button-primary, .button-secondary, .button-quiet, .button-danger, .button-link { transition: background .16s, color .16s, border-color .16s; }
|
||||
.button-primary, .button-secondary, .button-danger, .button-link { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; padding: 0 15px; text-decoration: none; font-size: 13px; font-weight: 650; white-space: nowrap; }
|
||||
.button-primary { border: 1px solid var(--accent); background: var(--accent); color: #081521; }
|
||||
|
|
@ -125,6 +132,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
|
|||
.channel-title { font-size: 15px; font-weight: 650; text-decoration: none; }
|
||||
.channel-title:hover { color: var(--accent); }
|
||||
.badge { font-size: 11px; color: var(--subtle); }
|
||||
.badge-new { padding: 2px 8px; border-radius: 999px; background: rgba(255,107,104,.13); color: var(--error); font-weight: 650; white-space: nowrap; }
|
||||
.unsubscribe-button { margin-left: auto; min-height: 28px; border: 0; border-radius: 6px; padding: 0 8px; color: var(--subtle); background: transparent; font-size: 11px; }
|
||||
.unsubscribe-button:hover { color: var(--error); background: rgba(255,107,104,.1); }
|
||||
.channel-categories { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
|
|
@ -271,7 +279,8 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
|
|||
}
|
||||
@media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } }
|
||||
@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 { min-height: 44px; }
|
||||
.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; }
|
||||
.reorder-buttons .icon-button { width: 40px; height: 40px; }
|
||||
.popover-create input, .popover-create button { height: 44px; }
|
||||
.popover-create button { width: 44px; }
|
||||
|
|
|
|||
|
|
@ -58,9 +58,11 @@ export interface ChannelDto {
|
|||
description: string | null
|
||||
thumbnail_url: string | null
|
||||
uploads_playlist_id: string | null
|
||||
subscriber_count: number | null
|
||||
subscribed: boolean
|
||||
last_synced_at: string | null
|
||||
category_ids: number[]
|
||||
new_videos_count: number
|
||||
}
|
||||
|
||||
export function getChannel(channelId: number) {
|
||||
|
|
@ -96,6 +98,7 @@ export interface CategoryDto {
|
|||
slug: string
|
||||
sort_order: number
|
||||
channel_count: number
|
||||
new_videos_count: number
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
|
|
@ -211,6 +214,8 @@ export function getFeed(
|
|||
downloaded?: boolean
|
||||
search?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
newOnly?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const qs = new URLSearchParams()
|
||||
|
|
@ -219,7 +224,9 @@ export function getFeed(
|
|||
if (params.channelId != null) qs.set('channel_id', String(params.channelId))
|
||||
if (params.downloaded) qs.set('downloaded', 'true')
|
||||
if (params.search) qs.set('search', params.search)
|
||||
if (params.newOnly) qs.set('new_only', '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: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ function Sidebar({ close }: { close: () => void }) {
|
|||
</div>
|
||||
<div className="sidebar-group sidebar-categories">
|
||||
<div className="sidebar-heading">Категории</div>
|
||||
{categories.map((category) => <NavLink key={category.id} to={`/category/${category.id}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span><span className="sidebar-count">{category.channel_count}</span></NavLink>)}
|
||||
{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>
|
||||
{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>}
|
||||
<Link to="/settings/categories" onClick={close} className="sidebar-link sidebar-add"><Icon name="plus" size={18} /><span>Новая категория</span></Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
{channel.thumbnail_url ? <img src={channel.thumbnail_url} alt="" loading="lazy" /> : <span className="channel-avatar-placeholder">{channel.title.charAt(0).toUpperCase()}</span>}
|
||||
<div className="channel-info">
|
||||
<div className="channel-head"><Link to={`/channels/${channel.id}/videos`} className="channel-title">{channel.title}</Link>
|
||||
{channel.new_videos_count > 0 && <span className="badge badge-new" title="Недавно вышедшие видео">{channel.new_videos_count} новых</span>}
|
||||
{!channel.subscribed && <span className="badge">отписан</span>}
|
||||
{channel.subscribed && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -3,17 +3,19 @@ import { Link, useParams } from 'react-router-dom'
|
|||
import { getChannel, getFeed } from '../api/client'
|
||||
import Icon from '../components/Icon'
|
||||
import VideoCard from '../components/VideoCard'
|
||||
import { formatSubscriberCount } from '../utils/format'
|
||||
|
||||
function ChannelVideos() {
|
||||
const { channelId } = useParams<{ channelId: string }>()
|
||||
const id = Number(channelId)
|
||||
const valid = Number.isInteger(id) && id > 0
|
||||
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid })
|
||||
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id], queryFn: ({ pageParam }) => getFeed({ channelId: id, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
|
||||
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id], queryFn: ({ pageParam }) => getFeed({ channelId: id, limit: 20, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
|
||||
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
||||
const subscriberLabel = channelQuery.data ? formatSubscriberCount(channelQuery.data.subscriber_count) : null
|
||||
return <section className="page">
|
||||
<Link to="/channels" className="back-link"><Icon name="arrow" size={17} /> Каналы</Link>
|
||||
<div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">Последние ролики из твоих подписок.</p></div></div>
|
||||
<div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div></div>
|
||||
{(channelQuery.isError || feedQuery.isError || !valid) && <div className="empty-state" role="alert"><h2>Не удалось открыть канал</h2><Link to="/channels" className="button-primary">К списку каналов</Link></div>}
|
||||
{feedQuery.isLoading && <div className="video-grid">{Array.from({ length: 6 }, (_, index) => <div className="video-card" key={index}><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /></div></div>)}</div>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function Feed() {
|
|||
const categoryNumber = categoryId && /^\d+$/.test(categoryId) ? Number(categoryId) : undefined
|
||||
const isLocal = location.pathname === '/local'
|
||||
const isUncategorized = location.pathname === '/uncategorized'
|
||||
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'
|
||||
|
|
@ -30,12 +31,13 @@ function Feed() {
|
|||
enabled: isUncategorized || (!categoryNumber && !isLocal && !search),
|
||||
})
|
||||
const feedQuery = useInfiniteQuery({
|
||||
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized],
|
||||
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly],
|
||||
queryFn: ({ pageParam }) => getFeed({
|
||||
categoryId: isLocal ? localCategory : categoryNumber,
|
||||
uncategorized: isUncategorized || localUncategorized,
|
||||
downloaded: isLocal,
|
||||
search,
|
||||
newOnly,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
|
|
@ -63,7 +65,7 @@ function Feed() {
|
|||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) },
|
||||
})
|
||||
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 subtitle = newOnly ? 'Только новые видео' : 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
|
||||
|
|
@ -78,7 +80,7 @@ function Feed() {
|
|||
|
||||
return <section className="page">
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}</p></div>
|
||||
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={`/category/${categoryNumber}`}>Показать все</Link></>}</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="Категории">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ export function formatDuration(seconds: number | null): string | null {
|
|||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const SUBSCRIBERS_FMT = new Intl.NumberFormat('ru-RU', { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
export function formatSubscriberCount(value: number | null | undefined): string | null {
|
||||
if (value == null) return null
|
||||
return `${SUBSCRIBERS_FMT.format(value)} подписчиков`
|
||||
}
|
||||
|
||||
const RTF = new Intl.RelativeTimeFormat('ru', { numeric: 'auto' })
|
||||
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue