Owner Finish control on the Series detail page: confirm-gated finish, instant un-finish (#158)
This commit is contained in:
@@ -110,6 +110,11 @@ type seriesRowView struct {
|
||||
// raced answer: the row stays and says a fresh Bookmark caught the press.
|
||||
CanRemove bool
|
||||
RemovalRefused bool
|
||||
// Finished is the row's display of the owner's finish stamp: the list row
|
||||
// shows the state and never offers the control — that lives on the detail
|
||||
// page, where a press that retires a Series from the Lane is on purpose
|
||||
// and confirm-gated (issue #158).
|
||||
Finished bool
|
||||
}
|
||||
|
||||
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
||||
@@ -182,6 +187,96 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
||||
}
|
||||
|
||||
// adminSeriesFinish is the owner's Finish control: it stamps the Series'
|
||||
// finished_at and answers with the freshly rendered meta fragment, so the
|
||||
// "finished <age> ago" line describes the state after the press. The Lane's
|
||||
// next pass reads the stamp and stops polling the Series (issue #157). The
|
||||
// owner gate is the route's, not this handler's; the body is capped like the
|
||||
// API path caps its bodies; the key is validated here — a malformed key is a
|
||||
// 400 and an unknown one a 404.
|
||||
func (h *Handler) adminSeriesFinish(w http.ResponseWriter, r *http.Request) {
|
||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||
if !ok || site == "" || seriesID == "" {
|
||||
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
log.Printf("series finish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := h.store.SetSeriesFinished(site, seriesID, time.Now().UnixMilli()); err != nil {
|
||||
log.Printf("series finish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Re-read after the stamp: the answer must describe the state after the
|
||||
// press, so the line reads "finished just now". The control's one caller
|
||||
// is the detail page, which swaps the meta fragment in place.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series finish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||
}
|
||||
|
||||
// adminSeriesUnfinish is the reversal of the Finish control: it clears the
|
||||
// stamp (writes zero) and answers with the freshly rendered meta fragment, so
|
||||
// the Series is back in the Lane's queue from its next pass. Reversal, so it
|
||||
// fires instantly with no confirm row (issue #158).
|
||||
func (h *Handler) adminSeriesUnfinish(w http.ResponseWriter, r *http.Request) {
|
||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||
if !ok || site == "" || seriesID == "" {
|
||||
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
log.Printf("series unfinish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := h.store.SetSeriesFinished(site, seriesID, 0); err != nil {
|
||||
log.Printf("series unfinish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Re-read after the write: the answer must describe the state after the
|
||||
// press, so the fragment no longer carries the finished line.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series unfinish %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||
}
|
||||
|
||||
// adminSeriesCorrectLatest is the Latest Chapter correction: the owner types
|
||||
// one number and the Series' Latest Chapter becomes it, stamped as a
|
||||
// Correction. The number must be a finite float greater than zero — a
|
||||
@@ -475,8 +570,6 @@ func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) {
|
||||
if err != nil {
|
||||
return seriesListView{}, err
|
||||
}
|
||||
// A page past the end is not an empty list: the store's window count runs
|
||||
// over the rows the result set carries, so an overflow page reports zero
|
||||
// rows and zero total, and the list re-reads at page 1 to know the truth.
|
||||
if len(data.Rows) == 0 && page > 1 {
|
||||
page = 1
|
||||
@@ -572,6 +665,7 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
CanRemove: a.ReaderCount == 0,
|
||||
Pending: pending,
|
||||
Requested: requested,
|
||||
Finished: a.FinishedAt > 0,
|
||||
}
|
||||
if a.LatestChapterNum != nil {
|
||||
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
||||
|
||||
Reference in New Issue
Block a user