Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
|
|
|
|
import { useState } from 'react'
|
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
|
import { Link } from 'react-router-dom'
|
|
|
|
|
|
import {
|
|
|
|
|
|
createCategory,
|
|
|
|
|
|
deleteCategory,
|
|
|
|
|
|
listCategories,
|
2026-09-16 20:35:39 +00:00
|
|
|
|
listChannels,
|
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
|
|
|
|
reorderCategories,
|
|
|
|
|
|
updateCategory,
|
|
|
|
|
|
} from '../api/client'
|
|
|
|
|
|
|
|
|
|
|
|
function Categories() {
|
|
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
|
|
const [newName, setNewName] = useState('')
|
|
|
|
|
|
const [editingId, setEditingId] = useState<number | null>(null)
|
|
|
|
|
|
const [editingName, setEditingName] = useState('')
|
|
|
|
|
|
|
|
|
|
|
|
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
2026-09-16 20:35:39 +00:00
|
|
|
|
const uncategorizedQuery = useQuery({
|
|
|
|
|
|
queryKey: ['channels', 'uncategorized-count'],
|
|
|
|
|
|
queryFn: () => listChannels({ subscribed: true, uncategorized: true }),
|
|
|
|
|
|
})
|
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
|
|
|
|
|
|
|
|
|
|
const invalidate = () => {
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['categories'] })
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const createMutation = useMutation({
|
|
|
|
|
|
mutationFn: (name: string) => createCategory(name),
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
setNewName('')
|
|
|
|
|
|
invalidate()
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const updateMutation = useMutation({
|
|
|
|
|
|
mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name),
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
setEditingId(null)
|
|
|
|
|
|
invalidate()
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const deleteMutation = useMutation({
|
|
|
|
|
|
mutationFn: (id: number) => deleteCategory(id),
|
|
|
|
|
|
onSuccess: invalidate,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const reorderMutation = useMutation({
|
|
|
|
|
|
mutationFn: (ids: number[]) => reorderCategories(ids),
|
|
|
|
|
|
onSuccess: invalidate,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const categories = categoriesQuery.data ?? []
|
|
|
|
|
|
|
|
|
|
|
|
function move(index: number, direction: -1 | 1) {
|
|
|
|
|
|
const target = index + direction
|
|
|
|
|
|
if (target < 0 || target >= categories.length) return
|
|
|
|
|
|
const ids = categories.map((c) => c.id)
|
|
|
|
|
|
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
|
|
|
|
|
reorderMutation.mutate(ids)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="categories-screen">
|
|
|
|
|
|
<Link to="/" className="back-link">
|
|
|
|
|
|
← Каналы
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<h1>Категории</h1>
|
|
|
|
|
|
|
|
|
|
|
|
<form
|
|
|
|
|
|
onSubmit={(e) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
if (newName.trim()) createMutation.mutate(newName.trim())
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="category-form"
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="Новая категория"
|
|
|
|
|
|
value={newName}
|
|
|
|
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button type="submit" disabled={!newName.trim() || createMutation.isPending}>
|
|
|
|
|
|
Создать
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{createMutation.isError && <p className="error">{(createMutation.error as Error).message}</p>}
|
|
|
|
|
|
|
|
|
|
|
|
<ul className="category-list">
|
2026-09-16 20:35:39 +00:00
|
|
|
|
<li className="category-row category-row-virtual">
|
|
|
|
|
|
<div className="reorder-buttons" />
|
|
|
|
|
|
<span className="category-name">Без категории</span>
|
|
|
|
|
|
<span className="category-count">
|
|
|
|
|
|
{uncategorizedQuery.isLoading ? '…' : `${uncategorizedQuery.data?.length ?? 0} канал(ов)`}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
|
Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching
40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00
|
|
|
|
{categories.map((category, index) => (
|
|
|
|
|
|
<li key={category.id} className="category-row">
|
|
|
|
|
|
<div className="reorder-buttons">
|
|
|
|
|
|
<button onClick={() => move(index, -1)} disabled={index === 0} aria-label="Выше">
|
|
|
|
|
|
↑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onClick={() => move(index, 1)} disabled={index === categories.length - 1} aria-label="Ниже">
|
|
|
|
|
|
↓
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{editingId === category.id ? (
|
|
|
|
|
|
<form
|
|
|
|
|
|
className="category-edit-form"
|
|
|
|
|
|
onSubmit={(e) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() })
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<input value={editingName} onChange={(e) => setEditingName(e.target.value)} autoFocus />
|
|
|
|
|
|
<button type="submit">Сохранить</button>
|
|
|
|
|
|
<button type="button" onClick={() => setEditingId(null)}>
|
|
|
|
|
|
Отмена
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="category-name">{category.name}</span>
|
|
|
|
|
|
<span className="category-count">{category.channel_count} канал(ов)</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setEditingId(category.id)
|
|
|
|
|
|
setEditingName(category.name)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Переименовать
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (confirm(`Удалить категорию "${category.name}"? Каналы останутся.`)) {
|
|
|
|
|
|
deleteMutation.mutate(category.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Удалить
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
|
|
{categories.length === 0 && !categoriesQuery.isLoading && <p>Категорий пока нет.</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default Categories
|