Compare commits

..
8 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 9472bfdd83 Спеки+беклог: закрытие батча UI-задач (favicon, cancelled, значок типа, ellipsis, oob-панель)
Реализованное поведение перенесено в openspec/specs:
- web-ui: cancelled скрыт по умолчанию наравне с deleted; значок типа в карточке;
- review: панель действий обновляется oob-фрагментом при свопе источника.
Пять закрытых задач удалены из docs/backlog + индекса.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:46:28 +03:00
avandClaude Opus 4.8 3f80d36727 Веб-UI: правило .type-ico для значка типа в карточке (ревью-nit)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:35:32 +03:00
av 49747e1281 Слияние WT-3: oob-обновление панели действий ревью 2026-07-11 09:32:53 +03:00
av 3465c1ea62 Слияние WT-2: список загрузок и форма приёма (cancelled hide, значок типа, ellipsis) 2026-07-11 09:32:52 +03:00
avandClaude Opus 4.8 44c5952e49 Список: скрытие cancelled, значок типа, ellipsis имени .torrent
Три мелкие правки веб-UI списка загрузок:

- Скрываем cancelled в общем списке наравне с deleted: под группой all
  без IncludeDeleted теперь `state NOT IN (deleted, cancelled)`. reverted
  остаётся видимым. Тумблер и комментарии-инварианты приведены в
  соответствие («включая отменённые и удалённые»).
- Значок типа (🎬 фильм / 📺 сериал) в строке списка: media_type текущей
  попытки распознавания протянут через LEFT JOIN в ListDownloadsPage
  (Download.RecMediaType, симметрично RecTitle) и downloadView.MediaType;
  значок рендерится в card.html, при нераспознанном типе значка нет.
- Ellipsis для длинного имени .torrent-файла: у .btn-file max-width +
  overflow/ellipsis на лейбле, чтобы длинное имя не распирало .add-row;
  показываем базовое имя без .torrent, полное — в title (JS-энхансмент).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:31:27 +03:00
