Hide unsubscribed channels from the channels list, add a Saved tab

- GET /api/channels called with subscribed=true from the Channels page, so
  a channel disappears from the list as soon as it's unsubscribed instead
  of lingering with a badge
- GET /api/feed gains a `downloaded` filter (this was already anticipated
  in the original TZ's feed query params but left unimplemented until
  download_jobs existed) -- matches on each video's *latest* job only, so
  a redownload/delete history doesn't leave stale matches
- New /saved route: same category filtering, video grid and download
  actions as the main feed, scoped to downloaded videos only

2 new backend tests (76 total).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-09-16 20:23:33 +00:00
parent 62f8fb746b
commit b29bcfb8d7
7 changed files with 127 additions and 3 deletions

View file

@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom'
import { getAuthStatus } from './api/client'
import Connect from './pages/Connect'
import Feed from './pages/Feed'
import Saved from './pages/Saved'
import Channels from './pages/Channels'
import ChannelVideos from './pages/ChannelVideos'
import VideoPage from './pages/VideoPage'
@ -43,6 +44,7 @@ function App() {
<div className="app">
<Routes>
<Route path="/" element={<Feed />} />
<Route path="/saved" element={<Saved />} />
<Route path="/channels" element={<Channels />} />
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
<Route path="/video/:youtubeVideoId" element={<VideoPage />} />

View file

@ -64,12 +64,13 @@ export function getChannel(channelId: number) {
}
export function listChannels(
params: { search?: string; categoryId?: number; uncategorized?: boolean } = {},
params: { search?: string; categoryId?: number; uncategorized?: boolean; subscribed?: boolean } = {},
) {
const qs = new URLSearchParams()
if (params.search) qs.set('search', params.search)
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.subscribed != null) qs.set('subscribed', String(params.subscribed))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return request<ChannelDto[]>(`/api/channels${suffix}`)
}
@ -199,12 +200,19 @@ export function recheckLocal(youtubeVideoId: string) {
}
export function getFeed(
params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {},
params: {
categoryId?: number
uncategorized?: boolean
channelId?: number
downloaded?: boolean
cursor?: string
} = {},
) {
const qs = new URLSearchParams()
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.channelId != null) qs.set('channel_id', String(params.channelId))
if (params.downloaded) qs.set('downloaded', 'true')
if (params.cursor) qs.set('cursor', params.cursor)
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`)

View file

@ -16,6 +16,7 @@ function Channels() {
queryKey: ['channels', filter],
queryFn: () =>
listChannels({
subscribed: true,
categoryId: typeof filter === 'number' ? filter : undefined,
uncategorized: filter === 'uncategorized',
}),
@ -58,6 +59,7 @@ function Channels() {
<h1>Каналы</h1>
<div className="actions">
<Link to="/">Лента</Link>
<Link to="/saved">Сохранённые</Link>
<Link to="/categories">Категории</Link>
<button onClick={() => syncMutation.mutate()} disabled={running}>
{running ? 'Синхронизация...' : 'Обновить сейчас'}

View file

@ -53,6 +53,7 @@ function Feed() {
<header>
<h1>Лента</h1>
<div className="actions">
<Link to="/saved">Сохранённые</Link>
<Link to="/channels">Каналы</Link>
<Link to="/categories">Категории</Link>
<button onClick={() => syncMutation.mutate()} disabled={running}>

View file

@ -0,0 +1,70 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { getFeed, listCategories } from '../api/client'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
import VideoCard from '../components/VideoCard'
import { useState } from 'react'
function Saved() {
const [filter, setFilter] = useState<CategoryFilter>('all')
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? []
const feedQuery = useInfiniteQuery({
queryKey: ['feed', 'saved', filter],
queryFn: ({ pageParam }) =>
getFeed({
categoryId: typeof filter === 'number' ? filter : undefined,
uncategorized: filter === 'uncategorized',
downloaded: true,
cursor: pageParam,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
})
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
return (
<div className="feed-screen">
<header>
<h1>Сохранённые</h1>
<div className="actions">
<Link to="/">Лента</Link>
<Link to="/channels">Каналы</Link>
<Link to="/categories">Категории</Link>
</div>
</header>
<div className="layout">
<CategoryNav categories={categories} value={filter} onChange={setFilter} />
<div className="content">
{feedQuery.isLoading && <p>Загрузка...</p>}
{feedQuery.isError && <p className="error">Не удалось загрузить сохранённые видео</p>}
<ul className="video-grid">
{items.map((video) => (
<VideoCard key={video.youtube_video_id} video={video} />
))}
</ul>
{!feedQuery.isLoading && items.length === 0 && <p>Здесь пока пусто. Скачай видео из ленты.</p>}
{feedQuery.hasNextPage && (
<button
className="load-more"
onClick={() => feedQuery.fetchNextPage()}
disabled={feedQuery.isFetchingNextPage}
>
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
</button>
)}
</div>
</div>
</div>
)
}
export default Saved