Перенос дизайна в server-rendered веб-UI (web-ui)

Новая capability web-ui: презентационный перенос готового дизайна
(семантический HTML + единый jellybit.css, тёмная тема по настройке ОС)
в html/template без шага сборки.

- Встроенная (go:embed) отдача статики под /static с Cache-Control и
  cache-busting (?v=<hash> по содержимому css/js).
- Шрифты IBM Plex self-hosted (@font-face, cyrillic+latin), без CDN.
- Наколеночный менеджер зависимостей: вендор (htmx + шрифты) не хранится
  в репо (gitignore), идемпотентно добывается `task assets` по
  web/assets.manifest с проверкой sha256; task build/run зависят от assets.
- Общие партиалы: шапка, бейдж статуса (карта всех 14 состояний),
  виджет «файл источника → раскладка» (общий для review и download).
- Страницы: список с фильтром/поиском, ревью, новая страница просмотра
  загрузки (/download/{id}). deleted скрыт по умолчанию.
- Превью раскладки берётся из единой логики internal/layout
  (buildFileRows), без дублирования правил имён в шаблонах.
- Убраны meta-refresh и инлайн-стили; copyHash на vanilla с fallback
  на execCommand и честной индикацией (целевой деплой — HTTP LAN).

Вне scope (отдельный change): живые обновления прогресса и раздел
раздачи, клиентский режим ручной раскладки файл→серия (с Alpine.js).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-06-30 19:59:19 +03:00
co-authored by Claude Opus 4.8
parent d190072647
commit 4e2593ac31
26 changed files with 1960 additions and 303 deletions
+108
View File
@@ -0,0 +1,108 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// --- Страница просмотра одной загрузки ---
type downloadDetailView struct {
ID int64
Title string
Source string
Infohash string
InfohashShort string
Context string
State string
Error string
Note string
CreatedAt string
UpdatedAt string
// Распознавание (если есть план).
HasPlan bool
MediaType string
IsSeries bool
RecTitle string
OriginalTitle string
Year int
Provider string
ProviderID string
NoBase bool
Confidence string
Files []fileRow
// Действия по состоянию (как на главной).
Terminal bool
Reviewable bool
Undoable bool
Relinkable bool
Retriable bool
}
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "некорректный id", http.StatusBadRequest)
return
}
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "задача не найдена", http.StatusNotFound)
return
}
s.deps.Logger.Error("download detail data", "id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
d := rd.Download
view := downloadDetailView{
ID: id,
Title: d.SourceRef,
Source: shorten(d.SourceRef, 120),
Infohash: d.Infohash.String,
InfohashShort: shortenHash(d.Infohash.String),
Context: d.Context,
State: string(d.State),
Error: d.ErrorMsg.String,
Note: desyncNote(d.State),
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
Terminal: d.State.IsTerminal(),
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
d.State == store.StateTargetMissing,
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
}
if rd.Recognition != nil {
view.HasPlan = len(rd.Plan.Files) > 0
view.MediaType = string(rd.Plan.Type)
view.IsSeries = rd.Plan.Type == "series"
view.RecTitle = rd.Plan.Title
view.OriginalTitle = rd.Plan.OriginalTitle
view.Year = rd.Plan.Year
switch rd.Provider {
case "", "none":
view.NoBase = rd.Provider == "none"
default:
view.Provider = rd.Provider
view.ProviderID = rd.ProviderID
}
if rd.Recognition.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
}
// Файл источника → целевой путь из превью (единая логика layout).
view.Files = buildFileRows(rd.Plan, rd.Preview)
}
s.render(w, "download.html", view)
}
+57
View File
@@ -0,0 +1,57 @@
package httpapi
import (
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
// fileRow — строка виджета «файл источника → раскладка», общего для экранов
// ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика
// internal/layout), а не вычисляется в шаблоне.
type fileRow struct {
Src string
Dst string // целевой путь; пусто — файл не раскладывается
RoleLabel string
Linked bool
Ignored bool
}
// buildFileRows сшивает файлы плана с целевыми путями из превью раскладки.
func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
dstBySrc := make(map[string]string, len(preview))
for _, l := range preview {
dstBySrc[l.Src] = l.Dst
}
rows := make([]fileRow, 0, len(plan.Files))
for _, f := range plan.Files {
dst := dstBySrc[f.Src]
rows = append(rows, fileRow{
Src: f.Src,
Dst: dst,
RoleLabel: roleLabel(string(f.Role)),
Linked: dst != "",
Ignored: f.Role == "ignore",
})
}
return rows
}
// roleLabel — человекочитаемая роль файла раскладки.
func roleLabel(role string) string {
switch role {
case "episode":
return "эпизод"
case "main", "movie":
return "фильм"
case "subtitle":
return "субтитры"
case "extra":
return "допматериал"
case "sample":
return "семпл"
case "ignore":
return "игнор"
default:
return role
}
}
+67 -38
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"net/url"
@@ -54,24 +55,32 @@ type Deps struct {
}
type server struct {
deps Deps
index *template.Template
review *template.Template
deps Deps
tmpl *template.Template
assetVer string
}
// NewRouter собирает HTTP-обработчик сервиса.
func NewRouter(d Deps) (http.Handler, error) {
index, err := template.ParseFS(web.FS, "templates/index.html")
assetVer, err := assetVersion()
if err != nil {
return nil, err
}
review, err := template.New("review.html").
Funcs(template.FuncMap{"add": func(a, b int) int { return a + b }}).
ParseFS(web.FS, "templates/review.html")
funcs := template.FuncMap{
"add": func(a, b int) int { return a + b },
"asset": func(p string) string { return "/static/" + p + "?v=" + assetVer },
"badgeLabel": badgeLabel,
}
tmpl, err := template.New("").Funcs(funcs).
ParseFS(web.FS, "templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, err
}
s := &server{deps: d, index: index, review: review}
staticFS, err := fs.Sub(web.FS, "static")
if err != nil {
return nil, err
}
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
r := chi.NewRouter()
r.Use(middleware.RequestID)
@@ -80,8 +89,12 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Get("/healthz", handleHealthz)
// Статика (встроенная, с длинным кэшем; URL версионируются ?v=).
r.Handle("/static/*", http.StripPrefix("/static/", staticHandler(staticFS)))
// Веб-UI.
r.Get("/", s.handleIndex)
r.Get("/download/{id}", s.handleDownload)
r.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
@@ -124,18 +137,23 @@ type indexView struct {
}
type downloadView struct {
ID int64
Source string
Infohash string
Context string
State string
Error string
Terminal bool
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
Retriable bool // failed/stuck — можно повторить попытку
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
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
Deleted bool // скрыт по умолчанию на главной
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
Retriable bool // failed/stuck — можно повторить попытку
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -149,10 +167,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
for _, d := range downloads {
view.Downloads = append(view.Downloads, toView(d))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.index.Execute(w, view); err != nil {
s.deps.Logger.Error("render index", "error", err)
}
s.render(w, "index.html", view)
}
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
@@ -323,16 +338,22 @@ func toDTO(d store.Download) downloadDTO {
}
func toView(d store.Download) downloadView {
state := string(d.State)
return downloadView{
ID: d.ID,
Source: shorten(d.SourceRef, 64),
Infohash: d.Infohash.String,
Context: d.Context,
State: string(d.State),
Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(),
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone,
ID: d.ID,
Title: d.SourceRef,
Source: shorten(d.SourceRef, 64),
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(),
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 ||
d.State == store.StateTargetMissing,
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
@@ -340,6 +361,14 @@ func toView(d store.Download) downloadView {
}
}
// shortenHash усекает infohash до вида "a1b2c3d4e5…f0" для компактного показа.
func shortenHash(h string) string {
if len(h) <= 12 {
return h
}
return h[:10] + "…" + h[len(h)-2:]
}
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
func desyncNote(s store.State) string {
switch s {
@@ -429,9 +458,9 @@ func userErr(r *http.Request, err error, downloadID int64) string {
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
}
// requestLogger пишет структурированный лог по каждому запросу. Частые
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
// requestLogger пишет структурированный лог по каждому запросу. Служебные и
// навигационные GET (healthcheck, страницы веб-UI, статика) пишем на DEBUG,
// чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -459,8 +488,8 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
}
}
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
// requestLogLevel понижает уровень для служебных и навигационных запросов:
// healthcheck и GET-страницы веб-UI (включая статику). Мутации и REST
// API (`/api/...`) остаются на INFO.
func requestLogLevel(r *http.Request) slog.Level {
switch {
+1 -1
View File
@@ -353,7 +353,7 @@ func TestReviewRenders(t *testing.T) {
}
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
"Season 02", "Применить", "Уточнить",
"База метаданных", "269613", "выбрать", "Без базы"} {
"Источник совпадения", "269613", "выбрать", "Без базы"} {
if !strings.Contains(string(body), want) {
t.Errorf("страница ревью не содержит %q", want)
}
+1 -1
View File
@@ -12,7 +12,7 @@ func TestRequestLogLevel(t *testing.T) {
want slog.Level
}{
{"GET", "/healthz", slog.LevelDebug}, // healthcheck — тихо
{"GET", "/", slog.LevelDebug}, // список (авто-рефреш)
{"GET", "/", slog.LevelDebug}, // список загрузок
{"GET", "/review/1", slog.LevelDebug}, // страница ревью
{"GET", "/api/downloads", slog.LevelInfo}, // REST API — на INFO
{"POST", "/ui/downloads/1/apply", slog.LevelInfo}, // мутация — на INFO
+104
View File
@@ -0,0 +1,104 @@
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
}
}
// stateGroup — группа фильтра на главной (чипы): ждут меня / в работе / готово
// / проблемы / прочее. Завязано на смысл состояния, не на цвет.
func stateGroup(state string) string {
switch state {
case "review", "deferred":
return "review"
case "downloading", "completed", "recognizing", "linking":
return "active"
case "done":
return "done"
case "stuck", "failed", "target_missing", "orphaned":
return "problem"
default:
return "other"
}
}
// 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)
}
+111
View File
@@ -0,0 +1,111 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// stubReader — минимальный Reader для проверки рендера списка.
type stubReader struct {
list []store.Download
one *store.Download
}
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error) {
if s.one == nil {
return nil, store.ErrNotFound
}
return s.one, nil
}
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
type stubReviewer struct{ data *worker.ReviewData }
func (s stubReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
if s.data == nil {
return nil, store.ErrNotFound
}
return s.data, nil
}
func (stubReviewer) Apply(context.Context, int64) error { return nil }
func (stubReviewer) Refine(context.Context, int64, string) error { return nil }
func (stubReviewer) SetType(context.Context, int64, string) error { return nil }
func (stubReviewer) IgnoreFile(context.Context, int64, string) error { return nil }
func (stubReviewer) Defer(context.Context, int64) error { return nil }
func (stubReviewer) Undo(context.Context, int64) error { return nil }
func (stubReviewer) Relink(context.Context, int64) error { return nil }
func (stubReviewer) Rerecognize(context.Context, int64) error { return nil }
func (stubReviewer) ChooseCandidate(context.Context, int64, int64) error { return nil }
func (stubReviewer) SetProviderID(context.Context, int64, string, string) error { return nil }
func (stubReviewer) ClearProvider(context.Context, int64) error { return nil }
func testRouter(t *testing.T, r stubReader, rv stubReviewer) http.Handler {
t.Helper()
h, err := NewRouter(Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Reader: r,
Reviewer: rv,
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return h
}
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
return rr
}
// TestRouterRendersPages проверяет, что шаблоны парсятся и страницы рендерятся.
func TestRouterRendersPages(t *testing.T) {
dl := store.Download{ID: 7, SourceRef: "Fargo.S02", Infohash: store.NullString("a1b2c3d4e5f6a7b8"), State: store.StateReview}
h := testRouter(t,
stubReader{list: []store.Download{dl}, one: &dl},
stubReviewer{data: &worker.ReviewData{Download: dl}},
)
if rr := get(t, h, "/"); rr.Code != http.StatusOK {
t.Fatalf("GET / = %d, want 200", rr.Code)
} else if !strings.Contains(rr.Body.String(), "st-review") {
t.Errorf("index не содержит бейдж статуса")
}
if rr := get(t, h, "/download/7"); rr.Code != http.StatusOK {
t.Fatalf("GET /download/7 = %d, want 200", rr.Code)
}
}
// TestStaticServed проверяет отдачу встроенной статики с кэш-заголовком.
func TestStaticServed(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
rr := get(t, h, "/static/css/jellybit.css")
if rr.Code != http.StatusOK {
t.Fatalf("GET css = %d, want 200", rr.Code)
}
if cc := rr.Header().Get("Cache-Control"); cc == "" {
t.Errorf("нет Cache-Control на статике")
}
if !strings.Contains(rr.Body.String(), "@font-face") {
t.Errorf("css без @font-face (шрифты не self-hosted?)")
}
}
// TestDownloadNotFound — несуществующая загрузка → 404.
func TestDownloadNotFound(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
if rr := get(t, h, "/download/999"); rr.Code != http.StatusNotFound {
t.Fatalf("GET /download/999 = %d, want 404", rr.Code)
}
}
+5 -33
View File
@@ -46,21 +46,13 @@ type reviewView struct {
Confidence string
Reasons []string
Hints []string
Files []reviewFileView
Preview []string
Files []fileRow
HasPlan bool
HasLinks bool // есть хотя бы один целевой путь → можно применять
NoBase bool // выбрано «без базы»
Candidates []candidateView
}
type reviewFileView struct {
Src string
Role string
Season string
Episode string
Ignored bool
}
type candidateView struct {
ID int64
Provider string
@@ -114,16 +106,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
if rec.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
}
for _, f := range rd.Plan.Files {
view.Files = append(view.Files, reviewFileView{
Src: f.Src,
Role: string(f.Role),
Season: intPtrStr(f.Season),
Episode: intPtrStr(f.Episode),
Ignored: f.Role == "ignore",
})
}
view.Files = buildFileRows(rd.Plan, rd.Preview)
view.HasPlan = len(rd.Plan.Files) > 0
view.HasLinks = len(rd.Preview) > 0
for _, c := range rd.Candidates {
view.Candidates = append(view.Candidates, candidateView{
ID: c.ID,
@@ -136,14 +121,8 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
})
}
}
for _, l := range rd.Preview {
view.Preview = append(view.Preview, l.Dst)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.review.Execute(w, view); err != nil {
s.deps.Logger.Error("render review", "error", err)
}
s.render(w, "review.html", view)
}
// --- Действия ревью (POST → redirect) ---
@@ -281,10 +260,3 @@ func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string
}
http.Redirect(w, r, u, http.StatusSeeOther)
}
func intPtrStr(p *int) string {
if p == nil {
return "—"
}
return strconv.Itoa(*p)
}