Compare commits
8
Commits
e8ce828296
...
9472bfdd83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9472bfdd83
|
||
|
|
3f80d36727
|
||
|
|
49747e1281
|
||
|
|
3465c1ea62
|
||
|
|
44c5952e49
|
||
|
|
847d471852
|
||
|
|
2545d64e8b
|
||
|
|
c4c0c6bb3f
|
@@ -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)_
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# Сделать фавиконку для jellybit
|
|
||||||
|
|
||||||
**Приоритет:** низкий
|
|
||||||
|
|
||||||
_Описание не заполнено._
|
|
||||||
@@ -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).
|
|
||||||
@@ -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).
|
||||||
@@ -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).
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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: Снятие матча в пользу нейронки
|
||||||
|
|
||||||
|
|||||||
@@ -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** загрузок под текущим фильтром больше, чем помещается на одну
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -61,12 +61,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Панель действий (выходы из ревью — обычные формы, полная навигация) -->
|
<!-- Панель действий (выходы из ревью — обычные формы, полная навигация) -->
|
||||||
<div class="action-bar">
|
{{template "review_action_bar" .}}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
{{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>
|
<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}}/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>
|
<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}}
|
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
|
||||||
{{end}}
|
{{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}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user