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.
This commit is contained in:
vrubelroman 2026-09-22 07:53:19 +00:00
parent d4950f6979
commit fb6e639309
4 changed files with 71 additions and 14 deletions

View file

@ -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"`) и `<DownloadButton video={video} />`.
- `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` и только с `<DownloadButton>`; удалить «Смотреть» и импорт `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 && <div className="video-actions"><DownloadButton video={video} /></div>}`; удалить «Смотреть»-Link и импорт `Icon`.
2. `Feed.tsx`: `<VideoCard key={…} video={video} showActions={isLocal} />`.
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 не трогаем.

View file

@ -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 { 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 { color: var(--accent); text-decoration: none; font-weight: 650; }
.page-subtitle a:hover { text-decoration: underline; } .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, .button-primary, .button-secondary, .button-quiet, .button-danger { transition: background .16s, color .16s, border-color .16s; }
.button-primary, .button-secondary, .button-danger, .button-link { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; padding: 0 15px; text-decoration: none; font-size: 13px; font-weight: 650; white-space: nowrap; } .button-primary, .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 { border: 1px solid var(--accent); background: var(--accent); color: #081521; }
.button-primary:hover { background: #79c1ff; border-color: #79c1ff; } .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 { 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: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 { 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-quiet:hover { color: var(--text); background: var(--hover); }
.button-danger { background: #a72c2a; border: 1px solid #a72c2a; color: white; } .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-channel { color: #c8c8c8; }
.video-meta span + span::before { content: '·'; margin-right: 7px; color: var(--subtle); } .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 { 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-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 { position: relative; display: inline-flex; align-items: center; gap: 5px; min-height: 36px; overflow: hidden; border-radius: 7px; padding: 0 10px; background: var(--surface-raised); color: var(--muted); font-size: 12px; white-space: nowrap; }
.download-badge.completed { color: var(--success); background: var(--success-soft); } .download-badge.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-thumbnail { border-radius: 9px; }
.video-info { padding: 10px 2px 0; } .video-info { padding: 10px 2px 0; }
.video-actions { padding-top: 11px; } .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; } .channels-toolbar { align-items: stretch; }
.manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; } .manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
.manage-link svg { flex: none; } .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 (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } }
@media (pointer: coarse) { @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; } .sidebar-badge { display: inline-flex; align-items: center; }
.view-toggle a { min-height: 44px; } .view-toggle a { min-height: 44px; }
.reorder-buttons .icon-button { width: 40px; height: 40px; } .reorder-buttons .icon-button { width: 40px; height: 40px; }

View file

@ -2,13 +2,13 @@ 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
showActions?: boolean
} }
function VideoCard({ video }: Props) { function VideoCard({ video, showActions = false }: 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 location = useLocation()
@ -30,10 +30,7 @@ function VideoCard({ video }: Props) {
{video.title} {video.title}
</Link> </Link>
<div className="video-meta"><span className="video-channel">{video.channel.title}</span><span>{formatRelativeTime(video.published_at)}</span></div> <div className="video-meta"><span className="video-channel">{video.channel.title}</span><span>{formatRelativeTime(video.published_at)}</span></div>
<div className="video-actions"> {showActions && <div className="video-actions"><DownloadButton video={video} /></div>}
<Link to={href} state={{ from }} onClick={rememberScroll} className="button-link"><Icon name="play" size={16} /> Смотреть</Link>
<DownloadButton video={video} />
</div>
</div> </div>
</li> </li>
) )

View file

@ -110,7 +110,7 @@ function Feed() {
{hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? <div className="empty-state"><h2>Категория не найдена</h2><Link className="button-primary" to="/">Ко всем видео</Link></div> : isChannelsView ? <ChannelActivityList categoryId={categoryNumber} uncategorized={isUncategorized} syncRunning={syncRunning} /> : <> {hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? <div className="empty-state"><h2>Категория не найдена</h2><Link className="button-primary" to="/">Ко всем видео</Link></div> : isChannelsView ? <ChannelActivityList categoryId={categoryNumber} uncategorized={isUncategorized} syncRunning={syncRunning} /> : <>
{feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>} {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.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 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} showActions={isLocal} />)}</ul>}
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncRunning && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>} {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncRunning && <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>} {feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</>} </>}