2026-09-16 21:40:32 +00:00
import { useEffect , useRef , useState } from 'react'
2026-09-18 09:39:18 +00:00
import { useMutation , useQuery , useQueryClient } from '@tanstack/react-query'
2026-09-16 21:40:32 +00:00
import { Link , NavLink , useLocation , useNavigate } from 'react-router-dom'
2026-09-18 09:39:18 +00:00
import { getSyncStatus , listCategories , syncSubscriptions , syncVideos } from '../api/client'
2026-09-16 21:40:32 +00:00
import { formatRelativeTime } from '../utils/format'
import Icon from './Icon'
function SyncIndicator() {
const [ open , setOpen ] = useState ( false )
const ref = useRef < HTMLDivElement > ( null )
2026-09-18 09:39:18 +00:00
const queryClient = useQueryClient ( )
2026-09-16 21:40:32 +00:00
const { data } = useQuery ( {
queryKey : [ 'sync-status' ] , queryFn : getSyncStatus ,
refetchInterval : ( query ) = > query . state . data ? . videos . running || query . state . data ? . subscriptions . running ? 2000 : 60_000 ,
} )
2026-09-18 09:39:18 +00:00
const syncAllMutation = useMutation ( {
mutationFn : async ( ) = > {
const ignoreInProgress = ( error : unknown ) = > error instanceof Error && error . message . includes ( 'already in progress' )
try {
await syncSubscriptions ( )
} catch ( error ) {
if ( ! ignoreInProgress ( error ) ) throw error
}
try {
await syncVideos ( )
} catch ( error ) {
if ( ! ignoreInProgress ( error ) ) throw error
}
} ,
onMutate : ( ) = > { queryClient . invalidateQueries ( { queryKey : [ 'sync-status' ] } ) } ,
onSettled : ( ) = > { queryClient . invalidateQueries ( { queryKey : [ 'sync-status' ] } ) } ,
} )
2026-09-16 21:40:32 +00:00
useEffect ( ( ) = > {
if ( ! open ) return
const close = ( event : MouseEvent ) = > { if ( ! ref . current ? . contains ( event . target as Node ) ) setOpen ( false ) }
document . addEventListener ( 'mousedown' , close )
return ( ) = > document . removeEventListener ( 'mousedown' , close )
} , [ open ] )
const running = data ? . videos . running || data ? . subscriptions . running
2026-09-18 09:39:18 +00:00
const busy = syncAllMutation . isPending || running
2026-09-16 21:40:32 +00:00
const failed = data ? . videos . status === 'failed' || data ? . subscriptions . status === 'failed'
const recent = [ data ? . videos . finished_at , data ? . subscriptions . finished_at ] . filter ( ( value ) : value is string = > Boolean ( value ) ) . sort ( ) . at ( - 1 )
const label = running ? 'Обновление...' : failed ? 'Ошибка синхронизации' : recent ? ` Обновлено ${ formatRelativeTime ( recent ) } ` : 'Синхронизация'
return < div className = "sync-wrap" ref = { ref } >
< button type = "button" className = { ` sync-indicator ${ failed ? 'is-error' : '' } ` } onClick = { ( ) = > setOpen ( ! open ) } aria-expanded = { open } aria-label = { label } >
< Icon name = { failed ? 'alert' : running ? 'refresh' : 'check' } size = { 17 } className = { running ? 'spin' : '' } / >
< span > { label } < / span >
< / button >
2026-09-18 09:39:18 +00:00
{ open && < div className = "sync-popover" >
2026-09-16 21:40:32 +00:00
< strong > С и н х р о н и з а ц и я < / strong >
< p > П о д п и с к и : { data ? . subscriptions . running ? 'обновляются' : data ? . subscriptions . finished_at ? formatRelativeTime ( data . subscriptions . finished_at ) : 'ещё не обновлялись' } < / p >
< p > В и д е о : { data ? . videos . running ? 'обновляются' : data ? . videos . finished_at ? formatRelativeTime ( data . videos . finished_at ) : 'ещё не обновлялись' } < / p >
{ failed && < p className = "error" > П о с л е д н е е о б н о в л е н и е з а в е р ш и л о с ь о ш и б к о й . П о д р о б н о с т и в н а с т р о й к а х . < / p > }
2026-09-18 09:39:18 +00:00
< button type = "button" className = "button-secondary sync-all-button" onClick = { ( ) = > syncAllMutation . mutate ( ) } disabled = { busy } >
< Icon name = "refresh" size = { 17 } className = { busy ? 'spin' : '' } / >
{ busy ? 'Обновляем…' : 'Обновить всё' }
< / button >
{ syncAllMutation . isError && < p className = "error" role = "alert" > Н е у д а л о с ь з а п у с т и т ь о б н о в л е н и е . П о п р о б у й т е п о з ж е . < / p > }
2026-09-16 21:40:32 +00:00
< / div > }
< / div >
}
function Sidebar ( { close } : { close : ( ) = > void } ) {
const categoriesQuery = useQuery ( { queryKey : [ 'categories' ] , queryFn : listCategories } )
const categories = categoriesQuery . data ? ? [ ]
2026-09-18 12:13:32 +00:00
const location = useLocation ( )
// Keep the current view mode ("Лента | Каналы") when switching between
// "В с е видео" and category links; badges (?new=1) are feed-specific.
const viewQuery = new URLSearchParams ( location . search ) . get ( 'view' ) === 'channels' ? '?view=channels' : ''
2026-09-16 21:40:32 +00:00
const links = [
2026-09-18 12:13:32 +00:00
{ to : ` / ${ viewQuery } ` , icon : 'home' as const , text : 'В с е видео' , end : true } ,
2026-09-16 21:40:32 +00:00
{ to : '/local' , icon : 'server' as const , text : 'Н а сервере' } ,
]
return < nav className = "sidebar-nav" aria-label = "Основная навигация" >
< div className = "sidebar-group" >
{ links . map ( ( item ) = > < NavLink key = { item . to } to = { item . to } end = { item . end } onClick = { close } className = { ( { isActive } ) = > ` sidebar-link ${ isActive ? 'active' : '' } ` } > < Icon name = { item . icon } size = { 19 } / > < span > { item . text } < / span > < / NavLink > ) }
< / div >
< div className = "sidebar-group sidebar-categories" >
< div className = "sidebar-heading" > К а т е г о р и и < / div >
2026-09-17 21:07:33 +00:00
{ categories . map ( ( category ) = > < div key = { category . id } className = "sidebar-category-row" >
2026-09-18 12:13:32 +00:00
< NavLink to = { ` /category/ ${ category . id } ${ viewQuery } ` } onClick = { close } className = { ( { isActive } ) = > ` sidebar-link category-link ${ isActive ? 'active' : '' } ` } > < span className = "category-dot" / > < span className = "truncate" > { category . name } < / span > < / NavLink >
2026-09-17 21:07:33 +00:00
{ category . new_videos_count > 0 && < Link to = { ` /category/ ${ category . id } ?new=1 ` } onClick = { close } className = "sidebar-badge sidebar-badge-link" aria-label = { ` ${ category . new_videos_count } новых видео в категории « ${ category . name } » ` } > { category . new_videos_count } < / Link > }
< / div > ) }
2026-09-16 21:40:32 +00:00
{ categories . length === 0 && ! categoriesQuery . isLoading && < p className = "sidebar-hint" > П о к а н е т к а т е г о р и й < / p > }
< Link to = "/settings/categories" onClick = { close } className = "sidebar-link sidebar-add" > < Icon name = "plus" size = { 18 } / > < span > Н о в а я к а т е г о р и я < / span > < / Link >
< / div >
< div className = "sidebar-group sidebar-bottom" >
< NavLink to = "/channels" onClick = { close } className = { ( { isActive } ) = > ` sidebar-link ${ isActive ? 'active' : '' } ` } > < Icon name = "users" size = { 19 } / > < span > К а н а л ы < / span > < / NavLink >
< NavLink to = "/settings" onClick = { close } className = { ( { isActive } ) = > ` sidebar-link ${ isActive ? 'active' : '' } ` } > < Icon name = "settings" size = { 19 } / > < span > Н а с т р о й к и < / span > < / NavLink >
< / div >
< / nav >
}
function SearchForm ( { initialValue } : { initialValue : string } ) {
const [ search , setSearch ] = useState ( initialValue )
const navigate = useNavigate ( )
return < form className = "header-search" role = "search" onSubmit = { ( event ) = > { event . preventDefault ( ) ; navigate ( search . trim ( ) ? ` /search?q= ${ encodeURIComponent ( search . trim ( ) ) } ` : '/' ) } } >
< Icon name = "search" size = { 19 } / >
< input aria-label = "Поиск по видео и каналам" placeholder = "Поиск видео и каналов" maxLength = { 200 } value = { search } onChange = { ( event ) = > setSearch ( event . target . value ) } / >
{ search && < button type = "submit" aria-label = "Искать" > < Icon name = "chevron" size = { 18 } / > < / button > }
< / form >
}
export default function AppShell ( { children } : { children : React.ReactNode } ) {
const [ drawerOpen , setDrawerOpen ] = useState ( false )
const drawerCloseRef = useRef < HTMLButtonElement > ( null )
const location = useLocation ( )
const initialSearch = location . pathname === '/search' ? new URLSearchParams ( location . search ) . get ( 'q' ) ? ? '' : ''
useEffect ( ( ) = > {
document . body . classList . toggle ( 'drawer-open' , drawerOpen )
if ( drawerOpen ) drawerCloseRef . current ? . focus ( )
const onEscape = ( event : KeyboardEvent ) = > { if ( event . key === 'Escape' ) setDrawerOpen ( false ) }
if ( drawerOpen ) document . addEventListener ( 'keydown' , onEscape )
else document . removeEventListener ( 'keydown' , onEscape )
return ( ) = > { document . body . classList . remove ( 'drawer-open' ) ; document . removeEventListener ( 'keydown' , onEscape ) }
} , [ drawerOpen ] )
return < div className = "app-shell" >
< header className = "topbar" >
< button type = "button" className = "icon-button mobile-menu" aria-label = "Открыть меню" onClick = { ( ) = > setDrawerOpen ( true ) } > < Icon name = "menu" / > < / button >
< Link to = "/" className = "brand" aria-label = "MyTube — все видео" > < span className = "brand-mark" > < Icon name = "play" size = { 18 } / > < / span > < span > My < span className = "brand-accent" > Tube < / span > < / span > < / Link >
< SearchForm key = { location . pathname + location . search } initialValue = { initialSearch } / >
< div className = "topbar-actions" > < SyncIndicator / > < Link to = "/settings" className = "icon-button header-settings" aria-label = "Настройки" > < Icon name = "settings" / > < / Link > < / div >
< / header >
{ drawerOpen && < button type = "button" className = "drawer-backdrop" aria-label = "Закрыть меню" onClick = { ( ) = > setDrawerOpen ( false ) } / > }
< aside className = { ` app-sidebar ${ drawerOpen ? 'open' : '' } ` } >
< div className = "drawer-header" > < span > М е н ю < / span > < button ref = { drawerCloseRef } type = "button" className = "icon-button" aria-label = "Закрыть меню" onClick = { ( ) = > setDrawerOpen ( false ) } > < Icon name = "close" / > < / button > < / div >
< Sidebar close = { ( ) = > setDrawerOpen ( false ) } / >
< / aside >
< main className = "main-content" id = "main-content" > { children } < / main >
< / div >
}