From d4950f69794bf9a1cc770c1f866d4a60dfb8e61d Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Fri, 18 Sep 2026 12:13:32 +0000 Subject: [PATCH] Add channels view toggle on the main page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New GET /api/channels/activity returns subscribed channels sorted by their latest video (no-video channels last) with the 3 newest videos per channel and cursor pagination. The feed page gains a 'Лента | Каналы' toggle (?view=channels) with compact video cards per channel row; category filters and the sidebar preserve the view. --- README.md | 1 + analytics/2026-09-17-channels-view-toggle.md | 88 +++++ backend/app/api/channels.py | 134 +++++++- frontend/src/App.css | 26 ++ frontend/src/api/client.ts | 17 + frontend/src/components/AppShell.tsx | 8 +- .../src/components/ChannelActivityList.tsx | 81 +++++ frontend/src/components/ChannelCard.tsx | 2 + frontend/src/components/CompactVideoCard.tsx | 34 ++ frontend/src/pages/Categories.tsx | 2 +- frontend/src/pages/Channels.tsx | 5 +- frontend/src/pages/Feed.tsx | 38 ++- tests/test_channel_activity.py | 301 ++++++++++++++++++ 13 files changed, 721 insertions(+), 16 deletions(-) create mode 100644 analytics/2026-09-17-channels-view-toggle.md create mode 100644 frontend/src/components/ChannelActivityList.tsx create mode 100644 frontend/src/components/CompactVideoCard.tsx create mode 100644 tests/test_channel_activity.py diff --git a/README.md b/README.md index 919c25b..2830d78 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ ### Что сделано - Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта). +- Режим «Каналы» на главной (`?view=channels`): переключатель «Лента | Каналы», список подписанных каналов по свежести последнего видео с тремя последними роликами в строке; фильтр категорий из сайдбара действует в обоих режимах. - Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`. - Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню. - Отписанные каналы скрываются из списка каналов. diff --git a/analytics/2026-09-17-channels-view-toggle.md b/analytics/2026-09-17-channels-view-toggle.md new file mode 100644 index 0000000..531c540 --- /dev/null +++ b/analytics/2026-09-17-channels-view-toggle.md @@ -0,0 +1,88 @@ +# Режим «Каналы» на главной: переключатель «Лента | Каналы» и эндпоинт активности каналов + +## Задача + +Добавить на главную страницу переключатель «Лента | Каналы». Режим «Лента» остаётся как сейчас. Режим «Каналы» — вместо сетки видео список подписанных каналов, отсортированный по свежести последнего видео; у каждого канала — три последних видео в одну широкую строку (компактные карточки). Фильтрация категориями из левого сайдбара действует и в режиме каналов. Режим задаётся URL-параметром `?view=channels`. Страница управления «Каналы» (`/channels`) не меняется. + +## Контекст + +- Главная уже является комбинированной страницей: `Feed.tsx` обрабатывает `/`, `/category/:id`, `/uncategorized`, `/local`, `/search`; фильтры (`category_id`, `uncategorized`, `new_only`, `downloaded`, `search`) передаются в `GET /api/feed` с курсорной пагинацией (`backend/app/api/feed.py`). +- `listChannels` (`GET /api/channels`) возвращает все подписанные каналы с `category_ids`, но без видео и без сортировки по активности (алфавитная); нарезать «3 последних видео» на клиенте из feed-запросов нельзя — нужен новый backend-эндпоинт. +- Синк видео выполняется в фоне постоянно (`sync_trigger` + APScheduler), поэтому offset-пагинация списка каналов будет «плыть» между страницами (дубли/пропуски при добавлении новых видео). Feed уже использует курсор `(published_at, id)` — для активности каналов берём тот же подход: курсор `(last_published_at, channel_id)`. +- Сериализация видео в ленте — `serialize_video` (`backend/app/services/video_presentation.py`) с `channel_categories_map` и `latest_jobs_map`; DTO `FeedVideoDto` уже типизован в `frontend/src/api/client.ts` — переиспользуем его для видео в строке канала. +- Ссылки категорий и «Все видео» строятся в `Sidebar` (`AppShell.tsx`) без query-параметров; чтобы режим сохранялся при переключении категории, ссылки должны переносить текущий `?view=channels`. Режим в URL гарантирует и сохранение при refresh/back. +- Каналы без видео должны уходить в конец списка (NULLS LAST) — это требует расширения курсора sentinel'ом для NULL-хвоста. +- Иконок для переключателя не требуется: это два текстовых звена в сегментированном контроле; новый набор иконок не вводим. + +## Затронутые подсистемы и файлы + +Backend: +- `backend/app/api/channels.py` — новый `GET /channels/activity` (объявить **после** `list_channels` и **до** `get_channel`: иначе `/channels/activity` перехватится маршрутом `/channels/{channel_id}` и вернёт 422, т.к. Starlette выбирает маршрут по паттерну, а валидация `int` падает после). +- `backend/app/services/video_presentation.py`, `backend/app/services/download_jobs.py` — только переиспользование (`channel_categories_map`, `latest_jobs_map`, `serialize_video`), без изменений. +- `backend/app/main.py` — без изменений (роутер channels уже подключён). +- Миграции не нужны (новых полей нет; сортировка по `max(videos.published_at)` вычисляется агрегатом). + +Frontend: +- `frontend/src/api/client.ts` — новый тип `ChannelActivityDto` (`ChannelDto` + `videos: FeedVideoDto[]`) и функция `getChannelActivity({ categoryId?, uncategorized?, cursor?, limit? })`. +- `frontend/src/pages/Feed.tsx` — чтение `view` из `searchParams`, переключатель в `page-heading` (справа, в освободившееся после переноса кнопки «Обновить» место) только на маршрутах главной, рендер `ChannelActivityList` вместо `video-grid`; mobile-category-nav сохраняет view; инвалидация кэша активности при завершении видео-синка. +- `frontend/src/components/ChannelActivityList.tsx` — **новый**: `useInfiniteQuery`, строка канала (аватар + название-ссылка на `/channels/{id}/videos` + 3 компактные карточки), скелетоны, пустое состояние, ошибка с «Повторить», «Показать ещё». +- `frontend/src/components/CompactVideoCard.tsx` — **новый**: миниатюра 16:9 + бейдж длительности + название (clamp 2 строки) + относительное время; клик → `/video/{youtube_video_id}` с `state={{ from }}` и сохранением скролла как в `VideoCard`. Без DownloadButton (только просмотр). +- `frontend/src/components/AppShell.tsx` — `Sidebar`: сохранять `?view=channels` в ссылках «Все видео» и категорий (через `useLocation`). +- `frontend/src/App.css` — стили переключателя (`.view-toggle`), `.channel-activity-list` / `.channel-activity-row` / `.channel-activity-videos` / `.compact-video-card`, адаптив. +- `frontend/src/components/VideoCard.tsx` — не трогаем (компактная карточка — отдельный лёгкий компонент, не перегружаем VideoCard). + +Тесты: +- `tests/test_channel_activity.py` — **новый**, паттерн фикстуры `client` как в `tests/test_channels.py` / `tests/test_feed.py`. + +## Критерии приёмки + +1. Переключатель «Лента | Каналы» виден на `/`, `/category/:id`, `/uncategorized`; на `/local` и `/search` его нет, `?view=channels` там игнорируется (остаётся лента). Активный режим визуально выделен. +2. Режим в URL: `?view=channels`; переключение сохраняет текущий маршрут; refresh сохраняет режим. Ссылки категорий и «Все видео» в сайдбаре и в mobile-category-nav переносят текущий `view` при переключении категории; обратный переключатель на «Ленту» сбрасывает параметры (`{pathname}` без query). +3. Список каналов отсортирован по дате последнего видео (desc); каналы без видео — в конце. Строка: аватар + название (ссылка на `/channels/{id}/videos`) + 3 последних видео канала в одну широкую строку. +4. Компактная карточка: миниатюра + длительность + название + относительное время; клик открывает страницу видео. Кнопок скачивания в строке нет. +5. Фильтр категории применяется в режиме каналов: `/category/:id?view=channels` — только каналы категории; `/uncategorized?view=channels` — только каналы без категорий; `/` — все подписанные. Отписанные каналы не попадают. +6. Пагинация: ~20 каналов на страницу (DEFAULT_LIMIT 20, max 100) + кнопка «Показать ещё»; граница «каналы с видео → каналы без видео» не теряет и не дублирует каналы. +7. Пустое состояние режима каналов (нет подписок) с подсказкой и ссылкой на `/channels`; при идущем синке — состояние «Импортируем каналы». Ошибка загрузки — с кнопкой «Повторить». +8. Страница `/channels` (управление) не изменена и работает как раньше. +9. Backend `GET /api/channels/activity` отвечает 200 (не 422 — проверка порядка маршрутов), `GET /api/channels/{id}` продолжает работать; невалидный курсор → 400. +10. `pytest` (включая новый `tests/test_channel_activity.py`), `npm run lint`, `npm run build` — зелёные; деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK. + +## План + +1. **Backend — `GET /api/channels/activity`** (`backend/app/api/channels.py`): + - Параметры: `category_id: int | None`, `uncategorized: bool = False`, `limit: int = Query(20, ge=1, le=100)`, `cursor: str | None`. + - Подзапрос последнего видео: `select(Video.channel_id, func.max(Video.published_at).label('last_published_at')).group_by(Video.channel_id).subquery()`, `db.query(Channel, sub.c.last_published_at).outerjoin(...)`. + - Фильтры: `Channel.subscribed.is_(True)` всегда; `uncategorized` → `~Channel.id.in_(select(channel_categories.c.channel_id))`; иначе `category_id` → `Channel.id.in_(select(...).where(category_id == ...))` (паттерн как в `list_channels`). + - Сортировка: `last_published_at.desc().nullslast(), Channel.id.desc()`. + - Курсор: base64 `"{iso}|{channel_id}"`, `iso` пустой для каналов без видео. Условие: для непустого `iso` — `(last_published_at < pub) | ((last_published_at == pub) & (Channel.id < cid))`; для пустого — `last_published_at.is_(None) & (Channel.id < cid)`. Забрать `limit + 1` строк, отрезать `next_cursor` как в feed. + - Видео для страницы: один запрос с `row_number() OVER (PARTITION BY video.channel_id ORDER BY video.published_at DESC, video.id DESC)`, фильтр `rn <= 3` по id каналов страницы. + - Сериализация: `_serialize(channel, category_map, new_videos_count)` (уже есть) + `videos: [serialize_video(v, channel, categories, job) ...]` через `channel_categories_map` и `latest_jobs_map` — DTO видео идентичен ленте. Ответ: `{"items": [...], "next_cursor": ...}`. + - Курсорные хелперы `_encode_cursor/_decode_cursor` из `feed.py` приватные и заточены под видео — в `channels.py` сделать локальные версии под `(datetime | None, channel_id)`; общий модуль не выделяем, чтобы не трогать `feed.py`. +2. **Frontend — API** (`client.ts`): `ChannelActivityDto`, `getChannelActivity`; кодирование query-параметров как в `getFeed`. +3. **Frontend — компоненты**: `CompactVideoCard.tsx`, `ChannelActivityList.tsx` (queryKey: `['channel-activity', location.pathname, categoryNumber ?? null, isUncategorized]`; `getNextPageParam` по `next_cursor`; скелетоны/пусто/ошибка/«Показать ещё» в паттернах Feed.tsx). +4. **Feed.tsx**: `const isChannelsView = searchParams.get('view') === 'channels'`; переключатель рендерится при `!isLocal && !search`; при `isChannelsView` — рендер `ChannelActivityList` вместо сетки; подписи/`title` оставить; в `useEffect` по `videos.finished_at` дополнительно `invalidateQueries(['channel-activity'])`; mobile-category-nav — ссылки с сохранением `view`. +5. **AppShell.tsx (Sidebar)**: `useLocation`; `to={{ pathname: '/', search: viewQuery }}` и аналогично для категорий (через `URLSearchParams`); badge-ссылки `?new=1` оставить без `view` (они ведут в ленту «только новые» — feed-специфичны). +6. **App.css**: `.view-toggle` (сегментированный контроль в стиле `.category-nav`/`.local-filter-nav`), строки каналов (грид `repeat(3, minmax(0, 1fr))` для видео), компактные карточки, media-правила ≤900/≤620px. +7. **Тесты** `tests/test_channel_activity.py` (фикстура client как в test_channels, сиды через Channel/Video/Category/channel_categories): + - порядок по свежести последнего видео (каналы вперемешку по датам); + - каналы без видео в конце, после всех «с видео»; + - ровно 3 последних видео на канал в правильном порядке; + - фильтр `category_id` и `uncategorized`; только `subscribed`; + - курсорная пагинация, включая переход через границу «с видео → без видео» (без дублей/потерь); + - shape: поля канала + `videos` ≤ 3, поля видео как в `test_feed_item_shape`; + - `GET /api/channels/activity` → 200 (не 422) и `GET /api/channels/{id}` → 200 (порядок маршрутов); invalid cursor → 400; `limit` вне границ → 422. +8. **Проверка**: `pytest`, `npm run lint`, `npm run build`; ручная проверка сценариев (переключение на всех трёх маршрутах; сохранение view при смене категории в сайдбаре и на мобильном меню; refresh; пагинация; пустое состояние). Деплой по правилу команды. + +## Риски и ограничения + +- **Порядок маршрутов**: `/channels/activity` обязано быть объявлено до `/channels/{channel_id}`; тест-кейс закрывает регрессию. +- **Курсор с NULL-хвостом**: sentinel «пустой iso» усложняет `_decode_cursor`; граничный тест обязателен. SQLite в тестах (Python 3.13, SQLite ≥ 3.45) поддерживает `NULLS LAST` и оконные функции — совместимо. +- **Feed.tsx растёт**: держим разметку режима каналов в `ChannelActivityList`, в `Feed.tsx` только ветвление; VideoCard не перегружаем. +- **Инвалидация кэша**: `ChannelCard` (страница `/channels`) при смене категорий каналов инвалидирует `['feed']`/`['channels']`/`['categories']`, но не `['channel-activity']` — добавить туда же, иначе строки режима каналов устаревают после изменения категорий. +- **`?new=1` и `view`**: `new_only` — feed-специфичный фильтр; badge в сайдбаре всегда ведёт в ленту. Переключатель «Каналы» с URL `?new=1` сбрасывает `new` (строим `{pathname}?view=channels`), эндпоинт активности его не принимает. В обратную сторону «Лента» даёт `{pathname}` без параметров. +- **Производительность**: 3 запроса на страницу (каналы, видео через оконную функцию, категории+джобы) — приемлемо для single-user; при необходимости окно видео можно ограничить `limit`'ом страницы. +- **Серийная форма видео** в строке включает `local` (из `latest_jobs_map`) — фронт его в компактных карточках не показывает; DTO единый с лентой намеренно (переиспользование типа и потенциальный mobile-клиент). + +## Журнал изменений + +- 2026-09-18: документ создан перед реализацией. Зафиксированы решения: переключатель на `/`, `/category/:id`, `/uncategorized` (на `/local` и `/search` — нет); режим в URL `?view=channels`; сайдбар сохраняет view; эндпоинт `GET /api/channels/activity`; курсорная пагинация `(last_published_at, channel_id)` с NULL-хвостом; компактные карточки без DownloadButton (миниатюра + длительность + название + относительное время). diff --git a/backend/app/api/channels.py b/backend/app/api/channels.py index f3a87dd..73c19d9 100644 --- a/backend/app/api/channels.py +++ b/backend/app/api/channels.py @@ -1,7 +1,8 @@ +import base64 import logging from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -14,13 +15,19 @@ from app.models.channel import Channel from app.models.channel_category import channel_categories from app.models.video import Video from app.services import sync +from app.services.download_jobs import latest_jobs_map from app.services.google_oauth import OAuthNotConnected +from app.services.video_presentation import channel_categories_map, serialize_video from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded logger = logging.getLogger(__name__) router = APIRouter(dependencies=[Depends(require_session)]) +ACTIVITY_DEFAULT_LIMIT = 20 +ACTIVITY_MAX_LIMIT = 100 +ACTIVITY_VIDEOS_PER_CHANNEL = 3 + class ChannelCategoriesUpdate(BaseModel): category_ids: list[int] @@ -101,6 +108,131 @@ def list_channels( return [_serialize(c, category_map.get(c.id, []), new_videos_map.get(c.id, 0)) for c in channels] +def _encode_activity_cursor(last_published_at: datetime | None, channel_id: int) -> str: + raw = f"{last_published_at.isoformat() if last_published_at else ''}|{channel_id}" + return base64.urlsafe_b64encode(raw.encode()).decode() + + +def _decode_activity_cursor(cursor: str) -> tuple[datetime | None, int]: + try: + raw = base64.urlsafe_b64decode(cursor.encode()).decode() + iso_raw, channel_id_raw = raw.rsplit("|", 1) + published_at = datetime.fromisoformat(iso_raw) if iso_raw else None + if published_at is not None and published_at.tzinfo is None: + published_at = published_at.replace(tzinfo=timezone.utc) + return published_at, int(channel_id_raw) + except Exception: + raise HTTPException(status_code=400, detail="Invalid cursor") + + +# NOTE: this route must stay declared BEFORE /channels/{channel_id}, otherwise +# "activity" is captured by the path parameter and validation fails with 422. +@router.get("/channels/activity") +def channel_activity( + category_id: int | None = None, + uncategorized: bool = False, + limit: int = Query(ACTIVITY_DEFAULT_LIMIT, ge=1, le=ACTIVITY_MAX_LIMIT), + cursor: str | None = None, + db: Session = Depends(get_db), +) -> dict: + """Subscribed channels ordered by the freshness of their latest video + (channels without videos go last), with up to 3 recent videos each. + Cursor is (last_published_at, channel_id); an empty iso marks the + no-videos tail so pagination stays lossless across that boundary.""" + latest_video = ( + select(Video.channel_id, func.max(Video.published_at).label("last_published_at")) + .group_by(Video.channel_id) + .subquery() + ) + query = db.query(Channel, latest_video.c.last_published_at).outerjoin( + latest_video, latest_video.c.channel_id == Channel.id + ) + query = query.filter(Channel.subscribed.is_(True)) + + if uncategorized: + categorized_ids = select(channel_categories.c.channel_id) + query = query.filter(~Channel.id.in_(categorized_ids)) + elif category_id is not None: + channel_ids_in_category = select(channel_categories.c.channel_id).where( + channel_categories.c.category_id == category_id + ) + query = query.filter(Channel.id.in_(channel_ids_in_category)) + + if cursor: + cursor_published_at, cursor_channel_id = _decode_activity_cursor(cursor) + if cursor_published_at is not None: + # Channels without videos sort last (NULLS LAST), so they are + # also "after" this cursor and must pass the filter; the ORDER BY + # keeps them behind every channel that still has videos. + query = query.filter( + (latest_video.c.last_published_at < cursor_published_at) + | ( + (latest_video.c.last_published_at == cursor_published_at) + & (Channel.id < cursor_channel_id) + ) + | latest_video.c.last_published_at.is_(None) + ) + else: + query = query.filter( + latest_video.c.last_published_at.is_(None) & (Channel.id < cursor_channel_id) + ) + + rows = ( + query.order_by(latest_video.c.last_published_at.desc().nullslast(), Channel.id.desc()) + .limit(limit + 1) + .all() + ) + + next_cursor = None + if len(rows) > limit: + last_channel, last_published_at = rows[limit - 1] + next_cursor = _encode_activity_cursor(last_published_at, last_channel.id) + rows = rows[:limit] + + channels_by_id = {channel.id: channel for channel, _ in rows} + channel_ids = list(channels_by_id) + videos_by_channel: dict[int, list[Video]] = {channel_id: [] for channel_id in channel_ids} + + if channel_ids: + # Top-N videos per channel in a single windowed query (no N+1). + row_number = func.row_number().over( + partition_by=Video.channel_id, + order_by=(Video.published_at.desc(), Video.id.desc()), + ).label("rn") + windowed = ( + db.query(Video.id, row_number) + .filter(Video.channel_id.in_(channel_ids)) + .subquery() + ) + recent_ids = select(windowed.c.id).where(windowed.c.rn <= ACTIVITY_VIDEOS_PER_CHANNEL) + videos = db.query(Video).filter(Video.id.in_(recent_ids)).all() + for video in videos: + videos_by_channel[video.channel_id].append(video) + for channel_videos in videos_by_channel.values(): + channel_videos.sort(key=lambda video: (video.published_at, video.id), reverse=True) + + categories_map = channel_categories_map(db, channel_ids) + jobs_map = latest_jobs_map(db, [video.id for videos in videos_by_channel.values() for video in videos]) + new_videos_map = _new_videos_counts(db, channel_ids) + + items = [] + for channel, _ in rows: + categories = categories_map.get(channel.id, []) + items.append( + { + "channel": _serialize( + channel, [category["id"] for category in categories], new_videos_map.get(channel.id, 0) + ), + "videos": [ + serialize_video(video, channel, categories, jobs_map.get(video.id)) + for video in videos_by_channel.get(channel.id, []) + ], + } + ) + + return {"items": items, "next_cursor": next_cursor} + + @router.get("/channels/{channel_id}") def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict: channel = db.get(Channel, channel_id) diff --git a/frontend/src/App.css b/frontend/src/App.css index e272a70..088013b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -97,6 +97,27 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt .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; } +.view-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); } +.view-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; } +.view-toggle a:hover { color: var(--text); background: var(--hover); } +.view-toggle a.active { color: var(--accent); background: var(--accent-soft); } +.channel-activity-list { list-style: none; display: grid; gap: 25px; padding: 0; margin: 0; } +.channel-activity-row { display: grid; grid-template-columns: 228px minmax(0, 1fr); gap: 18px; align-items: start; min-width: 0; padding-bottom: 25px; border-bottom: 1px solid var(--border); } +.channel-activity-identity { display: flex; align-items: center; gap: 12px; min-width: 0; padding-top: 2px; color: var(--text); text-decoration: none; } +.channel-activity-identity img, .channel-activity-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; object-fit: cover; } +.channel-activity-avatar { display: grid; place-items: center; background: #344557; color: var(--text); font-size: 17px; font-weight: 700; } +.channel-activity-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 650; } +.channel-activity-identity:hover .channel-activity-name { color: var(--accent); } +.channel-activity-videos { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; min-width: 0; } +.channel-activity-empty-videos { margin: 0; align-self: center; color: var(--subtle); font-size: 12px; } +.compact-video-card { min-width: 0; display: flex; flex-direction: column; } +.compact-thumbnail { position: relative; display: block; overflow: hidden; width: 100%; aspect-ratio: 16 / 9; border-radius: 9px; background: var(--surface-raised); } +.compact-thumbnail img, .compact-thumbnail .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; } +.compact-video-title { margin-top: 7px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--text); text-decoration: none; font-size: 13px; font-weight: 600; line-height: 1.42; } +.compact-video-title:hover { color: var(--accent); } +.compact-video-time { margin-top: 4px; color: var(--muted); font-size: 11px; } +.skeleton-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; background: linear-gradient(90deg, #222 25%, #303030 50%, #222 75%); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; } +.channel-activity-skeleton .skeleton-line { margin: 0; } .empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); } .empty-state h2 { margin: 0 0 9px; font-size: 20px; } .empty-state p { font-size: 14px; line-height: 1.55; margin-bottom: 20px; } @@ -233,6 +254,8 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt .mobile-category-nav { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -5px -28px 25px; padding: 0 28px 5px; } .mobile-category-nav a { flex: none; min-height: 35px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 0 14px; background: var(--surface); color: var(--muted); font-size: 12px; text-decoration: none; } .mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); } + .channel-activity-row { grid-template-columns: 1fr; gap: 12px; padding-bottom: 21px; } + .channel-activity-identity { padding-top: 0; } } @media (max-width: 620px) { .topbar { height: 112px; display: grid; grid-template-columns: 44px 1fr auto; grid-template-rows: 55px 49px; column-gap: 7px; row-gap: 0; padding: 0 15px 8px; } @@ -247,7 +270,9 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt .page { padding: 24px 16px 58px; } .page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; } .page-heading h1 { font-size: 27px; } + .view-toggle a { min-height: 38px; padding: 0 12px; } .mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; } + .channel-activity-videos { grid-template-columns: repeat(2, minmax(0, 1fr)); } .video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; } .video-thumbnail { border-radius: 9px; } .video-info { padding: 10px 2px 0; } @@ -281,6 +306,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt @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, .sidebar-badge { min-height: 44px; } .sidebar-badge { display: inline-flex; align-items: center; } + .view-toggle a { min-height: 44px; } .reorder-buttons .icon-button { width: 40px; height: 40px; } .popover-create input, .popover-create button { height: 44px; } .popover-create button { width: 44px; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8480ae7..5a8012d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -81,6 +81,23 @@ export function listChannels( return request(`/api/channels${suffix}`) } +export interface ChannelActivityItemDto { + channel: ChannelDto + videos: FeedVideoDto[] +} + +export function getChannelActivity( + params: { categoryId?: number; uncategorized?: boolean; cursor?: string; limit?: number } = {}, +) { + const qs = new URLSearchParams() + if (params.categoryId != null) qs.set('category_id', String(params.categoryId)) + if (params.uncategorized) qs.set('uncategorized', 'true') + if (params.cursor) qs.set('cursor', params.cursor) + if (params.limit != null) qs.set('limit', String(params.limit)) + const suffix = qs.toString() ? `?${qs.toString()}` : '' + return request<{ items: ChannelActivityItemDto[]; next_cursor: string | null }>(`/api/channels/activity${suffix}`) +} + export function setChannelCategories(channelId: number, categoryIds: number[]) { return request(`/api/channels/${channelId}/categories`, { method: 'PUT', diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index e15b462..bae735a 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -63,8 +63,12 @@ function SyncIndicator() { function Sidebar({ close }: { close: () => void }) { const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categories = categoriesQuery.data ?? [] + const location = useLocation() + // Keep the current view mode ("Лента | Каналы") when switching between + // "Все видео" and category links; badges (?new=1) are feed-specific. + const viewQuery = new URLSearchParams(location.search).get('view') === 'channels' ? '?view=channels' : '' const links = [ - { to: '/', icon: 'home' as const, text: 'Все видео', end: true }, + { to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true }, { to: '/local', icon: 'server' as const, text: 'На сервере' }, ] return