2026-09-16 21:40:32 +00:00
import { useEffect , useRef , useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link , NavLink , useLocation , useNavigate } from 'react-router-dom'
import { getSyncStatus , listCategories } from '../api/client'
import { formatRelativeTime } from '../utils/format'
import Icon from './Icon'
function SyncIndicator() {
const [ open , setOpen ] = useState ( false )
const ref = useRef < HTMLDivElement > ( null )
const { data } = useQuery ( {
queryKey : [ 'sync-status' ] , queryFn : getSyncStatus ,
refetchInterval : ( query ) = > query . state . data ? . videos . running || query . state . data ? . subscriptions . running ? 2000 : 60_000 ,
} )
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
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 >
{ open && < div className = "sync-popover" role = "status" >
< 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 > }
< / div > }
< / div >
}
function Sidebar ( { close } : { close : ( ) = > void } ) {
const categoriesQuery = useQuery ( { queryKey : [ 'categories' ] , queryFn : listCategories } )
const categories = categoriesQuery . data ? ? [ ]
const links = [
{ to : '/' , icon : 'home' as const , text : 'В с е видео' , end : true } ,
{ 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" >
< NavLink to = { ` /category/ ${ category . id } ` } onClick = { close } className = { ( { isActive } ) = > ` sidebar-link category-link ${ isActive ? 'active' : '' } ` } > < span className = "category-dot" / > < span className = "truncate" > { category . name } < / span > < / NavLink >
{ 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 >
}