UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)
- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
(added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+189
-11
@@ -42,6 +42,7 @@ type Commander interface {
|
||||
// Reader читает задачи (store.Store).
|
||||
type Reader interface {
|
||||
ListDownloads(ctx context.Context) ([]store.Download, error)
|
||||
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
|
||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||
}
|
||||
|
||||
@@ -139,26 +140,54 @@ func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
// --- Веб-UI ---
|
||||
|
||||
// pageSize — размер страницы списка загрузок (серверная пагинация).
|
||||
const pageSize = 25
|
||||
|
||||
type indexView struct {
|
||||
Error string
|
||||
Downloads []downloadView
|
||||
|
||||
// Фильтр/поиск (серверные, в query).
|
||||
Filter string // активная группа (all/review/active/done/problem)
|
||||
Query string // текст поиска
|
||||
ShowAll bool // показывать удалённые
|
||||
Chips []filterChip // чипы фильтра со ссылками
|
||||
ShowURL string // ссылка тумблера «показать всё»
|
||||
|
||||
// Пагинация.
|
||||
Page int
|
||||
Pages int // всего страниц (>=1)
|
||||
Total int // всего строк под фильтром
|
||||
Searching bool // активны фильтр/поиск — влияет на текст пустого состояния
|
||||
PrevURL string // пусто — на первой странице
|
||||
NextURL string // пусто — на последней
|
||||
PageLinks []pageLink // пронумерованные страницы (окно)
|
||||
}
|
||||
|
||||
type filterChip struct {
|
||||
Key string
|
||||
Label string
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type pageLink struct {
|
||||
Num int
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type downloadView struct {
|
||||
ID int64
|
||||
Title string // отображаемый заголовок карточки
|
||||
Source string
|
||||
Infohash string // полный (для копирования)
|
||||
InfohashShort string // усечённый (для показа)
|
||||
Context string
|
||||
State string
|
||||
Group string // группа фильтра (review/active/done/problem/other)
|
||||
SearchText string // haystack для клиентского поиска (lowercase)
|
||||
Error string
|
||||
Terminal bool
|
||||
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||
Deleted bool // скрыт по умолчанию на главной
|
||||
Reviewable bool // review/deferred — есть экран ревью
|
||||
Undoable bool // done — можно откатить раскладку
|
||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||
@@ -166,14 +195,75 @@ type downloadView struct {
|
||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||
}
|
||||
|
||||
// listChips — определения чипов фильтра списка (порядок = порядок показа).
|
||||
var listChips = []struct {
|
||||
Key string
|
||||
Label string
|
||||
Group store.StateGroup
|
||||
}{
|
||||
{"all", "Все", store.GroupAll},
|
||||
{"review", "Ждут меня", store.GroupReview},
|
||||
{"active", "В работе", store.GroupActive},
|
||||
{"done", "Готово", store.GroupDone},
|
||||
{"problem", "Проблемы", store.GroupProblem},
|
||||
}
|
||||
|
||||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
||||
q := r.URL.Query()
|
||||
group := parseGroup(q.Get("f"))
|
||||
query := strings.TrimSpace(q.Get("q"))
|
||||
showAll := q.Get("all") == "1"
|
||||
page := parsePage(q.Get("page"))
|
||||
|
||||
downloads, total, err := s.deps.Reader.ListDownloadsPage(r.Context(), store.ListFilter{
|
||||
Group: group,
|
||||
Query: query,
|
||||
IncludeDeleted: showAll,
|
||||
Limit: pageSize,
|
||||
Offset: (page - 1) * pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("list downloads", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
view := indexView{Error: r.URL.Query().Get("err")}
|
||||
|
||||
pages := max((total+pageSize-1)/pageSize, 1)
|
||||
|
||||
view := indexView{
|
||||
Error: q.Get("err"),
|
||||
Filter: string(group),
|
||||
Query: query,
|
||||
ShowAll: showAll,
|
||||
Page: page,
|
||||
Pages: pages,
|
||||
Total: total,
|
||||
Searching: query != "" || group != store.GroupAll || showAll || page > 1,
|
||||
}
|
||||
// Чипы: сохраняют q/all, сбрасывают страницу.
|
||||
for _, c := range listChips {
|
||||
view.Chips = append(view.Chips, filterChip{
|
||||
Key: c.Key,
|
||||
Label: c.Label,
|
||||
URL: listURL(c.Group, query, showAll, 1),
|
||||
Active: c.Group == group,
|
||||
})
|
||||
}
|
||||
// Тумблер «показать всё» переключает all, сохраняя фильтр/поиск.
|
||||
view.ShowURL = listURL(group, query, !showAll, 1)
|
||||
// Пагинация: сохраняет f/q/all.
|
||||
if page > 1 {
|
||||
view.PrevURL = listURL(group, query, showAll, page-1)
|
||||
}
|
||||
if page < pages {
|
||||
view.NextURL = listURL(group, query, showAll, page+1)
|
||||
}
|
||||
for _, n := range pageWindow(page, pages) {
|
||||
view.PageLinks = append(view.PageLinks, pageLink{
|
||||
Num: n, URL: listURL(group, query, showAll, n), Active: n == page,
|
||||
})
|
||||
}
|
||||
|
||||
for _, d := range downloads {
|
||||
v := toView(d)
|
||||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||
@@ -187,6 +277,78 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "index.html", view)
|
||||
}
|
||||
|
||||
// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
|
||||
func parseGroup(s string) store.StateGroup {
|
||||
switch store.StateGroup(s) {
|
||||
case store.GroupReview:
|
||||
return store.GroupReview
|
||||
case store.GroupActive:
|
||||
return store.GroupActive
|
||||
case store.GroupDone:
|
||||
return store.GroupDone
|
||||
case store.GroupProblem:
|
||||
return store.GroupProblem
|
||||
default:
|
||||
return store.GroupAll
|
||||
}
|
||||
}
|
||||
|
||||
// parsePage разбирает номер страницы (1-based); мусор/<1 → 1. За последней
|
||||
// страницей отдаём как есть — запрос вернёт пустую страницу (не ошибка).
|
||||
func parsePage(s string) int {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// listURL строит ссылку списка с сохранением состояния фильтра/поиска/страницы.
|
||||
// Дефолты (all, пустой поиск, page 1) в query не пишем — URL чистый.
|
||||
func listURL(group store.StateGroup, query string, showAll bool, page int) string {
|
||||
v := url.Values{}
|
||||
if group != store.GroupAll {
|
||||
v.Set("f", string(group))
|
||||
}
|
||||
if query != "" {
|
||||
v.Set("q", query)
|
||||
}
|
||||
if showAll {
|
||||
v.Set("all", "1")
|
||||
}
|
||||
if page > 1 {
|
||||
v.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
if len(v) == 0 {
|
||||
return "/"
|
||||
}
|
||||
return "/?" + v.Encode()
|
||||
}
|
||||
|
||||
// pageWindow возвращает номера страниц вокруг текущей (окно до 7), чтобы пагинация
|
||||
// не разрасталась на больших списках.
|
||||
func pageWindow(page, pages int) []int {
|
||||
const win = 7
|
||||
if pages <= win {
|
||||
out := make([]int, pages)
|
||||
for i := range out {
|
||||
out[i] = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
start := max(page-win/2, 1)
|
||||
end := start + win - 1
|
||||
if end > pages {
|
||||
end = pages
|
||||
start = end - win + 1
|
||||
}
|
||||
out := make([]int, 0, win)
|
||||
for n := start; n <= end; n++ {
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
redirectErr(w, r, "не удалось разобрать форму")
|
||||
@@ -358,18 +520,14 @@ func toView(d store.Download) downloadView {
|
||||
state := string(d.State)
|
||||
return downloadView{
|
||||
ID: d.ID,
|
||||
Title: d.SourceRef,
|
||||
Source: shorten(d.SourceRef, 64),
|
||||
Title: downloadTitle(d),
|
||||
Infohash: d.Infohash.String,
|
||||
InfohashShort: shortenHash(d.Infohash.String),
|
||||
Context: d.Context,
|
||||
State: state,
|
||||
Group: stateGroup(state),
|
||||
SearchText: strings.ToLower(d.SourceRef + " " + d.Infohash.String + " " + d.Context),
|
||||
Error: d.ErrorMsg.String,
|
||||
Terminal: d.State.IsTerminal(),
|
||||
IsDownloading: d.State == store.StateDownloading,
|
||||
Deleted: d.State == store.StateDeleted,
|
||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||
Undoable: d.State == store.StateDone,
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||
@@ -379,6 +537,26 @@ func toView(d store.Download) downloadView {
|
||||
}
|
||||
}
|
||||
|
||||
// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
|
||||
// то, что ушло в qBittorrent) → распознанное название (RecTitle из листинга) →
|
||||
// усечённый до одной строки сырой источник. Сырой magnet не должен занимать
|
||||
// несколько строк заголовка.
|
||||
func downloadTitle(d store.Download) string {
|
||||
if d.DisplayName != "" {
|
||||
return d.DisplayName
|
||||
}
|
||||
if d.RecTitle.Valid && d.RecTitle.String != "" {
|
||||
return d.RecTitle.String
|
||||
}
|
||||
return shorten(oneLine(d.SourceRef), 80)
|
||||
}
|
||||
|
||||
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
|
||||
// заголовок кладём одной строкой.
|
||||
func oneLine(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// shortenHash усекает infohash до вида "a1b2c3d4e5…f0" для компактного показа.
|
||||
func shortenHash(h string) string {
|
||||
if len(h) <= 12 {
|
||||
|
||||
Reference in New Issue
Block a user