Move manual sync to a single 'Update all' button in the header popover
The feed/channels 'Update' buttons triggered a global sync of all channels, which was misleading inside a specific category or page context. Now one 'Update all' button in the sync indicator popover runs subscriptions then videos sequentially, tolerates an already-running sync (409), and pages keep their finished_at-based cache invalidation.
This commit is contained in:
parent
5fa5a391d7
commit
0dc1336951
5 changed files with 94 additions and 19 deletions
|
|
@ -20,6 +20,8 @@
|
|||
.sync-popover { position: absolute; top: calc(100% + 12px); right: 0; width: 280px; padding: 17px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); box-shadow: 0 16px 50px rgba(0,0,0,.5); font-size: 13px; color: var(--muted); }
|
||||
.sync-popover strong { display: block; color: var(--text); margin-bottom: 12px; }
|
||||
.sync-popover p + p { margin-top: 9px; }
|
||||
.sync-popover .sync-all-button { width: 100%; min-height: 40px; margin-top: 14px; }
|
||||
.sync-popover .sync-all-button + .error { margin-top: 9px; }
|
||||
.mobile-menu, .drawer-header, .drawer-backdrop { display: none; }
|
||||
.app-sidebar { position: fixed; z-index: 30; top: 66px; left: 0; bottom: 0; width: 244px; overflow-y: auto; border-right: 1px solid var(--border); background: var(--bg); }
|
||||
.sidebar-nav { display: flex; flex-direction: column; min-height: 100%; padding: 20px 12px; }
|
||||
|
|
@ -245,8 +247,6 @@ 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; }
|
||||
.refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
|
||||
.refresh-button svg { flex: none; }
|
||||
.mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; }
|
||||
.video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
|
||||
.video-thumbnail { border-radius: 9px; }
|
||||
|
|
|
|||
|
|
@ -1,17 +1,35 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { getSyncStatus, listCategories } from '../api/client'
|
||||
import { getSyncStatus, listCategories, syncSubscriptions, syncVideos } from '../api/client'
|
||||
import { formatRelativeTime } from '../utils/format'
|
||||
import Icon from './Icon'
|
||||
|
||||
function SyncIndicator() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['sync-status'], queryFn: getSyncStatus,
|
||||
refetchInterval: (query) => query.state.data?.videos.running || query.state.data?.subscriptions.running ? 2000 : 60_000,
|
||||
})
|
||||
const syncAllMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ignoreInProgress = (error: unknown) => error instanceof Error && error.message.includes('already in progress')
|
||||
try {
|
||||
await syncSubscriptions()
|
||||
} catch (error) {
|
||||
if (!ignoreInProgress(error)) throw error
|
||||
}
|
||||
try {
|
||||
await syncVideos()
|
||||
} catch (error) {
|
||||
if (!ignoreInProgress(error)) throw error
|
||||
}
|
||||
},
|
||||
onMutate: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }) },
|
||||
onSettled: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }) },
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) }
|
||||
|
|
@ -19,6 +37,7 @@ function SyncIndicator() {
|
|||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [open])
|
||||
const running = data?.videos.running || data?.subscriptions.running
|
||||
const busy = syncAllMutation.isPending || running
|
||||
const failed = data?.videos.status === 'failed' || data?.subscriptions.status === 'failed'
|
||||
const recent = [data?.videos.finished_at, data?.subscriptions.finished_at].filter((value): value is string => Boolean(value)).sort().at(-1)
|
||||
const label = running ? 'Обновление...' : failed ? 'Ошибка синхронизации' : recent ? `Обновлено ${formatRelativeTime(recent)}` : 'Синхронизация'
|
||||
|
|
@ -27,11 +46,16 @@ function SyncIndicator() {
|
|||
<Icon name={failed ? 'alert' : running ? 'refresh' : 'check'} size={17} className={running ? 'spin' : ''} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
{open && <div className="sync-popover" role="status">
|
||||
{open && <div className="sync-popover">
|
||||
<strong>Синхронизация</strong>
|
||||
<p>Подписки: {data?.subscriptions.running ? 'обновляются' : data?.subscriptions.finished_at ? formatRelativeTime(data.subscriptions.finished_at) : 'ещё не обновлялись'}</p>
|
||||
<p>Видео: {data?.videos.running ? 'обновляются' : data?.videos.finished_at ? formatRelativeTime(data.videos.finished_at) : 'ещё не обновлялись'}</p>
|
||||
{failed && <p className="error">Последнее обновление завершилось ошибкой. Подробности в настройках.</p>}
|
||||
<button type="button" className="button-secondary sync-all-button" onClick={() => syncAllMutation.mutate()} disabled={busy}>
|
||||
<Icon name="refresh" size={17} className={busy ? 'spin' : ''} />
|
||||
{busy ? 'Обновляем…' : 'Обновить всё'}
|
||||
</button>
|
||||
{syncAllMutation.isError && <p className="error" role="alert">Не удалось запустить обновление. Попробуйте позже.</p>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getSyncStatus, listCategories, listChannels, syncSubscriptions } from '../api/client'
|
||||
import { getSyncStatus, listCategories, listChannels } from '../api/client'
|
||||
import ChannelCard from '../components/ChannelCard'
|
||||
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
|
||||
import Icon from '../components/Icon'
|
||||
|
|
@ -17,7 +17,6 @@ function Channels() {
|
|||
queryFn: () => listChannels({ subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized' }),
|
||||
})
|
||||
const syncStatusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.subscriptions.running ? 2000 : 60_000 })
|
||||
const syncMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['sync-status'] }) } })
|
||||
const finishedAt = syncStatusQuery.data?.subscriptions.finished_at
|
||||
const previousFinished = useRef(finishedAt)
|
||||
useEffect(() => {
|
||||
|
|
@ -34,15 +33,14 @@ function Channels() {
|
|||
const running = syncStatusQuery.data?.subscriptions.running
|
||||
|
||||
return <section className="page channels-screen">
|
||||
<div className="page-heading"><div><p className="eyebrow">Мои подписки</p><h1>Каналы</h1><p className="page-subtitle">Распределяй подписки по темам, чтобы лента была удобнее.</p></div><button className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={running || syncMutation.isPending}><Icon name="refresh" size={17} className={running ? 'spin' : ''} />{running ? 'Обновляется' : 'Обновить'}</button></div>
|
||||
{syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление подписок.</div>}
|
||||
<div className="page-heading"><div><p className="eyebrow">Мои подписки</p><h1>Каналы</h1><p className="page-subtitle">Распределяй подписки по темам, чтобы лента была удобнее.</p></div></div>
|
||||
{syncStatusQuery.data?.subscriptions.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление подписок завершилось ошибкой.</div>}
|
||||
<div className="channels-toolbar"><label className="input-search"><Icon name="search" size={19} /><input type="search" placeholder="Поиск каналов" aria-label="Поиск каналов" value={search} onChange={(event) => setSearch(event.target.value)} /></label><Link className="button-secondary manage-link" to="/settings/categories"><Icon name="folder" size={17} />Управлять категориями</Link></div>
|
||||
<CategoryNav categories={categories} value={filter} onChange={setFilter} allCount={allChannels.length} uncategorizedCount={uncategorizedCount} />
|
||||
{channelsQuery.isLoading && <div className="channel-loading" aria-label="Загрузка каналов">{Array.from({ length: 6 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
|
||||
{channelsQuery.isError && <div className="empty-state" role="alert"><h2>Не удалось загрузить каналы</h2><button className="button-primary" onClick={() => channelsQuery.refetch()}>Повторить</button></div>}
|
||||
{!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length > 0 && <ul className="channel-list">{filteredChannels.map((channel) => <ChannelCard key={channel.id} channel={channel} categories={categories} />)}</ul>}
|
||||
{!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={running ? 'refresh' : 'users'} size={28} /></span><h2>{running ? 'Импортируем каналы' : search ? 'Каналы не найдены' : filter === 'uncategorized' ? 'Все каналы распределены' : 'Здесь пока нет каналов'}</h2><p>{running ? 'Список подписок появится здесь автоматически.' : search ? 'Попробуй другое название.' : filter === 'all' ? 'Обнови подписки, чтобы импортировать свои каналы.' : 'Выбери другую категорию или назначь её каналам.'}</p></div>}
|
||||
{!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={running ? 'refresh' : 'users'} size={28} /></span><h2>{running ? 'Импортируем каналы' : search ? 'Каналы не найдены' : filter === 'uncategorized' ? 'Все каналы распределены' : 'Здесь пока нет каналов'}</h2><p>{running ? 'Список подписок появится здесь автоматически.' : search ? 'Попробуй другое название.' : filter === 'all' ? 'Нажми на индикатор синхронизации вверху и выбери «Обновить всё», чтобы импортировать свои каналы.' : 'Выбери другую категорию или назначь её каналам.'}</p></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useInfiniteQuery, 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 { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels } from '../api/client'
|
||||
import Icon from '../components/Icon'
|
||||
import VideoCard from '../components/VideoCard'
|
||||
|
||||
|
|
@ -60,10 +60,6 @@ function Feed() {
|
|||
if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
previousSubscriptionsFinished.current = subscriptionsFinished
|
||||
}, [subscriptionsFinished, queryClient])
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: syncVideos,
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) },
|
||||
})
|
||||
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 items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
||||
|
|
@ -81,7 +77,6 @@ function Feed() {
|
|||
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>
|
||||
{!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>
|
||||
|
|
@ -94,7 +89,6 @@ function Feed() {
|
|||
<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> : <>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue