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
59
analytics/2026-09-17-sync-all-button.md
Normal file
59
analytics/2026-09-17-sync-all-button.md
Normal 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 не меняется).
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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> : <>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue