From 2f8e6e35767b15c399b76a6645a199c06a955822 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 4 Jul 2026 14:37:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1-UI:=20htmx-=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=BF=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BC=D0=B5=D1=81=D1=82=D0=B5=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20PRG-=D1=80=D0=B5=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мутирующие действия больше не уводят со страницы: undo/relink/retry/cancel в списке свопят карточку (#card-{id}), на /download/{id} — #download-main; петля ревью (refine/rerecognize) свопит #review-main и допалливает recognizing до готового плана (fragment /fragments/downloads/{id}/review, every 2s). Выходы ревью (apply/defer/cancel) остаются навигацией. Без htmx — прежний PRG-редирект (деградация). Ошибка действия на htmx-пути — HTTP 200 с сообщением в фрагменте (ActionError), иначе htmx не свопит DOM. Разметка вынесена в партиалы card/download_main/review_main (корень = элемент с целевым id), различение поверхности — скрытым полем surface=list|download. Извлечены buildCardView/buildDownloadView. handleSetProvider переведён на reviewBlockAction (консистентность source-действий). Реализация change htmx-action-swap (OpenSpec). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/httpapi/action_swap_test.go | 285 ++++++++++++++++++ internal/httpapi/download.go | 35 ++- internal/httpapi/httpapi.go | 102 +++++-- internal/httpapi/review.go | 68 +++-- .../changes/htmx-action-swap/.openspec.yaml | 2 + openspec/changes/htmx-action-swap/design.md | 202 +++++++++++++ openspec/changes/htmx-action-swap/proposal.md | 73 +++++ .../htmx-action-swap/specs/review/spec.md | 57 ++++ .../htmx-action-swap/specs/web-ui/spec.md | 71 +++++ openspec/changes/htmx-action-swap/tasks.md | 92 ++++++ web/templates/download.html | 92 +----- web/templates/index.html | 37 +-- web/templates/partials/card.html | 36 +++ web/templates/partials/download_main.html | 94 ++++++ web/templates/partials/review_main.html | 72 +++++ web/templates/review.html | 70 +---- 16 files changed, 1140 insertions(+), 248 deletions(-) create mode 100644 internal/httpapi/action_swap_test.go create mode 100644 openspec/changes/htmx-action-swap/.openspec.yaml create mode 100644 openspec/changes/htmx-action-swap/design.md create mode 100644 openspec/changes/htmx-action-swap/proposal.md create mode 100644 openspec/changes/htmx-action-swap/specs/review/spec.md create mode 100644 openspec/changes/htmx-action-swap/specs/web-ui/spec.md create mode 100644 openspec/changes/htmx-action-swap/tasks.md create mode 100644 web/templates/partials/card.html create mode 100644 web/templates/partials/download_main.html create mode 100644 web/templates/partials/review_main.html diff --git a/internal/httpapi/action_swap_test.go b/internal/httpapi/action_swap_test.go new file mode 100644 index 0000000..4356f53 --- /dev/null +++ b/internal/httpapi/action_swap_test.go @@ -0,0 +1,285 @@ +package httpapi + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/store" + "git.vakhrushev.me/av/jellybit/internal/worker" +) + +// stubCommander — Commander-заглушка с инъекцией ошибок cancel/retry. +type stubCommander struct{ cancelErr, retryErr error } + +func (s stubCommander) Cancel(context.Context, string) error { return s.cancelErr } +func (s stubCommander) Retry(context.Context, string) error { return s.retryErr } + +// actionReviewer — Reviewer-заглушка с инъекцией ошибок петлевых/своп-действий +// и захватом подсказки refine. +type actionReviewer struct { + stubReviewer + undoErr error + relinkErr error + rerecognizeErr error + refineErr error + gotHint *string // если не nil — сюда пишется hint из Refine +} + +func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr } +func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr } +func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr } +func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error { + if a.gotHint != nil { + *a.gotHint = hint + } + return a.refineErr +} + +func testRouterAction(t *testing.T, r stubReader, rv Reviewer, cmd Commander, lv stubLive) http.Handler { + t.Helper() + h, err := NewRouter(Deps{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Reader: r, + Reviewer: rv, + Commander: cmd, + Live: lv, + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return h +} + +// post отправляет POST-форму; htmx=true добавляет заголовок HX-Request. +func post(t *testing.T, h http.Handler, path string, form url.Values, htmx bool) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if htmx { + req.Header.Set("HX-Request", "true") + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr +} + +func dlState(s store.State) store.Download { + return store.Download{ + ID: testULID, + SourceRef: "Fargo.S02", + Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ihswap", Kind: store.HashV1}}, + State: s, + } +} + +// TestUndoListHTMXSwapsCard: откат из списка (surface=list) при htmx → 200 и +// фрагмент карточки с новым состоянием, не редирект. +func TestUndoListHTMXSwapsCard(t *testing.T) { + dl := dlState(store.StateReverted) // GetDownload после действия отдаёт новое состояние + h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"list"}}, true) + if rr.Code != http.StatusOK { + t.Fatalf("undo (htmx) = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, `id="card-`+testULID+`"`) { + t.Errorf("ответ не содержит фрагмент карточки: %s", body) + } + if !strings.Contains(body, "st-reverted") { + t.Errorf("карточка без нового состояния (st-reverted): %s", body) + } +} + +// TestUndoNoHTMXRedirects: без htmx откат деградирует до PRG-редиректа на список. +func TestUndoNoHTMXRedirects(t *testing.T) { + dl := dlState(store.StateReverted) + h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/undo", nil, false) + if rr.Code != http.StatusSeeOther { + t.Fatalf("undo (no htmx) = %d, want 303", rr.Code) + } + if loc := rr.Header().Get("Location"); loc != "/" { + t.Errorf("redirect на %q, want /", loc) + } +} + +// TestDownloadSurfaceHTMXSwapsMain: действие со страницы загрузки (surface=download) +// при htmx → 200 и фрагмент download_main. +func TestDownloadSurfaceHTMXSwapsMain(t *testing.T) { + dl := dlState(store.StateReverted) + rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}} + h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, true) + if rr.Code != http.StatusOK { + t.Fatalf("undo download (htmx) = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), `id="download-main"`) { + t.Errorf("ответ не содержит фрагмент download_main: %s", rr.Body.String()) + } +} + +// TestRerecognizeHTMXSwapsReviewMain: перераспознавание при htmx → 200 и тело +// ревью review_main в состоянии recognizing с поллером. +func TestRerecognizeHTMXSwapsReviewMain(t *testing.T) { + dl := dlState(store.StateRecognizing) + rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}} + h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/rerecognize", nil, true) + if rr.Code != http.StatusOK { + t.Fatalf("rerecognize (htmx) = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, `id="review-main"`) { + t.Errorf("ответ не содержит фрагмент review_main: %s", body) + } + if !strings.Contains(body, `hx-trigger="every 2s"`) { + t.Errorf("recognizing без поллера (hx-trigger every 2s): %s", body) + } +} + +// TestRerecognizeNoHTMXRedirects: без htmx перераспознавание → редирект на ревью. +func TestRerecognizeNoHTMXRedirects(t *testing.T) { + dl := dlState(store.StateRecognizing) + rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}} + h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/rerecognize", nil, false) + if rr.Code != http.StatusSeeOther { + t.Fatalf("rerecognize (no htmx) = %d, want 303", rr.Code) + } + if loc := rr.Header().Get("Location"); loc != "/review/"+testULID { + t.Errorf("redirect на %q, want /review/{id}", loc) + } +} + +// TestFragReviewPollerStops: фрагмент тела ревью несёт поллер в recognizing и не +// несёт его в review (опрос сам прекращается). +func TestFragReviewPollerStops(t *testing.T) { + recDL := dlState(store.StateRecognizing) + hRec := testRouterAction(t, stubReader{one: &recDL}, + actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: recDL}}}, stubCommander{}, stubLive{}) + if rr := get(t, hRec, "/fragments/downloads/"+testULID+"/review"); !strings.Contains(rr.Body.String(), `hx-trigger="every 2s"`) { + t.Errorf("recognizing: фрагмент без поллера") + } + + revDL := dlState(store.StateReview) + hRev := testRouterAction(t, stubReader{one: &revDL}, + actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: revDL}}}, stubCommander{}, stubLive{}) + if rr := get(t, hRev, "/fragments/downloads/"+testULID+"/review"); strings.Contains(rr.Body.String(), `hx-trigger="every 2s"`) { + t.Errorf("review: поллер не прекратился") + } +} + +// TestActionErrorHTMX200: ошибка действия на htmx-пути → 200 с сообщением в +// фрагменте (иначе htmx не свопит DOM). +func TestActionErrorHTMX200(t *testing.T) { + dl := dlState(store.StateReview) + cmd := stubCommander{cancelErr: worker.ErrConflict} + h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, cmd, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", url.Values{"surface": {"list"}}, true) + if rr.Code != http.StatusOK { + t.Fatalf("cancel с ошибкой (htmx) = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, `id="card-`+testULID+`"`) { + t.Errorf("ответ не содержит фрагмент карточки") + } + if !strings.Contains(body, "действие недоступно") { + t.Errorf("нет сообщения об ошибке в фрагменте: %s", body) + } +} + +// TestRefineHTMXSwapsReviewMain: уточнить (refine) при htmx → 200 + review_main, +// подсказка из формы доходит до доменного вызова. +func TestRefineHTMXSwapsReviewMain(t *testing.T) { + dl := dlState(store.StateRecognizing) + var hint string + rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}, gotHint: &hint} + h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/refine", url.Values{"hint": {"это Fargo 2014"}}, true) + if rr.Code != http.StatusOK { + t.Fatalf("refine (htmx) = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), `id="review-main"`) { + t.Errorf("refine не отдал review_main") + } + if hint != "это Fargo 2014" { + t.Errorf("hint = %q, want «это Fargo 2014» (форма не разобрана)", hint) + } +} + +// TestDownloadSurfaceNoHTMXRedirects: действие со страницы загрузки без htmx → +// 303 (деградация на редирект, как и для списка). +func TestDownloadSurfaceNoHTMXRedirects(t *testing.T) { + dl := dlState(store.StateReverted) + rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}} + h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, false) + if rr.Code != http.StatusSeeOther { + t.Fatalf("undo download (no htmx) = %d, want 303", rr.Code) + } +} + +// TestReviewCancelNoHTMXNavigates: отклонение из ревью (форма без hx-*/surface) — +// навигация на список (выход из ревью), а не своп. +func TestReviewCancelNoHTMXNavigates(t *testing.T) { + dl := dlState(store.StateReview) + h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", nil, false) + if rr.Code != http.StatusSeeOther { + t.Fatalf("cancel из ревью (no htmx) = %d, want 303", rr.Code) + } + if loc := rr.Header().Get("Location"); loc != "/" { + t.Errorf("redirect на %q, want / (выход из ревью)", loc) + } +} + +// TestActionErrorDownloadSurface: ошибка действия на странице загрузки (htmx) → +// 200 + фрагмент download_main с сообщением. +func TestActionErrorDownloadSurface(t *testing.T) { + dl := dlState(store.StateDone) + rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}, undoErr: worker.ErrConflict} + h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{}) + + rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, true) + if rr.Code != http.StatusOK { + t.Fatalf("undo download с ошибкой (htmx) = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, `id="download-main"`) { + t.Errorf("ответ не содержит download_main") + } + if !strings.Contains(body, "действие недоступно") { + t.Errorf("нет сообщения об ошибке в download_main: %s", body) + } +} + +// TestRetryListShowsProgress: retry из списка → карточка downloading с +// прогресс-поллером. +func TestRetryListShowsProgress(t *testing.T) { + dl := dlState(store.StateDownloading) + lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}} + h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, lv) + + rr := post(t, h, "/ui/downloads/"+testULID+"/retry", url.Values{"surface": {"list"}}, true) + if rr.Code != http.StatusOK { + t.Fatalf("retry (htmx) = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "/fragments/downloads/"+testULID+"/progress") { + t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String()) + } +} diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 0550323..a98491c 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -13,19 +13,20 @@ import ( // --- Страница просмотра одной загрузки --- type downloadDetailView struct { - ID string - Title string - SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте» - SourceFull string // полный источник (magnet) — блок «Информация о торренте» - Infohashes []string // все хеши загрузки (блок «Информация о торренте») - Context string - State string - Error string - Note string - Added string // дата добавления (source_added_at → created_at), как в списке - AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить - CreatedAt string - UpdatedAt string + ID string + Title string + SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте» + SourceFull string // полный источник (magnet) — блок «Информация о торренте» + Infohashes []string // все хеши загрузки (блок «Информация о торренте») + Context string + State string + Error string + ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg + Note string + Added string // дата добавления (source_added_at → created_at), как в списке + AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить + CreatedAt string + UpdatedAt string // Распознавание (если есть план). HasPlan bool @@ -82,7 +83,13 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) { http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) return } + s.render(w, "download.html", s.buildDownloadView(id, rd)) +} +// buildDownloadView собирает представление страницы загрузки из доменных данных +// и живого снимка. Общий для полной страницы (handleDownload) и htmx-свопа +// главной области после действия (renderDownloadFragment). +func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDetailView { d := rd.Download view := downloadDetailView{ ID: id, @@ -141,5 +148,5 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) { l, ok := s.liveFor(d) view.Seeding = buildSeeding(id, l, ok) - s.render(w, "download.html", view) + return view } diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index c477f96..4ba014e 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -113,6 +113,8 @@ func NewRouter(d Deps) (http.Handler, error) { // Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера). r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress) r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding) + // Тело ревью для поллинга recognizing (htmx-своп до готового плана). + r.Get("/fragments/downloads/{id}/review", s.handleFragReview) r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) r.Post("/ui/downloads/{id}/retry", s.handleUIRetry) @@ -199,6 +201,7 @@ type downloadView struct { Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново Retriable bool // failed/stuck — можно повторить попытку Note string // пояснение рассинхрона (target_missing/orphaned/deleted) + ActionError string // ошибка действия на htmx-пути (своп карточки), не error_msg // Обзор жизненного цикла (мета-строка карточки). Added string // абсолютная дата добавления (TZ сервера), «2006-01-02» @@ -290,22 +293,31 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { now := time.Now() for _, d := range downloads { - v := s.toView(d, now) - // Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг - // и размер нужны в любом состоянии, пока торрент есть в qBittorrent. - l, ok := s.liveFor(d) - v.Ratio = ratioText(l, ok) - v.Size = sizeText(l, ok, layoutSizes[d.ID]) - // Живой прогресс активных загрузок — со значениями уже в первом кадре - // (без мигания); дальше карточка дозапрашивает фрагмент поллингом. - if v.IsDownloading { - v.Progress = buildProgress(d.ID, true, l, ok) - } - view.Downloads = append(view.Downloads, v) + view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID])) } s.render(w, "index.html", view) } +// buildCardView собирает представление карточки списка из доменных данных и +// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки +// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку +// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести +// прогресс-поллер — поэтому Progress заполняется здесь. +func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView { + v := s.toView(d, now) + // Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг + // и размер нужны в любом состоянии, пока торрент есть в qBittorrent. + l, ok := s.liveFor(d) + v.Ratio = ratioText(l, ok) + v.Size = sizeText(l, ok, layoutSize) + // Живой прогресс активных загрузок — со значениями уже в первом кадре + // (без мигания); дальше карточка дозапрашивает фрагмент поллингом. + if v.IsDownloading { + v.Progress = buildProgress(d.ID, true, l, ok) + } + return v +} + // parseGroup разбирает параметр фильтра `f`; неизвестное → all. func parseGroup(s string) store.StateGroup { switch store.StateGroup(s) { @@ -400,11 +412,7 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, "некорректный id") return } - if err := s.deps.Commander.Cancel(r.Context(), id); err != nil { - redirectErr(w, r, userErr(r, err, id)) - return - } - http.Redirect(w, r, "/", http.StatusSeeOther) + s.surfaceAction(w, r, id, s.deps.Commander.Cancel(r.Context(), id)) } func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) { @@ -413,11 +421,65 @@ func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, "некорректный id") return } - if err := s.deps.Commander.Retry(r.Context(), id); err != nil { - redirectErr(w, r, userErr(r, err, id)) + s.surfaceAction(w, r, id, s.deps.Commander.Retry(r.Context(), id)) +} + +// surfaceAction завершает мутирующее действие, доступное и в списке, и на +// странице загрузки (undo/relink/retry/cancel). На htmx свопит фрагмент той +// поверхности, откуда пришло действие (скрытое поле surface=list|download), на +// ошибке — тот же фрагмент с сообщением и HTTP 200 (иначе htmx не подменит DOM). +// Без htmx — прежний PRG-редирект на список (форма выхода из ревью тоже сюда: +// нет htmx → навигация). actionErr — результат доменного вызова. +func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string, actionErr error) { + if !isHTMX(r) { + if actionErr != nil { + redirectErr(w, r, userErr(r, actionErr, id)) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) return } - http.Redirect(w, r, "/", http.StatusSeeOther) + if r.PostFormValue("surface") == "download" { + s.renderDownloadFragment(w, r, id, actionErr) + return + } + s.renderCardFragment(w, r, id, actionErr) +} + +// renderCardFragment перечитывает загрузку и рендерит партиал карточки списка +// (htmx-своп). На ошибке действия кладёт сообщение в ActionError и всё равно +// отвечает 200 — htmx не свопит DOM на 4xx/5xx. +func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { + d, err := s.deps.Reader.GetDownload(r.Context(), id) + if err != nil { + s.fragErr(w, err, id) + return + } + sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id}) + if err != nil { + s.deps.Logger.Error("layout sizes", "download_id", id, "error", err) + sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает + } + v := s.buildCardView(*d, time.Now(), sizes[id]) + if actionErr != nil { + v.ActionError = userErr(r, actionErr, id) + } + s.render(w, "card", v) +} + +// renderDownloadFragment перечитывает загрузку и рендерит главную область +// страницы загрузки (htmx-своп). Ошибка — в ActionError, ответ 200. +func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) { + rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) + if err != nil { + s.fragErr(w, err, id) + return + } + v := s.buildDownloadView(id, rd) + if actionErr != nil { + v.ActionError = userErr(r, actionErr, id) + } + s.render(w, "download_main", v) } // --- REST API --- diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 164965e..4afbab7 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -201,7 +201,9 @@ func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) { } func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) { - s.reviewAction(w, r, func(ctx context.Context, id string) error { + // Смена provider/id — операция над выбранным источником, как candidate/nobase/ + // source: свопит блок источника (#source-block), а не всё тело ревью. + s.reviewBlockAction(w, r, func(ctx context.Context, id string) error { _ = r.ParseForm() return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id")) }) @@ -327,11 +329,7 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, "некорректный id") return } - if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil { - redirectErr(w, r, userErr(r, err, id)) - return - } - http.Redirect(w, r, "/", http.StatusSeeOther) + s.surfaceAction(w, r, id, s.deps.Reviewer.Undo(r.Context(), id)) } // handleRelink повторно привязывает откатанную задачу: перезапускает @@ -342,28 +340,62 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, "некорректный id") return } - if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil { - redirectErr(w, r, userErr(r, err, id)) - return - } - http.Redirect(w, r, "/", http.StatusSeeOther) + s.surfaceAction(w, r, id, s.deps.Reviewer.Relink(r.Context(), id)) } -// reviewAction — общий помощник: выполнить действие и вернуться на страницу -// ревью (с ошибкой в ?err при неудаче). +// reviewAction — общий помощник петлевых действий ревью (уточнить/распознать +// заново/…): выполнить действие и обновить экран ревью. На htmx перечитывает +// состояние и рендерит партиал `review_main` на месте (при ошибке — сообщение в +// баннере и HTTP 200, иначе htmx не свопит DOM); без htmx деградирует до +// PRG-редиректа на `/review/{id}`. Действия асинхронны — своп отдаёт актуальное +// состояние (обычно `recognizing`), которое дальше само допалливается фрагментом +// (см. handleFragReview). func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) { id, err := pathID(r) if err != nil { redirectErr(w, r, "некорректный id") return } - if err := fn(r.Context(), id); err != nil { - // Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не - // логируя повторно (доменный слой/валидация — не дело транспорта). - redirectReview(w, r, id, userErr(r, err, id)) + actionErr := fn(r.Context(), id) + + if !isHTMX(r) { + msg := "" + if actionErr != nil { + msg = userErr(r, actionErr, id) + } + redirectReview(w, r, id, msg) return } - redirectReview(w, r, id, "") + + rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) + if err != nil { + s.deps.Logger.Error("review data", "id", id, "error", err) + http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) + return + } + msg := "" + if actionErr != nil { + msg = userErr(r, actionErr, id) + } + s.render(w, "review_main", buildReviewView(id, rd, msg)) +} + +// handleFragReview отдаёт партиал тела ревью (htmx-поллинг recognizing). Пока +// загрузка в `recognizing`, `review_main` несёт поллер и экран сам обновляется; +// как только состояние стало `review`, фрагмент возвращается без поллера — опрос +// прекращается. +func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + http.Error(w, "не найдено", http.StatusNotFound) + return + } + rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) + if err != nil { + s.fragErr(w, err, id) + return + } + s.render(w, "review_main", buildReviewView(id, rd, "")) } // isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу). diff --git a/openspec/changes/htmx-action-swap/.openspec.yaml b/openspec/changes/htmx-action-swap/.openspec.yaml new file mode 100644 index 0000000..d86f152 --- /dev/null +++ b/openspec/changes/htmx-action-swap/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-04 diff --git a/openspec/changes/htmx-action-swap/design.md b/openspec/changes/htmx-action-swap/design.md new file mode 100644 index 0000000..a179c1a --- /dev/null +++ b/openspec/changes/htmx-action-swap/design.md @@ -0,0 +1,202 @@ +## Context + +Действия над загрузкой в веб-UI сейчас — POST-формы с PRG-редиректом +(`internal/httpapi`). Обработчики после доменного вызова зовут `redirectErr` / +`redirectReview` / `http.Redirect(..., "/", ...)`. Из-за этого действие всегда +уводит пользователя со страницы: откат из списка и со страницы `/download/{id}` +уходит на `/`, перераспознавание перезагружает `/review/{id}` с прыжком +скролла. + +В коде уже есть ровно нужный паттерн для одного случая — выбор источника на +ревью: `reviewBlockAction` (`review.go:379`) проверяет `isHTMX(r)` +(`review.go:370`, заголовок `HX-Request`), и на htmx-запрос перечитывает +состояние и рендерит партиал `review_source_block` через `s.render` +(`render.go:78`, `ExecuteTemplate` по имени), а без htmx деградирует до +`redirectReview`. Задача — обобщить этот приём на остальные действия и на две +другие поверхности (карточка списка, страница загрузки). + +Стек фиксирован: htmx-first, без сборки и клиентских фреймворков (решение +`2026-06-30-web-ui-design-port`, D3/D6). htmx уже вендорится и подключён на всех +страницах. + +## Goals / Non-Goals + +**Goals:** +- Действие не уводит с текущей поверхности: список → карточка обновляется на + месте; `/download/{id}` → страница обновляется на месте; петля ревью + (`rerecognize`/`refine`) → тело ревью обновляется на месте. +- Никакого сброса контекста списка (фильтр/поиск/страница/скролл) и прыжков + скролла. +- Graceful degradation: без htmx — прежний PRG-редирект, доменные вызовы не + меняются. +- Единый источник разметки: фрагмент для свопа и инлайновый рендер страницы — + один и тот же `{{define}}`-блок, без дубля markup. + +**Non-Goals:** +- Не вводим Alpine.js/SPA и клиентский пересчёт доменного состояния. +- Не трогаем `live-status` (поллинг телеметрии) — он ортогонален. +- Не добавляем rerecognize отдельной кнопкой на `/download/{id}` (ревью — + часть «страницы загрузки», см. proposal). +- Не переводим на своп выходы из ревью (`apply`/`defer`/`cancel`) — они уводят + загрузку с экрана. + +## Decisions + +### D1. Ветвление в обработчике: htmx → фрагмент, иначе → редирект + +Каждый затрагиваемый обработчик после доменного вызова ветвится по `isHTMX(r)`: +на htmx перечитывает актуальное состояние тем же view-builder'ом, что и полная +страница, и рендерит соответствующий фрагмент через `s.render`; иначе — +существующий редирект. Доменный вызов (`Reviewer.*`, `worker`/`ingest`) не +меняется. Обобщаем помощник по образцу `reviewBlockAction`: вводим тонкие +обёртки `cardAction` (список) и `downloadAction` (страница загрузки), +аналогичные существующему `reviewAction`/`reviewBlockAction`. + +**Переиспользуемых builder'ов для карточки и страницы сейчас нет** — сборка +карточки размазана инлайн по `handleIndex` (`httpapi.go:291-304`: `liveFor`, +`ratioText`, `sizeText`, `layoutSizes`, `buildProgress`), сборка страницы — по +`handleDownload` (`download.go:86-142`). Чтобы «reuse без дубля» не превратился +в копипаст, сперва извлекаем `buildCardView(...)` и `buildDownloadView(id, rd)` +и переиспользуем их и на полной странице, и в фрагменте. Для `retry` +(→`downloading`) билдер карточки обязан выставить `IsDownloading`/`Progress`, +иначе свопнутая карточка потеряет прогресс-поллер. + +_Альтернатива:_ отвечать всегда фрагментом и полагаться только на htmx — +отвергнуто: ломает работу без JS, противоречит инварианту web-ui «действия +работают без JavaScript». + +### D2. Партиалы как единый источник разметки + +Выделяем переиспользуемые `{{define}}`-блоки, чтобы один markup рендерился и +инлайн на странице, и как ответ-фрагмент: +- `partials/card.html` → `{{define "card"}}` — карточка списка целиком + (сейчас инлайн в `index.html:55-88`). В `index.html` цикл вызывает + `{{template "card" .}}`. Обработчик действия рендерит `card` для одной + загрузки. +- Фрагмент главной области страницы загрузки: `{{define "download_main"}}` + (обёртка вокруг содержимого `
` в `download.html`). Страница включает + его, обработчик рендерит его же как св swap-ответ. +- Ревью: для петлевых действий рендерим тело ревью. Переиспользуем/выделяем + `{{define "review_main"}}` (содержимое `
` в `review.html`, уже + включающего `review_source_block`). + +Каждый блок получает стабильный `id` для `hx-target`: `id="card-{{.ID}}"` на +`
`, `id="download-main"` и `id="review-main"` на обёртках. + +### D3. Разметка форм: hx-post + hx-target + outerHTML + +Формы затрагиваемых действий получают `hx-post="<тот же action>"`, +`hx-target="#<фрагмент>"`, `hx-swap="outerHTML"`. `action` формы сохраняется — +это и есть fallback без htmx. Цели: +- Список: `hx-target="#card-{{.ID}}"`, своп карточки. +- Страница загрузки: `hx-target="#download-main"`. +- Петля ревью (`refine`, `rerecognize`): `hx-target="#review-main"`. +- Выходы ревью (`apply`/`defer`/`cancel`) остаются **обычными** POST-формами + без `hx-*` → полная навигация (редирект), как сейчас. Так не нужен + `HX-Redirect`, а «уйти с экрана» выражено самой навигацией. + +`hx-swap="outerHTML"` возвращает прокрутку не наверх, а сохраняет позицию — +именно то, что требуется против «прыжка скролла». + +**Различение поверхности.** Действия `undo`/`relink`/`retry`/`cancel` — единые +роуты (`httpapi.go:117-118,131-132`), приходят и со списка, и со страницы +загрузки, но фрагмент ответа разный (`card` vs `download_main`). Различаем +**скрытым полем `surface=list|download`** в форме — оно самодокументируемо и не +зависит от резолва таргета (в отличие от заголовка `HX-Target`). Обработчик +читает `surface` и зовёт `cardAction`/`downloadAction`. + +### D4. Ошибка действия — фрагмент с HTTP 200, отдельное поле ActionError + +htmx по умолчанию НЕ свопит DOM на ответы 4xx/5xx (`responseHandling`, см. +вендорный htmx). Поэтому на ошибке доменного вызова обработчик MUST отвечать +**HTTP 200 с фрагментом**, несущим сообщение об ошибке (по образцу `BlockError` +в `reviewBlockAction`, `review.go:405-408`), а не транслировать доменную ошибку +в статус. Для карточки и `download_main` вводим **отдельное поле `ActionError`** +и явную ветку разметки — не переиспользуем `card-meta`/`.Error`, потому что они +несут `Note`/`error_msg` (для `target_missing` `Note` непуст и перекрыл бы +сообщение). На экране ревью `review_main` использует существующий `.Error`. +Текст — нейтральный через `userErr`/`classifyErr`; при ошибке активное состояние +не меняется. Без htmx — прежний редирект с `?err=`. + +_Замечание по деградации страницы загрузки:_ `handleDownload` сейчас не читает +`?err=` из query, поэтому без htmx действие со страницы при ошибке уводит на +список (`/?err=`), как и раньше. Это осознанно оставляем — no-JS путь не +регрессирует; на месте ошибка показывается только на htmx-пути через +`ActionError`. + +### D6. Авто-поллер recognizing на экране ревью + +`rerecognize`/`refine` асинхронны: переводят загрузку в `recognizing` +(`worker/review.go:374,399`), фактическое распознавание доделывает цикл +воркера. Поэтому своп петлевого действия отдаёт `review_main` в состоянии +`recognizing` (заглушка `review.html:33-38`), а не сразу «новый план». Чтобы +пользователь увидел готовый план без ручного refresh, блок `recognizing` +оснащаем поллером `hx-get="/fragments/downloads/{id}/review" +hx-trigger="every 2s" hx-target="#review-main" hx-swap="outerHTML"` (по образцу +существующих `progress`/`seeding`-фрагментов в `live.go`). Новый эндпоинт +`handleFragReview` рендерит `review_main` по текущему состоянию: пока +`recognizing` — с поллером; как только состояние стало `review` — фрагмент без +поллера, опрос сам прекращается. Ручная ссылка «Обновить» остаётся fallback'ом +без JS. + +_Граница scope:_ формально это тот же приём, что и `live-status` (htmx-поллинг +UI-фрагмента), но здесь опрос ведёт браузер по своему же серверу и не читает +телеметрию qBittorrent — инвариант live-status «браузер не опрашивает +qBittorrent напрямую» не затрагивается. + +### D5. Состояние карточки после свопа vs текущий фильтр + +После свопа карточка показывает новое состояние, даже если оно уже не подходит +под активный фильтр (например, фильтр `review`, а `undo`→`reverted`). Карточка +остаётся на месте до следующей полной загрузки списка. Это осознанный +компромисс в пользу «остаться в контексте»: не переупорядочиваем и не убираем +карточку на клиенте (клиентского пересчёта домена нет — инвариант web-ui). + +## Risks / Trade-offs + +- **Дрейф разметки фрагмент vs страница** → устранён D2: единый `{{define}}`, + включаемый и в страницу, и в swap-ответ; отдельного markup для фрагмента нет. +- **Свопнутая карточка не соответствует фильтру** (D5) → приемлемо; полная + перезагрузка/смена фильтра приводит список в согласованность. Документируем + поведение в спеке. +- **relink→recognizing на карточке/странице загрузки**: после свопа + показывается `recognizing`, но поллинга для этого состояния в списке/на + странице нет (только `downloading`/`seeding`) → там состояние обновится при + следующем открытии/перезагрузке. Не регресс относительно текущего поведения. + На экране ревью этот случай закрыт авто-поллером (D6). +- **retry→downloading**: единственный своп-случай, где новая карточка обязана + нести живой прогресс-поллер → билдер карточки (D1) должен выставить + `IsDownloading`/`Progress`; покрываем тестом. +- **Своп корня с потерей id**: ответный фрагмент обязан нести тот же корневой + `id` (`#card-{id}`/`#download-main`/`#review-main`), иначе следующий своп не + найдёт таргет. Инвариант «корень `{{define}}` == элемент с целевым `id`» + фиксируем в задачах 2.1-2.3. +- **hx-target ссылается на отсутствующий элемент** (рассинхрон id) → покрываем + тестом обработчика (ветка htmx возвращает ожидаемый фрагмент) и ручной + проверкой; id проставляются в тех же партиалах. +- **Вложенные поллеры при свопе всей карточки/main**: `outerHTML` удаляет старый + узел с его поллером и htmx `process`-инициализирует поллеры нового фрагмента — + двойного опроса нет. Условие корректности — тот же корневой `id` (см. выше). +- **Двойной сабмит** → снижен: своп заменяет кнопки на актуальный набор; htmx + по умолчанию не шлёт повторно во время запроса. +- **Доступность/фокус после свупа** → минорно; действия крупные, фокус-ловушек + нет. Не адресуем в этом change. + +## Migration Plan + +Миграции данных нет. Изменения — только шаблоны (`web/templates`) и обработчики +(`internal/httpapi`). Фича — прогрессивное улучшение: ветка без htmx сохраняет +прежний PRG-поток, поэтому откат — простой revert коммита. Развёртывание — +обычный бинарь (статика встроена `go:embed`). + +## Open Questions + +Закрыты на ревью дизайна: +- Различение поверхности для общих действий — решено скрытым полем + `surface=list|download` (D3), не `HX-Target`. +- Слот ошибки в карточке — решено отдельным полем `ActionError` и явной веткой + разметки (D4), а не переиспользованием `card-meta`/`.Error`. +- Поведение петли ревью при async-распознавании — решено авто-поллером + `recognizing` на экране ревью (D6). +- Деградация download-действий без JS при ошибке — уходит на список (`/?err=`), + как сейчас; на месте ошибка только на htmx-пути (D4). diff --git a/openspec/changes/htmx-action-swap/proposal.md b/openspec/changes/htmx-action-swap/proposal.md new file mode 100644 index 0000000..510785b --- /dev/null +++ b/openspec/changes/htmx-action-swap/proposal.md @@ -0,0 +1,73 @@ +## Why + +Сейчас действия над загрузкой (откат, привязать заново, распознать заново, +уточнить, применить и т.д.) — это POST-формы с PRG-редиректом: после действия +браузер уходит на другую страницу и теряет контекст. Откат со страницы +загрузки `/download/{id}` и из карточки списка одинаково выкидывает на `/`, а +перераспознавание на `/review/{id}` перезагружает страницу с прыжком скролла +наверх. Пользователь хочет оставаться там, где действовал: действие со списка — +остаёшься в списке, со страницы загрузки/ревью — остаёшься на ней. + +## What Changes + +- Мутирующие UI-действия выполняются через **htmx-swap на месте** вместо + server-side PRG-редиректа: сервер отвечает обновлённым HTML-фрагментом той + области, которую затронуло действие, htmx подменяет её в DOM без навигации. + Паттерн уже применяется в `reviewBlockAction` (выбор источника) — расширяем + его на остальные действия. +- **Карточка в списке** (`/`): действия `undo`, `relink`, `retry`, `cancel` + подменяют карточку (`
`) на месте — обновлённое + состояние, бейдж и набор кнопок. Фильтр/поиск/пагинация/прокрутка не + сбрасываются. +- **Страница загрузки** (`/download/{id}`): те же действия обновляют содержимое + страницы на месте (без перехода и без прыжка скролла), отражая новое + состояние. +- **Страница ревью** (`/review/{id}`): **петлевые** действия распознавания — + `rerecognize`, `refine` (и уже работающий выбор источника) — обновляют экран + на месте (без перезагрузки и прыжка скролла). Так как они асинхронны (переводят + загрузку в `recognizing`), своп отдаёт состояние `recognizing`, а экран сам + **допалливает** готовый план htmx-фрагментом (`GET /fragments/downloads/{id}/review`, + по образцу `progress`/`seeding`) и автоматически сменяется на план по + завершении — без ручного обновления. **Выходы** из ревью (`apply` → done, + `defer` → deferred, `cancel` → cancelled) уводят с экрана (загрузка покидает + ревью), поэтому остаются навигацией/редиректом. Отдельная кнопка + перераспознавания на `/download/{id}` НЕ добавляется — ревью считается частью + «страницы загрузки», действие остаётся в ревью-потоке. +- **Деградация без JS сохраняется**: формы остаются обычными POST; при + отсутствии htmx (нет заголовка `HX-Request`) сервер отвечает прежним + PRG-редиректом, поведение не ломается. +- **Ошибки действий** показываются на месте (в подменённом фрагменте), а не + только через `?err=` после редиректа. + +Новых зависимостей нет: Alpine.js/SPA не вводятся, стек остаётся htmx-first +(см. решение `2026-06-30-web-ui-design-port`). + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `web-ui`: действия над карточкой/страницей загрузки выполняются htmx-swap'ом + фрагмента на месте (без навигации и сброса контекста списка), с graceful + degradation на PRG-редирект без htmx. +- `review`: петлевые действия ревью (`rerecognize`, `refine`) обновляют экран + на месте htmx-свопом, не перезагружая страницу и не сбрасывая прокрутку; + выходы из ревью (`apply`/`defer`/`cancel`) остаются навигацией. + +## Impact + +- **Код**: `internal/httpapi` — обработчики действий (`review.go`, + `httpapi.go`): вместо `redirect*` отвечать фрагментом (HTTP 200) при + `HX-Request`, сохранив редирект-ветку; извлечь `buildCardView`/ + `buildDownloadView`; новый fragment-эндпоинт `handleFragReview` для + авто-поллинга recognizing (`live.go`). +- **Шаблоны** (`web/templates`): выделить переиспользуемые фрагменты — + карточка списка (в партиал), «главная область» страницы загрузки, тело + ревью; проставить `hx-post`/`hx-target`/`hx-swap` на формы действий, скрытое + поле `surface`, поллер на блок `recognizing`. +- **Зависимости**: без изменений (htmx уже вендорится). +- **Тесты**: обработчики действий — проверка ветвления `HX-Request` → + фрагмент vs редирект. +- **Вне scope**: живой поллинг телеметрии (`live-status`) не трогаем; + клиентских фреймворков не добавляем. diff --git a/openspec/changes/htmx-action-swap/specs/review/spec.md b/openspec/changes/htmx-action-swap/specs/review/spec.md new file mode 100644 index 0000000..732897f --- /dev/null +++ b/openspec/changes/htmx-action-swap/specs/review/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Петлевые действия ревью обновляют экран на месте + +Петлевые действия распознавания на экране ревью — **Распознать заново** (`rerecognize`) и **Уточнить** (`refine`) — SHALL выполняться htmx-запросом и обновлять тело экрана ревью на месте (partial swap), без полной перезагрузки страницы и без сброса позиции прокрутки. Поскольку эти действия асинхронны (переводят загрузку в `recognizing`, распознавание доделывает воркер), своп SHALL отражать актуальное состояние — состояние `recognizing` с индикацией «идёт распознавание», а не мгновенно готовый план. Накопленные подсказки и ручные override MUST переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»). + +Пока загрузка в `recognizing`, экран ревью SHALL сам обновляться поллингом +htmx-фрагмента (`GET /fragments/downloads/{id}/review`) и по завершении +распознавания SHALL автоматически смениться на готовый план (список источников, +инфо и предпросмотр активного источника), без ручного обновления страницы. Как +только состояние вышло из `recognizing`, фрагмент SHALL возвращаться без +поллера, и опрос прекращается. Без htmx экран SHALL деградировать до ручной +ссылки «Обновить». + +Выходы из ревью, после которых загрузка покидает `review` — **Применить** +(`apply` → раскладка/`done`), **Позже** (`defer` → `deferred`) и **Отклонить** +(`cancel` → `cancelled`), — НЕ обязаны свопить экран на месте и MAY уводить с +экрана ревью навигацией (редирект/`HX-Redirect`), поскольку загрузка перестаёт +быть предметом этого экрана. + +Поведение петлевых действий MUST деградировать без htmx: без заголовка +`HX-Request` обработчик SHALL исполнять то же доменное действие и отвечать +редиректом на `/review/{id}`, как раньше. + +#### Scenario: Перераспознавание свопит экран в состояние recognizing + +- **GIVEN** загрузка в `review`, экран ревью открыт +- **WHEN** пользователь нажимает «Распознать заново» или «Уточнить» с подсказкой + (htmx активен) +- **THEN** тело экрана ревью обновляется на месте в состояние `recognizing` с + индикацией «идёт распознавание», без полной перезагрузки и без прыжка + прокрутки наверх +- **AND** накопленные подсказки и ручные override сохраняются + +#### Scenario: Экран сам обновляется до готового плана + +- **GIVEN** экран ревью показывает состояние `recognizing` после петлевого + действия +- **WHEN** воркер завершает распознавание и загрузка снова в `review` +- **THEN** экран автоматически (поллингом фрагмента) сменяется на готовый план + (источники, инфо, предпросмотр), без ручного обновления +- **AND** после выхода из `recognizing` фрагмент возвращается без поллера и опрос + прекращается + +#### Scenario: Выход из ревью уводит с экрана + +- **GIVEN** загрузка в `review` с готовым превью +- **WHEN** пользователь нажимает «Применить», «Позже» или «Отклонить» +- **THEN** загрузка покидает `review` (соответственно `done`/`deferred`/ + `cancelled`), а интерфейс уводит пользователя с экрана ревью навигацией + +#### Scenario: Деградация петлевого действия без htmx + +- **WHEN** «Распознать заново» или «Уточнить» приходит POST-запросом без + заголовка `HX-Request` +- **THEN** обработчик исполняет то же доменное действие и отвечает редиректом на + `/review/{id}`, поведение без JavaScript не ломается diff --git a/openspec/changes/htmx-action-swap/specs/web-ui/spec.md b/openspec/changes/htmx-action-swap/specs/web-ui/spec.md new file mode 100644 index 0000000..de110ce --- /dev/null +++ b/openspec/changes/htmx-action-swap/specs/web-ui/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Действия обновляют интерфейс на месте + +Мутирующие действия над загрузкой в списке (`/`) и на странице `/download/{id}` SHALL выполняться htmx-запросом и обновлять затронутую область HTML на месте (partial swap), без навигации на другую страницу и без сброса контекста списка (фильтр, поиск, страница пагинации, позиция прокрутки). +Сервер SHALL отвечать на такой запрос HTML-фрагментом обновлённой области, а не +редиректом. + +Область свопа SHALL соответствовать поверхности действия: в списке — карточка +загрузки (`
`) целиком, отражающая новое состояние, бейдж +и допустимый набор действий; на странице `/download/{id}` — содержимое +страницы, отражающее новое состояние загрузки. После свопа набор показанных +действий MUST соответствовать новому состоянию (см. «Действия соответствуют +состоянию»). + +Поведение MUST деградировать без htmx: если запрос действия пришёл без признака +htmx (нет заголовка `HX-Request`), обработчик SHALL отвечать прежним +PRG-редиректом, и действие исполняется тем же доменным вызовом. Формы действий +остаются обычными POST-формами. + +Ошибка действия (доменная или валидации) SHALL показываться на месте — в +подменённом фрагменте той же области, — а не только через параметр `?err=` +после редиректа; при ошибке активное состояние загрузки не меняется молча. +Ответ на htmx-запрос действия SHALL иметь статус `200` даже при ошибке действия +(иначе htmx не подменит фрагмент): сообщение об ошибке несёт сам фрагмент. + +После свопа карточка SHALL оставаться на своём месте в списке, даже если её +новое состояние уже не подходит под активный фильтр; согласованность списка с +фильтром восстанавливается при следующей полной загрузке. Клиентского +переупорядочивания или пересчёта доменного состояния не выполняется. + +#### Scenario: Откат из карточки списка обновляет карточку на месте + +- **GIVEN** в списке есть загрузка в состоянии `done` с действием отката +- **WHEN** пользователь нажимает «Откатить» (htmx активен) +- **THEN** карточка этой загрузки подменяется на месте на её новое состояние + (`reverted`) с соответствующим бейджем и набором действий +- **AND** список не перезагружается: фильтр, поиск, страница и позиция прокрутки + сохраняются + +#### Scenario: Действие со страницы загрузки оставляет на странице + +- **GIVEN** открыта страница `GET /download/{id}` загрузки в состоянии `done` +- **WHEN** пользователь нажимает «Откатить» или «Привязать заново» (htmx активен) +- **THEN** содержимое страницы обновляется на месте под новое состояние + загрузки, без перехода на список и без прыжка прокрутки наверх + +#### Scenario: Деградация без htmx — прежний редирект + +- **WHEN** действие над загрузкой приходит POST-запросом без заголовка + `HX-Request` (htmx недоступен) +- **THEN** обработчик исполняет то же доменное действие и отвечает + PRG-редиректом, как раньше; поведение без JavaScript не ломается + +#### Scenario: Ошибка действия показана на месте + +- **GIVEN** пользователь запускает действие через htmx +- **WHEN** доменный вызов возвращает ошибку (например, состояние уже изменилось) +- **THEN** ответ имеет статус `200`, а сообщение об ошибке показывается в + подменённом фрагменте той же области, а не только на отдельной странице после + редиректа +- **AND** активное состояние загрузки не меняется + +#### Scenario: Свопнутая карточка остаётся вне фильтра + +- **GIVEN** список отфильтрован по группе состояний (например, `review`) и в нём + есть карточка загрузки +- **WHEN** действие через htmx переводит загрузку в состояние вне этого фильтра + (например, `cancelled`) +- **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до + следующей полной загрузки списка, без клиентского переупорядочивания diff --git a/openspec/changes/htmx-action-swap/tasks.md b/openspec/changes/htmx-action-swap/tasks.md new file mode 100644 index 0000000..7ae040d --- /dev/null +++ b/openspec/changes/htmx-action-swap/tasks.md @@ -0,0 +1,92 @@ +## 1. Извлечение переиспользуемых view-builder'ов + +- [x] 1.1 Вынести сборку карточки списка в `buildCardView(d, now, live, + layoutSize)` — перенести обвязку из `handleIndex` (`httpapi.go:291-304`: + `liveFor`, `ratioText`, `sizeText`, `layoutSizes[id]`, `buildProgress`); + `handleIndex` теперь зовёт её в цикле. Билдер MUST выставлять + `IsDownloading`/`Progress` (для retry→downloading карточка обязана нести + `progress`-партиал с поллером) и `Ratio`/`Size` +- [x] 1.2 Вынести сборку страницы загрузки в `buildDownloadView(id, rd)` — + перенести инлайн-сборку из `handleDownload` (`download.go:86-142`); + `handleDownload` зовёт её. Добавить в view отдельное поле `ActionError` +- [x] 1.3 Добавить в view карточки отдельное поле `ActionError` (НЕ + переиспользовать `card-meta`/`.Error` — они несут `Note`/`error_msg`) + +## 2. Партиалы и фрагменты (единый источник разметки) + +- [x] 2.1 Выделить карточку в `partials/card.html` (`{{define "card"}}`) с + `id="card-{{.ID}}"` на корневом `
`; `index.html` — цикл через + `{{template "card" .}}`. Инвариант: корень `define` == элемент с целевым `id` +- [x] 2.2 Обернуть `
` страницы загрузки в `{{define "download_main"}}` с + `id="download-main"`; `download.html` включает его +- [x] 2.3 Обернуть тело ревью в `{{define "review_main"}}` с `id="review-main"` + (внутри остаётся `review_source_block`); `review.html` включает его +- [x] 2.4 В обеих карточка/`download_main` добавить разметку слота `ActionError` + (видим только при непустом значении), не конфликтуя с `Note`/error-баннером +- [x] 2.5 Проставить `hx-post`/`hx-target`/`hx-swap="outerHTML"` на свопимые + формы: карточка (`undo`/`relink`/`retry`/`cancel` → `#card-{id}`), страница + загрузки (те же → `#download-main`), петля ревью (`refine`/`rerecognize` → + `#review-main`). Выходы ревью (`apply`/`defer`/`cancel`) — обычные POST-формы + БЕЗ `hx-*` (намеренно: единственный маркер «это выход из ревью») +- [x] 2.6 В формы общих действий (`undo`/`relink`/`retry`/`cancel`), доступных и + в списке, и на `/download/{id}`, добавить скрытое поле + `surface=list|download` — им обработчик выбирает фрагмент ответа + +## 3. Авто-поллер recognizing на экране ревью + +- [x] 3.1 Добавить fragment-эндпоинт `GET /fragments/downloads/{id}/review` → + `handleFragReview`, рендерит `review_main` по текущему состоянию (по образцу + `handleFragProgress`/`handleFragSeeding` в `live.go`) +- [x] 3.2 В `review_main` блок состояния `recognizing` (`review.html:33-38`) + оснастить поллером `hx-get="/fragments/downloads/{id}/review" + hx-trigger="every 2s" hx-target="#review-main" hx-swap="outerHTML"`; ручную + ссылку «Обновить» оставить как fallback без JS. Когда состояние выходит из + `recognizing`, `review_main` возвращается без поллера — опрос сам прекращается + +## 4. Обработчики: ветка htmx → фрагмент + +- [x] 4.1 Ввести помощник `cardAction` (по образцу `reviewBlockAction`): + доменный вызов; на `isHTMX` перечитать состояние, отрендерить `card` для + одной загрузки; на ошибке — тот же фрагмент с заполненным `ActionError` и + **HTTP 200** (иначе htmx не свопит); без htmx — прежний редирект +- [x] 4.2 Ввести помощник `downloadAction` аналогично, рендер `download_main` + (тоже 200 + `ActionError` на ошибке) +- [x] 4.3 Перевести `handleUndo`, `handleRelink`, `handleUIRetry` на выбор + помощника/фрагмента по полю `surface` (list→`cardAction`, + download→`downloadAction`); без htmx — прежний редирект +- [x] 4.4 Переписать единый `handleUICancel`: на `isHTMX` — своп по `surface` + (list→`card`, download→`download_main`); на не-htmx (в т.ч. форма выхода из + ревью и режим без JS) → редирект на `/`, как сейчас. Убрать деление «в + контексте ревью не трогать» — обработчик один +- [x] 4.5 Перевести петлевые `handleRefine`, `handleRerecognize` на рендер + `review_main` при `isHTMX` (200 + `.Error` на ошибке), сохранив + редирект-ветку на `/review/{id}` без htmx. Выходы ревью + (`handleApply`/`handleDefer`) не трогать +- [x] 4.6 Ошибки везде переводить существующими `userErr`/`classifyErr` (наружу + только нейтральный текст); секреты в логи не попадают — паритет сохранить + +## 5. Тесты + +- [x] 5.1 htmx-запрос (`HX-Request: true`) действия из списка (`surface=list`) + → 200 + фрагмент `card` с новым состоянием, не редирект +- [x] 5.2 htmx-запрос действия со страницы (`surface=download`) → 200 + + `download_main`; без `HX-Request` → прежний 303-редирект +- [x] 5.3 Петля ревью: `rerecognize`/`refine` при `HX-Request` → 200 + + `review_main` в состоянии `recognizing` с поллером; без htmx → 303 на + `/review/{id}` +- [x] 5.4 `handleFragReview`: пока `recognizing` — фрагмент содержит поллер; + после перехода в `review` — фрагмент без поллера (опрос прекращается) +- [x] 5.5 Ошибка действия: htmx-ответ — **200 + сообщение в теле** (в + `ActionError`/`.Error`), состояние не меняется; выход ревью (`cancel` без + `hx-*`) — по-прежнему навигация +- [x] 5.6 retry из списка при htmx → карточка `downloading` с `progress`-поллером + +## 6. Проверка и оформление + +- [x] 6.1 `task lint` и `task test` зелёные +- [ ] 6.2 Ручная проверка (`task run`): откат из списка — остаёшься в списке + (фильтр/скролл целы); откат/relink со страницы загрузки — остаёшься на ней; + rerecognize/refine — экран ревью свопится в recognizing и сам обновляется до + готового плана без тыканья; выключенный JS — прежний редирект-поток работает +- [ ] 6.3 Ревью кода (второй чекпоинт), затем sync дельт в `openspec/specs/` и + архивирование change diff --git a/web/templates/download.html b/web/templates/download.html index e36d45c..f519c02 100644 --- a/web/templates/download.html +++ b/web/templates/download.html @@ -12,96 +12,6 @@ {{template "header" ""}} -
- ← ко всем загрузкам - - {{if .Error}}

{{.Error}}

{{end}} - - -
-
-
{{.Title}}
-
- {{template "status_badge" .State}} - {{.ID}} - -
- {{if .Added}}

Добавлено {{.Added}} ({{.AddedAgo}})

{{end}} - {{if .Note}}

{{.Note}}

{{end}} -
-
- {{if .Reviewable}}Ревью →{{end}} - {{if .Undoable}}
{{end}} - {{if .Relinkable}}
{{end}} - {{if .Retriable}}
{{end}} - {{if not .Terminal}}
{{end}} -
-
- - -
-

Распознано как

- {{if .HasPlan}} -
-
Название
{{.RecTitle}}
-
Ориг. название
{{if .OriginalTitle}}{{.OriginalTitle}}{{else}}{{end}}
-
Тип
{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}
- {{if .IsSeries}}
Сезон
{{if .Season}}{{.Season}}{{else}}{{end}}
{{end}} -
Год
{{if .Year}}{{.Year}}{{else}}{{end}}
-
Режиссёр
-
База
{{if .Provider}}{{if .MatchURL}}{{.Provider}} · {{.ProviderID}} ↗{{else}}{{.Provider}} · {{.ProviderID}}{{end}}{{else if .NoBase}}без базы{{else}}{{end}}
-
Уверенность
{{if .Confidence}}{{.Confidence}}{{else}}{{end}}
-
- {{else}} -

Ещё не распознано.

- {{end}} -
- - - {{if .HasPlan}} -
-

Файлы и раскладка

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

- Целевые файлы связаны хардлинками — место не дублируется, исходник остаётся на раздаче. -

-
- {{end}} - - - {{template "seeding" .Seeding}} - - -
-

История

-
    - {{if .CreatedAt}}
  • Добавлено
  • {{end}} -
  • {{badgeLabel .State}} текущее
    {{if .UpdatedAt}}{{end}}
  • -
-
- - - {{if .Context}} -
-

Контекст

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

«{{.Context}}»

-
- {{end}} - - -
-

Информация о торренте

сырой источник и infohash
-
-
Тип источника
{{.SourceType}}
- {{range .Infohashes}}
infohash
{{.}}
{{end}} -
- {{if .SourceFull}} -
- {{.SourceFull}} - -
- {{end}} -
-
+{{template "download_main" .}} diff --git a/web/templates/index.html b/web/templates/index.html index ee84506..6075298 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -51,42 +51,7 @@ {{if .Downloads}}
- {{range .Downloads}} -
-
-
- -
- ID: - {{.ID}} - -
-
- {{template "status_badge" .State}} -
- -
- {{if .Added}}{{.Added}} ({{.AddedAgo}}){{else}}—{{end}} - · - {{.Size}} - · - ↑ {{.Ratio}} -
- - {{if .IsDownloading}}{{template "progress" .Progress}}{{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}} + {{range .Downloads}}{{template "card" .}}{{end}}
{{if gt .Pages 1}} diff --git a/web/templates/partials/card.html b/web/templates/partials/card.html new file mode 100644 index 0000000..4519d20 --- /dev/null +++ b/web/templates/partials/card.html @@ -0,0 +1,36 @@ +{{define "card"}} +
+
+
+ +
+ ID: + {{.ID}} + +
+
+ {{template "status_badge" .State}} +
+ +
+ {{if .Added}}{{.Added}} ({{.AddedAgo}}){{else}}—{{end}} + · + {{.Size}} + · + ↑ {{.Ratio}} +
+ + {{if .IsDownloading}}{{template "progress" .Progress}}{{end}} + +
+ {{if .ActionError}}{{.ActionError}}{{else}}{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}{{end}} +
+ {{if .Reviewable}}Ревью →{{end}} + {{if .Undoable}}
{{end}} + {{if .Relinkable}}
{{end}} + {{if .Retriable}}
{{end}} + {{if not .Terminal}}
{{end}} +
+
+
+{{end}} diff --git a/web/templates/partials/download_main.html b/web/templates/partials/download_main.html new file mode 100644 index 0000000..c12baef --- /dev/null +++ b/web/templates/partials/download_main.html @@ -0,0 +1,94 @@ +{{define "download_main"}} +
+ ← ко всем загрузкам + + {{if .Error}}

{{.Error}}

{{end}} + {{if .ActionError}}

{{.ActionError}}

{{end}} + + +
+
+
{{.Title}}
+
+ {{template "status_badge" .State}} + {{.ID}} + +
+ {{if .Added}}

Добавлено {{.Added}} ({{.AddedAgo}})

{{end}} + {{if .Note}}

{{.Note}}

{{end}} +
+
+ {{if .Reviewable}}Ревью →{{end}} + {{if .Undoable}}
{{end}} + {{if .Relinkable}}
{{end}} + {{if .Retriable}}
{{end}} + {{if not .Terminal}}
{{end}} +
+
+ + +
+

Распознано как

+ {{if .HasPlan}} +
+
Название
{{.RecTitle}}
+
Ориг. название
{{if .OriginalTitle}}{{.OriginalTitle}}{{else}}{{end}}
+
Тип
{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}
+ {{if .IsSeries}}
Сезон
{{if .Season}}{{.Season}}{{else}}{{end}}
{{end}} +
Год
{{if .Year}}{{.Year}}{{else}}{{end}}
+
Режиссёр
+
База
{{if .Provider}}{{if .MatchURL}}{{.Provider}} · {{.ProviderID}} ↗{{else}}{{.Provider}} · {{.ProviderID}}{{end}}{{else if .NoBase}}без базы{{else}}{{end}}
+
Уверенность
{{if .Confidence}}{{.Confidence}}{{else}}{{end}}
+
+ {{else}} +

Ещё не распознано.

+ {{end}} +
+ + + {{if .HasPlan}} +
+

Файлы и раскладка

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

+ Целевые файлы связаны хардлинками — место не дублируется, исходник остаётся на раздаче. +

+
+ {{end}} + + + {{template "seeding" .Seeding}} + + +
+

История

+
    + {{if .CreatedAt}}
  • Добавлено
  • {{end}} +
  • {{badgeLabel .State}} текущее
    {{if .UpdatedAt}}{{end}}
  • +
+
+ + + {{if .Context}} +
+

Контекст

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

«{{.Context}}»

+
+ {{end}} + + +
+

Информация о торренте

сырой источник и infohash
+
+
Тип источника
{{.SourceType}}
+ {{range .Infohashes}}
infohash
{{.}}
{{end}} +
+ {{if .SourceFull}} +
+ {{.SourceFull}} + +
+ {{end}} +
+
+{{end}} diff --git a/web/templates/partials/review_main.html b/web/templates/partials/review_main.html new file mode 100644 index 0000000..cc51ec3 --- /dev/null +++ b/web/templates/partials/review_main.html @@ -0,0 +1,72 @@ +{{define "review_main"}} +
+ ← ко всем загрузкам + + {{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 .Context}} +
+

Контекст

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

«{{.Context}}»

+
+ {{end}} + + {{if .HasPlan}} + + {{template "review_source_block" .}} + {{end}} + + +
+

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

+ {{if .Hints}} +
+ {{range $i, $h := .Hints}} +
#{{add $i 1}}{{$h}}
+ {{end}} +
+ {{end}} +
+ +
+ +
+
+
+ + без новой подсказки — по уже накопленному контексту +
+
+ + +
+ {{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}} +
+
+ {{if .HasLinks}}
{{end}} +
+ +
+{{end}} diff --git a/web/templates/review.html b/web/templates/review.html index 2507e31..87ff89b 100644 --- a/web/templates/review.html +++ b/web/templates/review.html @@ -12,74 +12,6 @@ {{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 .Context}} -
-

Контекст

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

«{{.Context}}»

-
- {{end}} - - {{if .HasPlan}} - - {{template "review_source_block" .}} - {{end}} - - -
-

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

- {{if .Hints}} -
- {{range $i, $h := .Hints}} -
#{{add $i 1}}{{$h}}
- {{end}} -
- {{end}} -
- -
- -
-
-
- - без новой подсказки — по уже накопленному контексту -
-
- - -
- {{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}} -
-
- {{if .HasLinks}}
{{end}} -
- -
+{{template "review_main" .}}