Redesign responsive MyTube interface
This commit is contained in:
parent
90eb012e0d
commit
c4a772b1e3
23 changed files with 787 additions and 1119 deletions
101
frontend/src/components/AppShell.tsx
Normal file
101
frontend/src/components/AppShell.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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: '/uncategorized', icon: 'folder' as const, text: 'Без категории' },
|
||||
{ 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>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue