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>
This commit is contained in:
commit
0ed20bb838
90 changed files with 8545 additions and 0 deletions
145
frontend/src/pages/Categories.tsx
Normal file
145
frontend/src/pages/Categories.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
listCategories,
|
||||
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 })
|
||||
|
||||
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">
|
||||
{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
|
||||
Loading…
Add table
Add a link
Reference in a new issue