avandClaude Opus 4.8 847d471852 Ревью: oob-обновление панели действий при свопе источника
Кнопка «Применить» (завязана на HasLinks) лежит вне #source-block и не
обновлялась при htmx-свопе выбора источника — в краевом случае (пустой
предпросмотр из-за коллизии) рассинхронивалась с превью до полной
перезагрузки. Выделил панель в партиал review_action_bar с id=action-bar;
reviewBlockAction теперь рендерит review_source_swap — свежий #source-block
плюс oob-копию панели (hx-swap-oob), так кнопка синхронно отражает HasLinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:28:45 +03:00
avandClaude Opus 4.8 2545d64e8b Веб-UI: фавиконка (SVG) для вкладки/PWA
SVG-иконка (jelly-фиолетовый бейдж с play-треугольником и «битами»),
подключена во все три страничных шаблона через asset-хелпер; статика
уже раздаётся и embed берёт web/static целиком.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:25:50 +03:00
avandClaude Opus 4.8 c4c0c6bb3f Беклог: груминг трёх задач из Tududi (значок типа, download id code, ревью уведомлений)
Перенос сырых задач проекта jellybit из Tududi в беклог с контекстом и
кросс-ссылками:
- значок типа (фильм/сериал) в списке загрузок;
- download id моноширинным (code) в Telegram — упирается в parse mode + escape;
- зонтичный аудит уведомлений бота.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:15:56 +03:00
23 changed files with 255 additions and 118 deletions
+2 -4
View File
@@ -40,9 +40,9 @@ Tududi (проект `jellybit`) больше **не** держит беклог
## Низкий ## Низкий
- [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
- [Download id в Telegram моноширинным (code) для tap-to-copy](telegram-download-id-code.md) — слать id как `code`; требует включить parse mode (HTML) в send() + escape всех текстов
- [Кнопка «Обновить имя»: полный формат ярлыка (Название (режиссёр, год), сезон)](knopka-obnovit-imya-polnyj-format.md) — Перелив имени даёт усечённое «Title (Year)» вместо полного формата add-шага (режиссёр+сезон); нужный `render` уже есть - [Кнопка «Обновить имя»: полный формат ярлыка (Название (режиссёр, год), сезон)](knopka-obnovit-imya-polnyj-format.md) — Перелив имени даёт усечённое «Title (Year)» вместо полного формата add-шага (режиссёр+сезон); нужный `render` уже есть
- [Скрывать отменённые (cancelled) в общем списке наравне с удалёнными](skryvat-otmenennye-v-spiske.md) — cancelled шумит в ленте; тумблер «показать удалённые» должен раскрывать и cancelled, и deleted
- [Панель действий ревью вне htmx-свопа блока источника](panel-review-vne-swap.md) — При выборе источника одним кликом обновляется только блок источника (#source-block)…
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает… - [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_ - [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало - [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
@@ -50,7 +50,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в… - [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
- [Улучшения UI: показывать матч с записью метабазы в Telegram](telegram-match-metabazy.md) — Название/год/провайдер+id в боте уже выводятся; осталась кликабельная ссылка на запись - [Улучшения UI: показывать матч с записью метабазы в Telegram](telegram-match-metabazy.md) — Название/год/провайдер+id в боте уже выводятся; осталась кликабельная ссылка на запись
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард) - [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
- [Форма приёма: длинное имя .torrent-файла ломает верстку add-row](torrent-fayl-dlinnoe-imya.md) — длинное имя файла распирает лейбл файл-пикера, поле source сжимается — нужен ellipsis
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска… - [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске - [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
- [Кэш метабаз (и опционально LLM)](kesh-metabaz.md) — Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и тем же… - [Кэш метабаз (и опционально LLM)](kesh-metabaz.md) — Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и тем же…
@@ -58,7 +57,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [[идея] Завершение загрузки через webhook](webhook-zavershenie-zagruzki.md) — ИДЕЯ (решим по опыту эксплуатации) - [[идея] Завершение загрузки через webhook](webhook-zavershenie-zagruzki.md) — ИДЕЯ (решим по опыту эксплуатации)
- [Авторизация веб-UI (на будущее)](avtorizaciya-web-ui.md) — Решено для v1: без авторизации в доверенной LAN, опц - [Авторизация веб-UI (на будущее)](avtorizaciya-web-ui.md) — Решено для v1: без авторизации в доверенной LAN, опц
- [Современный Web-UI как PWA](web-ui-pwa.md) — Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое, отзывчивое… - [Современный Web-UI как PWA](web-ui-pwa.md) — Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое, отзывчивое…
- [Сделать фавиконку для jellybit](favicon.md) — мелкая косметика: иконка вкладки/PWA для веб-UI
- [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](review-f4-f5-infohash-identity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_ - [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](review-f4-f5-infohash-identity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_
- [Приём/UI: мелкие фиксы границ и парсинга (F7, F8, F9, F10, N2)](review-f7-f10-ingest-ui-fixes.md) — мелочи приёма/UI: oversized→500, гонки дедупа, парсинг magnet, cap контекста, руны _(ревью 2026-07-08)_ - [Приём/UI: мелкие фиксы границ и парсинга (F7, F8, F9, F10, N2)](review-f7-f10-ingest-ui-fixes.md) — мелочи приёма/UI: oversized→500, гонки дедупа, парсинг magnet, cap контекста, руны _(ревью 2026-07-08)_
- [Жизненный цикл: мелкие находки (MINOR-8, MINOR-9, NIT-11, NIT-12)](review-lifecycle-minor.md) — claim-token распознавания, I/O под глобальным mutex, мелкие lookup/retry _(ревью 2026-07-08)_ - [Жизненный цикл: мелкие находки (MINOR-8, MINOR-9, NIT-11, NIT-12)](review-lifecycle-minor.md) — claim-token распознавания, I/O под глобальным mutex, мелкие lookup/retry _(ревью 2026-07-08)_
-5
View File
@@ -1,5 +0,0 @@
# Сделать фавиконку для jellybit
**Приоритет:** низкий
_Описание не заполнено._
-7
View File
@@ -1,7 +0,0 @@
# Панель действий ревью вне htmx-свопа блока источника
**Приоритет:** низкий
При выборе источника одним кликом обновляется только блок источника (#source-block) htmx-свопом, а нижняя панель действий (кнопка «Применить», завязанная на HasLinks) — вне блока и не обновляется до полной перезагрузки. Практически не мешает (хардлинки только по явному «Применить», Apply без плана вернёт ошибку), но в краевом случае (источник с пустым предпросмотром из-за коллизии) кнопка может остаться/пропасть не синхронно. Решение намечено в дизайне review-unified-source-block: обновлять панель hx-swap-oob из того же партиала.
Связано: openspec/specs/review, openspec/specs/web-ui, пакет httpapi.
@@ -1,35 +0,0 @@
# Скрывать отменённые (cancelled) в общем списке наравне с удалёнными
**Приоритет:** низкий · **Теги:** web-ui
Отменённые загрузки (`cancelled`) должны по умолчанию скрываться в общем списке
так же, как удалённые (`deleted`), а тумблер «Показать всё, включая удалённые»
(`web/templates/index.html:52`) должен показывать **и те, и те**. Сейчас под
группой `all` скрыт только `deleted`, а `cancelled` всегда виден и шумит в ленте
— тем более что новый стоп-кран «Закрыть» будет плодить `cancelled`-записи.
Где править:
- `internal/store/list.go:62-66` — ветка `else if !f.IncludeDeleted` под группой
`all` скрывает только `StateDeleted` (`state <> ?`). Расширить до
`state NOT IN (deleted, cancelled)`.
- Осмыслить семантику флага `ListFilter.IncludeDeleted` (`list.go:45`): теперь он
«показывать скрытые терминальные» (deleted **и** cancelled). Либо переименовать
(`IncludeHidden`/`IncludeTerminal`), либо оставить имя и уточнить комментарий.
- Метка тумблера (`index.html:52`) и текст — при желании подправить («…включая
отменённые и удалённые»); `ShowAll`/`all=1` уже управляет показом.
- Комментарии-инварианты про «под all видны» в `internal/store/list.go:22-26` и
`statesInGroup` (cancelled/reverted/deleted не имеют чипа) — привести в
соответствие.
Замечания:
- `cancelled` своей группы-чипа не имеет (`statesInGroup`, `list.go:29-40`) — под
группами `review/active/done/problem` он и так не появляется, правка нужна
только в ветке `all`.
- `reverted` пользователь не упоминал — по умолчанию оставляем видимым; при
желании обсудить симметрию отдельно.
- Обновить тест `TestListDownloadsPageFilterAndDeleted`
(`internal/store/list_test.go`) — добавить `cancelled` в скрытые по умолчанию и
видимые под `IncludeDeleted`.
Связано: `openspec/specs/web-ui`, пакеты `internal/store`, `internal/httpapi`,
конвенция [web-ui](../conventions/web-ui.md).
+21
View File
@@ -0,0 +1,21 @@
# Download id в Telegram моноширинным (code) для tap-to-copy
**Приоритет:** низкий
Сейчас download id выводится в уведомлениях бота обычным текстом с префиксом `#`
(`internal/tgbot/bot.go:288`, `render.go:26/50/151`). Хотим слать id как
`code`-текст (моноширинный) — в клиентах Telegram по нему работает tap-to-copy,
удобно скопировать id для перехода на `/download/{id}` или для диагностики.
Что учесть:
- `send()` (`internal/tgbot/bot.go:442`) сейчас не выставляет `ParseMode` — всё
уходит plain text. Для `code` нужно включить parse mode. Рекомендуется **HTML**
(`<code>%s</code>`) — экранирование проще и локальнее, чем у MarkdownV2.
- Включение parse mode затрагивает **все** исходящие сообщения: спецсимволы в
display name, путях, названиях сломают разметку, если их не экранировать.
Escape-хелпера сейчас нет — нужен, плюс аккуратный проход по всем текстам
`render.go`. Это часть общего [[telegram-revyu-uvedomleniy]] — имеет смысл делать
вместе.
Связано: `internal/tgbot` (`render.go`, `bot.go`).
@@ -0,0 +1,25 @@
# Ревью уведомлений в Telegram (аудит текстов и формата)
**Приоритет:** низкий
Зонтичная задача: пройтись по всем исходящим уведомлениям и запросам подтверждения
бота, выправить формулировки, состав данных и оформление. Тексты формируются в
`internal/tgbot/render.go`, отправка — `internal/tgbot/bot.go`; parse mode не задан
(plain text), моноширинных/жирных акцентов нет.
На что смотреть при аудите:
- **Полнота карточек:** показываем ли нужное — название, тип (фильм/сериал), год,
запись матча в метабазе, download id, причину `failed`.
- **Единый язык:** в текстах бота вперемешку «задача»/«раздача»/«загрузка» —
свести к доменному `Download` (см. [[ubiquitous-language-slovar]]).
- **Оформление:** моноширинный download id [[telegram-download-id-code]], возможно
акценты — упирается в parse mode + escape (общая развилка с задачей про code).
- **Не дублировать** уже заведённое: матч метабазы в боте
[[telegram-match-metabazy]], мульти-бот адресация уведомлений
[[uvedomleniya-multi-bot]], выбор из нескольких находок [[telegram-vybor-nahodok]].
Итог аудита — конкретные под-задачи (эта их порождает). Проход дешёвый, при желании
приоритет можно поднять.
Связано: `internal/tgbot`, [docs/specs/review-ux.md](../specs/review-ux.md).
-30
View File
@@ -1,30 +0,0 @@
# Форма приёма: длинное имя .torrent-файла ломает верстку add-row
**Приоритет:** низкий · **Теги:** web-ui, ingest
При выборе `.torrent`-файла с длинным именем (типичный релиз с трекера, напр.
«Бухта вдов Widow's Bay Сезон 1 Серии 1-10 из 10 (Хиро Мурай) [2026, США, …,
WEB-DL 1080p] 7 x MVO (HDr [rutracker-6869937].torrent») кнопка-лейбл файл-пикера
раздувается на всю ширину и ломает раскладку строки добавления: поле `source`
сжимается/прячется, «Добавить» уезжает.
Причина: onchange пишет полное `files[0].name` в `[data-file-label]`
(`web/templates/index.html:26-30`), а у `.btn-file` нет ограничения ширины
(`web/static/css/jellybit.css:194` — только цвет). В `.add-row`
(`display:flex`, `.input{flex:1}`) лейбл распирается по контенту и съедает строку.
Фикс (косметика, без домена):
- CSS `.btn-file`: `max-width` + `overflow:hidden; text-overflow:ellipsis;
white-space:nowrap` — имя усекается многоточием, а не растягивает строку.
- Опц.: показывать базовое имя без `.torrent` и класть полное в `title`
(тултип). Полное имя всё равно уходит на сабмит через сам file-input
(`header.Filename`), правка чисто визуальная.
Смежное (проверить заодно, не обязательно тем же фиксом): на приёме `source_ref`
выводится из имени раздачи/файла и может быть очень длинным. В списке/карточке он
усечён (`shorten(oneLine(d.SourceRef), 120)` — `internal/httpapi/download.go:67`),
так что отображение ок; убедиться лишь, что нет жёсткого лимита колонки в БД,
роняющего сам приём длинного имени.
Связано: `openspec/specs/ingest`, `openspec/specs/web-ui`, пакет `httpapi`,
конвенция [web-ui](../conventions/web-ui.md).
+66
View File
@@ -10,6 +10,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
) )
@@ -272,6 +274,70 @@ func TestActionErrorDownloadSurface(t *testing.T) {
} }
} }
// reviewDataWithSource собирает ReviewData ревью с одним нейронка-источником;
// withLinks управляет наличием превью раскладки (HasLinks).
func reviewDataWithSource(withLinks bool) *worker.ReviewData {
dl := dlState(store.StateReview)
plan := recognize.Plan{Type: "movie", Title: "Fargo", Year: 1996,
Files: []recognize.PlanFile{{Src: "fargo.mkv"}}}
rd := &worker.ReviewData{
Download: dl,
Recognition: &store.Recognition{},
Plan: plan,
Sources: []worker.SourceOption{
{Kind: worker.SourceNeural, Provider: "none", Title: "Fargo", Year: 1996, Active: true},
},
}
if withLinks {
rd.Preview = []layout.Link{{Src: "fargo.mkv", Dst: "/movies/Fargo (1996)/fargo.mkv"}}
}
return rd
}
// TestSourceSwapUpdatesActionBarOOB: своп выбора источника отдаёт свежий
// #source-block и oob-обновление панели действий (#action-bar с hx-swap-oob),
// чтобы кнопка «Применить» синхронно отражала актуальный HasLinks.
func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
t.Run("с превью → есть кнопка Применить", func(t *testing.T) {
rd := reviewDataWithSource(true)
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="source-block"`) {
t.Errorf("ответ без свежего #source-block: %s", body)
}
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
t.Errorf("ответ без oob-панели действий: %s", body)
}
if !strings.Contains(body, "/apply") {
t.Errorf("HasLinks=true, но в панели нет кнопки «Применить»: %s", body)
}
})
t.Run("без превью → кнопки Применить нет", func(t *testing.T) {
rd := reviewDataWithSource(false)
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
t.Errorf("ответ без oob-панели действий: %s", body)
}
if strings.Contains(body, "/apply") {
t.Errorf("HasLinks=false, но в панели осталась кнопка «Применить»: %s", body)
}
})
}
// TestRetryListShowsProgress: retry из списка → карточка downloading с // TestRetryListShowsProgress: retry из списка → карточка downloading с
// прогресс-поллером. // прогресс-поллером.
func TestRetryListShowsProgress(t *testing.T) { func TestRetryListShowsProgress(t *testing.T) {
+2
View File
@@ -200,6 +200,7 @@ type pageLink struct {
type downloadView struct { type downloadView struct {
ID string ID string
Title string // отображаемый заголовок карточки Title string // отображаемый заголовок карточки
MediaType string // тип контента для значка строки списка (movie/series; пусто — не распознан)
State string State string
Error string Error string
Terminal bool Terminal bool
@@ -652,6 +653,7 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
v := downloadView{ v := downloadView{
ID: d.ID, ID: d.ID,
Title: downloadTitle(d), Title: downloadTitle(d),
MediaType: d.RecMediaType.String, // пусто, если распознавания/типа ещё нет
State: state, State: state,
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(), Terminal: d.State.IsTerminal(),
+10 -4
View File
@@ -59,6 +59,7 @@ type reviewView struct {
NoBase bool // выбрано «без базы» NoBase bool // выбрано «без базы»
Sources []sourceView // единый список источников совпадения Sources []sourceView // единый список источников совпадения
BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=) BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=)
ActionBarOOB bool // рендерить панель действий как oob-фрагмент (своп источника)
} }
// sourceView — строка единого списка источников на экране ревью: нейронка или // sourceView — строка единого списка источников на экране ревью: нейронка или
@@ -455,9 +456,10 @@ func isHTMX(r *http.Request) bool {
// reviewBlockAction — помощник для действий выбора источника: выполнить // reviewBlockAction — помощник для действий выбора источника: выполнить
// операцию и вернуть свежий блок источника. На htmx-запрос перечитывает // операцию и вернуть свежий блок источника. На htmx-запрос перечитывает
// состояние и рендерит партиал `review_source_block` (ошибку кладёт в // состояние и рендерит `review_source_swap` — партиал `review_source_block`
// BlockError, активный источник не меняется); без htmx деградирует до // плюс oob-обновление панели действий `review_action_bar` (кнопка «Применить»
// PRG-редиректа, как reviewAction. // зависит от HasLinks, а лежит вне блока), ошибку кладёт в BlockError, активный
// источник не меняется; без htmx деградирует до PRG-редиректа, как reviewAction.
func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) { func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -487,7 +489,11 @@ func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn fu
if actionErr != nil { if actionErr != nil {
view.BlockError = userErr(r, actionErr, id) view.BlockError = userErr(r, actionErr, id)
} }
s.render(w, "review_source_block", view) // Панель действий («Применить» завязана на HasLinks) лежит вне #source-block,
// поэтому обновляем её тем же ответом через hx-swap-oob — иначе кнопка
// рассинхронилась бы с превью до полной перезагрузки.
view.ActionBarOOB = true
s.render(w, "review_source_swap", view)
} }
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL // matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
+5
View File
@@ -204,6 +204,11 @@ type Download struct {
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в // Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
// прочих выборках остаётся пустым. // прочих выборках остаётся пустым.
RecTitle sql.NullString `db:"rec_title"` RecTitle sql.NullString `db:"rec_title"`
// RecMediaType — тип текущей попытки распознавания (movie/series; LEFT JOIN
// recognition). Как и RecTitle, заполняется только листингом
// ListDownloadsPage — для значка типа в строке списка; в прочих выборках пуст.
RecMediaType sql.NullString `db:"rec_media_type"`
} }
// HashList — все хеши загрузки списком (для сопоставления с qBittorrent). // HashList — все хеши загрузки списком (для сопоставления с qBittorrent).
+14 -10
View File
@@ -11,7 +11,7 @@ import (
type StateGroup string type StateGroup string
const ( const (
GroupAll StateGroup = "all" // все, кроме deleted (если не IncludeDeleted) GroupAll StateGroup = "all" // все, кроме скрытых терминальных (deleted/cancelled), если не IncludeDeleted
GroupReview StateGroup = "review" // ждут человека GroupReview StateGroup = "review" // ждут человека
GroupActive StateGroup = "active" // в работе GroupActive StateGroup = "active" // в работе
GroupDone StateGroup = "done" // готово GroupDone StateGroup = "done" // готово
@@ -21,8 +21,9 @@ const (
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает // statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и // выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/ // нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
// reverted/deleted своей группы (чипа) не имеют — под all они видны (deleted — // reverted/deleted своей группы (чипа) не имеют. Под all скрытые терминальные
// только при IncludeDeleted), точечно к ним не фильтруем (см. tasks 2.1). // (deleted и cancelled — шум в ленте) видны только при IncludeDeleted; reverted
// остаётся видимым по умолчанию. Точечно к ним не фильтруем (см. tasks 2.1).
func statesInGroup(g StateGroup) []State { func statesInGroup(g StateGroup) []State {
switch g { switch g {
case GroupReview: case GroupReview:
@@ -42,7 +43,7 @@ func statesInGroup(g StateGroup) []State {
type ListFilter struct { type ListFilter struct {
Group StateGroup // группа состояний ("" / all — без сужения) Group StateGroup // группа состояний ("" / all — без сужения)
Query string // поиск по id/названию/infohash/контексту (подстрока) Query string // поиск по id/названию/infohash/контексту (подстрока)
IncludeDeleted bool // показывать состояние deleted (только под all) IncludeDeleted bool // показывать скрытые терминальные deleted+cancelled (только под all)
Limit int // размер страницы Limit int // размер страницы
Offset int // смещение страницы Offset int // смещение страницы
} }
@@ -60,9 +61,11 @@ func listWhere(f ListFilter) (string, []any) {
} }
conds = append(conds, "state IN ("+strings.Join(ph, ",")+")") conds = append(conds, "state IN ("+strings.Join(ph, ",")+")")
} else if !f.IncludeDeleted { } else if !f.IncludeDeleted {
// Группа all: скрываем удалённые по умолчанию (шум в ленте). // Группа all: скрываем терминальные deleted и cancelled по умолчанию (шум
conds = append(conds, "state <> ?") // в ленте; cancelled плодит универсальный стоп-кран «Закрыть»). reverted
args = append(args, string(StateDeleted)) // оставляем видимым. Оба показываются тумблером IncludeDeleted.
conds = append(conds, "state NOT IN (?, ?)")
args = append(args, string(StateDeleted), string(StateCancelled))
} }
if q := strings.TrimSpace(f.Query); q != "" { if q := strings.TrimSpace(f.Query); q != "" {
@@ -90,8 +93,9 @@ func listWhere(f ListFilter) (string, []any) {
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени // строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break // добавления в источник (source_added_at) с фолбеком на created_at и tie-break
// по id: без вторичного ключа при равных метках порядок неустойчив, и строки // по id: без вторичного ключа при равных метках порядок неустойчив, и строки
// задваивались бы/пропадали между страницами. RecTitle подтягивается LEFT JOIN // задваивались бы/пропадали между страницами. RecTitle и RecMediaType
// текущей попытки распознавания — для фолбека заголовка карточки. // подтягиваются LEFT JOIN текущей попытки распознавания — для фолбека заголовка
// и значка типа карточки.
func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) { func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) {
where, args := listWhere(f) where, args := listWhere(f)
@@ -100,7 +104,7 @@ func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download
return nil, 0, fmt.Errorf("list downloads page count: %w", err) return nil, 0, fmt.Errorf("list downloads page count: %w", err)
} }
q := `SELECT download.*, r.title AS rec_title q := `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
FROM download FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where + LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC ` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
+48 -6
View File
@@ -45,6 +45,7 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
active := mkDownload(t, st, 2, StateDownloading, "Fargo") active := mkDownload(t, st, 2, StateDownloading, "Fargo")
done := mkDownload(t, st, 3, StateDone, "Оно") done := mkDownload(t, st, 3, StateDone, "Оно")
deleted := mkDownload(t, st, 4, StateDeleted, "Старьё") deleted := mkDownload(t, st, 4, StateDeleted, "Старьё")
cancelled := mkDownload(t, st, 5, StateCancelled, "Отменённое")
// Группа review — только review/deferred. // Группа review — только review/deferred.
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50}) page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50})
@@ -55,29 +56,44 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review) t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review)
} }
// all: deleted скрыт по умолчанию. // all: deleted и cancelled скрыты по умолчанию (скрытые терминальные).
page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50}) page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if total != 3 { if total != 3 {
t.Fatalf("all (без deleted) total = %d, want 3; ids=%v", total, ids(page)) t.Fatalf("all (без скрытых терминальных) total = %d, want 3; ids=%v", total, ids(page))
} }
for _, d := range page { for _, d := range page {
if d.ID == deleted { if d.ID == deleted {
t.Fatalf("deleted не должен быть виден по умолчанию") t.Fatalf("deleted не должен быть виден по умолчанию")
} }
if d.ID == cancelled {
t.Fatalf("cancelled не должен быть виден по умолчанию")
}
} }
_ = active _ = active
_ = done _ = done
// all + IncludeDeleted: показываем всё. // all + IncludeDeleted: показываем всё, включая deleted и cancelled.
_, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50}) page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if total != 4 { if total != 5 {
t.Fatalf("all+deleted total = %d, want 4", total) t.Fatalf("all+скрытые терминальные total = %d, want 5", total)
}
var seenDeleted, seenCancelled bool
for _, d := range page {
switch d.ID {
case deleted:
seenDeleted = true
case cancelled:
seenCancelled = true
}
}
if !seenDeleted || !seenCancelled {
t.Fatalf("под IncludeDeleted должны быть видны deleted и cancelled: deleted=%v cancelled=%v", seenDeleted, seenCancelled)
} }
} }
@@ -278,6 +294,32 @@ func TestListDownloadsPageRecTitleFallback(t *testing.T) {
} }
} }
func TestListDownloadsPageRecMediaType(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
// Тип текущей попытки распознавания подтягивается join'ом для значка списка.
id := mkDownload(t, st, 1, StateReview, "Fargo")
if _, err := st.CreateRecognition(ctx, &Recognition{
DownloadID: id,
MediaType: NullString("series"),
Title: NullString("Fargo"),
}, nil); err != nil {
t.Fatal(err)
}
page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(page) != 1 {
t.Fatalf("len = %d", len(page))
}
if !page[0].RecMediaType.Valid || page[0].RecMediaType.String != "series" {
t.Fatalf("RecMediaType = %q (valid=%v), want series", page[0].RecMediaType.String, page[0].RecMediaType.Valid)
}
}
func TestCreateDownloadPersistsDisplayName(t *testing.T) { func TestCreateDownloadPersistsDisplayName(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
+7 -1
View File
@@ -117,7 +117,11 @@ qBittorrent, но и **готов к раскладке** — раздача в
выполняться через раундтрип на сервер (форма/htmx), без клиентского пересчёта выполняться через раундтрип на сервер (форма/htmx), без клиентского пересчёта
доменного состояния; при этом инфо-часть и предпросмотр раскладки SHALL доменного состояния; при этом инфо-часть и предпросмотр раскладки SHALL
немедленно обновляться под выбранный источник (частичный своп блока, без полной немедленно обновляться под выбранный источник (частичный своп блока, без полной
перезагрузки страницы). Экран SHALL позволять операции над этим списком: перезагрузки страницы). Тем же ответом свопа SHALL синхронно обновляться панель
действий — в частности доступность команды **Применить**, зависящая от наличия
предпросмотра раскладки, — через out-of-band-фрагмент, чтобы кнопка не
рассинхронизировалась с блоком источника (например при пустом предпросмотре
из-за коллизии путей). Экран SHALL позволять операции над этим списком:
выбрать кандидата базы, переключиться на другого кандидата и снять матч с базы выбрать кандидата базы, переключиться на другого кандидата и снять матч с базы
обратно на нейронку («без базы»). Список источников SHALL показываться только обратно на нейронку («без базы»). Список источников SHALL показываться только
при наличии плана распознавания. при наличии плана распознавания.
@@ -139,6 +143,8 @@ qBittorrent, но и **готов к раскладке** — раздача в
отдельного нажатия кнопки «выбрать» отдельного нажатия кнопки «выбрать»
- **AND** инфо-часть и предпросмотр раскладки сразу обновляются под выбранного - **AND** инфо-часть и предпросмотр раскладки сразу обновляются под выбранного
кандидата без полной перезагрузки страницы кандидата без полной перезагрузки страницы
- **AND** панель действий обновляется тем же ответом (out-of-band): доступность
«Применить» синхронна наличию предпросмотра раскладки
#### Scenario: Снятие матча в пользу нейронки #### Scenario: Снятие матча в пользу нейронки
+14 -6
View File
@@ -93,10 +93,12 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием, и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
задач — секцией живой статистики раздачи. Карточки активных (downloading) задач — секцией живой статистики раздачи. Карточки активных (downloading)
загрузок в списке SHALL содержать индикатор прогресса. Фильтр, поиск и номер загрузок в списке SHALL содержать индикатор прогресса. Карточка загрузки с
распознанным типом SHALL нести значок типа (фильм/сериал). Фильтр, поиск и номер
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`) страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
и SHALL работать без клиентского JavaScript. Состояние `deleted` SHALL быть и SHALL работать без клиентского JavaScript. Терминальные состояния `deleted` и
скрыто в списке по умолчанию (с переключателем «показать всё»). Механика живого `cancelled` SHALL быть скрыты в списке по умолчанию (переключатель «показать
всё» раскрывает оба). Механика живого
обновления прогресса и наполнение секции раздачи определяются capability обновления прогресса и наполнение секции раздачи определяются capability
`live-status`. `live-status`.
@@ -112,12 +114,18 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью - **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
и ETA) и ETA)
#### Scenario: Удалённые скрыты по умолчанию #### Scenario: Отменённые и удалённые скрыты по умолчанию
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать - **WHEN** в списке есть загрузки в состоянии `deleted` или `cancelled` и фильтр
всё» не включён «показать всё» не включён
- **THEN** они не отображаются, но доступны при включённом переключателе - **THEN** они не отображаются, но доступны при включённом переключателе
#### Scenario: Значок типа в карточке списка
- **WHEN** загрузка в списке имеет распознанный тип (`movie` или `series`)
- **THEN** её карточка показывает значок типа (🎬 фильм / 📺 сериал); при
отсутствии распознанного типа значок не показывается
#### Scenario: Пагинация списка #### Scenario: Пагинация списка
- **WHEN** загрузок под текущим фильтром больше, чем помещается на одну - **WHEN** загрузок под текущим фильтром больше, чем помещается на одну
+3 -1
View File
@@ -191,7 +191,8 @@ a:hover{text-decoration:underline}
padding:var(--sp-4);box-shadow:var(--shadow-sm);margin-bottom:var(--sp-5)} padding:var(--sp-4);box-shadow:var(--shadow-sm);margin-bottom:var(--sp-5)}
.add-row{display:flex;gap:var(--sp-2)} .add-row{display:flex;gap:var(--sp-2)}
.add-row .input{flex:1} .add-row .input{flex:1}
.btn-file{color:var(--text-dim);font-weight:500} .btn-file{color:var(--text-dim);font-weight:500;max-width:14rem;overflow:hidden}
.btn-file [data-file-label]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.btn-file.has-file{color:var(--accent);border-color:var(--accent)} .btn-file.has-file{color:var(--accent);border-color:var(--accent)}
.add-hint{margin:9px 2px 0;font-size:var(--fs-xs);color:var(--text-faint)} .add-hint{margin:9px 2px 0;font-size:var(--fs-xs);color:var(--text-faint)}
@@ -202,6 +203,7 @@ a:hover{text-decoration:underline}
.card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:var(--sp-3)} .card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:var(--sp-3)}
.card-title{font-weight:600;font-size:var(--fs-md);line-height:1.3;letter-spacing:-.005em; .card-title{font-weight:600;font-size:var(--fs-md);line-height:1.3;letter-spacing:-.005em;
word-break:break-word} word-break:break-word}
.type-ico{font-size:.9em;line-height:1;vertical-align:baseline}
.hash-row{display:flex;align-items:center;gap:8px;margin-top:7px;color:var(--text-dim); .hash-row{display:flex;align-items:center;gap:8px;margin-top:7px;color:var(--text-dim);
font-size:var(--fs-sm);flex-wrap:wrap} font-size:var(--fs-sm);flex-wrap:wrap}
.hash{font-family:var(--mono);font-size:var(--fs-sm)} .hash{font-family:var(--mono);font-size:var(--fs-sm)}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="jellybit">
<defs>
<linearGradient id="j" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8b5cf6"/>
<stop offset="1" stop-color="#6d28d9"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#j)"/>
<path d="M26 21 L45 32 L26 43 Z" fill="#fff"/>
<rect x="17" y="21" width="5" height="5" rx="1" fill="#fff" opacity=".85"/>
<rect x="17" y="38" width="5" height="5" rx="1" fill="#fff" opacity=".85"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — загрузка #{{.ID}}</title> <title>jellybit — загрузка #{{.ID}}</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}"> <link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
<script src="{{asset "vendor/htmx.min.js"}}" defer></script> <script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="{{asset "js/app.js"}}" defer></script> <script src="{{asset "js/app.js"}}" defer></script>
+3 -2
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — загрузки</title> <title>jellybit — загрузки</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}"> <link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
<script src="{{asset "vendor/htmx.min.js"}}" defer></script> <script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="{{asset "js/app.js"}}" defer></script> <script src="{{asset "js/app.js"}}" defer></script>
@@ -26,7 +27,7 @@
<label class="btn btn-file" title="Выбрать .torrent-файл"> <label class="btn btn-file" title="Выбрать .torrent-файл">
<span data-file-label>.torrent</span> <span data-file-label>.torrent</span>
<input type="file" name="torrent" accept=".torrent,application/x-bittorrent" hidden <input type="file" name="torrent" accept=".torrent,application/x-bittorrent" hidden
onchange="var l=this.closest('.btn-file'),s=l.querySelector('[data-file-label]');s.textContent=this.files.length?this.files[0].name:'.torrent';l.classList.toggle('has-file',!!this.files.length)"> onchange="var l=this.closest('.btn-file'),s=l.querySelector('[data-file-label]'),f=this.files[0];s.textContent=f?f.name.replace(/\.torrent$/i,''):'.torrent';l.title=f?f.name:'Выбрать .torrent-файл';l.classList.toggle('has-file',!!f)">
</label> </label>
<button class="btn btn-primary btn-lg" type="submit">Добавить</button> <button class="btn btn-primary btn-lg" type="submit">Добавить</button>
</div> </div>
@@ -49,7 +50,7 @@
</div> </div>
<div class="toggle-line"> <div class="toggle-line">
<a href="{{.ShowURL}}">{{if .ShowAll}}☑{{else}}☐{{end}} Показать всё, включая удалённые</a> <a href="{{.ShowURL}}">{{if .ShowAll}}☑{{else}}☐{{end}} Показать всё, включая отменённые и удалённые</a>
</div> </div>
{{end}} {{end}}
+1 -1
View File
@@ -2,7 +2,7 @@
<article class="card" id="card-{{.ID}}"{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card" hx-trigger="every 3s" hx-swap="outerHTML"{{end}}> <article class="card" id="card-{{.ID}}"{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card" hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
<div class="card-top"> <div class="card-top">
<div> <div>
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div> <div class="card-title">{{if eq .MediaType "series"}}<span class="type-ico" title="сериал">📺</span> {{else if eq .MediaType "movie"}}<span class="type-ico" title="фильм">🎬</span> {{end}}<a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
<div class="hash-row"> <div class="hash-row">
<span class="hash-label">ID:</span> <span class="hash-label">ID:</span>
<span class="hash">{{.ID}}</span> <span class="hash">{{.ID}}</span>
+14 -6
View File
@@ -61,12 +61,20 @@
</div> </div>
<!-- Панель действий (выходы из ревью — обычные формы, полная навигация) --> <!-- Панель действий (выходы из ревью — обычные формы, полная навигация) -->
<div class="action-bar"> {{template "review_action_bar" .}}
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
</div>
</main> </main>
{{end}} {{end}}
{{/* Панель действий ревью. Живёт вне #source-block, но зависит от HasLinks
(есть ли превью раскладки), поэтому при свопе источника обновляется oob
из того же ответа (ActionBarOOB → hx-swap-oob). Выходы из ревью — обычные
POST-формы без hx-* (полная навигация, см. web-ui.md). */}}
{{define "review_action_bar"}}
<div class="action-bar" id="action-bar"{{if .ActionBarOOB}} hx-swap-oob="true"{{end}}>
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
</div>
{{end}}
@@ -72,3 +72,9 @@
</div> </div>
</div> </div>
{{end}} {{end}}
{{/* Ответ на своп выбора источника: свежий #source-block + oob-обновление
панели действий (#action-bar), чтобы кнопка «Применить» синхронно отражала
актуальный HasLinks. Рендерится с ActionBarOOB=true (reviewBlockAction). */}}
{{define "review_source_swap"}}{{template "review_source_block" .}}
{{template "review_action_bar" .}}{{end}}
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — ревью #{{.ID}}</title> <title>jellybit — ревью #{{.ID}}</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}"> <link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
<script src="{{asset "vendor/htmx.min.js"}}" defer></script> <script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="{{asset "js/app.js"}}" defer></script> <script src="{{asset "js/app.js"}}" defer></script>