Redesign responsive MyTube interface
This commit is contained in:
parent
90eb012e0d
commit
c4a772b1e3
23 changed files with 787 additions and 1119 deletions
17
README.md
17
README.md
|
|
@ -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).
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
<Routes>
|
||||||
<div className="app">
|
<Route path="/" element={<Feed />} />
|
||||||
<p className="error">Backend недоступен</p>
|
<Route path="/category/:categoryId" element={<Feed />} />
|
||||||
</div>
|
<Route path="/uncategorized" element={<Feed />} />
|
||||||
)
|
<Route path="/local" element={<Feed />} />
|
||||||
}
|
<Route path="/search" element={<Feed />} />
|
||||||
|
<Route path="/saved" element={<Navigate to="/local" replace />} />
|
||||||
if (!data?.authenticated) {
|
<Route path="/channels" element={<Channels />} />
|
||||||
return (
|
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
|
||||||
<div className="app">
|
<Route path="/video/:youtubeVideoId" element={<VideoPage />} />
|
||||||
<Connect />
|
<Route path="/settings" element={<Settings email={data.email} />} />
|
||||||
</div>
|
<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>
|
||||||
return (
|
</AppShell>
|
||||||
<div className="app">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Feed />} />
|
|
||||||
<Route path="/saved" element={<Saved />} />
|
|
||||||
<Route path="/channels" element={<Channels />} />
|
|
||||||
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
|
|
||||||
<Route path="/video/:youtubeVideoId" element={<VideoPage />} />
|
|
||||||
<Route path="/categories" element={<Categories />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
101
frontend/src/components/AppShell.tsx
Normal file
101
frontend/src/components/AppShell.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { getSyncStatus, listCategories } from '../api/client'
|
||||||
|
import { formatRelativeTime } from '../utils/format'
|
||||||
|
import Icon from './Icon'
|
||||||
|
|
||||||
|
function SyncIndicator() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['sync-status'], queryFn: getSyncStatus,
|
||||||
|
refetchInterval: (query) => query.state.data?.videos.running || query.state.data?.subscriptions.running ? 2000 : 60_000,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) }
|
||||||
|
document.addEventListener('mousedown', close)
|
||||||
|
return () => document.removeEventListener('mousedown', close)
|
||||||
|
}, [open])
|
||||||
|
const running = data?.videos.running || data?.subscriptions.running
|
||||||
|
const failed = data?.videos.status === 'failed' || data?.subscriptions.status === 'failed'
|
||||||
|
const recent = [data?.videos.finished_at, data?.subscriptions.finished_at].filter((value): value is string => Boolean(value)).sort().at(-1)
|
||||||
|
const label = running ? 'Обновление...' : failed ? 'Ошибка синхронизации' : recent ? `Обновлено ${formatRelativeTime(recent)}` : 'Синхронизация'
|
||||||
|
return <div className="sync-wrap" ref={ref}>
|
||||||
|
<button type="button" className={`sync-indicator ${failed ? 'is-error' : ''}`} onClick={() => setOpen(!open)} aria-expanded={open} aria-label={label}>
|
||||||
|
<Icon name={failed ? 'alert' : running ? 'refresh' : 'check'} size={17} className={running ? 'spin' : ''} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
{open && <div className="sync-popover" role="status">
|
||||||
|
<strong>Синхронизация</strong>
|
||||||
|
<p>Подписки: {data?.subscriptions.running ? 'обновляются' : data?.subscriptions.finished_at ? formatRelativeTime(data.subscriptions.finished_at) : 'ещё не обновлялись'}</p>
|
||||||
|
<p>Видео: {data?.videos.running ? 'обновляются' : data?.videos.finished_at ? formatRelativeTime(data.videos.finished_at) : 'ещё не обновлялись'}</p>
|
||||||
|
{failed && <p className="error">Последнее обновление завершилось ошибкой. Подробности в настройках.</p>}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({ close }: { close: () => void }) {
|
||||||
|
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||||
|
const categories = categoriesQuery.data ?? []
|
||||||
|
const links = [
|
||||||
|
{ to: '/', icon: 'home' as const, text: 'Все видео', end: true },
|
||||||
|
{ to: '/uncategorized', icon: 'folder' as const, text: 'Без категории' },
|
||||||
|
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
|
||||||
|
]
|
||||||
|
return <nav className="sidebar-nav" aria-label="Основная навигация">
|
||||||
|
<div className="sidebar-group">
|
||||||
|
{links.map((item) => <NavLink key={item.to} to={item.to} end={item.end} onClick={close} className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}><Icon name={item.icon} size={19} /><span>{item.text}</span></NavLink>)}
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-group sidebar-categories">
|
||||||
|
<div className="sidebar-heading">Категории</div>
|
||||||
|
{categories.map((category) => <NavLink key={category.id} to={`/category/${category.id}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span><span className="sidebar-count">{category.channel_count}</span></NavLink>)}
|
||||||
|
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}
|
||||||
|
<Link to="/settings/categories" onClick={close} className="sidebar-link sidebar-add"><Icon name="plus" size={18} /><span>Новая категория</span></Link>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-group sidebar-bottom">
|
||||||
|
<NavLink to="/channels" onClick={close} className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}><Icon name="users" size={19} /><span>Каналы</span></NavLink>
|
||||||
|
<NavLink to="/settings" onClick={close} className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}><Icon name="settings" size={19} /><span>Настройки</span></NavLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchForm({ initialValue }: { initialValue: string }) {
|
||||||
|
const [search, setSearch] = useState(initialValue)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return <form className="header-search" role="search" onSubmit={(event) => { event.preventDefault(); navigate(search.trim() ? `/search?q=${encodeURIComponent(search.trim())}` : '/') }}>
|
||||||
|
<Icon name="search" size={19} />
|
||||||
|
<input aria-label="Поиск по видео и каналам" placeholder="Поиск видео и каналов" maxLength={200} value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||||
|
{search && <button type="submit" aria-label="Искать"><Icon name="chevron" size={18} /></button>}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
const drawerCloseRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const location = useLocation()
|
||||||
|
const initialSearch = location.pathname === '/search' ? new URLSearchParams(location.search).get('q') ?? '' : ''
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.toggle('drawer-open', drawerOpen)
|
||||||
|
if (drawerOpen) drawerCloseRef.current?.focus()
|
||||||
|
const onEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setDrawerOpen(false) }
|
||||||
|
if (drawerOpen) document.addEventListener('keydown', onEscape)
|
||||||
|
else document.removeEventListener('keydown', onEscape)
|
||||||
|
return () => { document.body.classList.remove('drawer-open'); document.removeEventListener('keydown', onEscape) }
|
||||||
|
}, [drawerOpen])
|
||||||
|
return <div className="app-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<button type="button" className="icon-button mobile-menu" aria-label="Открыть меню" onClick={() => setDrawerOpen(true)}><Icon name="menu" /></button>
|
||||||
|
<Link to="/" className="brand" aria-label="MyTube — все видео"><span className="brand-mark"><Icon name="play" size={18} /></span><span>My<span className="brand-accent">Tube</span></span></Link>
|
||||||
|
<SearchForm key={location.pathname + location.search} initialValue={initialSearch} />
|
||||||
|
<div className="topbar-actions"><SyncIndicator /><Link to="/settings" className="icon-button header-settings" aria-label="Настройки"><Icon name="settings" /></Link></div>
|
||||||
|
</header>
|
||||||
|
{drawerOpen && <button type="button" className="drawer-backdrop" aria-label="Закрыть меню" onClick={() => setDrawerOpen(false)} />}
|
||||||
|
<aside className={`app-sidebar ${drawerOpen ? 'open' : ''}`}>
|
||||||
|
<div className="drawer-header"><span>Меню</span><button ref={drawerCloseRef} type="button" className="icon-button" aria-label="Закрыть меню" onClick={() => setDrawerOpen(false)}><Icon name="close" /></button></div>
|
||||||
|
<Sidebar close={() => setDrawerOpen(false)} />
|
||||||
|
</aside>
|
||||||
|
<main className="main-content" id="main-content">{children}</main>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
@ -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)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
32
frontend/src/components/Icon.tsx
Normal file
32
frontend/src/components/Icon.tsx
Normal 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>
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ function Player({ video }: Props) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="video-page-source">● Локальная копия · mediaVM</div>
|
<div className="video-page-source">Источник: локальная копия</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
cursor: pageParam,
|
search,
|
||||||
}),
|
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>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>}
|
|
||||||
{syncStatusQuery.data?.videos.status === 'failed' && (
|
|
||||||
<p className="error">Последняя синхронизация видео завершилась ошибкой: {syncStatusQuery.data.videos.error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="layout">
|
|
||||||
<CategoryNav
|
|
||||||
categories={categories}
|
|
||||||
value={filter}
|
|
||||||
onChange={setFilter}
|
|
||||||
allCount={allChannels.length}
|
|
||||||
uncategorizedCount={uncategorizedCount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="content">
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
<nav className="mobile-category-nav" aria-label="Категории">
|
||||||
|
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
||||||
|
<Link to="/uncategorized" className={isUncategorized ? 'active' : ''}>Без категории</Link>
|
||||||
|
<Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link>
|
||||||
|
{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="Фильтр сохранённых видео">
|
||||||
|
<Link to="/local" className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
|
||||||
|
<Link to="/local?uncategorized=true" className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
|
||||||
|
{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>)}
|
||||||
|
</nav>}
|
||||||
|
{syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление видео.</div>}
|
||||||
|
{syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>}
|
||||||
|
{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>}
|
||||||
|
{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 && !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"><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>}
|
||||||
|
</>}
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Feed
|
export default Feed
|
||||||
|
|
|
||||||
29
frontend/src/pages/Settings.tsx
Normal file
29
frontend/src/pages/Settings.tsx
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue