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:
av
2026-07-01 09:50:24 +03:00
co-authored by Claude Opus 4.8
parent 2a9bf7efb0
commit c0b5ab7295
33 changed files with 1679 additions and 110 deletions
+113
View File
@@ -0,0 +1,113 @@
package store
import (
"context"
"fmt"
"strings"
)
// StateGroup — группа состояний для фильтра списка загрузок. Единый источник
// истины «группа → состояния» для транспорта (чипы) и запроса (WHERE).
type StateGroup string
const (
GroupAll StateGroup = "all" // все, кроме deleted (если не IncludeDeleted)
GroupReview StateGroup = "review" // ждут человека
GroupActive StateGroup = "active" // в работе
GroupDone StateGroup = "done" // готово
GroupProblem StateGroup = "problem" // проблемы
)
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
// reverted/deleted своей группы (чипа) не имеют — под all они видны (deleted —
// только при IncludeDeleted), точечно к ним не фильтруем (см. tasks 2.1).
func statesInGroup(g StateGroup) []State {
switch g {
case GroupReview:
return []State{StateReview, StateDeferred}
case GroupActive:
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking}
case GroupDone:
return []State{StateDone}
case GroupProblem:
return []State{StateStuck, StateFailed, StateTargetMissing, StateOrphaned}
default:
return nil
}
}
// ListFilter — параметры страницы списка загрузок.
type ListFilter struct {
Group StateGroup // группа состояний ("" / all — без сужения)
Query string // поиск по названию/infohash/контексту (подстрока)
IncludeDeleted bool // показывать состояние deleted (только под all)
Limit int // размер страницы
Offset int // смещение страницы
}
// listWhere строит условие WHERE и его аргументы по фильтру.
func listWhere(f ListFilter) (string, []any) {
var conds []string
var args []any
if states := statesInGroup(f.Group); states != nil {
ph := make([]string, len(states))
for i, st := range states {
ph[i] = "?"
args = append(args, string(st))
}
conds = append(conds, "state IN ("+strings.Join(ph, ",")+")")
} else if !f.IncludeDeleted {
// Группа all: скрываем удалённые по умолчанию (шум в ленте).
conds = append(conds, "state <> ?")
args = append(args, string(StateDeleted))
}
if q := strings.TrimSpace(f.Query); q != "" {
// LIKE даёт регистронезависимость только для ASCII (встроенное
// case-folding SQLite), для кириллицы поиск чувствителен к регистру —
// ICU в чистом modernc-драйвере нет. Достаточно для инфохэша/латиницы;
// кириллические названия ищем как введены.
like := "%" + q + "%"
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+
"OR display_name LIKE ? COLLATE NOCASE "+
"OR context LIKE ? COLLATE NOCASE "+
"OR IFNULL(infohash,'') LIKE ? COLLATE NOCASE)")
args = append(args, like, like, like, like)
}
if len(conds) == 0 {
return "", args
}
return " WHERE " + strings.Join(conds, " AND "), args
}
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
// по id: без вторичного ключа при равных метках порядок неустойчив, и строки
// задваивались бы/пропадали между страницами. RecTitle подтягивается LEFT JOIN
// текущей попытки распознавания — для фолбека заголовка карточки.
func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) {
where, args := listWhere(f)
var total int
if err := s.DB.GetContext(ctx, &total, `SELECT COUNT(*) FROM download`+where, args...); err != nil {
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
}
q := `SELECT download.*, r.title AS rec_title
FROM download
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
LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
var out []Download
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
return nil, 0, fmt.Errorf("list downloads page: %w", err)
}
return out, total, nil
}