Files
jellybit/openspec/specs/web-ui/spec.md
T
avandClaude Opus 4.8 bb245a90a3 Веб-UI: обзор жизненного цикла в карточке загрузки
Карточка списка на главной теперь даёт краткий обзор «от загрузки до
решения об удалении»: метка «ID:» перед идентификатором, дата добавления
(абсолютная + относительная, всегда), размер раздачи и рейтинг отдачи.
Спойлер контекста убран — контекст смотрят на /download/{id}.

Данные:
- рейтинг и общий размер — из живого снимка воркера (qbt total_size →
  worker.Live.TotalSize); размер доступен для любой раздачи в снимке;
- размер-фолбэк, когда торрента нет в qBittorrent (orphaned) — сумма
  размеров разложенных файлов: новая колонка file_link.size, layouter
  пишет размер при линковке, ридер LayoutSizeByDownload суммирует по
  странице одним запросом (дедуп по dst_path);
- дата — source_added_at → фолбэк created_at, показ в TZ сервера.

handleIndex читает снимок для всех карточек (map-lookup), рейтинг/размер
статичны на рендере (без поллинга). Миграция 0007, ER-схема обновлена.
Change download-card-lifecycle-overview влит в спеки и заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:34:45 +03:00

336 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# web-ui Specification
## Purpose
Презентационный слой веб-интерфейса: встроенная (`go:embed`) отдача статики и
self-hosted шрифтов, единая дизайн-система (`jellybit.css`, тёмная тема по
настройке ОС), рендеринг страниц (список загрузок, ревью, просмотр) с бейджами
состояний и клиентскими взаимодействиями без сборки (копирование идентификатора
загрузки, спойлер контекста). Превью раскладки берётся из единой логики `internal/layout`,
а не дублируется в шаблонах. Тонкий транспорт над ядром (приём — `ingest`,
команды — `worker`, чтение — `store`).
## Requirements
### Requirement: Встроенная отдача статики
Сервис SHALL отдавать статические ассеты (CSS, клиентский JS, вендорные
библиотеки, шрифты) из встроенной (`go:embed`) файловой системы под префиксом
`/static/`, без внешних зависимостей времени выполнения. Бинарь остаётся
единым и самодостаточным.
#### Scenario: Запрос встроенного ассета
- **WHEN** клиент запрашивает `GET /static/css/jellybit.css`
- **THEN** сервис отвечает `200` с телом из встроенной FS и корректным
`Content-Type`
#### Scenario: Кэширование статики
- **WHEN** сервис отдаёт ответ на запрос под `/static/`
- **THEN** ответ содержит заголовок `Cache-Control`, разрешающий кэширование
ассета браузером
#### Scenario: Несуществующий ассет
- **WHEN** клиент запрашивает несуществующий путь под `/static/`
- **THEN** сервис отвечает `404` и не раскрывает структуру файловой системы
### Requirement: Шрифты без внешних сетей
Веб-UI SHALL подключать шрифты (IBM Plex Sans, IBM Plex Mono) только из
локально встроенных файлов через `@font-face`. Страницы MUST NOT обращаться к
сторонним хостам (Google Fonts CDN и т. п.) для загрузки шрифтов или стилей.
#### Scenario: Нет внешних ссылок на шрифты
- **WHEN** браузер открывает любую страницу веб-UI
- **THEN** все используемые шрифты загружаются из-под `/static/` того же
origin, без обращений к внешним доменам
### Requirement: Единая дизайн-система
Все страницы веб-UI SHALL использовать единственный CSS-файл `jellybit.css` с
дизайн-токенами; страницы MUST NOT содержать инлайн-`<style>` или хардкод
цветов вне токенов. Тема (светлая/тёмная) SHALL определяться настройкой ОС
(`color-scheme` + `prefers-color-scheme`), без отдельного переключателя.
#### Scenario: Тёмная тема по настройке ОС
- **WHEN** в ОS пользователя выбрана тёмная тема и открывается страница
- **THEN** интерфейс отображается в тёмной палитре из токенов, без действий
пользователя
#### Scenario: Нет инлайн-стилей
- **WHEN** рендерится любая страница веб-UI
- **THEN** оформление берётся из подключённого `jellybit.css`, в разметке нет
блоков `<style>` с хардкодом цветов
### Requirement: Бейдж статуса загрузки
Веб-UI SHALL отображать состояние каждой загрузки бейджем
`<span class="badge st-{STATE}">` с человекочитаемой русской подписью; класс
определяет цвет группы. Маппинг SHALL покрывать все состояния домена
(`downloading`, `completed`, `recognizing`, `linking`, `review`, `deferred`,
`done`, `stuck`, `target_missing`, `failed`, `orphaned`, `cancelled`,
`reverted`, `deleted`).
#### Scenario: Состояние отрисовано бейджем
- **WHEN** загрузка находится в состоянии `review`
- **THEN** в списке у неё бейдж с классом `st-review` и подписью «на ревью»
#### Scenario: Полнота покрытия
- **WHEN** загрузка находится в любом из состояний домена
- **THEN** для него существует класс бейджа и русская подпись (неизвестных
состояний без оформления нет)
### Requirement: Страницы веб-UI
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
добавления, **серверными фильтром по группе состояний, поиском и постраничной
выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`)
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
задач — секцией живой статистики раздачи. Карточки активных (downloading)
загрузок в списке SHALL содержать индикатор прогресса. Фильтр, поиск и номер
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
и SHALL работать без клиентского JavaScript. Состояние `deleted` SHALL быть
скрыто в списке по умолчанию (с переключателем «показать всё»). Механика живого
обновления прогресса и наполнение секции раздачи определяются capability
`live-status`.
#### Scenario: Просмотр одной загрузки
- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
историей
#### Scenario: Прогресс активной загрузки в списке
- **WHEN** в списке есть загрузка в состоянии `downloading`
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
и ETA)
#### Scenario: Удалённые скрыты по умолчанию
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать
всё» не включён
- **THEN** они не отображаются, но доступны при включённом переключателе
#### Scenario: Пагинация списка
- **WHEN** загрузок под текущим фильтром больше, чем помещается на одну
страницу, и клиент запрашивает `GET /?page=N`
- **THEN** возвращается N-я страница результатов и элементы навигации по
страницам, сохраняющие текущие фильтр и поисковый запрос
#### Scenario: Серверный фильтр и поиск
- **WHEN** клиент запрашивает список с параметрами фильтра по состоянию и/или
строкой поиска (`GET /?f=review&q=дюна`)
- **THEN** сервер возвращает только подходящие загрузки (по группе состояний и
совпадению строки в названии, любом идентификаторе загрузки — `download.id`
ИЛИ infohash — и контексте), отфильтрованные на стороне БД, а не на клиенте
### Requirement: Порядок списка загрузок
Список загрузок SHALL сортироваться по времени добавления торрента в источник
(qBittorrent `added_on`), от новых к старым. Если время добавления в источник
неизвестно, для сортировки SHALL использоваться время создания загрузки в
jellybit (`created_at`). Порядок MUST быть согласован между страницами
пагинации.
#### Scenario: Сортировка по времени добавления в источник
- **WHEN** отрисовывается список загрузок, у которых известно время добавления
в qBittorrent
- **THEN** загрузки идут от недавно добавленных к более старым по этому времени
#### Scenario: Фолбек порядка без времени источника
- **WHEN** у загрузки нет сохранённого времени добавления в источник
- **THEN** для её позиции в списке используется время создания загрузки в
jellybit
### Requirement: Заголовок загрузки из имени раздачи
Веб-UI SHALL показывать заголовком загрузки (в карточке списка и в шапке
страницы `/download/{id}`) сохранённое отображаемое имя раздачи (`display_name`,
переданное в qBittorrent при приёме). Если имя пусто, заголовок SHALL брать
распознанное название из плана; если и его нет — сырой источник (`source_ref`),
усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать
несколько строк сырым magnet.
#### Scenario: Заголовок из имени раздачи
- **WHEN** у загрузки сохранено отображаемое имя раздачи
- **THEN** карточка и страница показывают это имя заголовком
#### Scenario: Фолбек до распознавания и без имени
- **WHEN** отображаемого имени нет, но есть распознанное название
- **THEN** заголовком служит распознанное название
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
строки сырой источник, а не многострочный magnet
### Requirement: Матч с записью метабазы ссылкой
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
как ссылку на эту запись — на странице просмотра `/download/{id}` (блок
распознавания) и в едином списке источников совпадения экрана ревью (у
активного источника-кандидата). Ссылка SHALL открываться в новой вкладке с
`rel="noopener"`. Рядом со ссылкой SHALL быть видны провайдер, идентификатор
записи и (при наличии) год.
#### Scenario: Матч виден ссылкой на странице просмотра
- **WHEN** у загрузки подтверждён матч с записью метабазы и известен URL записи
- **THEN** в блоке распознавания на `/download/{id}` матч показан ссылкой на
запись с провайдером и id
#### Scenario: URL записи неизвестен
- **WHEN** матч подтверждён (например, id задан вручную), но канонический URL
записи построить нельзя
- **THEN** матч показывается текстом (провайдер и id) без ссылки, строка списка
не ломается
### Requirement: Блок информации о торренте
Страница `/download/{id}` SHALL содержать блок «Информация о торренте» с полным
исходным источником (magnet/URL/`.torrent`-ссылка) и infohash с кнопкой
копирования. Сырой источник MUST выноситься в этот блок, а не в заголовок.
#### Scenario: Полный источник в отдельном блоке
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** полный источник (magnet) и infohash показаны в блоке «Информация о
торренте», а заголовок страницы остаётся человекочитаемым именем
### Requirement: Действия соответствуют состоянию
Каждая страница SHALL показывать только действия, допустимые в текущем
состоянии загрузки, и каждое действие-кнопка SHALL отправлять форму с полями,
имена которых совпадают с ожидаемыми обработчиком (`internal/httpapi`).
#### Scenario: Набор действий по состоянию
- **WHEN** загрузка в состоянии `done`
- **THEN** доступно действие отката (undo), но недоступны «применить»/«позже»
#### Scenario: Имена полей формы
- **WHEN** пользователь отправляет форму действия (например, «уточнить»)
- **THEN** поля формы имеют `name`, которые обработчик читает без
переименования, и действие исполняется
### Requirement: Превью раскладки через единую логику именования
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
выбранного (активного) источника** — эфемерно на сервере, без записи
сохранённого матча самим показом. При смене выбранного источника превью SHALL
пересчитываться под него и обновляться частичным свопом блока. Показанные для
источника пути MUST совпадать с теми, что создались бы при применении этого
источника.
#### Scenario: Превью совпадает с реальной раскладкой
- **WHEN** на экране ревью отображается превью целевых путей для выбранного
источника
- **THEN** эти пути идентичны тем, что создаст применение этого источника (те же
правила имён, спецвыпусков, мультифайла, запрещённых символов, тега провайдера
и коллизий)
#### Scenario: Смена источника пересчитывает превью
- **GIVEN** на экране ревью показан предпросмотр раскладки активного источника
- **WHEN** пользователь выбирает другой источник в списке
- **THEN** превью пересчитывается под выбранный источник и обновляется без
полной перезагрузки страницы
#### Scenario: Переключение источника не тянет чужие поля
- **GIVEN** активен кандидат с запиненными название/год, затем выбран
источник без собственных названия/года (нейронка или ручной кандидат)
- **WHEN** строится превью и затем выполняется применение выбранного источника
- **THEN** и превью, и применение используют название/год этого источника
(из плана распознавания), без унаследованных от прежнего кандидата
### Requirement: Клиентские взаимодействия без сборки
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
фреймворков: копирование идентификатора загрузки (vanilla JS). Основной
копируемый идентификатор в карточке списка и в шапке страницы просмотра SHALL
быть `download.id` (ULID) — тот же ключ, что пишется в логи (`download_id`). Все
действия над загрузкой SHALL выполняться через формы/htmx (раундтрип на сервер),
без клиентского пересчёта доменного состояния.
#### Scenario: Копирование идентификатора загрузки
- **WHEN** пользователь нажимает кнопку копирования рядом с идентификатором
загрузки (`download.id`) в карточке списка или шапке страницы просмотра
- **THEN** значение `download.id` копируется в буфер обмена без перезагрузки
страницы
#### Scenario: Действия только через раундтрип
- **WHEN** пользователь выполняет действие над загрузкой
- **THEN** оно исполняется формой/htmx-запросом на сервер, без клиентского
пересчёта доменного состояния
### Requirement: Обзор жизненного цикла в карточке списка
Карточка загрузки в списке SHALL показывать обзорную мета-строку для решения о
судьбе раздачи: метку `ID:` перед копируемым идентификатором загрузки, дату
добавления раздачи (всегда), размер раздачи и рейтинг отдачи. Контекст загрузки
MUST NOT показываться в карточке списка — он доступен на странице `/download/{id}`.
Дата добавления SHALL показываться всегда как абсолютная дата и относительная
давность (например «`2026-06-30 · 5 дней назад`»); источником SHALL быть время
добавления раздачи в источник (`source_added_at`, qBittorrent `added_on`) с
фолбэком на время создания загрузки (`created_at`), согласованным с порядком
списка.
Рейтинг отдачи SHALL браться из живого снимка телеметрии; если торрента нет в
снимке (источник ушёл из qBittorrent), рейтинг SHALL отображаться прочерком «—».
Размер раздачи SHALL браться из живого снимка (общий размер торрента), а при
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
если неизвестно ни то, ни другое — прочерк «—».
#### Scenario: Метка идентификатора
- **WHEN** рендерится карточка загрузки в списке
- **THEN** перед значением `download.id` показана метка «ID:», а кнопка
копирования копирует именно `download.id`
#### Scenario: Дата добавления показана всегда
- **WHEN** рендерится любая карточка списка
- **THEN** в ней показана дата добавления раздачи абсолютной датой и
относительной давностью
- **AND** если `source_added_at` неизвестно, используется `created_at`
#### Scenario: Рейтинг из живого снимка
- **WHEN** торрент загрузки присутствует в живом снимке
- **THEN** в карточке показан его рейтинг отдачи
- **AND** если торрента в снимке нет, рейтинг показан прочерком «—»
#### Scenario: Размер с фолбэком на разложенные файлы
- **WHEN** торрент загрузки присутствует в живом снимке
- **THEN** размер раздачи в карточке берётся из общего размера торрента
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
размер берётся из суммарного размера этих файлов
#### Scenario: Контекст не в карточке
- **WHEN** у загрузки есть переданный контекст
- **THEN** он не показывается в карточке списка, но доступен на странице
`/download/{id}`