Веб-UI: htmx-своп действий на месте вместо PRG-редиректа
Мутирующие действия больше не уводят со страницы: 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type downloadDetailView struct {
|
|||||||
Context string
|
Context string
|
||||||
State string
|
State string
|
||||||
Error string
|
Error string
|
||||||
|
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
|
||||||
Note string
|
Note string
|
||||||
Added string // дата добавления (source_added_at → created_at), как в списке
|
Added string // дата добавления (source_added_at → created_at), как в списке
|
||||||
AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить
|
AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить
|
||||||
@@ -82,7 +83,13 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||||
return
|
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
|
d := rd.Download
|
||||||
view := downloadDetailView{
|
view := downloadDetailView{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -141,5 +148,5 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
l, ok := s.liveFor(d)
|
l, ok := s.liveFor(d)
|
||||||
view.Seeding = buildSeeding(id, l, ok)
|
view.Seeding = buildSeeding(id, l, ok)
|
||||||
|
|
||||||
s.render(w, "download.html", view)
|
return view
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-11
@@ -113,6 +113,8 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
|
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
|
||||||
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
|
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
|
||||||
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
|
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", s.handleUIAdd)
|
||||||
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
||||||
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
||||||
@@ -199,6 +201,7 @@ type downloadView struct {
|
|||||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||||
Retriable bool // failed/stuck — можно повторить попытку
|
Retriable bool // failed/stuck — можно повторить попытку
|
||||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||||
|
ActionError string // ошибка действия на htmx-пути (своп карточки), не error_msg
|
||||||
|
|
||||||
// Обзор жизненного цикла (мета-строка карточки).
|
// Обзор жизненного цикла (мета-строка карточки).
|
||||||
Added string // абсолютная дата добавления (TZ сервера), «2006-01-02»
|
Added string // абсолютная дата добавления (TZ сервера), «2006-01-02»
|
||||||
@@ -290,20 +293,29 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, d := range downloads {
|
for _, d := range downloads {
|
||||||
|
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)
|
v := s.toView(d, now)
|
||||||
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
|
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
|
||||||
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
|
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
|
||||||
l, ok := s.liveFor(d)
|
l, ok := s.liveFor(d)
|
||||||
v.Ratio = ratioText(l, ok)
|
v.Ratio = ratioText(l, ok)
|
||||||
v.Size = sizeText(l, ok, layoutSizes[d.ID])
|
v.Size = sizeText(l, ok, layoutSize)
|
||||||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||||
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
|
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
|
||||||
if v.IsDownloading {
|
if v.IsDownloading {
|
||||||
v.Progress = buildProgress(d.ID, true, l, ok)
|
v.Progress = buildProgress(d.ID, true, l, ok)
|
||||||
}
|
}
|
||||||
view.Downloads = append(view.Downloads, v)
|
return v
|
||||||
}
|
|
||||||
s.render(w, "index.html", view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
|
// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
|
||||||
@@ -400,11 +412,7 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
|
|||||||
redirectErr(w, r, "некорректный id")
|
redirectErr(w, r, "некорректный id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Commander.Cancel(r.Context(), id); err != nil {
|
s.surfaceAction(w, r, id, s.deps.Commander.Cancel(r.Context(), id))
|
||||||
redirectErr(w, r, userErr(r, err, id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
|
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")
|
redirectErr(w, r, "некорректный id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Commander.Retry(r.Context(), id); err != nil {
|
s.surfaceAction(w, r, id, s.deps.Commander.Retry(r.Context(), id))
|
||||||
redirectErr(w, r, userErr(r, err, 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
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 ---
|
// --- REST API ---
|
||||||
|
|||||||
+50
-18
@@ -201,7 +201,9 @@ func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleSetProvider(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()
|
_ = r.ParseForm()
|
||||||
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
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")
|
redirectErr(w, r, "некорректный id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
|
s.surfaceAction(w, r, id, s.deps.Reviewer.Undo(r.Context(), id))
|
||||||
redirectErr(w, r, userErr(r, err, id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRelink повторно привязывает откатанную задачу: перезапускает
|
// handleRelink повторно привязывает откатанную задачу: перезапускает
|
||||||
@@ -342,28 +340,62 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
|||||||
redirectErr(w, r, "некорректный id")
|
redirectErr(w, r, "некорректный id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
|
s.surfaceAction(w, r, id, s.deps.Reviewer.Relink(r.Context(), id))
|
||||||
redirectErr(w, r, userErr(r, err, id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
|
// reviewAction — общий помощник петлевых действий ревью (уточнить/распознать
|
||||||
// ревью (с ошибкой в ?err при неудаче).
|
// заново/…): выполнить действие и обновить экран ревью. На 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) {
|
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
redirectErr(w, r, "некорректный id")
|
redirectErr(w, r, "некорректный id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := fn(r.Context(), id); err != nil {
|
actionErr := fn(r.Context(), id)
|
||||||
// Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не
|
|
||||||
// логируя повторно (доменный слой/валидация — не дело транспорта).
|
if !isHTMX(r) {
|
||||||
redirectReview(w, r, id, userErr(r, err, id))
|
msg := ""
|
||||||
|
if actionErr != nil {
|
||||||
|
msg = userErr(r, actionErr, id)
|
||||||
|
}
|
||||||
|
redirectReview(w, r, id, msg)
|
||||||
return
|
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 (ждёт партиал, а не полную страницу).
|
// isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу).
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-04
|
||||||
@@ -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"}}`
|
||||||
|
(обёртка вокруг содержимого `<main>` в `download.html`). Страница включает
|
||||||
|
его, обработчик рендерит его же как св swap-ответ.
|
||||||
|
- Ревью: для петлевых действий рендерим тело ревью. Переиспользуем/выделяем
|
||||||
|
`{{define "review_main"}}` (содержимое `<main>` в `review.html`, уже
|
||||||
|
включающего `review_source_block`).
|
||||||
|
|
||||||
|
Каждый блок получает стабильный `id` для `hx-target`: `id="card-{{.ID}}"` на
|
||||||
|
`<article>`, `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).
|
||||||
@@ -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`
|
||||||
|
подменяют карточку (`<article class="card">`) на месте — обновлённое
|
||||||
|
состояние, бейдж и набор кнопок. Фильтр/поиск/пагинация/прокрутка не
|
||||||
|
сбрасываются.
|
||||||
|
- **Страница загрузки** (`/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
|
||||||
|
<!-- Нет новых capability — меняется поведение существующих. -->
|
||||||
|
|
||||||
|
### 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`) не трогаем;
|
||||||
|
клиентских фреймворков не добавляем.
|
||||||
@@ -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 не ломается
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Действия обновляют интерфейс на месте
|
||||||
|
|
||||||
|
Мутирующие действия над загрузкой в списке (`/`) и на странице `/download/{id}` SHALL выполняться htmx-запросом и обновлять затронутую область HTML на месте (partial swap), без навигации на другую страницу и без сброса контекста списка (фильтр, поиск, страница пагинации, позиция прокрутки).
|
||||||
|
Сервер SHALL отвечать на такой запрос HTML-фрагментом обновлённой области, а не
|
||||||
|
редиректом.
|
||||||
|
|
||||||
|
Область свопа SHALL соответствовать поверхности действия: в списке — карточка
|
||||||
|
загрузки (`<article class="card">`) целиком, отражающая новое состояние, бейдж
|
||||||
|
и допустимый набор действий; на странице `/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** карточка подменяется на месте новым состоянием и остаётся видимой до
|
||||||
|
следующей полной загрузки списка, без клиентского переупорядочивания
|
||||||
@@ -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}}"` на корневом `<article>`; `index.html` — цикл через
|
||||||
|
`{{template "card" .}}`. Инвариант: корень `define` == элемент с целевым `id`
|
||||||
|
- [x] 2.2 Обернуть `<main>` страницы загрузки в `{{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
|
||||||
@@ -12,96 +12,6 @@
|
|||||||
|
|
||||||
{{template "header" ""}}
|
{{template "header" ""}}
|
||||||
|
|
||||||
<main class="main">
|
{{template "download_main" .}}
|
||||||
<a class="back-link" href="/">← ко всем загрузкам</a>
|
|
||||||
|
|
||||||
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
|
||||||
|
|
||||||
<!-- Шапка -->
|
|
||||||
<div class="review-head" style="margin-bottom:var(--sp-4)">
|
|
||||||
<div>
|
|
||||||
<div class="review-title" style="font-size:var(--fs-xl)">{{.Title}}</div>
|
|
||||||
<div class="hash-row" style="margin-top:9px">
|
|
||||||
{{template "status_badge" .State}}
|
|
||||||
<span class="hash">{{.ID}}</span>
|
|
||||||
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.ID}}')">⧉ копировать</button>
|
|
||||||
</div>
|
|
||||||
{{if .Added}}<p class="muted" style="margin:var(--sp-2) 0 0">Добавлено {{.Added}} <span class="ago">({{.AddedAgo}})</span></p>{{end}}
|
|
||||||
{{if .Note}}<p class="muted" style="margin:var(--sp-2) 0 0">{{.Note}}</p>{{end}}
|
|
||||||
</div>
|
|
||||||
<div class="card-acts" style="align-items:center">
|
|
||||||
{{if .Reviewable}}<a class="btn btn-primary" href="/review/{{.ID}}">Ревью →</a>{{end}}
|
|
||||||
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo"><button class="btn btn-danger" type="submit">↩ Откатить</button></form>{{end}}
|
|
||||||
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink"><button class="btn" type="submit">Привязать заново</button></form>{{end}}
|
|
||||||
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry"><button class="btn" type="submit">Повторить</button></form>{{end}}
|
|
||||||
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-ghost" type="submit">Отменить</button></form>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Распознано как -->
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-head"><h2>Распознано как</h2></div>
|
|
||||||
{{if .HasPlan}}
|
|
||||||
<dl class="kv">
|
|
||||||
<dt>Название</dt><dd>{{.RecTitle}}</dd>
|
|
||||||
<dt>Ориг. название</dt><dd>{{if .OriginalTitle}}{{.OriginalTitle}}{{else}}<span class="faint">—</span>{{end}}</dd>
|
|
||||||
<dt>Тип</dt><dd>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</dd>
|
|
||||||
{{if .IsSeries}}<dt>Сезон</dt><dd>{{if .Season}}{{.Season}}{{else}}<span class="faint">—</span>{{end}}</dd>{{end}}
|
|
||||||
<dt>Год</dt><dd>{{if .Year}}{{.Year}}{{else}}<span class="faint">—</span>{{end}}</dd>
|
|
||||||
<dt>Режиссёр</dt><dd><span class="faint">—</span></dd>
|
|
||||||
<dt>База</dt><dd>{{if .Provider}}{{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">{{.Provider}} · {{.ProviderID}} ↗</a>{{else}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{end}}{{else if .NoBase}}без базы{{else}}<span class="faint">—</span>{{end}}</dd>
|
|
||||||
<dt>Уверенность</dt><dd class="mono">{{if .Confidence}}{{.Confidence}}{{else}}<span class="faint">—</span>{{end}}</dd>
|
|
||||||
</dl>
|
|
||||||
{{else}}
|
|
||||||
<p class="muted" style="margin:0">Ещё не распознано.</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Файлы и раскладка -->
|
|
||||||
{{if .HasPlan}}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-head"><h2>Файлы и раскладка</h2><span class="hint">файл источника → целевой хардлинк</span></div>
|
|
||||||
{{template "layout_widget" .Files}}
|
|
||||||
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
|
|
||||||
Целевые файлы связаны хардлинками — место не дублируется, исходник остаётся на раздаче.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Раздача (живая статистика; секция деградирует, если торрент не сидирует) -->
|
|
||||||
{{template "seeding" .Seeding}}
|
|
||||||
|
|
||||||
<!-- История -->
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-head"><h2>История</h2></div>
|
|
||||||
<ul class="timeline">
|
|
||||||
{{if .CreatedAt}}<li class="tl-item is-done"><div class="tl-t">Добавлено</div><time>{{.CreatedAt}}</time></li>{{end}}
|
|
||||||
<li class="tl-item is-now"><div class="tl-t">{{badgeLabel .State}} <span class="badge st-{{.State}}">текущее</span></div>{{if .UpdatedAt}}<time>{{.UpdatedAt}}</time>{{end}}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Контекст -->
|
|
||||||
{{if .Context}}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-head"><h2>Контекст</h2><span class="hint">сообщение при добавлении</span></div>
|
|
||||||
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Информация о торренте -->
|
|
||||||
<div class="section" style="margin-bottom:0">
|
|
||||||
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
|
|
||||||
<dl class="kv">
|
|
||||||
<dt>Тип источника</dt><dd>{{.SourceType}}</dd>
|
|
||||||
{{range .Infohashes}}<dt>infohash</dt><dd class="mono">{{.}}</dd>{{end}}
|
|
||||||
</dl>
|
|
||||||
{{if .SourceFull}}
|
|
||||||
<div class="src-block">
|
|
||||||
<code class="src-full">{{.SourceFull}}</code>
|
|
||||||
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.SourceFull}}')">⧉ копировать</button>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -51,42 +51,7 @@
|
|||||||
{{if .Downloads}}
|
{{if .Downloads}}
|
||||||
<!-- Список карточек -->
|
<!-- Список карточек -->
|
||||||
<div id="list">
|
<div id="list">
|
||||||
{{range .Downloads}}
|
{{range .Downloads}}{{template "card" .}}{{end}}
|
||||||
<article class="card">
|
|
||||||
<div class="card-top">
|
|
||||||
<div>
|
|
||||||
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
|
||||||
<div class="hash-row">
|
|
||||||
<span class="hash-label">ID:</span>
|
|
||||||
<span class="hash">{{.ID}}</span>
|
|
||||||
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.ID}}')">⧉ копировать</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{template "status_badge" .State}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-life">
|
|
||||||
<span>{{if .Added}}{{.Added}} <span class="ago">({{.AddedAgo}})</span>{{else}}—{{end}}</span>
|
|
||||||
<span class="sep">·</span>
|
|
||||||
<span>{{.Size}}</span>
|
|
||||||
<span class="sep">·</span>
|
|
||||||
<span title="рейтинг раздачи (отдано/скачано)">↑ {{.Ratio}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{if .IsDownloading}}{{template "progress" .Progress}}{{end}}
|
|
||||||
|
|
||||||
<div class="card-foot">
|
|
||||||
<span class="card-meta">{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}</span>
|
|
||||||
<div class="card-acts">
|
|
||||||
{{if .Reviewable}}<a class="btn btn-primary btn-sm" href="/review/{{.ID}}">Ревью →</a>{{end}}
|
|
||||||
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo"><button class="btn btn-sm btn-ghost" type="submit">Откатить</button></form>{{end}}
|
|
||||||
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink"><button class="btn btn-sm" type="submit">Привязать заново</button></form>{{end}}
|
|
||||||
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry"><button class="btn btn-sm" type="submit">Повторить</button></form>{{end}}
|
|
||||||
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-sm btn-danger" type="submit">{{if .Reviewable}}Отклонить{{else}}Отменить{{end}}</button></form>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if gt .Pages 1}}
|
{{if gt .Pages 1}}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{{define "card"}}
|
||||||
|
<article class="card" id="card-{{.ID}}">
|
||||||
|
<div class="card-top">
|
||||||
|
<div>
|
||||||
|
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
||||||
|
<div class="hash-row">
|
||||||
|
<span class="hash-label">ID:</span>
|
||||||
|
<span class="hash">{{.ID}}</span>
|
||||||
|
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.ID}}')">⧉ копировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "status_badge" .State}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-life">
|
||||||
|
<span>{{if .Added}}{{.Added}} <span class="ago">({{.AddedAgo}})</span>{{else}}—{{end}}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>{{.Size}}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span title="рейтинг раздачи (отдано/скачано)">↑ {{.Ratio}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .IsDownloading}}{{template "progress" .Progress}}{{end}}
|
||||||
|
|
||||||
|
<div class="card-foot">
|
||||||
|
{{if .ActionError}}<span class="card-meta" style="color:var(--st-err)">{{.ActionError}}</span>{{else}}<span class="card-meta">{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}</span>{{end}}
|
||||||
|
<div class="card-acts">
|
||||||
|
{{if .Reviewable}}<a class="btn btn-primary btn-sm" href="/review/{{.ID}}">Ревью →</a>{{end}}
|
||||||
|
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo" hx-post="/ui/downloads/{{.ID}}/undo" hx-target="#card-{{.ID}}" hx-swap="outerHTML"><input type="hidden" name="surface" value="list"><button class="btn btn-sm btn-ghost" type="submit">Откатить</button></form>{{end}}
|
||||||
|
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink" hx-post="/ui/downloads/{{.ID}}/relink" hx-target="#card-{{.ID}}" hx-swap="outerHTML"><input type="hidden" name="surface" value="list"><button class="btn btn-sm" type="submit">Привязать заново</button></form>{{end}}
|
||||||
|
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry" hx-post="/ui/downloads/{{.ID}}/retry" hx-target="#card-{{.ID}}" hx-swap="outerHTML"><input type="hidden" name="surface" value="list"><button class="btn btn-sm" type="submit">Повторить</button></form>{{end}}
|
||||||
|
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel" hx-post="/ui/downloads/{{.ID}}/cancel" hx-target="#card-{{.ID}}" hx-swap="outerHTML"><input type="hidden" name="surface" value="list"><button class="btn btn-sm btn-danger" type="submit">{{if .Reviewable}}Отклонить{{else}}Отменить{{end}}</button></form>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{{define "download_main"}}
|
||||||
|
<main class="main" id="download-main">
|
||||||
|
<a class="back-link" href="/">← ко всем загрузкам</a>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||||
|
{{if .ActionError}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.ActionError}}</h2></div>{{end}}
|
||||||
|
|
||||||
|
<!-- Шапка -->
|
||||||
|
<div class="review-head" style="margin-bottom:var(--sp-4)">
|
||||||
|
<div>
|
||||||
|
<div class="review-title" style="font-size:var(--fs-xl)">{{.Title}}</div>
|
||||||
|
<div class="hash-row" style="margin-top:9px">
|
||||||
|
{{template "status_badge" .State}}
|
||||||
|
<span class="hash">{{.ID}}</span>
|
||||||
|
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.ID}}')">⧉ копировать</button>
|
||||||
|
</div>
|
||||||
|
{{if .Added}}<p class="muted" style="margin:var(--sp-2) 0 0">Добавлено {{.Added}} <span class="ago">({{.AddedAgo}})</span></p>{{end}}
|
||||||
|
{{if .Note}}<p class="muted" style="margin:var(--sp-2) 0 0">{{.Note}}</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="card-acts" style="align-items:center">
|
||||||
|
{{if .Reviewable}}<a class="btn btn-primary" href="/review/{{.ID}}">Ревью →</a>{{end}}
|
||||||
|
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo" hx-post="/ui/downloads/{{.ID}}/undo" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn btn-danger" type="submit">↩ Откатить</button></form>{{end}}
|
||||||
|
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink" hx-post="/ui/downloads/{{.ID}}/relink" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn" type="submit">Привязать заново</button></form>{{end}}
|
||||||
|
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry" hx-post="/ui/downloads/{{.ID}}/retry" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn" type="submit">Повторить</button></form>{{end}}
|
||||||
|
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel" hx-post="/ui/downloads/{{.ID}}/cancel" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn btn-ghost" type="submit">Отменить</button></form>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Распознано как -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>Распознано как</h2></div>
|
||||||
|
{{if .HasPlan}}
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Название</dt><dd>{{.RecTitle}}</dd>
|
||||||
|
<dt>Ориг. название</dt><dd>{{if .OriginalTitle}}{{.OriginalTitle}}{{else}}<span class="faint">—</span>{{end}}</dd>
|
||||||
|
<dt>Тип</dt><dd>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</dd>
|
||||||
|
{{if .IsSeries}}<dt>Сезон</dt><dd>{{if .Season}}{{.Season}}{{else}}<span class="faint">—</span>{{end}}</dd>{{end}}
|
||||||
|
<dt>Год</dt><dd>{{if .Year}}{{.Year}}{{else}}<span class="faint">—</span>{{end}}</dd>
|
||||||
|
<dt>Режиссёр</dt><dd><span class="faint">—</span></dd>
|
||||||
|
<dt>База</dt><dd>{{if .Provider}}{{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">{{.Provider}} · {{.ProviderID}} ↗</a>{{else}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{end}}{{else if .NoBase}}без базы{{else}}<span class="faint">—</span>{{end}}</dd>
|
||||||
|
<dt>Уверенность</dt><dd class="mono">{{if .Confidence}}{{.Confidence}}{{else}}<span class="faint">—</span>{{end}}</dd>
|
||||||
|
</dl>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted" style="margin:0">Ещё не распознано.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Файлы и раскладка -->
|
||||||
|
{{if .HasPlan}}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>Файлы и раскладка</h2><span class="hint">файл источника → целевой хардлинк</span></div>
|
||||||
|
{{template "layout_widget" .Files}}
|
||||||
|
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
|
||||||
|
Целевые файлы связаны хардлинками — место не дублируется, исходник остаётся на раздаче.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Раздача (живая статистика; секция деградирует, если торрент не сидирует) -->
|
||||||
|
{{template "seeding" .Seeding}}
|
||||||
|
|
||||||
|
<!-- История -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>История</h2></div>
|
||||||
|
<ul class="timeline">
|
||||||
|
{{if .CreatedAt}}<li class="tl-item is-done"><div class="tl-t">Добавлено</div><time>{{.CreatedAt}}</time></li>{{end}}
|
||||||
|
<li class="tl-item is-now"><div class="tl-t">{{badgeLabel .State}} <span class="badge st-{{.State}}">текущее</span></div>{{if .UpdatedAt}}<time>{{.UpdatedAt}}</time>{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Контекст -->
|
||||||
|
{{if .Context}}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>Контекст</h2><span class="hint">сообщение при добавлении</span></div>
|
||||||
|
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Информация о торренте -->
|
||||||
|
<div class="section" style="margin-bottom:0">
|
||||||
|
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Тип источника</dt><dd>{{.SourceType}}</dd>
|
||||||
|
{{range .Infohashes}}<dt>infohash</dt><dd class="mono">{{.}}</dd>{{end}}
|
||||||
|
</dl>
|
||||||
|
{{if .SourceFull}}
|
||||||
|
<div class="src-block">
|
||||||
|
<code class="src-full">{{.SourceFull}}</code>
|
||||||
|
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.SourceFull}}')">⧉ копировать</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{{define "review_main"}}
|
||||||
|
<main class="main" id="review-main">
|
||||||
|
<a class="back-link" href="/">← ко всем загрузкам</a>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||||
|
{{if .StateError}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.StateError}}</h2></div>{{end}}
|
||||||
|
|
||||||
|
<!-- Шапка ревью -->
|
||||||
|
<div class="review-head">
|
||||||
|
<div>
|
||||||
|
<div class="review-title">{{.Source}}</div>
|
||||||
|
<div class="reason-line">
|
||||||
|
{{template "status_badge" .State}}
|
||||||
|
{{if .Reasons}}<span>Причины: {{range $i, $r := .Reasons}}{{if $i}} · {{end}}<b>{{$r}}</b>{{end}}</span>{{end}}
|
||||||
|
{{if .Confidence}}<span class="conf">уверенность {{.Confidence}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if eq .State "recognizing"}}
|
||||||
|
<!-- Идёт распознавание: сам допалливаем тело ревью до готового плана -->
|
||||||
|
<div class="section" hx-get="/fragments/downloads/{{.ID}}/review" hx-trigger="every 2s" hx-target="#review-main" hx-swap="outerHTML">
|
||||||
|
<p style="margin:0">⏳ Идёт распознавание — экран обновится сам.</p>
|
||||||
|
<p style="margin:var(--sp-2) 0 0"><a href="/review/{{.ID}}">Обновить</a></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Контекст -->
|
||||||
|
{{if .Context}}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>Контекст</h2><span class="hint">исходное сообщение при добавлении</span></div>
|
||||||
|
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .HasPlan}}
|
||||||
|
<!-- Единый блок: выбор источника → инфо → предпросмотр раскладки -->
|
||||||
|
{{template "review_source_block" .}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Уточнить и перераспознать -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>Уточнить и перераспознать</h2></div>
|
||||||
|
{{if .Hints}}
|
||||||
|
<div class="hint-list">
|
||||||
|
{{range $i, $h := .Hints}}
|
||||||
|
<div class="hint-item"><span class="idx">#{{add $i 1}}</span><span class="txt">{{$h}}</span></div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/refine" hx-post="/ui/downloads/{{.ID}}/refine" hx-target="#review-main" hx-swap="outerHTML">
|
||||||
|
<textarea class="textarea" name="hint" placeholder="Например: «это Fargo, год сериала 2014, не 2015» или «sample.mkv не считать эпизодом»"></textarea>
|
||||||
|
<div class="refine-row">
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">🔁 Уточнить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" hx-post="/ui/downloads/{{.ID}}/rerecognize" hx-target="#review-main" hx-swap="outerHTML" style="margin-top:var(--sp-2)">
|
||||||
|
<button class="btn btn-sm" type="submit">🔄 Распознать заново</button>
|
||||||
|
<span class="hint">без новой подсказки — по уже накопленному контексту</span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Панель действий (выходы из ревью — обычные формы, полная навигация) -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
|
||||||
|
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
@@ -12,74 +12,6 @@
|
|||||||
|
|
||||||
{{template "header" ""}}
|
{{template "header" ""}}
|
||||||
|
|
||||||
<main class="main">
|
{{template "review_main" .}}
|
||||||
<a class="back-link" href="/">← ко всем загрузкам</a>
|
|
||||||
|
|
||||||
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
|
||||||
{{if .StateError}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.StateError}}</h2></div>{{end}}
|
|
||||||
|
|
||||||
<!-- Шапка ревью -->
|
|
||||||
<div class="review-head">
|
|
||||||
<div>
|
|
||||||
<div class="review-title">{{.Source}}</div>
|
|
||||||
<div class="reason-line">
|
|
||||||
{{template "status_badge" .State}}
|
|
||||||
{{if .Reasons}}<span>Причины: {{range $i, $r := .Reasons}}{{if $i}} · {{end}}<b>{{$r}}</b>{{end}}</span>{{end}}
|
|
||||||
{{if .Confidence}}<span class="conf">уверенность {{.Confidence}}</span>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{if eq .State "recognizing"}}
|
|
||||||
<div class="section">
|
|
||||||
<p style="margin:0">⏳ Идёт распознавание — обновите страницу через несколько секунд.</p>
|
|
||||||
<p style="margin:var(--sp-2) 0 0"><a href="/review/{{.ID}}">Обновить</a></p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Контекст -->
|
|
||||||
{{if .Context}}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-head"><h2>Контекст</h2><span class="hint">исходное сообщение при добавлении</span></div>
|
|
||||||
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .HasPlan}}
|
|
||||||
<!-- Единый блок: выбор источника → инфо → предпросмотр раскладки -->
|
|
||||||
{{template "review_source_block" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Уточнить и перераспознать -->
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-head"><h2>Уточнить и перераспознать</h2></div>
|
|
||||||
{{if .Hints}}
|
|
||||||
<div class="hint-list">
|
|
||||||
{{range $i, $h := .Hints}}
|
|
||||||
<div class="hint-item"><span class="idx">#{{add $i 1}}</span><span class="txt">{{$h}}</span></div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/refine">
|
|
||||||
<textarea class="textarea" name="hint" placeholder="Например: «это Fargo, год сериала 2014, не 2015» или «sample.mkv не считать эпизодом»"></textarea>
|
|
||||||
<div class="refine-row">
|
|
||||||
<button class="btn btn-primary btn-sm" type="submit">🔁 Уточнить</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:var(--sp-2)">
|
|
||||||
<button class="btn btn-sm" type="submit">🔄 Распознать заново</button>
|
|
||||||
<span class="hint">без новой подсказки — по уже накопленному контексту</span>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Панель действий -->
|
|
||||||
<div class="action-bar">
|
|
||||||
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
|
|
||||||
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user