Веб-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:
+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) {
|
||||
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 (ждёт партиал, а не полную страницу).
|
||||
|
||||
Reference in New Issue
Block a user