- Detect shorts by duration (SHORTS_MAX_DURATION_SECONDS, default 180)
and expose is_short in feed, video and channel-activity DTOs.
- Feed gains type=all|long|short and anchor; lists show a two-mode
'Обычные | Shorts' filter (no 'Все') persisted in the URL.
- Clicking a short opens /shorts/🆔 a vertical scroll-snap feed with
autoplay for the active slide, context-aware endpoints and infinite
loading.
- Keep the sound choice across swipes, syncing with the player's own
mute control and guarding against the widget's stale isMuted() reads.
140 lines
9.6 KiB
TypeScript
140 lines
9.6 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||
import { getSyncStatus, listCategories, syncSubscriptions, syncVideos } 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 queryClient = useQueryClient()
|
||
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'] }) },
|
||
})
|
||
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
|
||
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">
|
||
<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>}
|
||
</div>}
|
||
</div>
|
||
}
|
||
|
||
function Sidebar({ close }: { close: () => void }) {
|
||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||
const categories = categoriesQuery.data ?? []
|
||
const location = useLocation()
|
||
// Keep the current view mode ("Лента | Каналы") and the video-type filter
|
||
// ("Обычные | Shorts") when switching between "Все видео" and category
|
||
// links; badges (?new=1) are feed-specific. Only `?type=short` is carried —
|
||
// "Обычные" is the default and has no parameter.
|
||
const currentParams = new URLSearchParams(location.search)
|
||
const preservedParams = new URLSearchParams()
|
||
if (currentParams.get('view') === 'channels') preservedParams.set('view', 'channels')
|
||
const activeType = currentParams.get('type') === 'short' ? 'short' : null
|
||
if (activeType) preservedParams.set('type', activeType)
|
||
const viewQuery = preservedParams.toString() ? `?${preservedParams.toString()}` : ''
|
||
const typeQuery = activeType ? '?type=short' : ''
|
||
const typeSuffix = activeType ? '&type=short' : ''
|
||
const links = [
|
||
{ to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true },
|
||
{ to: `/local${typeQuery}`, 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}${viewQuery}`} 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${typeSuffix}`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
|
||
</div>)}
|
||
{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>
|
||
}
|