From 4e2593ac31349804806c8e6356294fac333e2812 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Tue, 30 Jun 2026 19:59:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20?= =?UTF-8?q?=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD=D0=B0=20=D0=B2=20server-re?= =?UTF-8?q?ndered=20=D0=B2=D0=B5=D0=B1-UI=20(web-ui)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая capability web-ui: презентационный перенос готового дизайна (семантический HTML + единый jellybit.css, тёмная тема по настройке ОС) в html/template без шага сборки. - Встроенная (go:embed) отдача статики под /static с Cache-Control и cache-busting (?v= по содержимому 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) --- .gitignore | 4 + Taskfile.yml | 21 + internal/httpapi/download.go | 108 ++++ internal/httpapi/files.go | 57 +++ internal/httpapi/httpapi.go | 105 ++-- internal/httpapi/httpapi_test.go | 2 +- internal/httpapi/loglevel_internal_test.go | 2 +- internal/httpapi/render.go | 104 ++++ internal/httpapi/render_test.go | 111 ++++ internal/httpapi/review.go | 38 +- .../.openspec.yaml | 2 + .../2026-06-30-web-ui-design-port/design.md | 155 ++++++ .../2026-06-30-web-ui-design-port/proposal.md | 63 +++ .../specs/web-ui/spec.md | 147 ++++++ .../2026-06-30-web-ui-design-port/tasks.md | 46 ++ openspec/specs/web-ui/spec.md | 159 ++++++ web/assets.manifest | 19 + web/static/css/jellybit.css | 472 ++++++++++++++++++ web/static/js/app.js | 75 +++ web/templates/download.html | 90 ++++ web/templates/index.html | 170 +++---- web/templates/partials/header.html | 11 + web/templates/partials/layout_widget.html | 22 + web/templates/partials/status_badge.html | 1 + web/templates/review.html | 268 +++++----- web/web.go | 11 +- 26 files changed, 1960 insertions(+), 303 deletions(-) create mode 100644 internal/httpapi/download.go create mode 100644 internal/httpapi/files.go create mode 100644 internal/httpapi/render.go create mode 100644 internal/httpapi/render_test.go create mode 100644 openspec/changes/archive/2026-06-30-web-ui-design-port/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-30-web-ui-design-port/design.md create mode 100644 openspec/changes/archive/2026-06-30-web-ui-design-port/proposal.md create mode 100644 openspec/changes/archive/2026-06-30-web-ui-design-port/specs/web-ui/spec.md create mode 100644 openspec/changes/archive/2026-06-30-web-ui-design-port/tasks.md create mode 100644 openspec/specs/web-ui/spec.md create mode 100644 web/assets.manifest create mode 100644 web/static/css/jellybit.css create mode 100644 web/static/js/app.js create mode 100644 web/templates/download.html create mode 100644 web/templates/partials/header.html create mode 100644 web/templates/partials/layout_widget.html create mode 100644 web/templates/partials/status_badge.html diff --git a/.gitignore b/.gitignore index 6dfdb09..cffdb3f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ /jellybit /dist/ +# Вендорные фронтенд-ассеты — не храним в репо, добываются `task assets` +# по web/assets.manifest (идемпотентно, с проверкой sha256). +/web/static/vendor/ + # Реальный конфиг (секреты) и локальная БД /config.toml /.env diff --git a/Taskfile.yml b/Taskfile.yml index 4b78ca2..297cff6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -20,11 +20,13 @@ tasks: run: desc: 'Локальный запуск (нужен ./config.toml с db_path -> ./jellybit.db)' + deps: [assets] cmds: - go run {{.PKG}} --config ./config.toml build: desc: Статический бинарь linux/amd64 для сервера (Intel N150) + deps: [assets] cmds: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o {{.BINARY}} {{.PKG}} @@ -43,6 +45,25 @@ tasks: cmds: - go mod tidy + assets: + desc: 'Идемпотентный фетч вендорных ассетов в web/static/vendor (по web/assets.manifest, проверка sha256; Node не нужен)' + cmds: + - | + set -e + while IFS= read -r line; do + case "$line" in ''|\#*) continue;; esac + set -- $line + dest=$1; url=$2; sum=$3 + file="web/static/$dest" + if [ -f "$file" ] && echo "$sum $file" | sha256sum -c --status - 2>/dev/null; then + continue + fi + mkdir -p "$(dirname "$file")" + echo "↓ $dest" + curl -fsSL --retry 3 -o "$file" "$url" + echo "$sum $file" | sha256sum -c - + done < web/assets.manifest + image: desc: Docker-образ из готового бинаря (см. docs/adr docker-deploy) deps: [build] diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go new file mode 100644 index 0000000..337c461 --- /dev/null +++ b/internal/httpapi/download.go @@ -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) +} diff --git a/internal/httpapi/files.go b/internal/httpapi/files.go new file mode 100644 index 0000000..5bf2503 --- /dev/null +++ b/internal/httpapi/files.go @@ -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 + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index e13c111..42b94fe 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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 { diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 0559ebb..501cff7 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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) } diff --git a/internal/httpapi/loglevel_internal_test.go b/internal/httpapi/loglevel_internal_test.go index 55f27d4..7569a81 100644 --- a/internal/httpapi/loglevel_internal_test.go +++ b/internal/httpapi/loglevel_internal_test.go @@ -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 diff --git a/internal/httpapi/render.go b/internal/httpapi/render.go new file mode 100644 index 0000000..5f0028c --- /dev/null +++ b/internal/httpapi/render.go @@ -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= в шаблонах, поэтому свежий +// деплой не отдаёт устаревший файл. +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-; неизвестное состояние показываем +// как есть, чтобы не терять его в 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) +} diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go new file mode 100644 index 0000000..4e37291 --- /dev/null +++ b/internal/httpapi/render_test.go @@ -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) + } +} diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 2818cee..712601f 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -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) -} diff --git a/openspec/changes/archive/2026-06-30-web-ui-design-port/.openspec.yaml b/openspec/changes/archive/2026-06-30-web-ui-design-port/.openspec.yaml new file mode 100644 index 0000000..d6b53de --- /dev/null +++ b/openspec/changes/archive/2026-06-30-web-ui-design-port/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/archive/2026-06-30-web-ui-design-port/design.md b/openspec/changes/archive/2026-06-30-web-ui-design-port/design.md new file mode 100644 index 0000000..eb7f359 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-web-ui-design-port/design.md @@ -0,0 +1,155 @@ +## Context + +Веб-UI — тонкий транспорт над ядром (приём в `ingest`, команды в `worker`, +чтение в `store`). Рендер server-rendered через `html/template`, ассеты +встраиваются `go:embed` (`web/web.go`). Сейчас встроены только шаблоны; +статика (CSS/JS/шрифты) не отдаётся, «живость» сделана грубым +``, оформление — инлайн-` + jellybit — загрузки + + + -

