Ревью: единый блок выбора источника, клик = выбор (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:
+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