Redesign responsive MyTube interface

This commit is contained in:
vrubelroman 2026-09-16 21:40:32 +00:00
parent 90eb012e0d
commit c4a772b1e3
23 changed files with 787 additions and 1119 deletions

View file

@ -2,8 +2,23 @@
Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube. Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube.
Полное техническое задание: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md). Полное техническое задание: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md).
UI/UX-спека (дизайн-система и сценарии интерфейса): [`UI_UX_spec.md`](./UI_UX_spec.md).
Текущий статус: **Phase 1 — skeleton** (FastAPI backend, PostgreSQL, Alembic, React frontend, Docker Compose, healthcheck). Текущий статус: **функционально готово** (Phases 1-6 из ТЗ + hardening по ходу тестирования). Развёрнуто и вручную протестировано на тестовом окружении (`testmyyoutube.vrubel.xyz` → hermesVM `192.168.8.173` → MeTube на mediaVM `192.168.8.177`).
### Что сделано
- Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта).
- Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`.
- Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню.
- Отписанные каналы скрываются из списка каналов.
- **Отклонение от исходного MVP ТЗ (раздел 2)**: реальная отписка от канала на YouTube (`subscriptions.delete`) реализована по явной просьбе пользователя, хотя ТЗ изначально исключало управление подписками из MVP. Из-за этого OAuth scope расширен с `youtube.readonly` до полного `youtube` (read/write) — см. `backend/app/services/google_oauth.py`.
- Удаление скачанного видео с сервера (`DELETE /api/videos/{id}/download`) — тоже сверх исходного MVP-скоупа, добавлено по запросу.
### Что осталось / сознательно отложено
- Остальные пункты раздела 35 ТЗ («Возможные улучшения после MVP») не делались: PWA, watch later, Shorts-фильтр, SponsorBlock и т.д.
- Формальный Phase 7 hardening из ТЗ (retry/recovery, тесты) закрывался по факту находок в live-тестировании, а не отдельным проходом — см. `git log` для конкретных багфиксов (flapping статусов загрузки, неверный id в MeTube `/delete`, OAuth scope mismatch).
## Стек ## Стек

View file

@ -2,7 +2,7 @@ import base64
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.auth_dependency import require_session from app.core.auth_dependency import require_session
@ -79,6 +79,7 @@ def get_feed(
uncategorized: bool = False, uncategorized: bool = False,
channel_id: int | None = None, channel_id: int | None = None,
downloaded: bool = False, downloaded: bool = False,
search: str | None = Query(None, max_length=200),
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
cursor: str | None = None, cursor: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -99,6 +100,13 @@ def get_feed(
if downloaded: if downloaded:
query = query.filter(Video.id.in_(_completed_video_ids())) query = query.filter(Video.id.in_(_completed_video_ids()))
if search and search.strip():
escaped = search.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
needle = f"%{escaped}%"
query = query.join(Channel, Channel.id == Video.channel_id).filter(
or_(Video.title.ilike(needle, escape="\\"), Channel.title.ilike(needle, escape="\\"))
)
if cursor: if cursor:
cursor_published_at, cursor_id = _decode_cursor(cursor) cursor_published_at, cursor_id = _decode_cursor(cursor)
query = query.filter( query = query.filter(

View file

@ -1,10 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <meta name="theme-color" content="#0f0f0f" />
<meta name="description" content="Персональная лента YouTube-подписок с категориями и сохранёнными видео." />
<title>MyTube — моя лента</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 243 B

Before After
Before After

View file

@ -1,478 +1,279 @@
.app { #root { min-height: 100vh; }
max-width: 960px; .app-shell { min-height: 100vh; }
margin: 0 auto; .topbar { height: 66px; position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 28px; padding: 0 26px; background: rgba(15,15,15,.96); border-bottom: 1px solid var(--border); backdrop-filter: blur(16px); }
padding: 24px; .brand { display: inline-flex; align-items: center; gap: 10px; min-width: 214px; text-decoration: none; font-size: 21px; font-weight: 750; letter-spacing: -.7px; color: var(--text); white-space: nowrap; }
text-align: left; .brand-mark { display: inline-grid; place-items: center; flex: none; width: 34px; height: 27px; border-radius: 8px; background: var(--accent); color: #07121b; }
} .brand-accent { color: var(--accent); }
.header-search { margin: 0 auto; width: min(560px, 100%); height: 40px; border: 1px solid var(--border); border-radius: 999px; display: flex; align-items: center; gap: 11px; padding: 0 14px; color: var(--muted); background: var(--surface); transition: border-color .18s, background .18s; }
.error { .header-search:focus-within { border-color: var(--accent); background: var(--surface-raised); }
color: #d33; .header-search input { flex: 1; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 14px; }
} .header-search input::placeholder { color: var(--subtle); }
.header-search button { border: 0; background: none; color: var(--muted); display: grid; place-items: center; padding: 2px; }
.connect-screen { .topbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 214px; }
text-align: center; .icon-button { display: inline-grid; place-items: center; width: 44px; height: 44px; flex: none; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: 10px; text-decoration: none; }
padding-top: 80px; .icon-button:hover { color: var(--text); background: var(--hover); }
} .sync-wrap { position: relative; }
.sync-indicator { min-height: 40px; display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 9px; padding: 0 10px; background: transparent; color: var(--muted); font-size: 12px; white-space: nowrap; }
.connect-screen button { .sync-indicator svg { color: var(--success); }
font-size: 16px; .sync-indicator.is-error svg { color: var(--error); }
padding: 10px 24px; .sync-indicator:hover { background: var(--hover); color: var(--text); }
cursor: pointer; .sync-popover { position: absolute; top: calc(100% + 12px); right: 0; width: 280px; padding: 17px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); box-shadow: 0 16px 50px rgba(0,0,0,.5); font-size: 13px; color: var(--muted); }
} .sync-popover strong { display: block; color: var(--text); margin-bottom: 12px; }
.sync-popover p + p { margin-top: 9px; }
.channels-screen header, .mobile-menu, .drawer-header, .drawer-backdrop { display: none; }
.feed-screen header { .app-sidebar { position: fixed; z-index: 30; top: 66px; left: 0; bottom: 0; width: 244px; overflow-y: auto; border-right: 1px solid var(--border); background: var(--bg); }
display: flex; .sidebar-nav { display: flex; flex-direction: column; min-height: 100%; padding: 20px 12px; }
align-items: center; .sidebar-group { display: grid; gap: 3px; padding-bottom: 21px; }
justify-content: space-between; .sidebar-group + .sidebar-group { padding-top: 21px; border-top: 1px solid var(--border); }
gap: 12px; .sidebar-heading { padding: 0 13px 11px; color: var(--subtle); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
margin-bottom: 16px; .sidebar-link { display: flex; align-items: center; gap: 14px; min-height: 42px; border-radius: 9px; padding: 0 13px; color: var(--muted); font-size: 14px; font-weight: 500; text-decoration: none; transition: background .16s, color .16s; }
} .sidebar-link:hover { color: var(--text); background: var(--hover); }
.sidebar-link.active { color: var(--text); background: var(--surface-raised); font-weight: 650; }
.channels-screen .actions, .sidebar-link.active svg { color: var(--accent); }
.feed-screen .actions { .category-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
display: flex; .category-link { gap: 15px; padding-left: 18px; }
gap: 8px; .category-link.active .category-dot { background: var(--accent); }
align-items: center; .sidebar-count { margin-left: auto; font-size: 11px; color: var(--subtle); }
} .sidebar-add { color: var(--accent); font-size: 13px; margin-top: 4px; }
.sidebar-hint { margin: 0; padding: 5px 13px 9px; color: var(--subtle); font-size: 12px; }
.channels-screen .actions a, .sidebar-bottom { margin-top: auto; padding-bottom: 0; }
.feed-screen .actions a { .truncate { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
align-self: center; .main-content { margin-left: 244px; min-width: 0; }
} .page { max-width: 1820px; margin: 0 auto; padding: 35px clamp(24px, 3vw, 54px) 75px; }
.page-heading { display: flex; justify-content: space-between; align-items: flex-end; gap: 22px; margin-bottom: 28px; }
.channels-screen input[type='text'] { .eyebrow { color: var(--accent); text-transform: uppercase; letter-spacing: .13em; font-size: 11px; font-weight: 700; margin: 0 0 8px; }
width: 100%; h1, h2, h3, p { margin-top: 0; }
padding: 8px; h1 { color: var(--text); font-size: clamp(25px, 2.25vw, 34px); line-height: 1.18; letter-spacing: -.035em; font-weight: 700; margin-bottom: 8px; }
margin-bottom: 16px; h2 { color: var(--text); font-size: 18px; line-height: 1.3; font-weight: 650; }
box-sizing: border-box; .page-subtitle { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
} button, .button-primary, .button-secondary, .button-quiet, .button-danger, .button-link { transition: background .16s, color .16s, border-color .16s; }
.button-primary, .button-secondary, .button-danger, .button-link { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; padding: 0 15px; text-decoration: none; font-size: 13px; font-weight: 650; white-space: nowrap; }
.layout { .button-primary { border: 1px solid var(--accent); background: var(--accent); color: #081521; }
display: flex; .button-primary:hover { background: #79c1ff; border-color: #79c1ff; }
gap: 20px; .button-secondary, .button-link { border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); }
align-items: flex-start; .button-secondary:hover, .button-link:hover { background: var(--hover); border-color: #555; }
} .button-quiet { min-height: 40px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; background: transparent; border: 0; color: var(--muted); border-radius: 8px; font-size: 13px; text-decoration: none; }
.button-quiet:hover { color: var(--text); background: var(--hover); }
.category-nav { .button-danger { background: #a72c2a; border: 1px solid #a72c2a; color: white; }
display: flex; .button-danger:hover { background: #c93633; }
flex-direction: column; .error { color: var(--error); }
gap: 4px; .notice { display: flex; align-items: center; gap: 10px; padding: 13px 16px; margin: 0 0 24px; border-radius: 9px; font-size: 13px; }
min-width: 160px; .error-notice { border: 1px solid rgba(255,107,104,.25); color: #ffb1ae; background: rgba(255,107,104,.09); }
flex-shrink: 0; .onboarding-notice { flex-wrap: wrap; border: 1px solid rgba(62,166,255,.25); color: var(--text); background: var(--accent-soft); }
} .onboarding-notice > span { flex: 1; min-width: 190px; }
.onboarding-notice a { display: inline-flex; align-items: center; gap: 3px; color: var(--accent); text-decoration: none; font-weight: 650; white-space: nowrap; }
.category-nav button { .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
display: flex; .spin { animation: spin 1.6s linear infinite; }
align-items: center; @keyframes spin { to { transform: rotate(360deg); } }
justify-content: space-between; .mobile-category-nav { display: none; }
gap: 8px; .video-grid { list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(265px, 1fr)); gap: 31px 18px; padding: 0; margin: 0; }
text-align: left; .video-card { min-width: 0; display: flex; flex-direction: column; }
padding: 6px 10px; .video-thumbnail { display: block; position: relative; overflow: hidden; width: 100%; aspect-ratio: 16 / 9; border-radius: 11px; background: var(--surface-raised); }
border: none; .video-thumbnail img, .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; }
background: transparent; .video-thumbnail-placeholder { background: linear-gradient(135deg, #232323, #303030); }
cursor: pointer; .duration-badge { position: absolute; bottom: 8px; right: 8px; border-radius: 4px; background: rgba(0,0,0,.82); color: white; padding: 3px 5px; font-size: 11px; font-weight: 650; line-height: 1; }
border-radius: 6px; .video-info { padding: 11px 2px 0; min-width: 0; flex: 1; display: flex; flex-direction: column; }
} .video-title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--text); text-decoration: none; font-size: 15px; font-weight: 620; line-height: 1.42; }
.video-title:hover { color: var(--accent); }
.category-nav button.active { .video-meta { display: flex; flex-wrap: wrap; gap: 2px 7px; margin-top: 7px; color: var(--muted); font-size: 12px; line-height: 1.4; }
background: var(--accent-bg, #eee); .video-channel { color: #c8c8c8; }
font-weight: 600; .video-meta span + span::before { content: '·'; margin-right: 7px; color: var(--subtle); }
} .video-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: auto; padding-top: 13px; }
.video-actions .button-link, .video-actions .button-secondary { min-height: 36px; padding: 0 11px; }
.category-nav-count { .download-inline-actions, .download-error { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 6px; }
font-size: 11px; .download-badge { position: relative; display: inline-flex; align-items: center; gap: 5px; min-height: 36px; overflow: hidden; border-radius: 7px; padding: 0 10px; background: var(--surface-raised); color: var(--muted); font-size: 12px; white-space: nowrap; }
color: #999; .download-badge.completed { color: var(--success); background: var(--success-soft); }
background: #eee; .download-badge.error { color: var(--error); background: rgba(255,107,104,.13); }
border-radius: 8px; .download-badge.downloading { color: var(--warning); background: rgba(245,166,35,.11); }
padding: 1px 6px; .download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); }
} .download-action-error { color: var(--error); font-size: 12px; }
.load-more { display: flex; margin: 34px auto 0; }
.content { .empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); }
flex: 1; .empty-state h2 { margin: 0 0 9px; font-size: 20px; }
min-width: 0; .empty-state p { font-size: 14px; line-height: 1.55; margin-bottom: 20px; }
} .empty-icon { width: 60px; height: 60px; margin: 0 auto 18px; display: grid; place-items: center; color: var(--accent); border-radius: 18px; background: var(--accent-soft); }
.skeleton-thumbnail, .skeleton-line, .channel-skeleton span, .channel-skeleton i { background: linear-gradient(90deg, #222 25%, #303030 50%, #222 75%); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; }
.channel-list { .skeleton-thumbnail { aspect-ratio: 16 / 9; border-radius: 11px; }
list-style: none; .skeleton-line { height: 13px; border-radius: 5px; width: 70%; margin: 8px 0; }
padding: 0; .skeleton-line.wide { width: 93%; }
margin: 0; .skeleton-line.short { width: 44%; }
display: grid; @keyframes shimmer { to { background-position: -200% 0; } }
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); .channels-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 18px; }
gap: 12px; .input-search { display: flex; align-items: center; gap: 10px; width: min(420px, 100%); height: 43px; border: 1px solid var(--border); border-radius: 9px; padding: 0 12px; color: var(--subtle); background: var(--surface); }
} .input-search:focus-within { border-color: var(--accent); }
.input-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 14px; }
.channel-card { .input-search input::placeholder { color: var(--subtle); }
display: flex; .category-nav { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 0 0 19px; padding-bottom: 2px; }
align-items: flex-start; .category-nav::-webkit-scrollbar, .mobile-category-nav::-webkit-scrollbar { display: none; }
gap: 10px; .category-nav button { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; flex: none; border: 1px solid var(--border); border-radius: 999px; padding: 0 13px; background: var(--surface); color: var(--muted); font-size: 12px; }
border: 1px solid #ddd; .category-nav button:hover { background: var(--hover); color: var(--text); }
border-radius: 8px; .category-nav button.active { background: var(--accent-soft); border-color: rgba(62,166,255,.55); color: var(--accent); }
padding: 8px; .category-nav-count { color: var(--subtle); font-size: 11px; }
} .category-nav button.active .category-nav-count { color: var(--accent); }
.local-filter-nav { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -3px 0 23px; padding-bottom: 3px; }
.channel-card img { .local-filter-nav::-webkit-scrollbar { display: none; }
width: 40px; .local-filter-nav a { display: inline-flex; align-items: center; gap: 8px; flex: none; min-height: 36px; border: 1px solid var(--border); border-radius: 999px; padding: 0 13px; color: var(--muted); background: var(--surface); font-size: 12px; text-decoration: none; }
height: 40px; .local-filter-nav a:hover { color: var(--text); background: var(--hover); }
border-radius: 50%; .local-filter-nav a.active { color: var(--accent); border-color: rgba(62,166,255,.55); background: var(--accent-soft); }
object-fit: cover; .local-filter-nav span { font-size: 11px; color: var(--subtle); }
} .local-filter-nav a.active span { color: var(--accent); }
.channel-list { list-style: none; max-width: 1000px; padding: 0; margin: 0; border-top: 1px solid var(--border); }
.channel-info { .channel-card { display: flex; align-items: flex-start; gap: 15px; padding: 18px 3px; border-bottom: 1px solid var(--border); }
flex: 1; .channel-card > img, .channel-avatar-placeholder { display: grid; place-items: center; width: 48px; height: 48px; flex: none; border-radius: 50%; object-fit: cover; background: #344557; color: var(--text); font-size: 18px; font-weight: 700; }
min-width: 0; .channel-info { flex: 1; min-width: 0; }
} .channel-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-height: 23px; }
.channel-title { font-size: 15px; font-weight: 650; text-decoration: none; }
.channel-title { .channel-title:hover { color: var(--accent); }
font-weight: 500; .badge { font-size: 11px; color: var(--subtle); }
color: inherit; .unsubscribe-button { margin-left: auto; min-height: 28px; border: 0; border-radius: 6px; padding: 0 8px; color: var(--subtle); background: transparent; font-size: 11px; }
text-decoration: none; .unsubscribe-button:hover { color: var(--error); background: rgba(255,107,104,.1); }
display: block; .channel-categories { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
} .chip, .chip-edit { display: inline-flex; align-items: center; gap: 5px; min-height: 29px; border: 1px solid var(--border); border-radius: 999px; padding: 0 10px; background: var(--surface-raised); color: var(--muted); text-decoration: none; font-size: 12px; }
.chip:hover { color: var(--text); }
.channel-title:hover { .removable-chip:hover { border-color: var(--error); color: var(--text); }
text-decoration: underline; .chip-edit { border-style: dashed; color: var(--accent); background: transparent; }
} .chip-edit:hover { background: var(--accent-soft); border-color: var(--accent); }
.category-popover-wrapper { position: relative; }
.badge { .category-popover { position: absolute; z-index: 20; left: 0; top: calc(100% + 8px); display: grid; gap: 4px; min-width: 230px; max-width: min(290px, calc(100vw - 40px)); max-height: 330px; overflow-y: auto; padding: 12px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface-raised); box-shadow: 0 16px 45px rgba(0,0,0,.55); }
font-size: 12px; .category-popover strong { padding: 3px 7px 7px; font-size: 13px; color: var(--text); }
color: #999; .category-popover-empty { padding: 8px; font-size: 12px; color: var(--muted); }
} .category-checkbox { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 8px; border-radius: 6px; color: var(--text); font-size: 13px; cursor: pointer; }
.category-checkbox:hover { background: var(--hover); }
.unsubscribe-button { .category-checkbox input { accent-color: var(--accent); width: 16px; height: 16px; }
font-size: 12px; .popover-create { display: flex; gap: 5px; margin-top: 7px; padding-top: 9px; border-top: 1px solid var(--border); }
padding: 2px 8px; .popover-create input { min-width: 0; flex: 1; height: 36px; border: 1px solid var(--border); border-radius: 6px; padding: 0 8px; background: var(--surface); color: var(--text); font-size: 12px; }
margin-left: 8px; .popover-create button { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 6px; background: var(--accent); color: #081521; }
border: 1px solid #ccc; .channel-loading { display: grid; gap: 1px; max-width: 1000px; }
border-radius: 10px; .channel-skeleton { display: flex; align-items: center; gap: 15px; height: 83px; border-bottom: 1px solid var(--border); }
background: transparent; .channel-skeleton span { width: 48px; height: 48px; border-radius: 50%; }
cursor: pointer; .channel-skeleton i { width: 45%; height: 14px; border-radius: 5px; }
} .back-link { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; padding: 0; margin: 0 0 20px; border: 0; background: transparent; color: var(--muted); text-decoration: none; font-size: 13px; }
.back-link:hover { color: var(--text); }
.unsubscribe-error { .video-page { max-width: 1320px; }
font-size: 12px; .player-wrapper { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 11px; background: #000; }
margin-top: 4px; .player-wrapper iframe, .player-wrapper video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
} .video-page-source { margin: 11px 0 22px; color: var(--muted); font-size: 12px; }
.video-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 25px; }
.channel-categories { .video-page-title { font-size: clamp(21px, 2vw, 29px); line-height: 1.32; margin-bottom: 9px; }
display: flex; .video-page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; }
flex-wrap: wrap; .video-page-meta a { color: var(--text); text-decoration: none; }
gap: 4px; .video-page-meta a:hover { color: var(--accent); }
margin-top: 6px; .video-page-categories { display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0 24px; }
align-items: center; .video-page-description { border-top: 1px solid var(--border); padding-top: 24px; margin-top: 24px; }
} .video-page-description h2 { font-size: 16px; margin-bottom: 12px; }
.video-page-description p { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); font-size: 14px; line-height: 1.65; }
.chip { .categories-screen { max-width: 1000px; }
font-size: 12px; .category-form { display: flex; gap: 10px; max-width: 630px; margin-bottom: 28px; }
background: #eee; .category-form input, .category-edit-form input { flex: 1; min-width: 0; height: 42px; border: 1px solid var(--border); border-radius: 8px; padding: 0 13px; background: var(--surface); color: var(--text); font-size: 14px; }
border-radius: 12px; .category-form input:focus, .category-edit-form input:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
padding: 2px 8px; .category-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); }
} .category-row { display: flex; align-items: center; gap: 14px; min-height: 70px; border-bottom: 1px solid var(--border); }
.reorder-buttons { display: flex; flex-direction: column; gap: 0; }
.chip-edit { .reorder-buttons .icon-button { width: 31px; height: 28px; }
font-size: 12px; .category-row-label { display: flex; align-items: center; gap: 13px; flex: 1; min-width: 0; }
padding: 2px 8px; .category-name { overflow: hidden; text-overflow: ellipsis; font-size: 14px; font-weight: 650; }
border-radius: 12px; .category-count { color: var(--muted); font-size: 12px; white-space: nowrap; }
border: 1px dashed #aaa; .category-row-actions { display: flex; gap: 3px; }
background: transparent; .category-row-actions .danger-hover:hover { color: var(--error); }
cursor: pointer; .category-edit-form { flex: 1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
} .modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.72); }
.confirm-dialog { width: min(100%, 430px); padding: 25px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: 0 24px 70px rgba(0,0,0,.6); }
.category-popover-wrapper { .confirm-dialog h2 { margin-bottom: 10px; }
position: relative; .confirm-dialog p { color: var(--muted); font-size: 14px; line-height: 1.55; }
display: inline-block; .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; }
} .settings-screen { max-width: 1250px; }
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.category-popover { .settings-panel { min-width: 0; padding: 23px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
position: absolute; .settings-panel-heading { display: flex; align-items: center; gap: 13px; margin-bottom: 22px; }
top: calc(100% + 4px); .settings-panel-heading h2 { margin: 0 0 2px; font-size: 16px; }
left: 0; .settings-panel-heading p { margin: 0; color: var(--muted); font-size: 12px; }
z-index: 10; .panel-icon { display: grid; place-items: center; width: 39px; height: 39px; flex: none; border-radius: 10px; background: var(--accent-soft); color: var(--accent); }
background: #fff; .settings-detail { display: flex; justify-content: space-between; gap: 15px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; }
border: 1px solid #ccc; .settings-detail span { color: var(--muted); }
border-radius: 8px; .settings-detail strong { font-weight: 550; text-align: right; overflow-wrap: anywhere; }
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); .settings-detail .success-text { color: var(--success); }
padding: 8px; .status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--success); }
display: flex; .settings-help { color: var(--muted); font-size: 13px; line-height: 1.5; }
flex-direction: column; .settings-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 15px; }
gap: 4px; .logout-button { margin-top: 24px; }
font-size: 13px; .connect-screen { min-height: 100vh; display: grid; place-items: center; padding: 25px; background: radial-gradient(circle at 50% 15%, #173046 0%, var(--bg) 42%); }
min-width: 160px; .connect-card { width: min(100%, 460px); padding: clamp(25px, 5vw, 44px); border: 1px solid var(--border); border-radius: 18px; background: var(--surface); text-align: center; box-shadow: 0 26px 75px rgba(0,0,0,.28); }
max-height: 240px; .connect-card .brand-mark { margin: 0 auto 22px; width: 52px; height: 40px; }
overflow-y: auto; .connect-card h1 { font-size: 29px; }
} .connect-card p { color: var(--muted); font-size: 14px; line-height: 1.6; }
.connect-card button { margin-top: 22px; }
.category-popover-empty { .app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; padding: 30px; color: var(--muted); text-align: center; }
color: #999; .app-loading h1 { font-size: 24px; }
white-space: nowrap; .app-loading button { min-height: 42px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); }
} @media (min-width: 1800px) { .video-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
@media (min-width: 1400px) and (max-width: 1799px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
.category-checkbox { @media (min-width: 1100px) and (max-width: 1399px) { .video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
display: flex; @media (max-width: 900px) {
align-items: center; .topbar { gap: 14px; padding: 0 18px; }
gap: 6px; .brand, .topbar-actions { min-width: 0; }
} .mobile-menu { display: inline-grid; margin-left: -8px; }
.app-sidebar { z-index: 80; top: 0; bottom: 0; width: min(290px, 85vw); visibility: hidden; transform: translateX(-100%); transition: transform .22s ease, visibility .22s; box-shadow: 18px 0 45px rgba(0,0,0,.45); }
.categories-screen { .app-sidebar.open { visibility: visible; transform: translateX(0); }
max-width: 600px; .drawer-header { height: 66px; display: flex; align-items: center; justify-content: space-between; padding: 0 18px 0 26px; color: var(--text); font-weight: 650; border-bottom: 1px solid var(--border); }
} .drawer-backdrop { display: block; position: fixed; z-index: 70; inset: 0; border: 0; padding: 0; background: rgba(0,0,0,.64); }
body.drawer-open { overflow: hidden; }
.back-link { .main-content { margin-left: 0; }
display: inline-block; .page { padding: 28px 28px 65px; }
margin-bottom: 8px; .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} .mobile-category-nav { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -5px -28px 25px; padding: 0 28px 5px; }
.mobile-category-nav a { flex: none; min-height: 35px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 0 14px; background: var(--surface); color: var(--muted); font-size: 12px; text-decoration: none; }
.category-form { .mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); }
display: flex; }
gap: 8px; @media (max-width: 620px) {
margin-bottom: 16px; .topbar { height: 112px; display: grid; grid-template-columns: 44px 1fr auto; grid-template-rows: 55px 49px; column-gap: 7px; row-gap: 0; padding: 0 15px 8px; }
} .brand { font-size: 19px; }
.brand-mark { width: 31px; height: 25px; }
.category-form input { .mobile-menu { margin-left: -7px; }
flex: 1; .header-search { grid-column: 1 / -1; grid-row: 2; width: 100%; height: 44px; }
padding: 8px; .topbar-actions { grid-column: 3; grid-row: 1; }
} .sync-indicator { width: 40px; padding: 0; justify-content: center; }
.sync-indicator span { display: none; }
.category-list { .header-settings { display: none; }
list-style: none; .page { padding: 24px 16px 58px; }
padding: 0; .page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
margin: 0; .page-heading h1 { font-size: 27px; }
display: flex; .refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
flex-direction: column; .refresh-button svg { flex: none; }
gap: 8px; .mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; }
} .video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
.video-thumbnail { border-radius: 9px; }
.category-row { .video-info { padding: 10px 2px 0; }
display: flex; .video-actions { padding-top: 11px; }
align-items: center; .video-actions .button-link, .video-actions .button-secondary, .download-badge { min-height: 44px; }
gap: 10px; .channels-toolbar { align-items: stretch; }
border: 1px solid #ddd; .manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
border-radius: 8px; .manage-link svg { flex: none; }
padding: 8px 12px; .channel-card { gap: 11px; padding: 16px 0; }
} .channel-card > img, .channel-avatar-placeholder { width: 42px; height: 42px; }
.unsubscribe-button { margin-left: 0; }
.category-row-virtual { .category-popover { left: auto; right: -5px; }
background: #f7f7f7; .category-checkbox, .chip, .chip-edit { min-height: 44px; }
border-style: dashed; .category-nav button, .mobile-category-nav a { min-height: 44px; }
color: #888; .local-filter-nav a { min-height: 44px; }
} .video-detail-head { flex-direction: column; gap: 9px; }
.player-wrapper { border-radius: 0; margin-left: -16px; width: calc(100% + 32px); }
.category-row-virtual .reorder-buttons { .video-page-source { margin-top: 9px; }
width: 14px; .settings-grid { grid-template-columns: 1fr; }
} .settings-panel { padding: 18px; }
.category-form { flex-wrap: wrap; }
.reorder-buttons { .category-form input { flex-basis: 100%; }
display: flex; .category-row { gap: 8px; }
flex-direction: column; .reorder-buttons .icon-button { width: 40px; height: 40px; }
} .category-row-label { flex-wrap: wrap; gap: 5px 10px; }
.category-count { flex-basis: 100%; margin-left: 21px; }
.reorder-buttons button { .category-edit-form input { flex-basis: 100%; }
border: none; .sync-popover { right: -7px; max-width: calc(100vw - 30px); }
background: transparent; }
cursor: pointer; @media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } }
line-height: 1; @media (pointer: coarse) {
} .video-actions .button-link, .video-actions .button-secondary, .download-badge, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button { min-height: 44px; }
.reorder-buttons .icon-button { width: 40px; height: 40px; }
.category-name { .popover-create input, .popover-create button { height: 44px; }
flex: 1; .popover-create button { width: 44px; }
font-weight: 500; }
} @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
.category-count {
font-size: 12px;
color: #999;
}
.category-edit-form {
flex: 1;
display: flex;
gap: 8px;
}
.video-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.video-card {
display: flex;
flex-direction: column;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.video-thumbnail {
position: relative;
display: block;
aspect-ratio: 16 / 9;
background: #eee;
}
.video-thumbnail img,
.video-thumbnail-placeholder {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.video-thumbnail-placeholder {
background: #ddd;
}
.duration-badge {
position: absolute;
right: 6px;
bottom: 6px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 12px;
padding: 1px 5px;
border-radius: 3px;
}
.video-info {
padding: 8px 10px;
}
.video-title {
font-weight: 500;
font-size: 14px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: inherit;
text-decoration: none;
}
.video-title:hover {
text-decoration: underline;
}
.video-meta {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.video-actions {
margin-top: 8px;
}
.button-link {
display: inline-block;
font-size: 13px;
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 6px;
text-decoration: none;
color: inherit;
}
.load-more {
display: block;
margin: 16px auto 0;
padding: 8px 20px;
cursor: pointer;
}
.video-page {
max-width: 900px;
}
.player-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
}
.player-wrapper iframe,
.player-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
background: #000;
}
.download-badge {
display: inline-block;
font-size: 12px;
padding: 3px 8px;
border-radius: 10px;
background: #eee;
}
.download-badge.completed {
background: #dcf5df;
color: #1a7f2e;
}
.download-badge.error {
background: #fbdcdc;
color: #a3221f;
}
.download-error,
.download-inline-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.video-actions button,
.video-page-meta button {
font-size: 13px;
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 6px;
background: transparent;
cursor: pointer;
}
.video-page-title {
font-size: 20px;
margin: 0 0 8px;
}
.video-page-meta {
color: #999;
font-size: 14px;
margin-bottom: 4px;
}
.video-page-meta a {
color: inherit;
}
.video-page-source {
font-size: 13px;
color: #999;
margin-bottom: 8px;
}
.video-page-categories {
display: flex;
gap: 6px;
margin-bottom: 16px;
}
.video-page-description {
white-space: pre-wrap;
font-size: 14px;
line-height: 1.5;
border-top: 1px solid #ddd;
padding-top: 16px;
}

View file

@ -1,57 +1,40 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Route, Routes } from 'react-router-dom' import { Link, Navigate, Route, Routes } from 'react-router-dom'
import { getAuthStatus } from './api/client' import { getAuthStatus } from './api/client'
import AppShell from './components/AppShell'
import Connect from './pages/Connect' import Connect from './pages/Connect'
import Feed from './pages/Feed' import Feed from './pages/Feed'
import Saved from './pages/Saved'
import Channels from './pages/Channels' import Channels from './pages/Channels'
import ChannelVideos from './pages/ChannelVideos' import ChannelVideos from './pages/ChannelVideos'
import VideoPage from './pages/VideoPage' import VideoPage from './pages/VideoPage'
import Categories from './pages/Categories' import Categories from './pages/Categories'
import Settings from './pages/Settings'
import './App.css' import './App.css'
function App() { function App() {
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({ queryKey: ['auth-status'], queryFn: getAuthStatus })
queryKey: ['auth-status'],
queryFn: getAuthStatus,
})
if (isLoading) { if (isLoading) return <div className="app-loading"><span className="brand-mark">▶</span><p>Открываем MyTube…</p></div>
return ( if (isError) return <div className="app-loading"><h1>Не удалось открыть MyTube</h1><p>Проверь подключение к серверу и обнови страницу.</p><button onClick={() => window.location.reload()}>Повторить</button></div>
<div className="app"> if (!data?.authenticated) return <Connect />
<p>Загрузка...</p>
</div>
)
}
if (isError) { return <AppShell>
return (
<div className="app">
<p className="error">Backend недоступен</p>
</div>
)
}
if (!data?.authenticated) {
return (
<div className="app">
<Connect />
</div>
)
}
return (
<div className="app">
<Routes> <Routes>
<Route path="/" element={<Feed />} /> <Route path="/" element={<Feed />} />
<Route path="/saved" element={<Saved />} /> <Route path="/category/:categoryId" element={<Feed />} />
<Route path="/uncategorized" element={<Feed />} />
<Route path="/local" element={<Feed />} />
<Route path="/search" element={<Feed />} />
<Route path="/saved" element={<Navigate to="/local" replace />} />
<Route path="/channels" element={<Channels />} /> <Route path="/channels" element={<Channels />} />
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} /> <Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
<Route path="/video/:youtubeVideoId" element={<VideoPage />} /> <Route path="/video/:youtubeVideoId" element={<VideoPage />} />
<Route path="/categories" element={<Categories />} /> <Route path="/settings" element={<Settings email={data.email} />} />
<Route path="/settings/categories" element={<Categories />} />
<Route path="/categories" element={<Navigate to="/settings/categories" replace />} />
<Route path="*" element={<div className="empty-state"><h1>Страница не найдена</h1><Link to="/">Вернуться к ленте</Link></div>} />
</Routes> </Routes>
</div> </AppShell>
)
} }
export default App export default App

View file

@ -39,6 +39,10 @@ export function getAuthStatus() {
return request<AuthStatus>('/api/auth/status') return request<AuthStatus>('/api/auth/status')
} }
export function getHealth() {
return request<{ status: string; database: string; metube: string }>('/api/health')
}
export function startGoogleAuth() { export function startGoogleAuth() {
window.location.href = '/api/auth/google/start' window.location.href = '/api/auth/google/start'
} }
@ -205,6 +209,7 @@ export function getFeed(
uncategorized?: boolean uncategorized?: boolean
channelId?: number channelId?: number
downloaded?: boolean downloaded?: boolean
search?: string
cursor?: string cursor?: string
} = {}, } = {},
) { ) {
@ -213,6 +218,7 @@ export function getFeed(
if (params.uncategorized) qs.set('uncategorized', 'true') if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.channelId != null) qs.set('channel_id', String(params.channelId)) if (params.channelId != null) qs.set('channel_id', String(params.channelId))
if (params.downloaded) qs.set('downloaded', 'true') if (params.downloaded) qs.set('downloaded', 'true')
if (params.search) qs.set('search', params.search)
if (params.cursor) qs.set('cursor', params.cursor) if (params.cursor) qs.set('cursor', params.cursor)
const suffix = qs.toString() ? `?${qs.toString()}` : '' const suffix = qs.toString() ? `?${qs.toString()}` : ''
return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`) return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`)

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

View file

@ -15,18 +15,20 @@ interface Props {
function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount, categoryCounts }: Props) { function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount, categoryCounts }: Props) {
return ( return (
<nav className="category-nav"> <nav className="category-nav" aria-label="Фильтр каналов по категориям">
<button className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}> <button type="button" aria-pressed={value === 'all'} className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
Все Все
{allCount != null && <span className="category-nav-count">{allCount}</span>} {allCount != null && <span className="category-nav-count">{allCount}</span>}
</button> </button>
<button className={value === 'uncategorized' ? 'active' : ''} onClick={() => onChange('uncategorized')}> <button type="button" aria-pressed={value === 'uncategorized'} className={value === 'uncategorized' ? 'active' : ''} onClick={() => onChange('uncategorized')}>
Без категории Без категории
{uncategorizedCount != null && <span className="category-nav-count">{uncategorizedCount}</span>} {uncategorizedCount != null && <span className="category-nav-count">{uncategorizedCount}</span>}
</button> </button>
{categories.map((category) => ( {categories.map((category) => (
<button <button
key={category.id} key={category.id}
type="button"
aria-pressed={value === category.id}
className={value === category.id ? 'active' : ''} className={value === category.id ? 'active' : ''}
onClick={() => onChange(category.id)} onClick={() => onChange(category.id)}
> >

View file

@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import type { CategoryDto, ChannelDto } from '../api/client' import type { CategoryDto, ChannelDto } from '../api/client'
import { setChannelCategories, unsubscribeChannel } from '../api/client' import { createCategory, setChannelCategories, unsubscribeChannel } from '../api/client'
import Icon from './Icon'
interface Props { interface Props {
channel: ChannelDto channel: ChannelDto
@ -11,6 +12,7 @@ interface Props {
function ChannelCard({ channel, categories }: Props) { function ChannelCard({ channel, categories }: Props) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [newName, setNewName] = useState('')
const popoverRef = useRef<HTMLDivElement>(null) const popoverRef = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -22,7 +24,9 @@ function ChannelCard({ channel, categories }: Props) {
} }
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', handleEscape)
return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape) }
}, [open]) }, [open])
const mutation = useMutation({ const mutation = useMutation({
@ -41,6 +45,7 @@ function ChannelCard({ channel, categories }: Props) {
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] }) queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['categories'] }) queryClient.invalidateQueries({ queryKey: ['categories'] })
queryClient.invalidateQueries({ queryKey: ['feed'] })
}, },
}) })
@ -51,6 +56,15 @@ function ChannelCard({ channel, categories }: Props) {
}, },
}) })
const createMutation = useMutation({
mutationFn: (name: string) => createCategory(name),
onSuccess: (category) => {
setNewName('')
queryClient.invalidateQueries({ queryKey: ['categories'] })
mutation.mutate([...channel.category_ids, category.id])
},
})
function toggleCategory(categoryId: number) { function toggleCategory(categoryId: number) {
const current = new Set(channel.category_ids) const current = new Set(channel.category_ids)
if (current.has(categoryId)) { if (current.has(categoryId)) {
@ -61,15 +75,13 @@ function ChannelCard({ channel, categories }: Props) {
mutation.mutate(Array.from(current)) mutation.mutate(Array.from(current))
} }
const assignedNames = categories.filter((c) => channel.category_ids.includes(c.id)).map((c) => c.name) const assignedCategories = categories.filter((c) => channel.category_ids.includes(c.id))
return ( return (
<li className="channel-card"> <li className="channel-card">
{channel.thumbnail_url && <img src={channel.thumbnail_url} alt={channel.title} />} {channel.thumbnail_url ? <img src={channel.thumbnail_url} alt="" loading="lazy" /> : <span className="channel-avatar-placeholder">{channel.title.charAt(0).toUpperCase()}</span>}
<div className="channel-info"> <div className="channel-info">
<Link to={`/channels/${channel.id}/videos`} className="channel-title"> <div className="channel-head"><Link to={`/channels/${channel.id}/videos`} className="channel-title">{channel.title}</Link>
{channel.title}
</Link>
{!channel.subscribed && <span className="badge">отписан</span>} {!channel.subscribed && <span className="badge">отписан</span>}
{channel.subscribed && ( {channel.subscribed && (
<button <button
@ -80,26 +92,24 @@ function ChannelCard({ channel, categories }: Props) {
} }
}} }}
disabled={unsubscribeMutation.isPending} disabled={unsubscribeMutation.isPending}
> >Отписаться</button>
Отписаться
</button>
)} )}
</div>
{unsubscribeMutation.isError && ( {unsubscribeMutation.isError && (
<p className="error unsubscribe-error">{(unsubscribeMutation.error as Error).message}</p> <p className="error unsubscribe-error">{(unsubscribeMutation.error as Error).message}</p>
)} )}
<div className="channel-categories"> <div className="channel-categories">
{assignedNames.map((name) => ( {assignedCategories.map((category) => (
<span key={name} className="chip"> <button type="button" key={category.id} className="chip removable-chip" onClick={() => toggleCategory(category.id)} title={`Убрать из категории «${category.name}»`}>{category.name}<Icon name="close" size={13} /></button>
{name}
</span>
))} ))}
<div className="category-popover-wrapper" ref={popoverRef}> <div className="category-popover-wrapper" ref={popoverRef}>
<button className="chip-edit" onClick={() => setOpen((v) => !v)}> <button type="button" className="chip-edit" onClick={() => setOpen((v) => !v)} aria-expanded={open} aria-label={`Выбрать категории для канала ${channel.title}`}>
+ Категория <Icon name="plus" size={15} /> Категория
</button> </button>
{open && ( {open && (
<div className="category-popover"> <div className="category-popover" role="group" aria-label="Категории канала">
<strong>Категории</strong>
{categories.length === 0 && <div className="category-popover-empty">Сначала создай категорию</div>} {categories.length === 0 && <div className="category-popover-empty">Сначала создай категорию</div>}
{categories.map((category) => ( {categories.map((category) => (
<label key={category.id} className="category-checkbox"> <label key={category.id} className="category-checkbox">
@ -111,6 +121,11 @@ function ChannelCard({ channel, categories }: Props) {
{category.name} {category.name}
</label> </label>
))} ))}
<form className="popover-create" onSubmit={(event) => { event.preventDefault(); if (newName.trim()) createMutation.mutate(newName.trim()) }}>
<input aria-label="Название новой категории" placeholder="Новая категория" value={newName} onChange={(event) => setNewName(event.target.value)} />
<button type="submit" aria-label="Создать категорию" disabled={!newName.trim() || createMutation.isPending}><Icon name="plus" size={18} /></button>
</form>
{(mutation.isError || createMutation.isError) && <span className="error">Не удалось изменить категории.</span>}
</div> </div>
)} )}
</div> </div>

View file

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { FeedVideoDto } from '../api/client' import type { FeedVideoDto } from '../api/client'
import { deleteDownload, downloadVideo, getDownloadStatus } from '../api/client' import { deleteDownload, downloadVideo, getDownloadStatus } from '../api/client'
import Icon from './Icon'
const ACTIVE_STATUSES = ['queued', 'downloading', 'postprocessing'] const ACTIVE_STATUSES = ['queued', 'downloading', 'postprocessing']
@ -12,9 +13,10 @@ const STATUS_LABELS: Record<string, string> = {
interface Props { interface Props {
video: FeedVideoDto video: FeedVideoDto
showDelete?: boolean
} }
function DownloadButton({ video }: Props) { function DownloadButton({ video, showDelete = false }: Props) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
// Once we start tracking a download (it was already active on load, or the // Once we start tracking a download (it was already active on load, or the
// user just clicked "Скачать"), the query stays enabled for good -- // user just clicked "Скачать"), the query stays enabled for good --
@ -64,17 +66,15 @@ function DownloadButton({ video }: Props) {
if (status === 'completed') { if (status === 'completed') {
return ( return (
<span className="download-inline-actions"> <span className="download-inline-actions">
<span className="download-badge completed">✓ На сервере</span> <span className="download-badge completed"><Icon name="check" size={15} /> На сервере</span>
<button {showDelete && <button className="button-quiet"
onClick={() => { onClick={() => {
if (confirm('Удалить скачанную копию с сервера?')) { if (confirm('Удалить скачанную копию с сервера?')) {
deleteMutation.mutate() deleteMutation.mutate()
} }
}} }}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >Удалить копию</button>}
Удалить
</button>
{deleteMutation.isError && ( {deleteMutation.isError && (
<span className="download-badge error" title={(deleteMutation.error as Error).message}> <span className="download-badge error" title={(deleteMutation.error as Error).message}>
{(deleteMutation.error as Error).message} {(deleteMutation.error as Error).message}
@ -85,28 +85,28 @@ function DownloadButton({ video }: Props) {
} }
if (status === 'downloading') { if (status === 'downloading') {
return <span className="download-badge">{percent ?? 0}%</span> return <span className="download-badge downloading"><span className="download-progress" style={{ width: `${Math.min(100, Math.max(0, percent ?? 0))}%` }} />Скачивается {percent ?? 0}%</span>
} }
if (status === 'queued' || status === 'postprocessing') { if (status === 'queued' || status === 'postprocessing') {
return <span className="download-badge">{STATUS_LABELS[status]}</span> return <span className="download-badge downloading">{STATUS_LABELS[status]}</span>
} }
if (status === 'failed') { if (status === 'failed') {
return ( return (
<span className="download-error"> <span className="download-error">
<span className="download-badge error">Ошибка</span> <button className="button-secondary" onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
<button onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
Повторить Повторить
</button> </button>
{downloadMutation.isError && <span className="sr-only" role="alert">Не удалось повторить загрузку.</span>}
</span> </span>
) )
} }
if (status === 'unknown') return <button className="button-secondary" onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}><Icon name="refresh" size={16} /> Загрузить снова</button>
return ( return (
<button onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}> <span className="download-inline-actions"><button className="button-secondary" onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}><Icon name="download" size={16} />{downloadMutation.isPending ? 'Добавляем…' : 'Скачать'}</button>{downloadMutation.isError && <span className="download-action-error" role="alert">Не удалось добавить видео в очередь.</span>}</span>
⬇ Скачать
</button>
) )
} }

View file

@ -0,0 +1,32 @@
import type { SVGProps } from 'react'
export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down'
const paths: Record<IconName, React.ReactNode> = {
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
close: <><path d="M6 6l12 12M18 6L6 18" /></>,
play: <><path d="m8 5 11 7-11 7V5Z" /></>,
download: <><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3" /></>,
home: <><path d="m3 10 9-7 9 7v10H3V10Z" /><path d="M9 20v-7h6v7" /></>,
folder: <><path d="M3 5h7l2 2h9v12H3V5Z" /></>,
server: <><rect x="3" y="4" width="18" height="7" rx="2" /><rect x="3" y="13" width="18" height="7" rx="2" /><path d="M7 7.5h.01M7 16.5h.01" /></>,
users: <><circle cx="9" cy="8" r="3" /><path d="M3 20v-2a6 6 0 0 1 12 0v2H3ZM17 5a3 3 0 0 1 0 6m1 4a5 5 0 0 1 3 5" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l-1.86 1.86A1.7 1.7 0 0 0 16 18.4a1.7 1.7 0 0 0-1.5 1.6h-5A1.7 1.7 0 0 0 8 18.4a1.7 1.7 0 0 0-1.88.34l-1.86-1.86A1.7 1.7 0 0 0 4.6 15 1.7 1.7 0 0 0 3 13.5v-3A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.88l1.86-1.86A1.7 1.7 0 0 0 8 5.6 1.7 1.7 0 0 0 9.5 4h5A1.7 1.7 0 0 0 16 5.6a1.7 1.7 0 0 0 1.88-.34l1.86 1.86A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 1.6 1.5v3a1.7 1.7 0 0 0-1.6 1.5Z" /></>,
search: <><circle cx="10.8" cy="10.8" r="6.8" /><path d="m16 16 5 5" /></>,
refresh: <><path d="M20 7v5h-5M4 17v-5h5" /><path d="M5 9a8 8 0 0 1 14-2l1 5M4 12l1 5a8 8 0 0 0 14-2" /></>,
plus: <><path d="M12 4v16M4 12h16" /></>,
check: <><path d="m4 12 5 5L20 6" /></>,
chevron: <><path d="m9 6 6 6-6 6" /></>,
arrow: <><path d="m14 6-6 6 6 6M8 12h13" /></>,
alert: <><path d="M12 3 2 21h20L12 3ZM12 9v5m0 3h.01" /></>,
logout: <><path d="M10 4H4v16h6M14 8l4 4-4 4M9 12h9" /></>,
youtube: <><rect x="2" y="5" width="20" height="14" rx="4" /><path d="m10 9 5 3-5 3V9Z" /></>,
edit: <><path d="m4 16 11-11 4 4L8 20H4v-4ZM13 7l4 4" /></>,
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6m4-6v6" /></>,
up: <><path d="m6 15 6-6 6 6" /></>,
down: <><path d="m6 9 6 6 6-6" /></>,
}
export default function Icon({ name, size = 20, ...props }: SVGProps<SVGSVGElement> & { name: IconName; size?: number }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>{paths[name]}</svg>
}

View file

@ -35,7 +35,7 @@ function Player({ video }: Props) {
}} }}
/> />
</div> </div>
<div className="video-page-source">● Локальная копия · mediaVM</div> <div className="video-page-source">Источник: локальная копия</div>
</> </>
) )
} }

View file

@ -1,7 +1,8 @@
import { Link } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client' import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format' import { formatDuration, formatRelativeTime } from '../utils/format'
import DownloadButton from './DownloadButton' import DownloadButton from './DownloadButton'
import Icon from './Icon'
interface Props { interface Props {
video: FeedVideoDto video: FeedVideoDto
@ -10,28 +11,27 @@ interface Props {
function VideoCard({ video }: Props) { function VideoCard({ video }: Props) {
const duration = formatDuration(video.duration_seconds) const duration = formatDuration(video.duration_seconds)
const href = `/video/${video.youtube_video_id}` const href = `/video/${video.youtube_video_id}`
const location = useLocation()
const from = location.pathname + location.search
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))
return ( return (
<li className="video-card"> <li className="video-card">
<Link to={href} className="video-thumbnail"> <Link to={href} state={{ from }} onClick={rememberScroll} className="video-thumbnail" aria-label={`Смотреть: ${video.title}`}>
{video.thumbnail_url ? ( {video.thumbnail_url ? (
<img src={video.thumbnail_url} alt={video.title} /> <img src={video.thumbnail_url} alt="" loading="lazy" />
) : ( ) : (
<div className="video-thumbnail-placeholder" /> <div className="video-thumbnail-placeholder" />
)} )}
{duration && <span className="duration-badge">{duration}</span>} {duration && <span className="duration-badge">{duration}</span>}
</Link> </Link>
<div className="video-info"> <div className="video-info">
<Link to={href} className="video-title" title={video.title}> <Link to={href} state={{ from }} onClick={rememberScroll} className="video-title" title={video.title}>
{video.title} {video.title}
</Link> </Link>
<div className="video-meta"> <div className="video-meta"><span className="video-channel">{video.channel.title}</span><span>{formatRelativeTime(video.published_at)}</span></div>
{video.channel.title} · {formatRelativeTime(video.published_at)}
</div>
<div className="video-actions"> <div className="video-actions">
<Link to={href} className="button-link"> <Link to={href} state={{ from }} onClick={rememberScroll} className="button-link"><Icon name="play" size={16} /> Смотреть</Link>
Смотреть
</Link>
<DownloadButton video={video} /> <DownloadButton video={video} />
</div> </div>
</div> </div>

View file

@ -1,111 +1,32 @@
:root { :root {
--text: #6b6375; font-family: Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--text-h: #08060d; color: #f1f1f1;
--bg: #fff; background: #0f0f0f;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; --bg: #0f0f0f;
--surface: #181818;
@media (max-width: 1024px) { --surface-raised: #202020;
font-size: 16px; --hover: #292929;
} --border: #303030;
} --text: #f1f1f1;
--muted: #aaa;
@media (prefers-color-scheme: dark) { --subtle: #777;
:root { --accent: #3ea6ff;
--text: #9ca3af; --accent-soft: rgba(62, 166, 255, .14);
--text-h: #f3f4f6; --success: #4bc467;
--bg: #16171d; --success-soft: rgba(75, 196, 103, .13);
--border: #2e303a; --warning: #f5a623;
--code-bg: #1f2028; --error: #ff6b68;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
} }
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: .55; }
a { color: inherit; }
img { max-width: 100%; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
::selection { background: var(--accent); color: #06111c; }

View file

@ -1,158 +1,45 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import { createCategory, deleteCategory, listCategories, reorderCategories, updateCategory } from '../api/client'
createCategory, import Icon from '../components/Icon'
deleteCategory,
listCategories,
listChannels,
reorderCategories,
updateCategory,
} from '../api/client'
function Categories() { function Categories() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [editingName, setEditingName] = useState('') const [editingName, setEditingName] = useState('')
const [deletingId, setDeletingId] = useState<number | null>(null)
const cancelDeleteRef = useRef<HTMLButtonElement>(null)
useEffect(() => { if (deletingId !== null) cancelDeleteRef.current?.focus() }, [deletingId])
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const uncategorizedQuery = useQuery({ const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) }
queryKey: ['channels', 'uncategorized-count'], const createMutation = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewName(''); invalidate() } })
queryFn: () => listChannels({ subscribed: true, uncategorized: true }), const updateMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), onSuccess: () => { setEditingId(null); invalidate() } })
}) const deleteMutation = useMutation({ mutationFn: deleteCategory, onSuccess: () => { setDeletingId(null); invalidate() } })
const reorderMutation = useMutation({ mutationFn: reorderCategories, onSuccess: invalidate })
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['categories'] })
queryClient.invalidateQueries({ queryKey: ['channels'] })
}
const createMutation = useMutation({
mutationFn: (name: string) => createCategory(name),
onSuccess: () => {
setNewName('')
invalidate()
},
})
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name),
onSuccess: () => {
setEditingId(null)
invalidate()
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteCategory(id),
onSuccess: invalidate,
})
const reorderMutation = useMutation({
mutationFn: (ids: number[]) => reorderCategories(ids),
onSuccess: invalidate,
})
const categories = categoriesQuery.data ?? [] const categories = categoriesQuery.data ?? []
const deleting = categories.find((category) => category.id === deletingId)
function move(index: number, direction: -1 | 1) { function move(index: number, direction: -1 | 1) {
const target = index + direction const target = index + direction
if (target < 0 || target >= categories.length) return if (target < 0 || target >= categories.length) return
const ids = categories.map((c) => c.id) const ids = categories.map((category) => category.id)
;[ids[index], ids[target]] = [ids[target], ids[index]] ;[ids[index], ids[target]] = [ids[target], ids[index]]
reorderMutation.mutate(ids) reorderMutation.mutate(ids)
} }
return <section className="page categories-screen">
return ( <Link to="/settings" className="back-link"><Icon name="arrow" size={17} /> Настройки</Link>
<div className="categories-screen"> <div className="page-heading"><div><p className="eyebrow">Моя лента</p><h1>Категории</h1><p className="page-subtitle">Создавай темы и меняй порядок в меню.</p></div></div>
<Link to="/" className="back-link"> <form className="category-form" onSubmit={(event) => { event.preventDefault(); if (newName.trim()) createMutation.mutate(newName.trim()) }}><label className="sr-only" htmlFor="category-name">Название новой категории</label><input id="category-name" placeholder="Название новой категории" value={newName} onChange={(event) => setNewName(event.target.value)} /><button type="submit" className="button-primary" disabled={!newName.trim() || createMutation.isPending}><Icon name="plus" size={17} /> Создать</button></form>
← Каналы {createMutation.isError && <p className="error" role="alert">Не удалось создать категорию. Возможно, такое имя уже есть.</p>}
</Link> {reorderMutation.isError && <p className="error" role="alert">Не удалось изменить порядок категорий.</p>}
<h1>Категории</h1> {categoriesQuery.isLoading && <div className="channel-loading">{Array.from({ length: 3 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
{categoriesQuery.isError && <div className="empty-state" role="alert"><h2>Не удалось загрузить категории</h2><button className="button-primary" onClick={() => categoriesQuery.refetch()}>Повторить</button></div>}
<form {categories.length > 0 && <ul className="category-list">{categories.map((category, index) => <li key={category.id} className="category-row"><div className="reorder-buttons"><button type="button" className="icon-button" onClick={() => move(index, -1)} disabled={index === 0 || reorderMutation.isPending} aria-label={`Поднять ${category.name}`}><Icon name="up" size={18} /></button><button type="button" className="icon-button" onClick={() => move(index, 1)} disabled={index === categories.length - 1 || reorderMutation.isPending} aria-label={`Опустить ${category.name}`}><Icon name="down" size={18} /></button></div>{editingId === category.id ? <form className="category-edit-form" onSubmit={(event) => { event.preventDefault(); if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() }) }}><input aria-label="Новое название категории" value={editingName} onChange={(event) => setEditingName(event.target.value)} autoFocus /><button className="button-primary" type="submit" disabled={!editingName.trim() || updateMutation.isPending}>Сохранить</button><button className="button-quiet" type="button" onClick={() => setEditingId(null)}>Отмена</button></form> : <><div className="category-row-label"><span className="category-dot" /><span className="category-name">{category.name}</span><span className="category-count">{category.channel_count} каналов</span></div><div className="category-row-actions"><button type="button" className="icon-button" aria-label={`Переименовать ${category.name}`} title="Переименовать" onClick={() => { setEditingId(category.id); setEditingName(category.name) }}><Icon name="edit" size={18} /></button><button type="button" className="icon-button danger-hover" aria-label={`Удалить ${category.name}`} title="Удалить" onClick={() => setDeletingId(category.id)}><Icon name="trash" size={18} /></button></div></>}</li>)}</ul>}
onSubmit={(e) => { {updateMutation.isError && <p className="error" role="alert">Не удалось переименовать категорию.</p>}
e.preventDefault() {!categoriesQuery.isLoading && !categoriesQuery.isError && categories.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name="folder" size={28} /></span><h2>Категорий пока нет</h2><p>Создай первую тему и добавь в неё каналы.</p></div>}
if (newName.trim()) createMutation.mutate(newName.trim()) {deleting && <div className="modal-backdrop" onClick={() => setDeletingId(null)} onKeyDown={(event) => { if (event.key === 'Escape') setDeletingId(null) }}><div className="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="delete-category-title" aria-describedby="delete-category-description" onClick={(event) => event.stopPropagation()}><h2 id="delete-category-title">Удалить «{deleting.name}»?</h2><p id="delete-category-description">Каналы останутся в подписках. Они просто больше не будут относиться к этой категории.</p>{deleteMutation.isError && <p className="error" role="alert">Не удалось удалить категорию.</p>}<div className="dialog-actions"><button ref={cancelDeleteRef} className="button-secondary" onClick={() => setDeletingId(null)}>Отмена</button><button className="button-danger" onClick={() => deleteMutation.mutate(deleting.id)} disabled={deleteMutation.isPending}>Удалить категорию</button></div></div></div>}
}} </section>
className="category-form"
>
<input
type="text"
placeholder="Новая категория"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<button type="submit" disabled={!newName.trim() || createMutation.isPending}>
Создать
</button>
</form>
{createMutation.isError && <p className="error">{(createMutation.error as Error).message}</p>}
<ul className="category-list">
<li className="category-row category-row-virtual">
<div className="reorder-buttons" />
<span className="category-name">Без категории</span>
<span className="category-count">
{uncategorizedQuery.isLoading ? '…' : `${uncategorizedQuery.data?.length ?? 0} канал(ов)`}
</span>
</li>
{categories.map((category, index) => (
<li key={category.id} className="category-row">
<div className="reorder-buttons">
<button onClick={() => move(index, -1)} disabled={index === 0} aria-label="Выше">
↑
</button>
<button onClick={() => move(index, 1)} disabled={index === categories.length - 1} aria-label="Ниже">
↓
</button>
</div>
{editingId === category.id ? (
<form
className="category-edit-form"
onSubmit={(e) => {
e.preventDefault()
if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() })
}}
>
<input value={editingName} onChange={(e) => setEditingName(e.target.value)} autoFocus />
<button type="submit">Сохранить</button>
<button type="button" onClick={() => setEditingId(null)}>
Отмена
</button>
</form>
) : (
<>
<span className="category-name">{category.name}</span>
<span className="category-count">{category.channel_count} канал(ов)</span>
<button
onClick={() => {
setEditingId(category.id)
setEditingName(category.name)
}}
>
Переименовать
</button>
<button
onClick={() => {
if (confirm(`Удалить категорию "${category.name}"? Каналы останутся.`)) {
deleteMutation.mutate(category.id)
}
}}
>
Удалить
</button>
</>
)}
</li>
))}
</ul>
{categories.length === 0 && !categoriesQuery.isLoading && <p>Категорий пока нет.</p>}
</div>
)
} }
export default Categories export default Categories

View file

@ -1,56 +1,24 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { getChannel, getFeed } from '../api/client' import { getChannel, getFeed } from '../api/client'
import Icon from '../components/Icon'
import VideoCard from '../components/VideoCard' import VideoCard from '../components/VideoCard'
function ChannelVideos() { function ChannelVideos() {
const { channelId } = useParams<{ channelId: string }>() const { channelId } = useParams<{ channelId: string }>()
const id = Number(channelId) const id = Number(channelId)
const valid = Number.isInteger(id) && id > 0
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id) }) const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid })
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id], queryFn: ({ pageParam }) => getFeed({ channelId: id, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
const feedQuery = useInfiniteQuery({
queryKey: ['feed', 'channel', id],
queryFn: ({ pageParam }) => getFeed({ channelId: id, cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
})
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? [] const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
return <section className="page feed-screen">
return ( <Link to="/channels" className="back-link"><Icon name="arrow" size={17} /> Каналы</Link>
<div className="feed-screen"> <div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">Последние ролики из твоих подписок.</p></div></div>
<header> {(channelQuery.isError || feedQuery.isError || !valid) && <div className="empty-state" role="alert"><h2>Не удалось открыть канал</h2><Link to="/channels" className="button-primary">К списку каналов</Link></div>}
<div> {feedQuery.isLoading && <div className="video-grid">{Array.from({ length: 6 }, (_, index) => <div className="video-card skeleton-card" key={index}><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /></div></div>)}</div>}
<Link to="/channels" className="back-link"> {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
← Каналы {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><h2>Пока нет видео</h2><p>Обнови ленту, чтобы получить новые ролики канала.</p></div>}
</Link> {feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
<h1>{channelQuery.data?.title ?? 'Канал'}</h1> </section>
</div>
</header>
{feedQuery.isLoading && <p>Загрузка...</p>}
{feedQuery.isError && <p className="error">Не удалось загрузить видео</p>}
<ul className="video-grid">
{items.map((video) => (
<VideoCard key={video.youtube_video_id} video={video} />
))}
</ul>
{!feedQuery.isLoading && items.length === 0 && <p>У этого канала пока нет синхронизированных видео.</p>}
{feedQuery.hasNextPage && (
<button
className="load-more"
onClick={() => feedQuery.fetchNextPage()}
disabled={feedQuery.isFetchingNextPage}
>
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
</button>
)}
</div>
)
} }
export default ChannelVideos export default ChannelVideos

View file

@ -1,121 +1,49 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { getSyncStatus, listCategories, listChannels, logout, syncSubscriptions } from '../api/client' import { getSyncStatus, listCategories, listChannels, syncSubscriptions } from '../api/client'
import ChannelCard from '../components/ChannelCard' import ChannelCard from '../components/ChannelCard'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
import Icon from '../components/Icon'
function Channels() { function Channels() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [filter, setFilter] = useState<CategoryFilter>('all') const [filter, setFilter] = useState<CategoryFilter>('all')
const queryClient = useQueryClient() const queryClient = useQueryClient()
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const allChannelsQuery = useQuery({ queryKey: ['channels', 'all-for-counts'], queryFn: () => listChannels({ subscribed: true }) })
const allChannelsQuery = useQuery({
queryKey: ['channels', 'all-for-counts'],
queryFn: () => listChannels({ subscribed: true }),
})
const allChannels = allChannelsQuery.data ?? []
const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length
const channelsQuery = useQuery({ const channelsQuery = useQuery({
queryKey: ['channels', filter], queryKey: ['channels', filter],
queryFn: () => queryFn: () => listChannels({ subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized' }),
listChannels({
subscribed: true,
categoryId: typeof filter === 'number' ? filter : undefined,
uncategorized: filter === 'uncategorized',
}),
}) })
const syncStatusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.subscriptions.running ? 2000 : 60_000 })
const filteredChannels = useMemo(() => { const syncMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['sync-status'] }) } })
const data = channelsQuery.data ?? [] const finishedAt = syncStatusQuery.data?.subscriptions.finished_at
if (!search.trim()) return data const previousFinished = useRef(finishedAt)
const needle = search.trim().toLowerCase() useEffect(() => {
return data.filter((c) => c.title.toLowerCase().includes(needle)) if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['channels'] })
}, [channelsQuery.data, search]) previousFinished.current = finishedAt
}, [finishedAt, queryClient])
const syncStatusQuery = useQuery({ const allChannels = allChannelsQuery.data ?? []
queryKey: ['sync-status'], const uncategorizedCount = allChannels.filter((channel) => channel.category_ids.length === 0).length
queryFn: getSyncStatus,
refetchInterval: (query) => (query.state.data?.subscriptions.running ? 1500 : false),
})
const syncMutation = useMutation({
mutationFn: syncSubscriptions,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
},
})
const logoutMutation = useMutation({
mutationFn: logout,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
},
})
const running = syncStatusQuery.data?.subscriptions.running
const categories = categoriesQuery.data ?? [] const categories = categoriesQuery.data ?? []
const filteredChannels = useMemo(() => {
const needle = search.trim().toLocaleLowerCase()
return (channelsQuery.data ?? []).filter((channel) => channel.title.toLocaleLowerCase().includes(needle))
}, [channelsQuery.data, search])
const running = syncStatusQuery.data?.subscriptions.running
return ( return <section className="page channels-screen">
<div className="channels-screen"> <div className="page-heading"><div><p className="eyebrow">Мои подписки</p><h1>Каналы</h1><p className="page-subtitle">Распределяй подписки по темам, чтобы лента была удобнее.</p></div><button className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={running || syncMutation.isPending}><Icon name="refresh" size={17} className={running ? 'spin' : ''} />{running ? 'Обновляется' : 'Обновить'}</button></div>
<header> {syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление подписок.</div>}
<h1>Каналы</h1> {syncStatusQuery.data?.subscriptions.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление подписок завершилось ошибкой.</div>}
<div className="actions"> <div className="channels-toolbar"><label className="input-search"><Icon name="search" size={19} /><input type="search" placeholder="Поиск каналов" aria-label="Поиск каналов" value={search} onChange={(event) => setSearch(event.target.value)} /></label><Link className="button-secondary manage-link" to="/settings/categories"><Icon name="folder" size={17} />Управлять категориями</Link></div>
<Link to="/">Лента</Link> <CategoryNav categories={categories} value={filter} onChange={setFilter} allCount={allChannels.length} uncategorizedCount={uncategorizedCount} />
<Link to="/saved">Сохранённые</Link> {channelsQuery.isLoading && <div className="channel-loading" aria-label="Загрузка каналов">{Array.from({ length: 6 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
<Link to="/categories">Категории</Link> {channelsQuery.isError && <div className="empty-state" role="alert"><h2>Не удалось загрузить каналы</h2><button className="button-primary" onClick={() => channelsQuery.refetch()}>Повторить</button></div>}
<button onClick={() => syncMutation.mutate()} disabled={running}> {!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length > 0 && <ul className="channel-list">{filteredChannels.map((channel) => <ChannelCard key={channel.id} channel={channel} categories={categories} />)}</ul>}
{running ? 'Синхронизация...' : 'Обновить сейчас'} {!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={running ? 'refresh' : 'users'} size={28} /></span><h2>{running ? 'Импортируем каналы' : search ? 'Каналы не найдены' : filter === 'uncategorized' ? 'Все каналы распределены' : 'Здесь пока нет каналов'}</h2><p>{running ? 'Список подписок появится здесь автоматически.' : search ? 'Попробуй другое название.' : filter === 'all' ? 'Обнови подписки, чтобы импортировать свои каналы.' : 'Выбери другую категорию или назначь её каналам.'}</p></div>}
</button> </section>
<button onClick={() => logoutMutation.mutate()}>Выйти</button>
</div>
</header>
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>}
{syncStatusQuery.data?.subscriptions.status === 'failed' && (
<p className="error">Последняя синхронизация завершилась ошибкой: {syncStatusQuery.data.subscriptions.error}</p>
)}
<div className="layout">
<CategoryNav
categories={categories}
value={filter}
onChange={setFilter}
allCount={allChannels.length}
uncategorizedCount={uncategorizedCount}
/>
<div className="content">
<input
type="text"
placeholder="Поиск по названию канала"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{channelsQuery.isLoading && <p>Загрузка...</p>}
{channelsQuery.isError && <p className="error">Не удалось загрузить каналы</p>}
<ul className="channel-list">
{filteredChannels.map((channel) => (
<ChannelCard key={channel.id} channel={channel} categories={categories} />
))}
</ul>
{channelsQuery.data && channelsQuery.data.length === 0 && (
<p>Каналы не найдены{filter === 'all' ? '. Нажми "Обновить сейчас".' : ' в этой категории.'}</p>
)}
{channelsQuery.data && channelsQuery.data.length > 0 && filteredChannels.length === 0 && (
<p>Ничего не найдено по запросу "{search}".</p>
)}
</div>
</div>
</div>
)
} }
export default Channels export default Channels

View file

@ -1,17 +1,19 @@
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { authErrorMessage, startGoogleAuth } from '../api/client' import { authErrorMessage, startGoogleAuth } from '../api/client'
import Icon from '../components/Icon'
function Connect() { function Connect() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const authError = searchParams.get('auth_error') const authError = searchParams.get('auth_error')
return ( return (
<div className="connect-screen"> <div className="connect-screen"><div className="connect-card">
<h1>MyYouTube</h1> <span className="brand-mark"><Icon name="play" size={26} /></span>
<p>Персональная лента YouTube-подписок с категориями.</p> <h1>Твоя лента, твои темы</h1>
<p>Подключи YouTube, чтобы видеть видео своих подписок и распределять каналы по категориям. Доступен только твой аккаунт.</p>
{authError && <p className="error">{authErrorMessage(authError)}</p>} {authError && <p className="error">{authErrorMessage(authError)}</p>}
<button onClick={startGoogleAuth}>Подключить YouTube</button> <button className="button-primary" onClick={startGoogleAuth}><Icon name="youtube" size={18} /> Подключить YouTube</button>
</div> </div></div>
) )
} }

View file

@ -1,116 +1,108 @@
import { useMutation, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom' import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getFeed, getSyncStatus, listCategories, listChannels, logout, syncVideos } from '../api/client' import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels, syncVideos } from '../api/client'
import Icon from '../components/Icon'
import VideoCard from '../components/VideoCard' import VideoCard from '../components/VideoCard'
import { useState } from 'react'
function VideoSkeleton() {
return <li className="video-card skeleton-card" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /><div className="skeleton-line short" /></div></li>
}
function Feed() { function Feed() {
const [filter, setFilter] = useState<CategoryFilter>('all') const location = useLocation()
const { categoryId } = useParams<{ categoryId: string }>()
const [searchParams] = useSearchParams()
const search = location.pathname === '/search' ? searchParams.get('q')?.trim() ?? '' : ''
const categoryNumber = categoryId && /^\d+$/.test(categoryId) ? Number(categoryId) : undefined
const isLocal = location.pathname === '/local'
const isUncategorized = location.pathname === '/uncategorized'
const localCategoryRaw = isLocal ? searchParams.get('category') : null
const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined
const localUncategorized = isLocal && searchParams.get('uncategorized') === 'true'
const queryClient = useQueryClient() const queryClient = useQueryClient()
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? [] const category = categoriesQuery.data?.find((item) => item.id === categoryNumber)
const savedCountsQuery = useQuery({ queryKey: ['feed', 'saved-counts'], queryFn: getSavedCounts, enabled: isLocal })
const allChannelsQuery = useQuery({ const countQuery = useQuery({
queryKey: ['channels', 'all-for-counts'], queryKey: ['channels', isUncategorized ? 'uncategorized-count' : 'all-for-counts'],
queryFn: () => listChannels({ subscribed: true }), queryFn: () => listChannels({ subscribed: true, uncategorized: isUncategorized }),
enabled: isUncategorized || (!categoryNumber && !isLocal && !search),
}) })
const allChannels = allChannelsQuery.data ?? []
const uncategorizedCount = allChannels.filter((c) => c.category_ids.length === 0).length
const feedQuery = useInfiniteQuery({ const feedQuery = useInfiniteQuery({
queryKey: ['feed', filter], queryKey: ['feed', location.pathname, search],
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) => getFeed({
getFeed({ categoryId: isLocal ? localCategory : categoryNumber,
categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: isUncategorized || localUncategorized,
uncategorized: filter === 'uncategorized', downloaded: isLocal,
search,
cursor: pageParam, cursor: pageParam,
}), }),
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
enabled: !categoryId || categoryNumber !== undefined,
}) })
const syncStatusQuery = useQuery({ const syncStatusQuery = useQuery({
queryKey: ['sync-status'], queryKey: ['sync-status'], queryFn: getSyncStatus,
queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.videos.running ? 2000 : 60_000,
refetchInterval: (query) => (query.state.data?.videos.running ? 1500 : false),
}) })
const finishedAt = syncStatusQuery.data?.videos.finished_at
const previousFinished = useRef(finishedAt)
useEffect(() => {
if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['feed'] })
previousFinished.current = finishedAt
}, [finishedAt, queryClient])
const subscriptionsFinished = syncStatusQuery.data?.subscriptions.finished_at
const previousSubscriptionsFinished = useRef(subscriptionsFinished)
useEffect(() => {
if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) queryClient.invalidateQueries({ queryKey: ['channels'] })
previousSubscriptionsFinished.current = subscriptionsFinished
}, [subscriptionsFinished, queryClient])
const syncMutation = useMutation({ const syncMutation = useMutation({
mutationFn: syncVideos, mutationFn: syncVideos,
onSuccess: () => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) },
queryClient.invalidateQueries({ queryKey: ['feed'] })
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
},
}) })
const title = search ? `Поиск: ${search}` : isLocal ? 'На сервере' : isUncategorized ? 'Без категории' : categoryId ? category?.name ?? 'Категория' : 'Все видео'
const logoutMutation = useMutation({ const subtitle = search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
mutationFn: logout,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
},
})
const running = syncStatusQuery.data?.videos.running
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? [] const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
useEffect(() => {
if (feedQuery.isLoading) return
const key = `feed-scroll:${location.pathname + location.search}`
const saved = sessionStorage.getItem(key)
if (saved) {
sessionStorage.removeItem(key)
requestAnimationFrame(() => window.scrollTo(0, Number(saved)))
}
}, [feedQuery.isLoading, location.pathname, location.search])
const hasInvalidCategory = Boolean(categoryId && !categoryNumber)
return ( return <section className="page feed-screen">
<div className="feed-screen"> <div className="page-heading">
<header> <div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}</p></div>
<h1>Лента</h1> {!isLocal && !search && <button type="button" className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={syncMutation.isPending || syncStatusQuery.data?.videos.running}><Icon name="refresh" size={17} className={syncStatusQuery.data?.videos.running ? 'spin' : ''} />{syncStatusQuery.data?.videos.running ? 'Обновляется' : 'Обновить'}</button>}
<div className="actions">
<Link to="/saved">Сохранённые</Link>
<Link to="/channels">Каналы</Link>
<Link to="/categories">Категории</Link>
<button onClick={() => syncMutation.mutate()} disabled={running}>
{running ? 'Синхронизация...' : 'Обновить сейчас'}
</button>
<button onClick={() => logoutMutation.mutate()}>Выйти</button>
</div> </div>
</header> <nav className="mobile-category-nav" aria-label="Категории">
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link>
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>} <Link to="/uncategorized" className={isUncategorized ? 'active' : ''}>Без категории</Link>
{syncStatusQuery.data?.videos.status === 'failed' && ( <Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link>
<p className="error">Последняя синхронизация видео завершилась ошибкой: {syncStatusQuery.data.videos.error}</p> {categoriesQuery.data?.map((item) => <Link key={item.id} to={`/category/${item.id}`} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
)} </nav>
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
<div className="layout"> <Link to="/local" className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
<CategoryNav <Link to="/local?uncategorized=true" className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
categories={categories} {categoriesQuery.data?.map((item) => <Link key={item.id} to={`/local?category=${item.id}`} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)}
value={filter} </nav>}
onChange={setFilter} {syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление видео.</div>}
allCount={allChannels.length} {syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>}
uncategorizedCount={uncategorizedCount} {location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 && <div className="notice onboarding-notice"><Icon name="folder" size={19} /><span>Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.</span><Link to="/channels">Настроить категории <Icon name="chevron" size={15} /></Link></div>}
/> {hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? <div className="empty-state"><h2>Категория не найдена</h2><Link className="button-primary" to="/">Ко всем видео</Link></div> : <>
{feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>}
<div className="content"> {feedQuery.isError && <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить видео</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => feedQuery.refetch()}>Повторить</button></div>}
{feedQuery.isLoading && <p>Загрузка...</p>} {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
{feedQuery.isError && <p className="error">Не удалось загрузить ленту</p>} {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncStatusQuery.data?.subscriptions.running && !syncStatusQuery.data?.videos.running && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>}
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
<ul className="video-grid"> </>}
{items.map((video) => ( </section>
<VideoCard key={video.youtube_video_id} video={video} />
))}
</ul>
{!feedQuery.isLoading && items.length === 0 && (
<p>Видео пока нет. Нажми "Обновить сейчас" или проверь, есть ли каналы в этой категории.</p>
)}
{feedQuery.hasNextPage && (
<button
className="load-more"
onClick={() => feedQuery.fetchNextPage()}
disabled={feedQuery.isFetchingNextPage}
>
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
</button>
)}
</div>
</div>
</div>
)
} }
export default Feed export default Feed

View file

@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate } from 'react-router-dom'
import { getHealth, getSyncStatus, logout, startGoogleAuth, syncSubscriptions, syncVideos } from '../api/client'
import { formatRelativeTime } from '../utils/format'
import Icon from '../components/Icon'
function Settings({ email }: { email: string | null }) {
const queryClient = useQueryClient()
const navigate = useNavigate()
const statusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: 30_000 })
const healthQuery = useQuery({ queryKey: ['health'], queryFn: getHealth, staleTime: 0 })
const subscriptionsMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) })
const videosMutation = useMutation({ mutationFn: syncVideos, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) })
const logoutMutation = useMutation({ mutationFn: logout, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['auth-status'] }); navigate('/') } })
const subscriptions = statusQuery.data?.subscriptions
const videos = statusQuery.data?.videos
return <section className="page settings-screen">
<div className="page-heading"><div><p className="eyebrow">Аккаунт и сервисы</p><h1>Настройки</h1><p className="page-subtitle">Подключения и обновление вашей ленты.</p></div></div>
<div className="settings-grid">
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="youtube" size={21} /></span><div><h2>YouTube</h2><p>Подписки и новые видео</p></div></div><div className="settings-detail"><span>Аккаунт</span><strong>{email ?? 'Неизвестен'}</strong></div><div className="settings-detail"><span>Статус</span><strong className="success-text"><span className="status-dot" /> Подключён</strong></div><div className="settings-detail"><span>Подписки обновлены</span><strong>{subscriptions?.finished_at ? formatRelativeTime(subscriptions.finished_at) : 'Ещё не обновлялись'}</strong></div><div className="settings-actions"><button className="button-secondary" onClick={() => subscriptionsMutation.mutate()} disabled={subscriptionsMutation.isPending || subscriptions?.running}><Icon name="refresh" size={16} />Обновить подписки</button><button className="button-quiet" onClick={startGoogleAuth}>Переподключить</button></div>{subscriptionsMutation.isError && <p className="error" role="alert">Не удалось запустить обновление.</p>}</section>
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="server" size={21} /></span><div><h2>Локальные видео</h2><p>Сохранённые копии</p></div></div><div className="settings-detail"><span>Соединение</span><strong className={healthQuery.data?.metube === 'ok' ? 'success-text' : 'error'}>{healthQuery.isLoading ? 'Проверяем…' : healthQuery.data?.metube === 'ok' ? 'Доступно' : 'Недоступно'}</strong></div><p className="settings-help">Скачанные видео доступны во вкладке «На сервере».</p><div className="settings-actions"><button className="button-secondary" onClick={() => healthQuery.refetch()} disabled={healthQuery.isFetching}><Icon name="refresh" size={16} />Проверить соединение</button><Link className="button-quiet" to="/local">Открыть видео</Link></div></section>
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="refresh" size={21} /></span><div><h2>Синхронизация</h2><p>Видео в вашей ленте</p></div></div><div className="settings-detail"><span>Видео обновлены</span><strong>{videos?.finished_at ? formatRelativeTime(videos.finished_at) : 'Ещё не обновлялись'}</strong></div><div className="settings-detail"><span>Состояние</span><strong className={videos?.status === 'failed' ? 'error' : ''}>{videos?.running ? 'Обновляются' : videos?.status === 'failed' ? 'Ошибка' : videos?.status === 'completed' ? 'Успешно' : 'Ещё не запускалась'}</strong></div><div className="settings-actions"><button className="button-secondary" onClick={() => videosMutation.mutate()} disabled={videosMutation.isPending || videos?.running}><Icon name="refresh" size={16} />Обновить видео</button></div>{videosMutation.isError && <p className="error" role="alert">Не удалось запустить обновление.</p>}</section>
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="folder" size={21} /></span><div><h2>Категории</h2><p>Организация каналов</p></div></div><p className="settings-help">Переименовывай категории и меняй их порядок.</p><div className="settings-actions"><Link className="button-secondary" to="/settings/categories"><Icon name="folder" size={16} />Управлять категориями</Link></div></section>
</div>
<button className="button-quiet logout-button" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}><Icon name="logout" size={17} /> Выйти из аккаунта</button>
</section>
}
export default Settings

View file

@ -1,73 +1,31 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom' import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import { getVideo } from '../api/client' import { getVideo } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format' import { formatDuration, formatRelativeTime } from '../utils/format'
import DownloadButton from '../components/DownloadButton' import DownloadButton from '../components/DownloadButton'
import Icon from '../components/Icon'
import Player from '../components/Player' import Player from '../components/Player'
function VideoPage() { function VideoPage() {
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>() const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
const location = useLocation()
const navigate = useNavigate()
const from = (location.state as { from?: string } | null)?.from
const videoQuery = useQuery({ queryKey: ['video', youtubeVideoId], queryFn: () => getVideo(youtubeVideoId!), enabled: !!youtubeVideoId })
const back = () => { if (from && window.history.length > 1) navigate(-1); else navigate(from ?? '/') }
const videoQuery = useQuery({ if (videoQuery.isLoading) return <section className="page video-page"><div className="player-wrapper skeleton-thumbnail" /><div className="skeleton-line wide" /><div className="skeleton-line" /></section>
queryKey: ['video', youtubeVideoId], if (videoQuery.isError || !videoQuery.data) return <section className="page video-page"><button className="back-link" onClick={back}><Icon name="arrow" size={17} /> Назад</button><div className="empty-state"><h1>Видео не найдено</h1><p>Возможно, оно больше не доступно в ленте.</p></div></section>
queryFn: () => getVideo(youtubeVideoId!),
enabled: !!youtubeVideoId,
})
if (videoQuery.isLoading) {
return (
<div className="video-page">
<p>Загрузка...</p>
</div>
)
}
if (videoQuery.isError || !videoQuery.data) {
return (
<div className="video-page">
<Link to="/" className="back-link">
← Лента
</Link>
<p className="error">Видео не найдено</p>
</div>
)
}
const video = videoQuery.data const video = videoQuery.data
const duration = formatDuration(video.duration_seconds) const duration = formatDuration(video.duration_seconds)
return <article className="page video-page">
return ( <button className="back-link" onClick={back}><Icon name="arrow" size={17} /> Назад к ленте</button>
<div className="video-page"> <Player key={video.youtube_video_id} video={video} />
<Link to="/" className="back-link"> <div className="video-detail-head"><div><h1 className="video-page-title">{video.title}</h1><p className="video-page-meta"><Link to={`/channels/${video.channel.id}/videos`}>{video.channel.title}</Link><span>·</span><span>{formatRelativeTime(video.published_at)}</span>{duration && <><span>·</span><span>{duration}</span></>}</p></div><DownloadButton video={video} showDelete /></div>
← Лента {video.categories.length > 0 && <div className="video-page-categories">{video.categories.map((category) => <Link key={category.id} to={`/category/${category.id}`} className="chip">{category.name}</Link>)}</div>}
</Link> {video.description && <section className="video-page-description"><h2>Описание</h2><p>{video.description}</p></section>}
</article>
<Player video={video} />
<h1 className="video-page-title">{video.title}</h1>
<div className="video-page-meta">
<Link to={`/channels/${video.channel.id}/videos`}>{video.channel.title}</Link>
{' · '}
{formatRelativeTime(video.published_at)}
{duration && ` · ${duration}`}
{' · '}
<DownloadButton video={video} />
</div>
{video.categories.length > 0 && (
<div className="video-page-categories">
{video.categories.map((c) => (
<span key={c.id} className="chip">
{c.name}
</span>
))}
</div>
)}
{video.description && <p className="video-page-description">{video.description}</p>}
</div>
)
} }
export default VideoPage export default VideoPage

View file

@ -11,6 +11,7 @@ from app.models.channel import Channel
from app.models.channel_category import channel_categories from app.models.channel_category import channel_categories
from app.models.download_job import DownloadJob from app.models.download_job import DownloadJob
from app.models.video import Video from app.models.video import Video
from app.api.feed import get_feed
@pytest.fixture @pytest.fixture
@ -67,6 +68,19 @@ def test_feed_orders_by_published_at_desc(client, db_session):
assert resp["next_cursor"] is None assert resp["next_cursor"] is None
def test_feed_searches_video_and_channel_titles(db_session):
_seed(db_session)
by_video = get_feed(search="video 4", limit=30, db=db_session)
assert [item["youtube_video_id"] for item in by_video["items"]] == ["vid4"]
by_channel = get_feed(search="channel b", limit=30, db=db_session)
assert [item["youtube_video_id"] for item in by_channel["items"]] == ["vid3", "vid1"]
literal_wildcard = get_feed(search="%", limit=30, db=db_session)
assert literal_wildcard["items"] == []
def test_feed_pagination_cursor(client, db_session): def test_feed_pagination_cursor(client, db_session):
_seed(db_session) _seed(db_session)