jellybit

-

обновляется каждые 5 с

-
- - - +{{template "header" "downloads"}} + +
+

Загрузки

+

Кинь magnet, ссылку, .torrent или сообщение бота — остальное jellybit сделает сам.

+ + {{if .Error}}

{{.Error}}

{{end}} + + + +
+ + +
+

Можно вставить целиком сообщение трекер-бота — magnet и контекст разберутся автоматически.

- {{if .Error}}

{{.Error}}

{{end}} + {{if .Downloads}} + +
+ +
+ + + + + +
+
- - - - - +
+ +
+ + +
{{range .Downloads}} -
- - - - - - - {{else}} - + {{end}} + + {{template "status_badge" .State}} + + + {{if .Context}} +
+ контекст +
«{{.Context}}»
+
+ {{end}} + +
+ {{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}} +
+ {{if .Reviewable}}Ревью →{{end}} + {{if .Undoable}}
{{end}} + {{if .Relinkable}}
{{end}} + {{if .Retriable}}
{{end}} + {{if not .Terminal}}
{{end}} +
+
+ {{end}} - -
#ИсточникКонтекстСостояние
{{.ID}}{{.Source}}{{.Context}} - {{.State}} - {{if .Note}}
{{.Note}}{{end}} - {{if .Error}}
{{.Error}}{{end}} -
-
- {{if .Reviewable}} - Ревью → - {{end}} - {{if .Undoable}} -
- -
- {{end}} - {{if .Relinkable}} -
- -
- {{end}} - {{if .Retriable}} -
- -
- {{end}} - {{if not .Terminal}} -
- -
- {{end}} +
+
+
+ + {{if .Infohash}} +
+ {{.InfohashShort}} +
-
пока пусто
+ + + + {{else}} +
+
🍿
+ Пока пусто. Добавь первую загрузку выше. +
+ {{end}} + +
diff --git a/web/templates/partials/header.html b/web/templates/partials/header.html new file mode 100644 index 0000000..1a82e2d --- /dev/null +++ b/web/templates/partials/header.html @@ -0,0 +1,11 @@ +{{define "header"}} +
+ +
+{{end}} diff --git a/web/templates/partials/layout_widget.html b/web/templates/partials/layout_widget.html new file mode 100644 index 0000000..17be72a --- /dev/null +++ b/web/templates/partials/layout_widget.html @@ -0,0 +1,22 @@ +{{define "layout_widget"}} +
+ + + + {{range $i, $f := .}} + + + + + + {{end}} + +
#файл источника → раскладкароль
{{add $i 1}} +
{{$f.Src}}
+ +
{{$f.RoleLabel}}
+
+{{end}} diff --git a/web/templates/partials/status_badge.html b/web/templates/partials/status_badge.html new file mode 100644 index 0000000..f5c6196 --- /dev/null +++ b/web/templates/partials/status_badge.html @@ -0,0 +1 @@ +{{define "status_badge"}}{{badgeLabel .}}{{end}} diff --git a/web/templates/review.html b/web/templates/review.html index c41ee6e..2eb8675 100644 --- a/web/templates/review.html +++ b/web/templates/review.html @@ -3,169 +3,151 @@ - jellybit · ревью #{{.ID}} - + jellybit — ревью #{{.ID}} + + + -

