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:
vrubelroman 2026-09-16 18:44:30 +00:00
commit 0ed20bb838
90 changed files with 8545 additions and 0 deletions

View file

@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import type { CategoryDto, ChannelDto } from '../api/client'
import { setChannelCategories } from '../api/client'
interface Props {
channel: ChannelDto
categories: CategoryDto[]
}
function ChannelCard({ channel, categories }: Props) {
const [open, setOpen] = useState(false)
const popoverRef = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient()
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const mutation = useMutation({
mutationFn: (categoryIds: number[]) => setChannelCategories(channel.id, categoryIds),
onMutate: async (categoryIds: number[]) => {
await queryClient.cancelQueries({ queryKey: ['channels'] })
const previous = queryClient.getQueriesData<ChannelDto[]>({ queryKey: ['channels'] })
queryClient.setQueriesData<ChannelDto[]>({ queryKey: ['channels'] }, (old) =>
old?.map((c) => (c.id === channel.id ? { ...c, category_ids: categoryIds } : c)),
)
return { previous }
},
onError: (_err, _vars, context) => {
context?.previous.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['categories'] })
},
})
function toggleCategory(categoryId: number) {
const current = new Set(channel.category_ids)
if (current.has(categoryId)) {
current.delete(categoryId)
} else {
current.add(categoryId)
}
mutation.mutate(Array.from(current))
}
const assignedNames = categories.filter((c) => channel.category_ids.includes(c.id)).map((c) => c.name)
return (
<li className="channel-card">
{channel.thumbnail_url && <img src={channel.thumbnail_url} alt={channel.title} />}
<div className="channel-info">
<Link to={`/channels/${channel.id}/videos`} className="channel-title">
{channel.title}
</Link>
{!channel.subscribed && <span className="badge">отписан</span>}
<div className="channel-categories">
{assignedNames.map((name) => (
<span key={name} className="chip">
{name}
</span>
))}
<div className="category-popover-wrapper" ref={popoverRef}>
<button className="chip-edit" onClick={() => setOpen((v) => !v)}>
+ Категория
</button>
{open && (
<div className="category-popover">
{categories.length === 0 && <div className="category-popover-empty">Сначала создай категорию</div>}
{categories.map((category) => (
<label key={category.id} className="category-checkbox">
<input
type="checkbox"
checked={channel.category_ids.includes(category.id)}
onChange={() => toggleCategory(category.id)}
/>
{category.name}
</label>
))}
</div>
)}
</div>
</div>
</div>
</li>
)
}
export default ChannelCard