- Список: серверные фильтр по группе состояний, поиск и пагинация (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>
88 lines
2.9 KiB
Go
88 lines
2.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io/fs"
|
|
"net/http"
|
|
|
|
"git.vakhrushev.me/av/jellybit/web"
|
|
)
|
|
|
|
// assetVersion — короткий хеш изменяемых ассетов (css/js) для cache-busting.
|
|
// Шрифты и вендор адресуются по неизменному имени, их версионировать не нужно.
|
|
func assetVersion() (string, error) {
|
|
h := sha256.New()
|
|
for _, p := range []string{"static/css/jellybit.css", "static/js/app.js"} {
|
|
b, err := web.FS.ReadFile(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, _ = h.Write(b)
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))[:12], nil
|
|
}
|
|
|
|
// staticHandler отдаёт встроенную статику с длинным иммутабельным кэшем —
|
|
// URL версионируются через ?v=<assetVersion> в шаблонах, поэтому свежий
|
|
// деплой не отдаёт устаревший файл.
|
|
func staticHandler(fsys fs.FS) http.Handler {
|
|
fileServer := http.FileServer(http.FS(fsys))
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
fileServer.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// badgeLabel — человекочитаемая подпись состояния для бейджа (см. handoff п.4).
|
|
// CSS-класс берётся отдельно как st-<state>; неизвестное состояние показываем
|
|
// как есть, чтобы не терять его в UI.
|
|
func badgeLabel(state string) string {
|
|
switch state {
|
|
case "downloading":
|
|
return "⬇ качается"
|
|
case "completed":
|
|
return "⬇ скачано"
|
|
case "recognizing":
|
|
return "🧠 распознаётся"
|
|
case "linking":
|
|
return "🔗 раскладка"
|
|
case "review":
|
|
return "🟡 на ревью"
|
|
case "deferred":
|
|
return "🕗 отложено"
|
|
case "done":
|
|
return "✅ готово"
|
|
case "stuck":
|
|
return "⏳ застряло"
|
|
case "target_missing":
|
|
return "⚠ нет цели"
|
|
case "failed":
|
|
return "⛔ ошибка"
|
|
case "orphaned":
|
|
return "⚠ потеряно"
|
|
case "cancelled":
|
|
return "✖ отменено"
|
|
case "reverted":
|
|
return "↩ откат"
|
|
case "deleted":
|
|
return "🗑 удалено"
|
|
default:
|
|
return state
|
|
}
|
|
}
|
|
|
|
// render отрисовывает именованный шаблон в буфер и только затем пишет ответ —
|
|
// при ошибке шаблона клиент не получит «полустраницу».
|
|
func (s *server) render(w http.ResponseWriter, name string, data any) {
|
|
var buf bytes.Buffer
|
|
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
s.deps.Logger.Error("render", "template", name, "error", err)
|
|
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = buf.WriteTo(w)
|
|
}
|