← к списку

-

Ревью #{{.ID}} {{.State}}

-

{{.Source}}

- {{if .Context}}

Контекст: «{{.Context}}»

{{end}} - {{if .Error}}

{{.Error}}

{{end}} - {{if .StateError}}

{{.StateError}}

{{end}} +{{template "header" ""}} + +
+ ← ко всем загрузкам + + {{if .Error}}

{{.Error}}

{{end}} + {{if .StateError}}

{{.StateError}}

{{end}} + + +
+
+
{{.Source}}
+
+ {{template "status_badge" .State}} + {{if .Reasons}}Причины: {{range $i, $r := .Reasons}}{{if $i}} · {{end}}{{$r}}{{end}}{{end}} + {{if .Confidence}}уверенность {{.Confidence}}{{end}} +
+
+
{{if eq .State "recognizing"}} -

⏳ Идёт распознавание, обновите страницу через несколько секунд…

-

Обновить

+
+

⏳ Идёт распознавание — обновите страницу через несколько секунд.

+

Обновить

+
{{end}} - {{if .Reasons}} -
- Причины ревью{{if .Confidence}} · уверенность {{.Confidence}}{{end}} -
    {{range .Reasons}}
  • {{.}}
  • {{end}}
-
+ + {{if .Context}} +
+

Контекст

исходное сообщение при добавлении
+

«{{.Context}}»

+
{{end}} {{if .HasPlan}} -
- Догадка -

- Тип: {{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}} - · Название: {{.Title}}{{if .OriginalTitle}} ({{.OriginalTitle}}){{end}} - {{if .Year}}· Год: {{.Year}}{{end}} - {{if .Provider}}· База: {{.Provider}} {{.ProviderID}}{{else if .NoBase}}· База: без базы{{end}} -

-
- Переключить тип: - - - (пересоберёт план) -
-
+ +
+

