Files
jellybit/internal/httpapi/render.go
T
avandClaude Opus 4.8 c0b5ab7295 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>
2026-07-01 09:50:24 +03:00

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)
}