UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)

- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
  f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
  (added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
  усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
  URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
  фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-01 09:50:24 +03:00
co-authored by Claude Opus 4.8
parent 2a9bf7efb0
commit c0b5ab7295
33 changed files with 1679 additions and 110 deletions
@@ -0,0 +1,48 @@
## 1. Персистентность имени раздачи и времени добавления (БД + ingest + worker)
- [x] 1.1 Миграция `0005` (goose): `download.display_name TEXT NOT NULL DEFAULT ''` и `download.source_added_at` (nullable timestamp), обратная миграция (down)
- [x] 1.2 Добавить поля `DisplayName` и `SourceAddedAt` в `store.Download`; писать `display_name` в `CreateDownload`; метод однократной записи `source_added_at` с SQL-гардом `WHERE source_added_at IS NULL`; экспортировать хелпер форматирования времени в формате `sqliteTimeLayout` (UTC)
- [x] 1.3 В `internal/ingest` заполнять `DisplayName` выведенным именем `rename` при создании загрузки; в `discover.adopt()``display_name = t.Name` для усыновлённых торрентов
- [x] 1.4 В `internal/worker` персистить `source_added_at` из `qbt.Torrent.AddedOn` в цикле `Poll` сразу после `byHash`-сопоставления (до `reconcile`, для любого класса состояния) и в `adopt()`; формат — `time.Unix(...).UTC().Format(sqliteTimeLayout)`; однократно (гард по `d.SourceAddedAt` + SQL IS NULL); под `w.mu`
- [x] 1.5 Обновить ER-схему `docs/specs/database.md` (колонки `display_name`, `source_added_at`)
- [x] 1.6 Тесты: ingest сохраняет `display_name`; adopt пишет `display_name` из имени торрента; worker пишет `source_added_at` однократно и в формате `created_at`; пустой контекст → пустое имя
## 2. Серверные фильтр, поиск, сортировка и пагинация списка (store)
- [x] 2.1 Вынести маппинг состояние↔группа фильтра в единый источник истины (используется и в UI, и в запросе); учесть `all` с исключением `deleted` и осознанно — группу `other` (cancelled/reverted/deleted) без своего чипа
- [x] 2.2 Метод `store.ListDownloadsPage(ctx, ListFilter)` → страница + общий count: `WHERE` по группе состояний, `deleted` только при «показать всё», поиск `LIKE`/`COLLATE NOCASE` по `source_ref`/`display_name`/`context`/`infohash`, сортировка `ORDER BY COALESCE(source_added_at, created_at) DESC, id DESC`, `LIMIT`/`OFFSET`
- [x] 2.3 Тесты `store`: фильтр по группе, поиск, скрытие `deleted` по умолчанию, порядок по времени добавления с фолбеком + тай-брейк по id при равных метках, границы пагинации и count
## 3. Список на главной (httpapi + шаблоны)
- [x] 3.1 Разбор GET-параметров `f`/`q`/`page`/`all` в `handleIndex` (клэмп `page`: `<1`/нечисловой → 1, за пределом → пустая страница), константа размера страницы (25), вызов `ListDownloadsPage`; расширить интерфейс `Reader` методом `ListDownloadsPage`
- [x] 3.2 View-модель списка: элементы страницы + данные пагинации + текущие фильтр/поиск; удалить ставшие мёртвыми `SearchText` и (если больше не нужен) `Group` из `downloadView`
- [x] 3.3 `index.html`: чипы-фильтр и «показать всё» как ссылки (сохраняют `q`), форма поиска (GET), навигация по страницам (сохраняет `f`/`q`); серверный рендер пустого состояния («ничего не найдено» при активных фильтре/поиске, иначе «пока пусто»)
- [x] 3.4 Убрать клиентскую JS-фильтрацию из `app.js` (`applyFilters`/`setFilter` + `DOMContentLoaded`-хук), оставить `copyHash` и `<details>`; сохранить живой прогресс активных карточек
- [x] 3.5 Заголовок карточки — фолбек-цепочка `display_name` → распознанное → усечённый источник (одна строка)
- [x] 3.6 Тесты `httpapi`: параметры списка → корректная выборка/пагинация; заголовок из `display_name` и фолбеки
## 4. Страница загрузки: имя, матч-ссылка, блок торрента (httpapi + шаблоны)
- [x] 4.1 Заголовок/шапка `/download/{id}` — та же фолбек-цепочка заголовка
- [x] 4.2 Хелпер `providerURL(provider, id, mediaType)` (tmdb: movie/tv; tvdb: медиазависимый путь; imdb: /title) + выбор URL матча: URL выбранного кандидата только если его provider+id == эффективным `rd.Provider`/`rd.ProviderID`, иначе построенный; нет надёжного URL → без ссылки
- [x] 4.3 «Распознано как»: показать матч ссылкой на запись (провайдер, id, год); при отсутствии URL — текст без ссылки
- [x] 4.4 Блок «Информация о торренте»: полный источник (magnet) + infohash с копированием
- [x] 4.5 Тесты `httpapi`: матч-ссылка при наличии/отсутствии URL; расхождение выбранного кандидата и эффективного provider_id (ссылка ведёт на эффективную запись); блок торрента
## 5. Ревью: матч-ссылка (httpapi + шаблон)
- [x] 5.1 В блоке «Источник совпадения» строку «Выбрано» показывать ссылкой на запись (та же логика выбора URL, что в 4.2)
## 6. Полировка вёрстки (CSS/шаблоны)
- [x] 6.1 «Распознано как»: снять `max-width` у списка полей — блок на всю ширину
- [x] 6.2 Layout-виджет: приклеить стрелку к строке файла-источника (источник и цель строго друг под другом)
- [x] 6.3 Проверить отсутствие инлайн-`<style>` с хардкодом цветов в изменённых шаблонах (правки — в `jellybit.css`)
## 7. Проверка и ревью
- [x] 7.1 `task test` и `task lint` зелёные
- [x] 7.2 `openspec validate web-ui-list-detail --strict`
- [ ] 7.3 Ручная проверка: список с фильтром/поиском/пагинацией, живой прогресс не сбрасывается; заголовки; матч-ссылки; блок торрента; полировка
- [x] 7.4 Ревью кода перед archive