Перенос дизайна в 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
+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 {