myYouTube/frontend/src/components/AppShell.tsx
vrubelroman 252f38597d Split shorts from regular videos with a vertical shorts feed
- 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.
2026-09-28 09:33:35 +00:00

140 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
}