From c4a772b1e32dd3b8585eb8248b7ae5e2c81f3121 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 16 Sep 2026 21:40:32 +0000 Subject: [PATCH] Redesign responsive MyTube interface --- README.md | 17 +- backend/app/api/feed.py | 10 +- frontend/index.html | 6 +- frontend/public/favicon.svg | 6 +- frontend/src/App.css | 757 ++++++++------------- frontend/src/App.tsx | 65 +- frontend/src/api/client.ts | 6 + frontend/src/components/AppShell.tsx | 101 +++ frontend/src/components/CategoryNav.tsx | 8 +- frontend/src/components/ChannelCard.tsx | 49 +- frontend/src/components/DownloadButton.tsx | 26 +- frontend/src/components/Icon.tsx | 32 + frontend/src/components/Player.tsx | 2 +- frontend/src/components/VideoCard.tsx | 20 +- frontend/src/index.css | 133 +--- frontend/src/pages/Categories.tsx | 165 +---- frontend/src/pages/ChannelVideos.tsx | 58 +- frontend/src/pages/Channels.tsx | 134 +--- frontend/src/pages/Connect.tsx | 12 +- frontend/src/pages/Feed.tsx | 182 +++-- frontend/src/pages/Settings.tsx | 29 + frontend/src/pages/VideoPage.tsx | 74 +- tests/test_feed.py | 14 + 23 files changed, 787 insertions(+), 1119 deletions(-) create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/Icon.tsx create mode 100644 frontend/src/pages/Settings.tsx diff --git a/README.md b/README.md index 7c7d962..8943fb9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,23 @@ Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube. Полное техническое задание: [`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). ## Стек diff --git a/backend/app/api/feed.py b/backend/app/api/feed.py index 2c9c596..a50e81e 100644 --- a/backend/app/api/feed.py +++ b/backend/app/api/feed.py @@ -2,7 +2,7 @@ import base64 from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.core.auth_dependency import require_session @@ -79,6 +79,7 @@ def get_feed( uncategorized: bool = False, channel_id: int | None = None, downloaded: bool = False, + search: str | None = Query(None, max_length=200), limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), cursor: str | None = None, db: Session = Depends(get_db), @@ -99,6 +100,13 @@ def get_feed( if downloaded: 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: cursor_published_at, cursor_id = _decode_cursor(cursor) query = query.filter( diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..bd76a7b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,12 @@ - + - frontend + + + MyTube — моя лента
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..31a9f28 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css index 82051b2..bd94692 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,478 +1,279 @@ -.app { - max-width: 960px; - margin: 0 auto; - padding: 24px; - text-align: left; -} - -.error { - color: #d33; -} - -.connect-screen { - text-align: center; - padding-top: 80px; -} - -.connect-screen button { - font-size: 16px; - padding: 10px 24px; - cursor: pointer; -} - -.channels-screen header, -.feed-screen header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; -} - -.channels-screen .actions, -.feed-screen .actions { - display: flex; - gap: 8px; - align-items: center; -} - -.channels-screen .actions a, -.feed-screen .actions a { - align-self: center; -} - -.channels-screen input[type='text'] { - width: 100%; - padding: 8px; - margin-bottom: 16px; - box-sizing: border-box; -} - -.layout { - display: flex; - gap: 20px; - align-items: flex-start; -} - -.category-nav { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 160px; - flex-shrink: 0; -} - -.category-nav button { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - text-align: left; - padding: 6px 10px; - border: none; - background: transparent; - cursor: pointer; - border-radius: 6px; -} - -.category-nav button.active { - background: var(--accent-bg, #eee); - font-weight: 600; -} - -.category-nav-count { - font-size: 11px; - color: #999; - background: #eee; - border-radius: 8px; - padding: 1px 6px; -} - -.content { - flex: 1; - min-width: 0; -} - -.channel-list { - list-style: none; - padding: 0; - margin: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 12px; -} - -.channel-card { - display: flex; - align-items: flex-start; - gap: 10px; - border: 1px solid #ddd; - border-radius: 8px; - padding: 8px; -} - -.channel-card img { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; -} - -.channel-info { - flex: 1; - min-width: 0; -} - -.channel-title { - font-weight: 500; - color: inherit; - text-decoration: none; - display: block; -} - -.channel-title:hover { - text-decoration: underline; -} - -.badge { - font-size: 12px; - color: #999; -} - -.unsubscribe-button { - font-size: 12px; - padding: 2px 8px; - margin-left: 8px; - border: 1px solid #ccc; - border-radius: 10px; - background: transparent; - cursor: pointer; -} - -.unsubscribe-error { - font-size: 12px; - margin-top: 4px; -} - -.channel-categories { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 6px; - align-items: center; -} - -.chip { - font-size: 12px; - background: #eee; - border-radius: 12px; - padding: 2px 8px; -} - -.chip-edit { - font-size: 12px; - padding: 2px 8px; - border-radius: 12px; - border: 1px dashed #aaa; - background: transparent; - cursor: pointer; -} - -.category-popover-wrapper { - position: relative; - display: inline-block; -} - -.category-popover { - position: absolute; - top: calc(100% + 4px); - left: 0; - z-index: 10; - background: #fff; - border: 1px solid #ccc; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - padding: 8px; - display: flex; - flex-direction: column; - gap: 4px; - font-size: 13px; - min-width: 160px; - max-height: 240px; - overflow-y: auto; -} - -.category-popover-empty { - color: #999; - white-space: nowrap; -} - -.category-checkbox { - display: flex; - align-items: center; - gap: 6px; -} - -.categories-screen { - max-width: 600px; -} - -.back-link { - display: inline-block; - margin-bottom: 8px; -} - -.category-form { - display: flex; - gap: 8px; - margin-bottom: 16px; -} - -.category-form input { - flex: 1; - padding: 8px; -} - -.category-list { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.category-row { - display: flex; - align-items: center; - gap: 10px; - border: 1px solid #ddd; - border-radius: 8px; - padding: 8px 12px; -} - -.category-row-virtual { - background: #f7f7f7; - border-style: dashed; - color: #888; -} - -.category-row-virtual .reorder-buttons { - width: 14px; -} - -.reorder-buttons { - display: flex; - flex-direction: column; -} - -.reorder-buttons button { - border: none; - background: transparent; - cursor: pointer; - line-height: 1; -} - -.category-name { - flex: 1; - font-weight: 500; -} - -.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; -} +#root { min-height: 100vh; } +.app-shell { min-height: 100vh; } +.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); } +.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; } +.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; } +.header-search:focus-within { border-color: var(--accent); background: var(--surface-raised); } +.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; } +.topbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 214px; } +.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; } +.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; } +.sync-indicator svg { color: var(--success); } +.sync-indicator.is-error svg { color: var(--error); } +.sync-indicator:hover { background: var(--hover); color: var(--text); } +.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; } +.mobile-menu, .drawer-header, .drawer-backdrop { display: none; } +.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); } +.sidebar-nav { display: flex; flex-direction: column; min-height: 100%; padding: 20px 12px; } +.sidebar-group { display: grid; gap: 3px; padding-bottom: 21px; } +.sidebar-group + .sidebar-group { padding-top: 21px; border-top: 1px solid var(--border); } +.sidebar-heading { padding: 0 13px 11px; color: var(--subtle); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.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; } +.sidebar-link.active svg { color: var(--accent); } +.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); } +.category-link { gap: 15px; padding-left: 18px; } +.category-link.active .category-dot { background: var(--accent); } +.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; } +.sidebar-bottom { margin-top: auto; padding-bottom: 0; } +.truncate { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.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; } +.eyebrow { color: var(--accent); text-transform: uppercase; letter-spacing: .13em; font-size: 11px; font-weight: 700; margin: 0 0 8px; } +h1, h2, h3, p { margin-top: 0; } +h1 { color: var(--text); font-size: clamp(25px, 2.25vw, 34px); line-height: 1.18; letter-spacing: -.035em; font-weight: 700; margin-bottom: 8px; } +h2 { color: var(--text); font-size: 18px; line-height: 1.3; font-weight: 650; } +.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; } +.button-primary { border: 1px solid var(--accent); background: var(--accent); color: #081521; } +.button-primary:hover { background: #79c1ff; border-color: #79c1ff; } +.button-secondary, .button-link { border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); } +.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); } +.button-danger { background: #a72c2a; border: 1px solid #a72c2a; color: white; } +.button-danger:hover { background: #c93633; } +.error { color: var(--error); } +.notice { display: flex; align-items: center; gap: 10px; padding: 13px 16px; margin: 0 0 24px; border-radius: 9px; font-size: 13px; } +.error-notice { border: 1px solid rgba(255,107,104,.25); color: #ffb1ae; background: rgba(255,107,104,.09); } +.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; } +.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; } +.spin { animation: spin 1.6s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.mobile-category-nav { display: none; } +.video-grid { list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(265px, 1fr)); gap: 31px 18px; padding: 0; margin: 0; } +.video-card { min-width: 0; display: flex; flex-direction: column; } +.video-thumbnail { display: block; position: relative; overflow: hidden; width: 100%; aspect-ratio: 16 / 9; border-radius: 11px; background: var(--surface-raised); } +.video-thumbnail img, .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; } +.video-thumbnail-placeholder { background: linear-gradient(135deg, #232323, #303030); } +.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; } +.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); } +.video-meta { display: flex; flex-wrap: wrap; gap: 2px 7px; margin-top: 7px; color: var(--muted); font-size: 12px; line-height: 1.4; } +.video-channel { color: #c8c8c8; } +.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; } +.download-inline-actions, .download-error { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 6px; } +.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; } +.download-badge.completed { color: var(--success); background: var(--success-soft); } +.download-badge.error { color: var(--error); background: rgba(255,107,104,.13); } +.download-badge.downloading { color: var(--warning); background: rgba(245,166,35,.11); } +.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; } +.empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); } +.empty-state h2 { margin: 0 0 9px; font-size: 20px; } +.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; } +.skeleton-thumbnail { aspect-ratio: 16 / 9; border-radius: 11px; } +.skeleton-line { height: 13px; border-radius: 5px; width: 70%; margin: 8px 0; } +.skeleton-line.wide { width: 93%; } +.skeleton-line.short { width: 44%; } +@keyframes shimmer { to { background-position: -200% 0; } } +.channels-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 18px; } +.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; } +.input-search input::placeholder { color: var(--subtle); } +.category-nav { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 0 0 19px; padding-bottom: 2px; } +.category-nav::-webkit-scrollbar, .mobile-category-nav::-webkit-scrollbar { display: none; } +.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; } +.category-nav button:hover { background: var(--hover); color: var(--text); } +.category-nav button.active { background: var(--accent-soft); border-color: rgba(62,166,255,.55); color: var(--accent); } +.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; } +.local-filter-nav::-webkit-scrollbar { display: none; } +.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; } +.local-filter-nav a:hover { color: var(--text); background: var(--hover); } +.local-filter-nav a.active { color: var(--accent); border-color: rgba(62,166,255,.55); background: var(--accent-soft); } +.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-card { display: flex; align-items: flex-start; gap: 15px; padding: 18px 3px; border-bottom: 1px solid var(--border); } +.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; } +.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:hover { color: var(--accent); } +.badge { font-size: 11px; color: var(--subtle); } +.unsubscribe-button { margin-left: auto; min-height: 28px; border: 0; border-radius: 6px; padding: 0 8px; color: var(--subtle); background: transparent; font-size: 11px; } +.unsubscribe-button:hover { color: var(--error); background: rgba(255,107,104,.1); } +.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); } +.removable-chip:hover { border-color: var(--error); color: var(--text); } +.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; } +.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); } +.category-popover strong { padding: 3px 7px 7px; font-size: 13px; color: var(--text); } +.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); } +.category-checkbox input { accent-color: var(--accent); width: 16px; height: 16px; } +.popover-create { display: flex; gap: 5px; margin-top: 7px; padding-top: 9px; border-top: 1px solid var(--border); } +.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; } +.popover-create button { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 6px; background: var(--accent); color: #081521; } +.channel-loading { display: grid; gap: 1px; max-width: 1000px; } +.channel-skeleton { display: flex; align-items: center; gap: 15px; height: 83px; border-bottom: 1px solid var(--border); } +.channel-skeleton span { width: 48px; height: 48px; border-radius: 50%; } +.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); } +.video-page { max-width: 1320px; } +.player-wrapper { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 11px; background: #000; } +.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; } +.video-page-title { font-size: clamp(21px, 2vw, 29px); line-height: 1.32; margin-bottom: 9px; } +.video-page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; } +.video-page-meta a { color: var(--text); text-decoration: none; } +.video-page-meta a:hover { color: var(--accent); } +.video-page-categories { display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0 24px; } +.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; } +.categories-screen { max-width: 1000px; } +.category-form { display: flex; gap: 10px; max-width: 630px; margin-bottom: 28px; } +.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; } +.category-form input:focus, .category-edit-form input:focus { outline: 2px solid var(--accent); outline-offset: 2px; } +.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; } +.reorder-buttons .icon-button { width: 31px; height: 28px; } +.category-row-label { display: flex; align-items: center; gap: 13px; flex: 1; min-width: 0; } +.category-name { overflow: hidden; text-overflow: ellipsis; font-size: 14px; font-weight: 650; } +.category-count { color: var(--muted); font-size: 12px; white-space: nowrap; } +.category-row-actions { display: flex; gap: 3px; } +.category-row-actions .danger-hover:hover { color: var(--error); } +.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); } +.confirm-dialog h2 { margin-bottom: 10px; } +.confirm-dialog p { color: var(--muted); font-size: 14px; line-height: 1.55; } +.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; } +.settings-panel { min-width: 0; padding: 23px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); } +.settings-panel-heading { display: flex; align-items: center; gap: 13px; margin-bottom: 22px; } +.settings-panel-heading h2 { margin: 0 0 2px; font-size: 16px; } +.settings-panel-heading p { margin: 0; color: var(--muted); font-size: 12px; } +.panel-icon { display: grid; place-items: center; width: 39px; height: 39px; flex: none; border-radius: 10px; background: var(--accent-soft); color: var(--accent); } +.settings-detail { display: flex; justify-content: space-between; gap: 15px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; } +.settings-detail span { color: var(--muted); } +.settings-detail strong { font-weight: 550; text-align: right; overflow-wrap: anywhere; } +.settings-detail .success-text { color: var(--success); } +.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--success); } +.settings-help { color: var(--muted); font-size: 13px; line-height: 1.5; } +.settings-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 15px; } +.logout-button { margin-top: 24px; } +.connect-screen { min-height: 100vh; display: grid; place-items: center; padding: 25px; background: radial-gradient(circle at 50% 15%, #173046 0%, var(--bg) 42%); } +.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); } +.connect-card .brand-mark { margin: 0 auto 22px; width: 52px; height: 40px; } +.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; } +.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; } +.app-loading h1 { font-size: 24px; } +.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)); } } +@media (min-width: 1100px) and (max-width: 1399px) { .video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 900px) { + .topbar { gap: 14px; padding: 0 18px; } + .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); } + .app-sidebar.open { visibility: visible; transform: translateX(0); } + .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; } + .main-content { margin-left: 0; } + .page { padding: 28px 28px 65px; } + .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; } + .mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); } +} +@media (max-width: 620px) { + .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; } + .mobile-menu { margin-left: -7px; } + .header-search { grid-column: 1 / -1; grid-row: 2; width: 100%; height: 44px; } + .topbar-actions { grid-column: 3; grid-row: 1; } + .sync-indicator { width: 40px; padding: 0; justify-content: center; } + .sync-indicator span { display: none; } + .header-settings { display: none; } + .page { padding: 24px 16px 58px; } + .page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; } + .page-heading h1 { font-size: 27px; } + .refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; } + .refresh-button svg { flex: none; } + .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; } + .video-info { padding: 10px 2px 0; } + .video-actions { padding-top: 11px; } + .video-actions .button-link, .video-actions .button-secondary, .download-badge { min-height: 44px; } + .channels-toolbar { align-items: stretch; } + .manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; } + .manage-link svg { flex: none; } + .channel-card { gap: 11px; padding: 16px 0; } + .channel-card > img, .channel-avatar-placeholder { width: 42px; height: 42px; } + .unsubscribe-button { margin-left: 0; } + .category-popover { left: auto; right: -5px; } + .category-checkbox, .chip, .chip-edit { min-height: 44px; } + .category-nav button, .mobile-category-nav a { min-height: 44px; } + .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); } + .video-page-source { margin-top: 9px; } + .settings-grid { grid-template-columns: 1fr; } + .settings-panel { padding: 18px; } + .category-form { flex-wrap: wrap; } + .category-form input { flex-basis: 100%; } + .category-row { gap: 8px; } + .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; } + .category-edit-form input { flex-basis: 100%; } + .sync-popover { right: -7px; max-width: calc(100vw - 30px); } +} +@media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } } +@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; } + .popover-create input, .popover-create button { height: 44px; } + .popover-create button { width: 44px; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index beccbf6..a8c762b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,57 +1,40 @@ 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 AppShell from './components/AppShell' import Connect from './pages/Connect' import Feed from './pages/Feed' -import Saved from './pages/Saved' import Channels from './pages/Channels' import ChannelVideos from './pages/ChannelVideos' import VideoPage from './pages/VideoPage' import Categories from './pages/Categories' +import Settings from './pages/Settings' import './App.css' function App() { - const { data, isLoading, isError } = useQuery({ - queryKey: ['auth-status'], - queryFn: getAuthStatus, - }) + const { data, isLoading, isError } = useQuery({ queryKey: ['auth-status'], queryFn: getAuthStatus }) - if (isLoading) { - return ( -
-

Загрузка...

-
- ) - } + if (isLoading) return
▶

Открываем MyTube…

+ if (isError) return

Не удалось открыть MyTube

Проверь подключение к серверу и обнови страницу.

+ if (!data?.authenticated) return - if (isError) { - return ( -
-

Backend недоступен

-
- ) - } - - if (!data?.authenticated) { - return ( -
- -
- ) - } - - return ( -
- - } /> - } /> - } /> - } /> - } /> - } /> - -
- ) + return + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> +

Страница не найдена

Вернуться к ленте} /> +
+
} export default App diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ce49d90..564d242 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -39,6 +39,10 @@ export function getAuthStatus() { return request('/api/auth/status') } +export function getHealth() { + return request<{ status: string; database: string; metube: string }>('/api/health') +} + export function startGoogleAuth() { window.location.href = '/api/auth/google/start' } @@ -205,6 +209,7 @@ export function getFeed( uncategorized?: boolean channelId?: number downloaded?: boolean + search?: string cursor?: string } = {}, ) { @@ -213,6 +218,7 @@ export function getFeed( if (params.uncategorized) qs.set('uncategorized', 'true') if (params.channelId != null) qs.set('channel_id', String(params.channelId)) if (params.downloaded) qs.set('downloaded', 'true') + if (params.search) qs.set('search', params.search) if (params.cursor) qs.set('cursor', params.cursor) const suffix = qs.toString() ? `?${qs.toString()}` : '' return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`) diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..3c07ae4 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -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(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
+ + {open &&
+ Синхронизация +

Подписки: {data?.subscriptions.running ? 'обновляются' : data?.subscriptions.finished_at ? formatRelativeTime(data.subscriptions.finished_at) : 'ещё не обновлялись'}

+

Видео: {data?.videos.running ? 'обновляются' : data?.videos.finished_at ? formatRelativeTime(data.videos.finished_at) : 'ещё не обновлялись'}

+ {failed &&

Последнее обновление завершилось ошибкой. Подробности в настройках.

} +
} +
+} + +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 +} + +function SearchForm({ initialValue }: { initialValue: string }) { + const [search, setSearch] = useState(initialValue) + const navigate = useNavigate() + return
{ event.preventDefault(); navigate(search.trim() ? `/search?q=${encodeURIComponent(search.trim())}` : '/') }}> + + setSearch(event.target.value)} /> + {search && } + +} + +export default function AppShell({ children }: { children: React.ReactNode }) { + const [drawerOpen, setDrawerOpen] = useState(false) + const drawerCloseRef = useRef(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
+
+ + MyTube + +
+
+ {drawerOpen &&
+ setDrawerOpen(false)} /> + +
{children}
+ +} diff --git a/frontend/src/components/CategoryNav.tsx b/frontend/src/components/CategoryNav.tsx index de3bfb6..d6397c7 100644 --- a/frontend/src/components/CategoryNav.tsx +++ b/frontend/src/components/CategoryNav.tsx @@ -15,18 +15,20 @@ interface Props { function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount, categoryCounts }: Props) { return ( -