Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок: список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать». - httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и BlockError; сводка сезонов (seasonSummary/seasonRanges) - тип movie↔series убран из UI (read-only); удалён веб-роут /type и handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты) - шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries - тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов - openspec: спеки review/web-ui синхронизированы, change заархивирован - беклог: сложные сериальные раздачи; oob-обновление панели действий Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
14 KiB
Markdown
178 lines
14 KiB
Markdown
## Context
|
||
|
||
Экран ревью (`web/templates/review.html` + `internal/httpapi/review.go`) сейчас
|
||
состоит из трёх секций:
|
||
|
||
- **Догадка** — тип (переключатель movie↔series, POST `/type`), название, год;
|
||
- **Источник совпадения** — единый список вариантов (`.Sources`); у каждого
|
||
не-активного варианта — нативный `<details>` с предпросмотром раскладки, и
|
||
отдельная кнопка «выбрать» (POST `/candidate` или `/nobase`); ниже — форма
|
||
ручного добавления (POST `/source`);
|
||
- **Раскладка** — та же таблица предпросмотра, но для активного источника.
|
||
|
||
Все действия — обычные `<form method=post>` c PRG-редиректом (`reviewAction` →
|
||
`redirectReview`, `303`). htmx подключён (`review.html:8`), но на странице ревью
|
||
не используется; на других страницах он уже применяется для фрагментов
|
||
(`hx-get .../progress`, `every 3s`).
|
||
|
||
Домен уже отдаёт всё нужное: `worker.ReviewData` строит `Sources []SourceOption`
|
||
(нейронка + кандидаты), у каждого — эффективные поля и эфемерный предпросмотр;
|
||
верхнеуровневые поля `reviewView` (`Title/Year/Files/...`) уже соответствуют
|
||
**активному** источнику. Инвариант «превью == применённое» обеспечивается тем,
|
||
что выбор источника пишет те же пины, что показаны в превью.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- Слить три секции в один блок: список вариантов (радио) → инфо о выбранном →
|
||
предпросмотр раскладки выбранного.
|
||
- Выбор варианта — одним кликом/тапом по строке; инфо и предпросмотр
|
||
обновляются немедленно, без полной перезагрузки (htmx частичный своп блока).
|
||
- Тип показывать read-only; убрать переключатель типа с веб-экрана.
|
||
- Сохранить: ручное добавление источника, инвариант «превью == применённое»,
|
||
тонкость транспорта (доменная логика в `worker` не трогается).
|
||
|
||
**Non-Goals:**
|
||
|
||
- Менять доменный слой `internal/worker/review.go` (выбор источника, построение
|
||
плана/превью остаются как есть).
|
||
- Менять команду `SetType` в домене и её доступность в Telegram (убираем только
|
||
веб-контрол).
|
||
- Клиентский рефреймворк/сборка. Оптимизация «не считать превью для не-активных
|
||
источников» — отдельная будущая задача, не входит сюда.
|
||
|
||
## Decisions
|
||
|
||
### Решение 1: htmx частичный своп единого блока (не полная перезагрузка)
|
||
|
||
Выделяем единый блок в партиал `web/templates/partials/review_source_block.html`
|
||
с корневым контейнером `id="source-block"`. Партиал рендерится из того же
|
||
`reviewView`: список радио из `.Sources`, инфо — из верхнеуровневых полей
|
||
активного источника (`.Title/.OriginalTitle/.Year/.IsSeries/.SeasonSummary`),
|
||
предпросмотр — из `.Files`.
|
||
|
||
Радиокнопка варианта несёт htmx-атрибуты: `hx-trigger="change"`,
|
||
`hx-target="#source-block"`, `hx-swap="outerHTML"` и `hx-post` на эндпоинт
|
||
выбора. Клик по строке (label оборачивает кликабельную зону строки) переключает
|
||
радио → `change` → POST → сервер сохраняет выбор и возвращает **свежий партиал
|
||
блока** → htmx подменяет блок. Инфо и предпросмотр в новом партиале уже
|
||
относятся к новому активному источнику.
|
||
|
||
Все радио вариантов имеют **общий `name`** для взаимной эксклюзивности;
|
||
кандидатские несут `value`=`candidate_id` и постят на `/candidate`, нейронка —
|
||
пустое `value` и постит на `/nobase` (тот `candidate_id` игнорирует).
|
||
|
||
**Внешняя ссылка «запись ↗»** у кандидата (открывается в новой вкладке) НЕ
|
||
должна попадать в кликабельную зону label — иначе клик по ссылке заодно
|
||
переключит источник. Выносим ссылку из `<label>` (или гасим всплытие клика),
|
||
чтобы «перейти к записи» и «выбрать источник» не конфликтовали.
|
||
|
||
**Почему так, а не клиентское переключение:** сохранение выбора — доменная
|
||
операция (пишет override/пины), поэтому нужен раундтрип; после него активный
|
||
источник и его превью пересчитываются на сервере единой логикой (инвариант
|
||
«превью == применённое» держится сам собой). Чистый клиентский свитч потребовал
|
||
бы дублировать превью-логику и рассинхронизировался бы с применением.
|
||
|
||
**Альтернатива (отклонено):** заранее рендерить инфо+превью всех источников и
|
||
показывать активный через CSS/JS без запроса. Отклонено: выбор не сохранялся бы,
|
||
«Применить» не знал бы что применять, и вернулась бы рассинхронизация
|
||
превью/применения.
|
||
|
||
### Решение 2: эндпоинты выбора становятся htmx-aware, без новых роутов
|
||
|
||
Переиспользуем существующие POST-эндпоинты `/candidate`, `/nobase`, `/source`.
|
||
Радио кандидата постит на `/candidate` (поле `candidate_id` = value радио), радио
|
||
нейронки — на `/nobase`, форма ручного добавления — на `/source` (тоже
|
||
`hx-post`, target = `#source-block`).
|
||
|
||
Хендлеры (`handleChooseCandidate`, `handleNoBase`, `handleAddSource`) после
|
||
успешной доменной операции определяют htmx-запрос по заголовку `HX-Request` и:
|
||
|
||
- при htmx — перечитывают `ReviewData`, рендерят **партиал блока** (`200`);
|
||
- без htmx (фолбек) — как сейчас, PRG-редирект на `/review/{id}`.
|
||
|
||
Это **новый паттерн** для проекта: существующие живые партиалы (`progress`,
|
||
`seeding`) работают через отдельные GET-роуты `/fragments/...` с htmx-поллингом,
|
||
а не через ветвление одного POST-эндпоинта по `HX-Request` — так что чтение
|
||
`r.Header.Get("HX-Request")` вводится здесь впервые. Сам механизм рендера одного
|
||
партиала уже есть: `server.render(w, "<name>", data)` вызывает
|
||
`ExecuteTemplate` по имени define (как `render(w, "progress", …)`), никаких
|
||
правок в `render` не нужно.
|
||
|
||
Ошибку (напр. невалидный ручной ввод) на htmx-пути рендерим тем же партиалом с
|
||
баннером ошибки **внутри блока** и **без смены активного источника**
|
||
(перечитанный `ReviewData` отражает прежний матч). Чтобы не задваивать баннер с
|
||
уже существующим верхним `?err=` (его показывают PRG-редиректы других действий —
|
||
apply/defer/cancel), ошибку блока держим в **отдельном поле** view (напр.
|
||
`BlockError`), которое рендерит только партиал; верхний `.Error` остаётся для
|
||
полностраничного `?err=`. Общий помощник — по образцу `reviewAction`, но с
|
||
ветвлением htmx/redirect (напр. `reviewBlockAction`).
|
||
|
||
**Почему не новый единый роут `/select`:** минимизируем изменения и
|
||
переиспользуем валидацию и доменные вызовы; семантика «кандидат» vs «без базы»
|
||
уже разведена по эндпоинтам.
|
||
|
||
### Решение 3: тип — read-only, веб-контрол `/type` убираем
|
||
|
||
Из шаблона убираем форму переключения типа; тип показываем текстом
|
||
(`фильм`/`сериал`) в инфо-части. Роут `/type` и `handleSetType` в `httpapi`
|
||
удаляем (веб — единственный их потребитель; Telegram вызывает `worker.SetType`
|
||
напрямую, доменный метод остаётся). Перед удалением — убедиться grep'ом, что на
|
||
`/type`/`handleSetType` в `httpapi` больше никто не ссылается.
|
||
|
||
Корректировать тип пользователь по-прежнему может через «Уточнить» (мягкая
|
||
подсказка «это сериал»), что согласовано в модифицированном требовании «Команды
|
||
ревью и их эффекты».
|
||
|
||
### Решение 4: инфо-часть — состав полей
|
||
|
||
Инфо-часть выбранного источника: тип (read-only), название, ориг. название, год,
|
||
для сериала — **сводка сезонов**, плюс зарезервированное место под режиссёра
|
||
(пустой прочерк).
|
||
|
||
Сезон в плане задан **на каждом файле** (`recognize.PlanFile.Season *int`), а не
|
||
на плане целиком — одна раздача может быть многосезонным паком. Поэтому в
|
||
инфо-части показываем компактную сводку по различным сезонам эпизодных файлов
|
||
эффективного плана (`rd.Plan`):
|
||
|
||
- один сезон → «Сезон 2»;
|
||
- несколько подряд → «Сезоны 1–3» (диапазон), с разрывами → список «Сезоны 1,
|
||
3, 4»;
|
||
- только спецвыпуски (`Season == nil`/0) → «Спецвыпуски».
|
||
|
||
Сводку собираем в транспорте из `rd.Plan.Files` по файлам с ролью `episode`
|
||
(игнор-файлы `applyOverrides` уже пометил ролью `ignore` — они выпадают из
|
||
фильтра). `PlanFile.Season` — `*int`, где и `nil`, и `*0` трактуются как
|
||
спецвыпуск. Добавляем в `reviewView` строковое поле `SeasonSummary string`
|
||
(пусто для фильма). Номер сезона построчно и так виден в предпросмотре раскладки
|
||
(`.../Season 02/...`); сводка — это верхнеуровневая подпись-страховка «что за
|
||
сезоны в раздаче», обычный случай — один сезон.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- **[Выбор одним кликом требует htmx/JS — нет чистого no-JS фолбека выбора]** →
|
||
htmx всегда загружен, инструмент однопользовательский домашний; прочие
|
||
действия (Применить/Уточнить/Позже/Отклонить) остаются обычными формами и
|
||
работают без JS; хендлеры сохраняют redirect-фолбек, так что без htmx выбор
|
||
деградирует до перезагрузки, а не ломается (radio без submit-кнопки, впрочем,
|
||
без JS не отправится — это осознанный компромисс UI-мелочи).
|
||
- **[Случайный клик меняет сохранённый матч]** → эффект не разрушительный
|
||
(хардлинки только по «Применить»), возврат — один клик по другому варианту.
|
||
- **[Панель действий вне свопаемого блока может рассинхрониться]** (`Применить`
|
||
зависит от `HasLinks`) → на практике план/превью есть всегда, когда есть
|
||
активный источник, поэтому набор действий при переключении источников не
|
||
меняется; если понадобится — обновляем панель через `hx-swap-oob` из того же
|
||
партиала.
|
||
- **[Мобильный тап]** → строка-вариант должна иметь крупную кликабельную зону
|
||
(label оборачивает всю строку), проверить на узком экране.
|
||
|
||
## Migration Plan
|
||
|
||
Чистая замена рендера страницы ревью; данные/БД не затрагиваются, миграций нет.
|
||
Откат — возврат шаблона и хендлеров. Деплой — обычная пересборка бинаря.
|
||
|
||
## Open Questions
|
||
|
||
- Нет — открытые вопросы закрыты (сезон показываем сводкой, см. Решение 4).
|