Догадка

+
+ Тип +
+ + + переключение пересоберёт план +
+
+
+
Название
{{.Title}}
+ {{if .OriginalTitle}}
Ориг. название
{{.OriginalTitle}}
{{end}} + {{if .Year}}
Год
{{.Year}}
{{end}} +
+
-
- База метаданных - {{if .Provider}}

Выбрано: {{.Provider}} {{.ProviderID}}

- {{else if .NoBase}}

Выбрано: без базы (тег папки не ставится)

- {{else}}

Матч не подтверждён — выберите кандидата, введите id или «без базы».

{{end}} + +
+

Источник совпадения

выбери базу или оставь распознавание нейронкой
+ + {{if .Provider}}

Выбрано: {{.Provider}} {{.ProviderID}}

+ {{else if .NoBase}}

Выбрано: без базы (тег папки не ставится)

+ {{else}}

Матч не подтверждён — выбери кандидата, введи id или «без базы».

{{end}} {{if .Candidates}} - - - +
{{range .Candidates}} -
- - - - - - - +
+ +
+
{{.Provider}} {{.Title}}{{if .Year}} · {{.Year}}{{end}}
+
id {{.ProviderID}}
+
+ {{if .URL}}ссылка ↗{{end}} +
+ + + +
{{end}} - -
провайдерназваниегодidссылка
{{.Provider}}{{.Title}}{{if .Year}}{{.Year}}{{end}}{{.ProviderID}}{{if .URL}}{{.Provider}}{{end}} -
- - -
-
+
{{end}} -
- Вручную: - + + + - - + +
-
- + +
-
- -
- Файлы → роль - - - - {{range $i, $f := .Files}} - - - - - - - - - {{end}} - -
#файлрольSE
{{add $i 1}}{{$f.Src}}{{$f.Role}}{{$f.Season}}{{$f.Episode}} - {{if not $f.Ignored}} -
- - -
- {{end}} -
-
- - {{if .Preview}} -
- Превью раскладки (будут созданы хардлинки) -
    {{range .Preview}}
  • {{.}}
  • {{end}}
-
- {{end}} - {{end}} - -
- Уточнить и перераспознать -
-

- -
-
- - (без новой подсказки — по уже накопленному контексту) -
- {{if .Hints}} -

Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}

- {{end}} -
- -
- {{if .Preview}} -
- {{end}} -
-
+ + +
+
+

Раскладка

+ файл источника → целевой хардлинк +
+ {{template "layout_widget" .Files}} +

+ Буквальные целевые пути. Применяются как хардлинки — исходник остаётся на раздаче. +

+
+ {{end}} + + +
+

Уточнить и перераспознать

+ {{if .Hints}} +
+ {{range $i, $h := .Hints}} +
#{{add $i 1}}{{$h}}
+ {{end}} +
+ {{end}} +
+ +
+ +
+
+
+ + без новой подсказки — по уже накопленному контексту +
+
+ + +
+ {{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}} +
+
+ {{if .HasLinks}}
{{end}} +
+ +
diff --git a/web/web.go b/web/web.go index 299bb38..cbfc6ee 100644 --- a/web/web.go +++ b/web/web.go @@ -1,9 +1,14 @@ -// Package web несёт встроенные (embed) ресурсы веб-UI: HTML-шаблоны. +// Package web несёт встроенные (embed) ресурсы веб-UI: HTML-шаблоны и статику +// (CSS, JS, шрифты, вендорные библиотеки). Бинарь самодостаточен — внешних +// ресурсов времени выполнения нет. package web import "embed" -// FS — встроенные шаблоны (templates/*.html). +// FS — встроенные ресурсы: templates/** (шаблоны и партиалы) и static/** +// (авторские css/js + вендор htmx/шрифты под static/vendor). Вендор в репо не +// хранится (gitignore); его добывает `task assets` по web/assets.manifest перед +// сборкой, поэтому `task build`/`run` зависят от `assets`. // -//go:embed templates/*.html +//go:embed templates static var FS embed.FS