myYouTube/frontend/src/components/AppShell.tsx

128 lines
8.7 KiB
TypeScript
Raw Normal View History

2026-09-16 21:40:32 +00:00
import { useEffect, useRef, useState } from 'react'
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'
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)
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,
})
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
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>
{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>}
<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 ?? []
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) => <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>
}