Ревью: единый блок выбора источника, клик = выбор (review-unified-source-block)
Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок: список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать». - httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и BlockError; сводка сезонов (seasonSummary/seasonRanges) - тип movie↔series убран из UI (read-only); удалён веб-роут /type и handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты) - шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries - тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов - openspec: спеки review/web-ui синхронизированы, change заархивирован - беклог: сложные сериальные раздачи; oob-обновление панели действий Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
)
|
||||
@@ -36,6 +40,69 @@ func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
|
||||
return rows
|
||||
}
|
||||
|
||||
// seasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по
|
||||
// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому
|
||||
// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры:
|
||||
// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски».
|
||||
func seasonSummary(plan recognize.Plan) string {
|
||||
seen := map[int]bool{}
|
||||
specials := false
|
||||
for _, f := range plan.Files {
|
||||
if f.Role != recognize.RoleEpisode {
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
if f.Season != nil {
|
||||
n = *f.Season
|
||||
}
|
||||
if n <= 0 {
|
||||
specials = true
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
}
|
||||
nums := make([]int, 0, len(seen))
|
||||
for n := range seen {
|
||||
nums = append(nums, n)
|
||||
}
|
||||
sort.Ints(nums)
|
||||
|
||||
var parts []string
|
||||
switch {
|
||||
case len(nums) == 1:
|
||||
parts = append(parts, "Сезон "+strconv.Itoa(nums[0]))
|
||||
case len(nums) > 1:
|
||||
parts = append(parts, "Сезоны "+seasonRanges(nums))
|
||||
}
|
||||
if specials {
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, "Спецвыпуски")
|
||||
} else {
|
||||
parts = append(parts, "спецвыпуски")
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// seasonRanges схлопывает возрастающие номера сезонов в диапазоны:
|
||||
// [1,2,3] → «1–3», [1,3,4] → «1, 3–4».
|
||||
func seasonRanges(nums []int) string {
|
||||
var out []string
|
||||
for i := 0; i < len(nums); {
|
||||
j := i
|
||||
for j+1 < len(nums) && nums[j+1] == nums[j]+1 {
|
||||
j++
|
||||
}
|
||||
if j == i {
|
||||
out = append(out, strconv.Itoa(nums[i]))
|
||||
} else {
|
||||
out = append(out, strconv.Itoa(nums[i])+"–"+strconv.Itoa(nums[j]))
|
||||
}
|
||||
i = j + 1
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// roleLabel — человекочитаемая роль файла раскладки.
|
||||
func roleLabel(role string) string {
|
||||
switch role {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
)
|
||||
|
||||
func TestSeasonSummary(t *testing.T) {
|
||||
// ep — эпизодный файл с заданным (или nil) сезоном.
|
||||
ep := func(season *int) recognize.PlanFile {
|
||||
return recognize.PlanFile{Role: recognize.RoleEpisode, Season: season}
|
||||
}
|
||||
n := func(v int) *int { return &v }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
files []recognize.PlanFile
|
||||
want string
|
||||
}{
|
||||
{"пусто", nil, ""},
|
||||
{"один сезон", []recognize.PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"},
|
||||
{"диапазон", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 1–3"},
|
||||
{"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 3–4"},
|
||||
{"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 1–3"},
|
||||
{"только спецвыпуски (nil)", []recognize.PlanFile{ep(nil)}, "Спецвыпуски"},
|
||||
{"только спецвыпуски (0)", []recognize.PlanFile{ep(n(0))}, "Спецвыпуски"},
|
||||
{"сезоны и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"},
|
||||
{"один сезон и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := seasonSummary(recognize.Plan{Files: c.files})
|
||||
if got != c.want {
|
||||
t.Errorf("seasonSummary = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов.
|
||||
func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) {
|
||||
s := 2
|
||||
plan := recognize.Plan{Files: []recognize.PlanFile{
|
||||
{Role: recognize.RoleEpisode, Season: &s},
|
||||
{Role: recognize.RoleMain},
|
||||
{Role: recognize.RoleSubtitle},
|
||||
{Role: recognize.RoleIgnore},
|
||||
}}
|
||||
if got := seasonSummary(plan); got != "Сезон 2" {
|
||||
t.Errorf("seasonSummary = %q, want «Сезон 2»", got)
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,6 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
r.Post("/ui/downloads/{id}/apply", s.handleApply)
|
||||
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
||||
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
|
||||
r.Post("/ui/downloads/{id}/type", s.handleSetType)
|
||||
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
|
||||
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
||||
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -395,7 +396,6 @@ type fakeReviewer struct {
|
||||
data *worker.ReviewData
|
||||
applyErr error
|
||||
refined map[string]string
|
||||
typed map[string]string
|
||||
ignored map[string]string
|
||||
chosen map[string]string
|
||||
providerSet map[string]string
|
||||
@@ -425,13 +425,6 @@ func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
||||
f.refined[id] = hint
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
|
||||
if f.typed == nil {
|
||||
f.typed = map[string]string{}
|
||||
}
|
||||
f.typed[id] = t
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src string) error {
|
||||
if f.ignored == nil {
|
||||
f.ignored = map[string]string{}
|
||||
@@ -541,11 +534,16 @@ func TestReviewRenders(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
||||
"Season 02", "Применить", "Уточнить",
|
||||
"Источник совпадения", "269613", "выбрать", "распознано нейронкой", "Добавить"} {
|
||||
"Источник и раскладка", "269613", "распознано нейронкой", "Добавить",
|
||||
"Сезон 2"} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("страница ревью не содержит %q", want)
|
||||
}
|
||||
}
|
||||
// Кнопки «выбрать» больше нет — выбор одним кликом по радио.
|
||||
if strings.Contains(string(body), ">выбрать<") {
|
||||
t.Error("страница ревью всё ещё содержит кнопку «выбрать»")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewShowsMatchLink(t *testing.T) {
|
||||
@@ -653,6 +651,45 @@ func TestAddManualSource_RejectsBadURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddManualSource_HTMXError: невалидный ручной ввод на htmx-пути возвращает
|
||||
// партиал блока с ошибкой (не редирект, не «внутренняя ошибка»), источник не
|
||||
// добавлен.
|
||||
func TestAddManualSource_HTMXError(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
form := url.Values{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}}
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/source",
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
resp, err := noRedirectClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (партиал с ошибкой)", resp.StatusCode)
|
||||
}
|
||||
if _, called := rv.sourceAdded[tid]; called {
|
||||
t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded)
|
||||
}
|
||||
if !strings.Contains(string(body), `id="source-block"`) {
|
||||
t.Error("htmx-ответ не содержит блок источника")
|
||||
}
|
||||
if !strings.Contains(string(body), "block-error") {
|
||||
t.Error("htmx-ответ не содержит баннер ошибки блока")
|
||||
}
|
||||
if strings.Contains(string(body), "внутренняя ошибка") {
|
||||
t.Error("ошибка ввода подана как внутренняя")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRedirectsToIndex(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
@@ -708,7 +745,7 @@ func TestRefinePostsHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreAndType(t *testing.T) {
|
||||
func TestIgnoreFile(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
@@ -721,13 +758,40 @@ func TestIgnoreAndType(t *testing.T) {
|
||||
if rv.ignored[tid] != "Fargo/sample.mkv" {
|
||||
t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/type",
|
||||
map[string][]string{"type": {"movie"}}); err != nil {
|
||||
// TestChooseCandidateHTMX: на htmx-запрос выбор возвращает партиал блока
|
||||
// источника (а не полную страницу и не редирект), обновлённый под выбор.
|
||||
func TestChooseCandidateHTMX(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/candidate",
|
||||
strings.NewReader("candidate_id="+cid))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rv.typed[tid] != "movie" {
|
||||
t.Errorf("SetType получил %q", rv.typed[tid])
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
resp, err := noRedirectClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (партиал, не редирект)", resp.StatusCode)
|
||||
}
|
||||
if rv.chosen[tid] != cid {
|
||||
t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
|
||||
}
|
||||
// Ответ — партиал блока, а не полная страница.
|
||||
if strings.Contains(string(body), "<!doctype html>") {
|
||||
t.Error("htmx-ответ должен быть партиалом, а не полной страницей")
|
||||
}
|
||||
if !strings.Contains(string(body), `id="source-block"`) {
|
||||
t.Error("htmx-ответ не содержит корневой контейнер блока #source-block")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
-19
@@ -18,7 +18,6 @@ type Reviewer interface {
|
||||
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||
Apply(ctx context.Context, id string) error
|
||||
Refine(ctx context.Context, id string, hint string) error
|
||||
SetType(ctx context.Context, id string, mediaType string) error
|
||||
IgnoreFile(ctx context.Context, id string, src string) error
|
||||
Defer(ctx context.Context, id string) error
|
||||
Undo(ctx context.Context, id string) error
|
||||
@@ -44,6 +43,7 @@ type reviewView struct {
|
||||
Title string
|
||||
OriginalTitle string
|
||||
Year int
|
||||
SeasonSummary string // сводка сезонов для сериала (пусто для фильма)
|
||||
Provider string
|
||||
ProviderID string
|
||||
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
|
||||
@@ -55,10 +55,13 @@ type reviewView struct {
|
||||
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
||||
NoBase bool // выбрано «без базы»
|
||||
Sources []sourceView // единый список источников совпадения
|
||||
BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=)
|
||||
}
|
||||
|
||||
// sourceView — строка единого списка источников на экране ревью: нейронка или
|
||||
// кандидат базы, с эффективными полями и предпросмотром целевых путей.
|
||||
// кандидат базы. Инфо и предпросмотр раскладки показываются для активного
|
||||
// источника из верхнеуровневых полей reviewView, поэтому per-source превью
|
||||
// строка не несёт.
|
||||
type sourceView struct {
|
||||
Kind string // "neural" | "candidate"
|
||||
CandidateID string
|
||||
@@ -66,10 +69,8 @@ type sourceView struct {
|
||||
ProviderID string
|
||||
Title string
|
||||
Year int
|
||||
IsSeries bool
|
||||
MatchURL string
|
||||
Active bool
|
||||
Files []fileRow // предпросмотр «файл → раскладка» этого источника
|
||||
}
|
||||
|
||||
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -90,12 +91,20 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.render(w, "review.html", buildReviewView(id, rd, r.URL.Query().Get("err")))
|
||||
}
|
||||
|
||||
// buildReviewView собирает представление страницы ревью из доменных данных.
|
||||
// Общий для полной страницы (handleReview) и htmx-свопа блока источника
|
||||
// (reviewBlockAction); errMsg — верхний баннер из ?err= (пусто на htmx-пути,
|
||||
// там ошибка идёт в BlockError).
|
||||
func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView {
|
||||
view := reviewView{
|
||||
ID: id,
|
||||
Source: shorten(rd.Download.SourceRef, 80),
|
||||
Context: rd.Download.Context,
|
||||
State: string(rd.Download.State),
|
||||
Error: r.URL.Query().Get("err"),
|
||||
Error: errMsg,
|
||||
StateError: rd.Download.ErrorMsg.String,
|
||||
Hints: rd.Hints,
|
||||
}
|
||||
@@ -105,6 +114,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
view.Title = rd.Plan.Title
|
||||
view.OriginalTitle = rd.Plan.OriginalTitle
|
||||
view.Year = rd.Plan.Year
|
||||
if view.IsSeries {
|
||||
view.SeasonSummary = seasonSummary(rd.Plan)
|
||||
}
|
||||
view.Reasons = rec.ReasonList()
|
||||
switch rd.Provider {
|
||||
case "", "none":
|
||||
@@ -128,9 +140,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
ProviderID: src.ProviderID,
|
||||
Title: src.Title,
|
||||
Year: src.Year,
|
||||
IsSeries: src.Type == "series",
|
||||
Active: src.Active,
|
||||
Files: buildFileRows(src.Plan, src.Preview),
|
||||
}
|
||||
if src.Kind == worker.SourceCandidate {
|
||||
sv.MatchURL = sourceMatchURL(src)
|
||||
@@ -138,8 +148,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
view.Sources = append(view.Sources, sv)
|
||||
}
|
||||
}
|
||||
|
||||
s.render(w, "review.html", view)
|
||||
return view
|
||||
}
|
||||
|
||||
// --- Действия ревью (POST → redirect) ---
|
||||
@@ -172,13 +181,6 @@ func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
return s.deps.Reviewer.SetType(ctx, id, r.PostForm.Get("type"))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
@@ -187,7 +189,7 @@ func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
// Входная граница: id кандидата из формы валидируется как ULID.
|
||||
candidateID, err := ident.Parse(r.PostForm.Get("candidate_id"))
|
||||
@@ -206,7 +208,7 @@ func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
|
||||
return s.deps.Reviewer.ClearProvider(ctx, id)
|
||||
})
|
||||
}
|
||||
@@ -214,7 +216,7 @@ func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
|
||||
// handleAddSource добавляет источник вручную по id или URL записи метабазы и
|
||||
// выбирает его. Разбор ввода — на входной границе транспорта.
|
||||
func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
provider, providerID, err := parseManualSource(r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
||||
if err != nil {
|
||||
@@ -364,6 +366,48 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
|
||||
redirectReview(w, r, id, "")
|
||||
}
|
||||
|
||||
// isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу).
|
||||
func isHTMX(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
// reviewBlockAction — помощник для действий выбора источника: выполнить
|
||||
// операцию и вернуть свежий блок источника. На htmx-запрос перечитывает
|
||||
// состояние и рендерит партиал `review_source_block` (ошибку кладёт в
|
||||
// BlockError, активный источник не меняется); без htmx деградирует до
|
||||
// PRG-редиректа, как reviewAction.
|
||||
func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
actionErr := fn(r.Context(), id)
|
||||
|
||||
if !isHTMX(r) {
|
||||
msg := ""
|
||||
if actionErr != nil {
|
||||
msg = userErr(r, actionErr, id)
|
||||
}
|
||||
redirectReview(w, r, id, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// htmx: перечитываем состояние (уже с новым активным источником при успехе)
|
||||
// и рендерим свежий партиал блока.
|
||||
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
|
||||
}
|
||||
view := buildReviewView(id, rd, "")
|
||||
if actionErr != nil {
|
||||
view.BlockError = userErr(r, actionErr, id)
|
||||
}
|
||||
s.render(w, "review_source_block", view)
|
||||
}
|
||||
|
||||
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
|
||||
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
||||
// (человек мог выбрать кандидата, затем вручную переопределить id — тогда
|
||||
|
||||
Reference in New Issue
Block a user