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:
vrubelroman 2026-09-18 09:39:18 +00:00
parent 5fa5a391d7
commit 0dc1336951
5 changed files with 94 additions and 19 deletions

View file

@ -0,0 +1,59 @@
# Перенос ручного обновления синков в кнопку «Обновить всё» (попап SyncIndicator)
## Задача
Собрать ручное обновление синков в единую кнопку «Обновить всё» в попапе индикатора синхронизации в шапке (`SyncIndicator` в `AppShell`). Убрать кнопки «Обновить» из заголовков страниц «Лента» (`Feed.tsx`) и «Каналы» (`Channels.tsx`). Backend не менять.
## Контекст
- Кнопка в ленте вызывает `POST /api/sync/videos` — глобальный синк всех каналов, а не текущей категории; вводит в заблуждение. Глобальное действие семантически принадлежит глобальному индикатору «Обновлено X назад» в шапке.
- Порядок «подписки → видео» важен: `sync_subscriptions` заполняет `uploads_playlist_id` новых каналов, а `sync_videos` выбирает только каналы с непустым `uploads_playlist_id` (`backend/app/services/sync.py:169-173`) — новые каналы сразу получают видео.
- Автотриггер: каждый аутентифицированный запрос (`require_session` → `maybe_trigger_videos_sync`) может запустить фоновый синк видео (`backend/app/services/sync_trigger.py`). Важно: сам `POST /api/sync/subscriptions` тоже проходит через `require_session`, поэтому сразу после клика «Обновить всё» видео-синк может уже стартовать в фоне, и следующий `POST /api/sync/videos` вернёт 409 — это штатный сценарий, не ошибка.
- Поведение backend при уже идущем синке: `sync.py` бросает `SyncInProgress`, API отдаёт **409** (`backend/app/api/sync.py`), detail: `"Subscriptions sync already in progress"` / `"Videos sync already in progress"`. Фронтовый `request()` кидает `Error(detail)` (`frontend/src/api/client.ts:27`).
- Поллинг статуса уже есть: query `['sync-status']` с `refetchInterval` 2 с при `running`, иначе 60 с (`AppShell.tsx:13`). `Feed.tsx` и `Channels.tsx` инвалидируют `['feed']` / `['channels']` в `useEffect` при смене `finished_at` — эта механика не зависит от того, откуда запущен синк, и продолжит работать после переноса кнопки.
- Синк подписок может быть занят и без пользователя: он запускается по расписанию (`backend/app/services/scheduler.py:15`).
- Backend не трогаем: `POST /api/sync/subscriptions` и `POST /api/sync/videos` остаются — пригодятся API-клиентам.
## Затронутые подсистемы и файлы
- `frontend/src/components/AppShell.tsx` — `SyncIndicator`: кнопка «Обновить всё» в попапе, последовательный запуск подписки → видео, толерантность к 409, disabled/спиннер, показ ошибки в попапе, инвалидация `['sync-status']`.
- `frontend/src/pages/Feed.tsx` — удалить `syncMutation`, кнопку (строка 84) и notice ошибки мутации (строка 97); сохранить notice по `sync-status` «Последнее обновление видео завершилось ошибкой…» (строка 98) и оба `useEffect`-инвалидации.
- `frontend/src/pages/Channels.tsx` — удалить `syncMutation` (строка 20), кнопку в заголовке (строка 37) и notice ошибки мутации (строка 38); сохранить notice о `failed` (строка 39); заменить подсказку пустого состояния (строка 45) на указание на индикатор в шапке.
- `frontend/src/api/client.ts` — `syncSubscriptions`/`syncVideos` начнёт использовать `AppShell`; сигнатуры не меняются.
- `frontend/src/App.css` — стиль кнопки внутри `.sync-popover`; удалить неиспользуемые правила `.refresh-button` (media query, строки 248–249), если после чистки не останется использований.
- Backend — не изменяется.
## Критерии приёмки
1. Единственная кнопка ручного обновления — «Обновить всё» в попапе `SyncIndicator` (шапка).
2. Запуск строго последовательный: сначала `POST /api/sync/subscriptions`, затем `POST /api/sync/videos`.
3. 409 при запуске видео-синка (в т.ч. из-за автотриггера) — не ошибка: повторный запуск пропускается, состояние показывает поллинг; попап не показывает ошибку в этом случае.
4. Кнопка disabled со спиннером, пока идёт мутация или любой из синков `running` (по `sync-status`); прочие ошибки (400/502/503/сеть) показываются в попапе.
5. `Feed.tsx` и `Channels.tsx` очищены: нет кнопок «Обновить», нет мутаций/вызовов `syncVideos`/`syncSubscriptions`, нет notice ошибок мутаций; notice «…завершилось ошибкой» (по `sync-status`) и `useEffect`-инвалидации кэша по `finished_at` сохранены и работают.
6. Пустое состояние «Каналы» ссылается на индикатор в шапке (текст с «Обновить всё»).
7. `npm run lint` и `npm run build` чистые; `git diff` не затрагивает `backend/`; `pytest` зелёный.
8. Деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
## План
1. `AppShell.tsx` (SyncIndicator):
- `useMutation` с `mutationFn`: `await syncSubscriptions()`, затем `await syncVideos()`; при ошибке видео, содержащей `already in progress` (409), «проглотить» и не считать ошибкой; 409 подписок — аналогично не ошибка.
- Кнопка «Обновить всё» в попапе: `disabled` при `isPending` или `data?.videos.running || data?.subscriptions.running`, спиннер при pending/running.
- `onSettled`: `invalidateQueries(['sync-status'])`; блок ошибки мутации в попапе (`role="status"` у попапа при наличии кнопки стоит пересмотреть: интерактивный элемент внутри status-региона — вынести кнопку или убрать role).
2. `Feed.tsx`: удалить `syncMutation`, кнопку, notice мутации и импорты `useMutation`/`syncVideos`; оставить `syncStatusQuery`, оба `useEffect`, notice о `failed`.
3. `Channels.tsx`: то же + новый текст подсказки пустого состояния со ссылкой на индикатор в шапке.
4. `App.css`: стили кнопки в попапе; вычистить `.refresh-button`.
5. Проверка: `npm run lint`, `npm run build`, `pytest`; ручная проверка сценариев (клик → подписки затем видео; повторный клик при идущем видео-синке не даёт ошибки; статусы обновляются поллингом; notice о failed остаётся на страницах).
6. Деплой по правилу команды.
## Риски и ограничения
- Распознавание 409 по тексту `Error.message` (`already in progress`) — хрупко, если backend изменит detail; приемлемо, т.к. backend не меняется. Альтернатива — типизованные ошибки в `client.ts`, но без изменения API.
- 409 подписок (фоновый часовой синк): трактовать симметрично (пропустить, поллинг покажет статус). Если при этом продолжить и запускать видео-синк, он может не увидеть каналы, чьи плейлисты ещё импортируются — приемлемо, следующий синк подхватит.
- После удаления кнопки из `page-heading` правая часть заголовков страниц опустеет — проверить, что вёрстка не ломается (внутренний `div` с заголовком остаётся).
- Попап закрывается по клику вне `ref`; кнопка внутри попапа не должна его закрывать — существующий обработчик это уже учитывает.
- Мобильная вёрстка: попап имеет адаптивное правило (`App.css:278`), кнопка должна быть удобной на узких экранах.
## Журнал изменений
- 2026-09-18: документ создан перед реализацией (первичный спек, backend не меняется).

