2026-09-16 21:40:32 +00:00
import { useEffect , useRef , useState } from 'react'
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 { useMutation , useQuery , useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
2026-09-16 21:40:32 +00:00
import { createCategory , deleteCategory , listCategories , reorderCategories , updateCategory } from '../api/client'
import Icon from '../components/Icon'
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
function Categories() {
const queryClient = useQueryClient ( )
const [ newName , setNewName ] = useState ( '' )
const [ editingId , setEditingId ] = useState < number | null > ( null )
const [ editingName , setEditingName ] = useState ( '' )
2026-09-16 21:40:32 +00:00
const [ deletingId , setDeletingId ] = useState < number | null > ( null )
const cancelDeleteRef = useRef < HTMLButtonElement > ( null )
useEffect ( ( ) = > { if ( deletingId !== null ) cancelDeleteRef . current ? . focus ( ) } , [ deletingId ] )
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 categoriesQuery = useQuery ( { queryKey : [ 'categories' ] , queryFn : listCategories } )
2026-09-16 21:40:32 +00:00
const invalidate = ( ) = > { queryClient . invalidateQueries ( { queryKey : [ 'categories' ] } ) ; queryClient . invalidateQueries ( { queryKey : [ 'channels' ] } ) ; queryClient . invalidateQueries ( { queryKey : [ 'feed' ] } ) }
const createMutation = useMutation ( { mutationFn : createCategory , onSuccess : ( ) = > { setNewName ( '' ) ; invalidate ( ) } } )
const updateMutation = useMutation ( { mutationFn : ( { id , name } : { id : number ; name : string } ) = > updateCategory ( id , name ) , onSuccess : ( ) = > { setEditingId ( null ) ; invalidate ( ) } } )
const deleteMutation = useMutation ( { mutationFn : deleteCategory , onSuccess : ( ) = > { setDeletingId ( null ) ; invalidate ( ) } } )
const reorderMutation = useMutation ( { mutationFn : reorderCategories , onSuccess : invalidate } )
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 categories = categoriesQuery . data ? ? [ ]
2026-09-16 21:40:32 +00:00
const deleting = categories . find ( ( category ) = > category . id === deletingId )
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
function move ( index : number , direction : - 1 | 1 ) {
const target = index + direction
if ( target < 0 || target >= categories . length ) return
2026-09-16 21:40:32 +00:00
const ids = categories . map ( ( category ) = > category . id )
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
; [ ids [ index ] , ids [ target ] ] = [ ids [ target ] , ids [ index ] ]
reorderMutation . mutate ( ids )
}
2026-09-16 21:40:32 +00:00
return < section className = "page categories-screen" >
< Link to = "/settings" className = "back-link" > < Icon name = "arrow" size = { 17 } / > Н а с т р о й к и < / Link >
< div className = "page-heading" > < div > < p className = "eyebrow" > М о я л е н т а < / p > < h1 > К а т е г о р и и < / h1 > < p className = "page-subtitle" > С о з д а в а й т е м ы и м е н я й п о р я д о к в м е н ю . < / p > < / div > < / div >
< form className = "category-form" onSubmit = { ( event ) = > { event . preventDefault ( ) ; if ( newName . trim ( ) ) createMutation . mutate ( newName . trim ( ) ) } } > < label className = "sr-only" htmlFor = "category-name" > Н а з в а н и е н о в о й к а т е г о р и и < / label > < input id = "category-name" placeholder = "Название новой категории" value = { newName } onChange = { ( event ) = > setNewName ( event . target . value ) } / > < button type = "submit" className = "button-primary" disabled = { ! newName . trim ( ) || createMutation . isPending } > < Icon name = "plus" size = { 17 } / > С о з д а т ь < / button > < / form >
{ createMutation . isError && < p className = "error" role = "alert" > Н е у д а л о с ь с о з д а т ь к а т е г о р и ю . В о з м о ж н о , т а к о е и м я у ж е е с т ь . < / p > }
{ reorderMutation . isError && < p className = "error" role = "alert" > Н е у д а л о с ь и з м е н и т ь п о р я д о к к а т е г о р и й . < / p > }
{ categoriesQuery . isLoading && < div className = "channel-loading" > { Array . from ( { length : 3 } , ( _ , index ) = > < div key = { index } className = "channel-skeleton" > < span / > < i / > < / div > ) } < / div > }
{ categoriesQuery . isError && < div className = "empty-state" role = "alert" > < h2 > Н е у д а л о с ь з а г р у з и т ь к а т е г о р и и < / h2 > < button className = "button-primary" onClick = { ( ) = > categoriesQuery . refetch ( ) } > П о в т о р и т ь < / button > < / div > }
{ categories . length > 0 && < ul className = "category-list" > { categories . map ( ( category , index ) = > < li key = { category . id } className = "category-row" > < div className = "reorder-buttons" > < button type = "button" className = "icon-button" onClick = { ( ) = > move ( index , - 1 ) } disabled = { index === 0 || reorderMutation . isPending } aria-label = { ` Поднять ${ category . name } ` } > < Icon name = "up" size = { 18 } / > < / button > < button type = "button" className = "icon-button" onClick = { ( ) = > move ( index , 1 ) } disabled = { index === categories . length - 1 || reorderMutation . isPending } aria-label = { ` Опустить ${ category . name } ` } > < Icon name = "down" size = { 18 } / > < / button > < / div > { editingId === category . id ? < form className = "category-edit-form" onSubmit = { ( event ) = > { event . preventDefault ( ) ; if ( editingName . trim ( ) ) updateMutation . mutate ( { id : category.id , name : editingName.trim ( ) } ) } } > < input aria-label = "Новое название категории" value = { editingName } onChange = { ( event ) = > setEditingName ( event . target . value ) } autoFocus / > < button className = "button-primary" type = "submit" disabled = { ! editingName . trim ( ) || updateMutation . isPending } > С о х р а н и т ь < / button > < button className = "button-quiet" type = "button" onClick = { ( ) = > setEditingId ( null ) } > О т м е н а < / button > < / form > : < > < div className = "category-row-label" > < span className = "category-dot" / > < span className = "category-name" > { category . name } < / span > < span className = "category-count" > { category . channel_count } к а н а л о в < / span > < / div > < div className = "category-row-actions" > < button type = "button" className = "icon-button" aria-label = { ` Переименовать ${ category . name } ` } title = "Переименовать" onClick = { ( ) = > { setEditingId ( category . id ) ; setEditingName ( category . name ) } } > < Icon name = "edit" size = { 18 } / > < / button > < button type = "button" className = "icon-button danger-hover" aria-label = { ` Удалить ${ category . name } ` } title = "Удалить" onClick = { ( ) = > setDeletingId ( category . id ) } > < Icon name = "trash" size = { 18 } / > < / button > < / div > < / > } < / li > ) } < / ul > }
{ updateMutation . isError && < p className = "error" role = "alert" > Н е у д а л о с ь п е р е и м е н о в а т ь к а т е г о р и ю . < / p > }
{ ! categoriesQuery . isLoading && ! categoriesQuery . isError && categories . length === 0 && < div className = "empty-state" > < span className = "empty-icon" > < Icon name = "folder" size = { 28 } / > < / span > < h2 > К а т е г о р и й п о к а н е т < / h2 > < p > С о з д а й п е р в у ю т е м у и д о б а в ь в н е ё к а н а л ы . < / p > < / div > }
{ deleting && < div className = "modal-backdrop" onClick = { ( ) = > setDeletingId ( null ) } onKeyDown = { ( event ) = > { if ( event . key === 'Escape' ) setDeletingId ( null ) } } > < div className = "confirm-dialog" role = "alertdialog" aria-modal = "true" aria-labelledby = "delete-category-title" aria-describedby = "delete-category-description" onClick = { ( event ) = > event . stopPropagation ( ) } > < h2 id = "delete-category-title" > У д а л и т ь « { deleting . name } » ? < / h2 > < p id = "delete-category-description" > К а н а л ы о с т а н у т с я в п о д п и с к а х . О н и п р о с т о б о л ь ш е н е б у д у т о т н о с и т ь с я к э т о й к а т е г о р и и . < / p > { deleteMutation . isError && < p className = "error" role = "alert" > Н е у д а л о с ь у д а л и т ь к а т е г о р и ю . < / p > } < div className = "dialog-actions" > < button ref = { cancelDeleteRef } className = "button-secondary" onClick = { ( ) = > setDeletingId ( null ) } > О т м е н а < / button > < button className = "button-danger" onClick = { ( ) = > deleteMutation . mutate ( deleting . id ) } disabled = { deleteMutation . isPending } > У д а л и т ь к а т е г о р и ю < / button > < / div > < / div > < / div > }
< / section >
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
}
export default Categories