From fb6e63930987c3475547d9aad1d6bf7cf296e5b4 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Tue, 22 Sep 2026 07:53:19 +0000 Subject: [PATCH] Remove watch/download buttons from feed video cards Cards stay fully clickable via thumbnail and title; downloads are managed from the video page. On the Saved page the download button remains (status/delete), and the dead .button-link styles are removed. --- analytics/2026-09-18-feed-card-cleanup.md | 60 +++++++++++++++++++++++ frontend/src/App.css | 14 +++--- frontend/src/components/VideoCard.tsx | 9 ++-- frontend/src/pages/Feed.tsx | 2 +- 4 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 analytics/2026-09-18-feed-card-cleanup.md diff --git a/analytics/2026-09-18-feed-card-cleanup.md b/analytics/2026-09-18-feed-card-cleanup.md new file mode 100644 index 0000000..c936e56 --- /dev/null +++ b/analytics/2026-09-18-feed-card-cleanup.md @@ -0,0 +1,60 @@ +# Чистка карточек видео: убрать «Смотреть» и «Скачать» из ленты, оставить действия на /local + +## Задача + +Убрать из карточек видео (`VideoCard`) кнопки «Смотреть» и «Скачать» на всех ленточных маршрутах: `/`, `/category/:id`, `/uncategorized`, `/search`, а также на странице видео канала `/channels/:id/videos`. Карточка остаётся кликабельной целиком через превью и заголовок (это ссылки), скачивание остаётся доступным со страницы видео. Исключение — `/local` («На сервере»): там кнопка скачивания остаётся, т.к. показывает статус загрузки и участвует в управлении копиями. Кнопка «Смотреть» не нужна нигде в карточках (включая /local) — превью/заголовок и так ведут на страницу видео. + +## Контекст + +- `VideoCard` (frontend/src/components/VideoCard.tsx) сейчас рендерит: thumbnail-Link (с `aria-label="Смотреть: …"`), title-Link, `.video-meta`, и блок `.video-actions` с двумя элементами: «Смотреть» (`Link className="button-link"` + `Icon name="play"`) и ``. +- `VideoCard` используется ровно в двух местах: `Feed.tsx` (строка 113 — все режимы ленты, включая `/local` и `/search`) и `ChannelVideos.tsx` (строка 21). Компактные карточки в режиме «Каналы» (`CompactVideoCard`) уже без кнопок и не затрагиваются. +- `DownloadButton` остаётся нужным в трёх местах: карточки `/local`, страница видео `VideoPage` (`showDelete` — там живёт «Удалить копию»), и он не меняется. На карточке `/local` сегодня он рендерится **без** `showDelete` (кнопки «Удалить копию» в карточке нет и не было — она только на `VideoPage`); на `/local` показываются статусы («Скачать» / «Скачивается N%» / «В очереди…» / «На сервере» / «Повторить» / «Загрузить снова»). Поведение на `/local` менять не требуется — сохранить как есть. +- Кнопка «Смотреть» — единственное использование класса `.button-link` и иконки `Icon` в `VideoCard`. После удаления кнопки класс `.button-link` становится мёртвым во всём CSS (проверено rg: только App.css и VideoCard.tsx), а импорт `Icon` в `VideoCard` — неиспользуемым (`noUnusedLocals` в tsconfig → ошибка сборки, если не убрать). +- В `DownloadButton` кэш-инвалидация на завершении/удалении (`['feed']`, `['video', id]`) не зависит от места рендера — на `/local` и `VideoPage` она продолжит работать как раньше. + +## Затронутые подсистемы и файлы + +Только frontend. Backend, тесты, миграции — не трогаем. + +- `frontend/src/components/VideoCard.tsx` — добавить опциональный проп `showActions` (default `false`); блок `.video-actions` рендерить только при `showActions` и только с ``; удалить «Смотреть» и импорт `Icon`; `DownloadButton` импорт оставить; `aria-label` на thumbnail-Link сохранить. +- `frontend/src/pages/Feed.tsx` — передать `showActions={isLocal}` в `VideoCard` (строка 113). `isLocal` уже вычислен (строка 19). +- `frontend/src/pages/ChannelVideos.tsx` — без изменений (проп по умолчанию `false`). +- `frontend/src/App.css` — чистка мёртвых правил `.button-link`: + - строка 58 (transition-список) — убрать `.button-link`; + - строка 59 (общий стиль кнопок) — убрать `.button-link`; + - строки 62–63 (`.button-secondary, .button-link` + hover) — оставить только `.button-secondary`; + - строка 91 — `.video-actions .button-link, .video-actions .button-secondary` → оставить только `.video-actions .button-secondary` (secondary внутри actions остаётся: его рендерит DownloadButton на /local); + - строка 280 (media ≤620px) — убрать `.video-actions .button-link,` из списка; + - строка 307 (pointer: coarse) — убрать `.video-actions .button-link,` из списка; + - `.video-actions` (строка 90) и `.download-*` правила остаются — используются DownloadButton на `/local` и `VideoPage`. +- `README.md` — не трогаем (кнопки карточек в README не документируются; это полировка UI, не фича). + +## Критерии приёмки + +1. На `/`, `/category/:id`, `/uncategorized`, `/search` и `/channels/:id/videos` в карточках нет ни «Смотреть», ни «Скачать»; карточка кликабельна через превью и заголовок, `aria-label="Смотреть: …"` на thumbnail-Link сохранён. +2. На `/local` кнопка скачивания осталась и ведёт себя как раньше: статусы (Скачать/Скачивается N%/В очереди…/Обработка…/На сервере/Повторить/Загрузить снова) и инвалидация кэшей `['feed']`/`['video', id]` по завершении работают. «Смотреть» в карточках нет и на `/local`. +3. `DownloadButton` не сломан нигде: на `VideoPage` (`showDelete`) — скачивание, статусы и «Удалить копию» (с инвалидацией) работают как раньше. +4. CSS почищен: `rg "button-link" frontend/src` не находит ни одного вхождения (ни в CSS, ни в TSX); правила `.video-actions` и `.download-*` остаются живыми (используются DownloadButton). +5. `npm run lint` и `npm run build` в `frontend/` чистые (нет unused-импортов/локалов). +6. Backend не тронут: `git status` показывает изменения только в `frontend/` и `analytics/`; `pytest` не требуется (изменений backend/тестов нет), при желании прогоняется без изменений. +7. Деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK. + +## План + +1. `VideoCard.tsx`: `interface Props { video: FeedVideoDto; showActions?: boolean }`; деструктуризация `({ video, showActions = false })`; `.video-actions` рендерить условно `{showActions &&
}`; удалить «Смотреть»-Link и импорт `Icon`. +2. `Feed.tsx`: ``. +3. `App.css`: вычистить `.button-link` из строк 58, 59, 62, 63, 91, 280, 307 (по списку выше), не трогая `.video-actions` и `.download-*`. +4. Проверки: `rg -n "button-link" frontend/src` → пусто; `npm run lint`, `npm run build` в `frontend/`; ручная проверка: лента/категории/поиск/страница канала без кнопок, `/local` с кнопкой и статусами, `VideoPage` с «Удалить копию»; деплой и health-проверка. + +## Риски и ограничения + +- **Не потерять действия на /local**: условие рендера должно опираться на `isLocal` из `Feed.tsx`, а не на проп, вычисляемый внутри карточки (VideoCard не знает маршрут надёжно — точнее держать флаг в родителе). `ChannelVideos` по умолчанию без действий. +- **`Icon`/`DownloadButton` импорты**: после удаления «Смотреть» импорт `Icon` в `VideoCard` неиспользуемый — при `noUnusedLocals` сборка упадёт, если его оставить; импорт `DownloadButton` нужен (условный рендер). +- **«Удалить копию» на карточке /local**: в карточке её нет и не было (DownloadButton там рендерится без `showDelete`); критерий — «не сломать», а не «добавить». Удаление копии остаётся на `VideoPage` (проп `showDelete` не трогаем). +- **CSS-чистка**: класс `.button-link` после правки нигде не используется — удалять его правила целиком; `.video-actions .button-secondary` оставить (secondary-кнопки DownloadButton в блоке действий). Медиа-правила ≤620px и pointer:coarse править точечно, не трогая соседние селекторы. +- **Empty-state /local**: подсказка «Нажми „Скачать“ у понравившегося ролика…» остаётся корректной, т.к. на /local кнопка сохраняется. Но на обычной ленте кнопки больше нет — путь скачивания теперь только через страницу видео (это осознанное UX-решение задачи). +- **`Feed.tsx` уже загружен**: изменения ограничиваются одной строкой рендера — не усложнять компонент дополнительной логикой. + +## Журнал изменений + +- 2026-09-18: документ создан перед реализацией. Зафиксированы решения: проп `showActions` в `VideoCard` (default false); «Смотреть» убирается везде, DownloadButton остаётся только на `/local` (как сегодня, без `showDelete`); `ChannelVideos` не меняется; CSS: полное удаление мёртвого класса `.button-link`, сохранение `.video-actions`/`.download-*`; README не трогаем; backend не трогаем. diff --git a/frontend/src/App.css b/frontend/src/App.css index 088013b..f93c5f7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -55,12 +55,12 @@ h2 { color: var(--text); font-size: 18px; line-height: 1.3; font-weight: 650; } .page-subtitle { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; } .page-subtitle a { color: var(--accent); text-decoration: none; font-weight: 650; } .page-subtitle a:hover { text-decoration: underline; } -button, .button-primary, .button-secondary, .button-quiet, .button-danger, .button-link { transition: background .16s, color .16s, border-color .16s; } -.button-primary, .button-secondary, .button-danger, .button-link { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; padding: 0 15px; text-decoration: none; font-size: 13px; font-weight: 650; white-space: nowrap; } +button, .button-primary, .button-secondary, .button-quiet, .button-danger { transition: background .16s, color .16s, border-color .16s; } +.button-primary, .button-secondary, .button-danger { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; padding: 0 15px; text-decoration: none; font-size: 13px; font-weight: 650; white-space: nowrap; } .button-primary { border: 1px solid var(--accent); background: var(--accent); color: #081521; } .button-primary:hover { background: #79c1ff; border-color: #79c1ff; } -.button-secondary, .button-link { border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); } -.button-secondary:hover, .button-link:hover { background: var(--hover); border-color: #555; } +.button-secondary { border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); } +.button-secondary:hover { background: var(--hover); border-color: #555; } .button-quiet { min-height: 40px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; background: transparent; border: 0; color: var(--muted); border-radius: 8px; font-size: 13px; text-decoration: none; } .button-quiet:hover { color: var(--text); background: var(--hover); } .button-danger { background: #a72c2a; border: 1px solid #a72c2a; color: white; } @@ -88,7 +88,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt .video-channel { color: #c8c8c8; } .video-meta span + span::before { content: '·'; margin-right: 7px; color: var(--subtle); } .video-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: auto; padding-top: 13px; } -.video-actions .button-link, .video-actions .button-secondary { min-height: 36px; padding: 0 11px; } +.video-actions .button-secondary { min-height: 36px; padding: 0 11px; } .download-inline-actions, .download-error { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 6px; } .download-badge { position: relative; display: inline-flex; align-items: center; gap: 5px; min-height: 36px; overflow: hidden; border-radius: 7px; padding: 0 10px; background: var(--surface-raised); color: var(--muted); font-size: 12px; white-space: nowrap; } .download-badge.completed { color: var(--success); background: var(--success-soft); } @@ -277,7 +277,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt .video-thumbnail { border-radius: 9px; } .video-info { padding: 10px 2px 0; } .video-actions { padding-top: 11px; } - .video-actions .button-link, .video-actions .button-secondary, .download-badge { min-height: 44px; } + .video-actions .button-secondary, .download-badge { min-height: 44px; } .channels-toolbar { align-items: stretch; } .manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; } .manage-link svg { flex: none; } @@ -304,7 +304,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt } @media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } } @media (pointer: coarse) { - .video-actions .button-link, .video-actions .button-secondary, .download-badge, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; } + .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; } diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 8cfd7af..4df6bcf 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -2,13 +2,13 @@ import { Link, useLocation } from 'react-router-dom' import type { FeedVideoDto } from '../api/client' import { formatDuration, formatRelativeTime } from '../utils/format' import DownloadButton from './DownloadButton' -import Icon from './Icon' interface Props { video: FeedVideoDto + showActions?: boolean } -function VideoCard({ video }: Props) { +function VideoCard({ video, showActions = false }: Props) { const duration = formatDuration(video.duration_seconds) const href = `/video/${video.youtube_video_id}` const location = useLocation() @@ -30,10 +30,7 @@ function VideoCard({ video }: Props) { {video.title}
{video.channel.title}{formatRelativeTime(video.published_at)}
-
- Смотреть - -
+ {showActions &&
} ) diff --git a/frontend/src/pages/Feed.tsx b/frontend/src/pages/Feed.tsx index 4a5bbc3..b94fdd6 100644 --- a/frontend/src/pages/Feed.tsx +++ b/frontend/src/pages/Feed.tsx @@ -110,7 +110,7 @@ function Feed() { {hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ?

Категория не найдена

Ко всем видео
: isChannelsView ? : <> {feedQuery.isLoading &&
    {Array.from({ length: 8 }, (_, index) => )}
} {feedQuery.isError &&

Не удалось загрузить видео

Проверь соединение и попробуй ещё раз.

} - {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 &&
    {items.map((video) => )}
} + {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 &&
    {items.map((video) => )}
} {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 &&

{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}

{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}

{!search && !isLocal && !syncRunning && Перейти к каналам}
} {feedQuery.hasNextPage && } }