View file

@ -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 { 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 strong { display: block; color: var(--text); margin-bottom: 12px; }
.sync-popover p + p { margin-top: 9px; } .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; } .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); } .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; } .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 { padding: 24px 16px 58px; }
.page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; } .page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
.page-heading h1 { font-size: 27px; } .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; } .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-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
.video-thumbnail { border-radius: 9px; } .video-thumbnail { border-radius: 9px; }

View file

@ -1,17 +1,35 @@
import { useEffect, useRef, useState } from 'react' 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 { 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 { formatRelativeTime } from '../utils/format'
import Icon from './Icon' import Icon from './Icon'
function SyncIndicator() { function SyncIndicator() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient()
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['sync-status'], queryFn: getSyncStatus, queryKey: ['sync-status'], queryFn: getSyncStatus,
refetchInterval: (query) => query.state.data?.videos.running || query.state.data?.subscriptions.running ? 2000 : 60_000, 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(() => { useEffect(() => {
if (!open) return if (!open) return
const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) } 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) return () => document.removeEventListener('mousedown', close)
}, [open]) }, [open])
const running = data?.videos.running || data?.subscriptions.running const running = data?.videos.running || data?.subscriptions.running
const busy = syncAllMutation.isPending || running
const failed = data?.videos.status === 'failed' || data?.subscriptions.status === 'failed' 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 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)}` : 'Синхронизация' 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' : ''} /> <Icon name={failed ? 'alert' : running ? 'refresh' : 'check'} size={17} className={running ? 'spin' : ''} />
<span>{label}</span> <span>{label}</span>
</button> </button>
{open && <div className="sync-popover" role="status"> {open && <div className="sync-popover">
<strong>Синхронизация</strong> <strong>Синхронизация</strong>
<p>Подписки: {data?.subscriptions.running ? 'обновляются' : data?.subscriptions.finished_at ? formatRelativeTime(data.subscriptions.finished_at) : 'ещё не обновлялись'}</p> <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> <p>Видео: {data?.videos.running ? 'обновляются' : data?.videos.finished_at ? formatRelativeTime(data.videos.finished_at) : 'ещё не обновлялись'}</p>
{failed && <p className="error">Последнее обновление завершилось ошибкой. Подробности в настройках.</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>}
</div> </div>
} }

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' 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 { 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 ChannelCard from '../components/ChannelCard'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
import Icon from '../components/Icon' import Icon from '../components/Icon'
@ -17,7 +17,6 @@ function Channels() {
queryFn: () => listChannels({ subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized' }), 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 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 finishedAt = syncStatusQuery.data?.subscriptions.finished_at
const previousFinished = useRef(finishedAt) const previousFinished = useRef(finishedAt)
useEffect(() => { useEffect(() => {
@ -34,15 +33,14 @@ function Channels() {
const running = syncStatusQuery.data?.subscriptions.running const running = syncStatusQuery.data?.subscriptions.running
return <section className="page channels-screen"> 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> <div className="page-heading"><div><p className="eyebrow">Мои подписки</p><h1>Каналы</h1><p className="page-subtitle">Распределяй подписки по темам, чтобы лента была удобнее.</p></div></div>
{syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление подписок.</div>}
{syncStatusQuery.data?.subscriptions.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление подписок завершилось ошибкой.</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> <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} /> <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.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.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 && <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> </section>
} }

View file

@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react' 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 { 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 Icon from '../components/Icon'
import VideoCard from '../components/VideoCard' import VideoCard from '../components/VideoCard'
@ -60,10 +60,6 @@ function Feed() {
if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) queryClient.invalidateQueries({ queryKey: ['channels'] }) if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) queryClient.invalidateQueries({ queryKey: ['channels'] })
previousSubscriptionsFinished.current = subscriptionsFinished previousSubscriptionsFinished.current = subscriptionsFinished
}, [subscriptionsFinished, queryClient]) }, [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 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} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? [] const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
@ -81,7 +77,6 @@ function Feed() {
return <section className="page"> return <section className="page">
<div className="page-heading"> <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> <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> </div>
<nav className="mobile-category-nav" aria-label="Категории"> <nav className="mobile-category-nav" aria-label="Категории">
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link> <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> <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>)} {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>} </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>} {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>} {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> : <>