Перенос дизайна в 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:
+67
-38
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user