101 lines
7.2 KiB
TypeScript
101 lines
7.2 KiB
TypeScript
|
|
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>
|
|||
|
|
{categories.map((category) => <NavLink key={category.id} to={`/category/${category.id}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span><span className="sidebar-count">{category.channel_count}</span></NavLink>)}
|
|||
|
|
{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>
|
|||
|
|
}
|