Implements spec #135 (spec 2 of 4, derived from wayfinder map #114; decisions settled in #120/#121/#125/#131). Blocked-by #134 is merged, so this lands on `main`. Four owner actions the dashboard can now perform, one ticket each: - **#149** — Latest Chapter correction: one numeric input, overwritten by the next machine write. - **#151** — Series URL repair: owner-typed, gated by the poller's own fetch gate. - **#150 / #153 / #154** — Cover replacement: addresses derived from bytes (`#150`), a Forced Poll replaces the Cover while an ordinary pass still only fills a blank one (`#153`), and byte reclamation is one guarded helper, file first / covers row last (`#154`). - **#155** — Orphan removal: one Series at a time, with the foreign key as the guard. Plus **#152** — Latest Chapter provenance: one derived line naming the actor class, so an owner can tell a hand-edited number from a machine read. - Migration `0015_latest_correction.sql` adds the correction/provenance columns; `0009` now derives cover addresses from bytes. - ADR `0014-cover-addresses-from-bytes.md` records the address scheme. Backend tests cover the store, poller, admin handlers, and web routes (`go test ./...`, needs Docker). Reviewed-on: #156 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #156.
This commit is contained in:
@@ -47,6 +47,9 @@ func (h *Handler) adminRoutes() []adminRoute {
|
||||
{"GET /admin/series", h.adminSeries},
|
||||
{"GET /admin/series/{key}", h.adminSeriesDetail},
|
||||
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
|
||||
{"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest},
|
||||
{"POST /admin/series/{key}/series-url", h.adminSeriesSetURL},
|
||||
{"POST /admin/series/{key}/remove", h.adminSeriesRemove},
|
||||
{"POST /admin/lanes/{site}/pause", h.adminLanePause},
|
||||
{"POST /admin/lanes/{site}/resume", h.adminLaneResume},
|
||||
{"GET /ui/admin/lanes", h.uiLanes},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -47,7 +49,6 @@ var seriesFilterOrder = []string{
|
||||
}
|
||||
|
||||
// seriesListView is the Series list page's data. The template renders strings
|
||||
// and flags, and every judgement about what a value means is made here.
|
||||
type seriesListView struct {
|
||||
Filters []seriesFilterOption
|
||||
Sites []string
|
||||
@@ -56,6 +57,9 @@ type seriesListView struct {
|
||||
FilterLabel string
|
||||
Rows []seriesRowView
|
||||
Total int
|
||||
// OOB marks the out-of-band copy of the heading the removal answer
|
||||
// carries; on the page itself it is false (issue #155).
|
||||
OOB bool
|
||||
// KindBoth / KindManga / KindNovel are the Library segment links, and
|
||||
// PrevHref / NextHref the pager's, all carrying the active filter, Site
|
||||
// and Kind so narrowing never drops state.
|
||||
@@ -100,6 +104,12 @@ type seriesRowView struct {
|
||||
CanPoll bool
|
||||
Pending bool
|
||||
Requested string // "requested 3m ago", rendered only while pending
|
||||
// CanRemove is the Remove control's visibility: only a Series no Reader
|
||||
// holds can be removed, so the owner is never offered a button that the
|
||||
// database will always refuse (issue #155). RemovalRefused marks the one
|
||||
// raced answer: the row stays and says a fresh Bookmark caught the press.
|
||||
CanRemove bool
|
||||
RemovalRefused bool
|
||||
}
|
||||
|
||||
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
||||
@@ -172,6 +182,269 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
||||
}
|
||||
|
||||
// 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
|
||||
// non-numeric, zero or negative value answers 400 and never reaches the
|
||||
// store, because a bad value would become every Reader's problem. The press
|
||||
// answers with the freshly rendered meta fragment, so the figures describe
|
||||
// the state after the press. The owner gate is the route's, not this
|
||||
// handler's; the body is capped like the API path caps its bodies.
|
||||
func (h *Handler) adminSeriesCorrectLatest(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
|
||||
}
|
||||
num, err := strconv.ParseFloat(r.PostFormValue("chapter"), 64)
|
||||
if err != nil || math.IsNaN(num) || math.IsInf(num, 0) || num <= 0 {
|
||||
http.Error(w, "chapter must be a finite number greater than zero", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
log.Printf("series correction %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.CorrectLatestChapter(site, seriesID, num, time.Now().UnixMilli()); err != nil {
|
||||
log.Printf("series correction %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 marker reads "corrected just now".
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series correction %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))
|
||||
}
|
||||
|
||||
// adminSeriesSetURL is the series URL repair: the owner types one address
|
||||
// and the Series' Poll fetches it from then on, verified by the same gate
|
||||
// the poller uses before it fetches anything — a URL failing
|
||||
// latest.FetchableSeriesURL answers 400 and never reaches the store. The
|
||||
// repair is a store, not a verification: it performs no outbound fetch, and
|
||||
// the owner presses Check now afterwards. This lifts the write-once rule of
|
||||
// Series.SeriesURL for the owner only — a Reader's PUT is still ignored. 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) adminSeriesSetURL(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
|
||||
}
|
||||
seriesURL := r.PostFormValue("series_url")
|
||||
if !latest.FetchableSeriesURL(site, seriesURL) {
|
||||
http.Error(w, "series URL must be an https address on this site's host", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
log.Printf("series url %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.SetSeriesURL(site, seriesID, seriesURL); err != nil {
|
||||
log.Printf("series url %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, like the correction's answer does.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series url %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))
|
||||
}
|
||||
|
||||
// seriesListHeadView is the list heading's data. The template renders it
|
||||
// inline at the top of the Series list and out of band in the removal answer
|
||||
// (OOB true, like the chrome partials' OOB flag): the count and the filter
|
||||
// label are one fact (issue #155).
|
||||
type seriesListHeadView struct {
|
||||
Total int
|
||||
FilterLabel string
|
||||
OOB bool
|
||||
}
|
||||
|
||||
// adminSeriesRemove is the orphan removal: one Series, one delete, refused by
|
||||
// the database while any Bookmark exists (translated by the store, never a
|
||||
// driver error on the page). 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.
|
||||
//
|
||||
// The Cover is read from the row before the delete and reclaimed after it:
|
||||
// ReclaimCover's guard cannot pass while a series row still points at the
|
||||
// address, so the order is the sequence, not a preference. A reclamation
|
||||
// failure is not a removal failure — the row is gone and the covers row
|
||||
// survives for a retry; the handler logs and answers success, because the
|
||||
// failure has no user-facing surface.
|
||||
//
|
||||
// Two callers, one handler, branched on HX-Target like adminSeriesPoll. The
|
||||
// detail page's remove answers with a navigation — to the No-Readers list on
|
||||
// success, back to the detail page when a fresh Bookmark raced the press,
|
||||
// where the new count is visible. The list row's answers with the removed
|
||||
// row's fragment and the heading re-rendered with the fresh count out of
|
||||
// band; HX-Reswap deletes the row through the same button that swaps the
|
||||
// refusal back in, and the count query runs over the press's own filter
|
||||
// state, so the heading describes the list the owner is looking at.
|
||||
func (h *Handler) adminSeriesRemove(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
|
||||
}
|
||||
// The row's Cover address is read before the delete because the delete is
|
||||
// what makes it reclaimable.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cover := a.CoverAddress
|
||||
if err := h.store.RemoveSeries(site, seriesID); err != nil {
|
||||
if errors.Is(err, store.ErrSeriesHasBookmarks) {
|
||||
// A Bookmark landed between the owner's read and the press: the
|
||||
// row stays, answered at its new count with the fact spelled
|
||||
// out — never a 500, and never a deleted row.
|
||||
if r.Header.Get("HX-Target") == "detail-meta" {
|
||||
seriesRemoveNavigation(w, r, "/admin/series/"+site+":"+seriesID)
|
||||
return
|
||||
}
|
||||
fresh, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
// A second press removed it while this one was refused; the
|
||||
// row has nothing left to say.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
band := 0
|
||||
if r.PostFormValue("band") == "1" {
|
||||
band = 1
|
||||
}
|
||||
row := seriesRow(fresh, band, time.Now())
|
||||
row.RemovalRefused = true
|
||||
h.render(w, http.StatusOK, "series-row", row)
|
||||
return
|
||||
}
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.store.ReclaimCover(cover); err != nil {
|
||||
log.Printf("series remove %s: reclaim cover: %v", site+":"+seriesID, err)
|
||||
}
|
||||
if r.Header.Get("HX-Target") == "detail-meta" {
|
||||
seriesRemoveNavigation(w, r, "/admin/series?filter="+store.SeriesFilterNoReaders)
|
||||
return
|
||||
}
|
||||
// The list answer: the removed row's fragment, plus the heading
|
||||
// re-rendered with the fresh count. HX-Reswap deletes the row through the
|
||||
// same button that swaps the refusal back in. The count query failing
|
||||
// does not undo the removal — log it and answer the row alone.
|
||||
w.Header().Set("HX-Reswap", "delete")
|
||||
band := 0
|
||||
if r.PostFormValue("band") == "1" {
|
||||
band = 1
|
||||
}
|
||||
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
||||
if head, err := h.seriesListHeadView(r); err != nil {
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
} else {
|
||||
h.render(w, http.StatusOK, "series-list-head", head)
|
||||
}
|
||||
}
|
||||
|
||||
// seriesListHeadView is the list heading with the count as it stands after a
|
||||
// removal: the same filter, Site and Kind the press's row carried (the list
|
||||
// row's button hx-includes the filterbar), so the figure describes the list
|
||||
// the owner is looking at — the All filter and an unknown one stay the
|
||||
// absent case. The count is the store's window total, one query.
|
||||
func (h *Handler) seriesListHeadView(r *http.Request) (seriesListHeadView, error) {
|
||||
filter := r.PostFormValue("filter")
|
||||
if _, ok := seriesFilterLabels[filter]; !ok {
|
||||
filter = store.SeriesFilterAll
|
||||
}
|
||||
site := r.PostFormValue("site")
|
||||
kind := r.PostFormValue("kind")
|
||||
if kind != store.KindManga && kind != store.KindNovel {
|
||||
kind = ""
|
||||
}
|
||||
data, err := h.store.SeriesPage(store.SeriesFilter{
|
||||
Site: site,
|
||||
Kind: kind,
|
||||
Name: filter,
|
||||
Cutoff: time.Now().Add(-ownerWindow).UnixMilli(),
|
||||
Page: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return seriesListHeadView{}, err
|
||||
}
|
||||
return seriesListHeadView{
|
||||
Total: data.Total,
|
||||
FilterLabel: seriesFilterLabels[filter],
|
||||
OOB: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// seriesRemoveNavigation answers a removal from the detail page. htmx gets a
|
||||
// full navigation (HX-Redirect): a bare 303 would be followed by the request
|
||||
// and the landing page swapped into the press's target, so the header is the
|
||||
// redirect htmx can see; plain clients get the 303 the ticket names.
|
||||
func seriesRemoveNavigation(w http.ResponseWriter, r *http.Request, to string) {
|
||||
if r.Header.Get("HX-Request") != "" {
|
||||
w.Header().Set("HX-Redirect", to)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// seriesListView assembles one Series list view from the request's query
|
||||
// string. An unknown filter value is the absent All case, never an error: the
|
||||
// select's options are not the only way this URL can be reached.
|
||||
@@ -296,6 +569,7 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
Readers: a.ReaderCount,
|
||||
Band: i%2 == 1,
|
||||
CanPoll: canPoll,
|
||||
CanRemove: a.ReaderCount == 0,
|
||||
Pending: pending,
|
||||
Requested: requested,
|
||||
}
|
||||
|
||||
@@ -24,14 +24,26 @@ type seriesDetailView struct {
|
||||
Cover string
|
||||
Chapter string // Latest Chapter number, or "—" before the first capture
|
||||
Checked string // how long ago the poller last checked, or "never"
|
||||
// URL is the stored source address, prefilled into the repair input —
|
||||
// the one stored string this page renders back into a form (issue #151).
|
||||
URL string
|
||||
Readers int
|
||||
// Corrected is the correction marker's text, "" while no Correction
|
||||
// stands: "corrected <age> ago" — the copy that says the value is the
|
||||
// owner's, and it dies with the stamp (a machine write of the number).
|
||||
Corrected string
|
||||
|
||||
// Provenance is the actor class behind the current Chapter: "correction",
|
||||
// "sighting" or "machine read"; "" while the Series was never read, when
|
||||
// the line is not rendered. Derived from the same anonymous stamps the
|
||||
// marks above read — no Reader identity crosses here.
|
||||
Provenance string
|
||||
|
||||
// Marks, one per hygiene fact, rendered only while it holds.
|
||||
Unpollable bool // no SeriesURL to fetch
|
||||
NoCover bool
|
||||
Orphan bool // no Reader holds the Series
|
||||
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
||||
|
||||
// Poll is the Check now control and the pending marker (issue #146): the
|
||||
// same derivation and visibility as the list row. CanPoll is false on a
|
||||
// Series with no page to fetch and on an orphan; Pending is derived —
|
||||
@@ -40,6 +52,10 @@ type seriesDetailView struct {
|
||||
CanPoll bool
|
||||
Pending bool
|
||||
Requested string
|
||||
// CanRemove is the Remove control's visibility (issue #155): only a
|
||||
// Series no Reader holds can be removed, so the owner is never offered a
|
||||
// button the database will always refuse.
|
||||
CanRemove bool
|
||||
}
|
||||
|
||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||
@@ -103,12 +119,14 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
Kind: a.Kind,
|
||||
Title: a.Title,
|
||||
Cover: h.store.CoverWireURL(a.CoverAddress),
|
||||
URL: a.SeriesURL,
|
||||
Readers: a.ReaderCount,
|
||||
Unpollable: a.SeriesURL == "",
|
||||
NoCover: a.CoverAddress == "",
|
||||
Orphan: a.ReaderCount == 0,
|
||||
SightingRaised: a.RaisedByReader,
|
||||
CanPoll: canPoll,
|
||||
CanRemove: a.ReaderCount == 0,
|
||||
Pending: pending,
|
||||
Requested: requested,
|
||||
}
|
||||
@@ -122,5 +140,34 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
} else {
|
||||
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
||||
}
|
||||
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
||||
|
||||
// Provenance: the actor class behind the current number, evaluated in the
|
||||
// order the classes outrank one another — the owner's stamp, which a
|
||||
// Correction leaves standing and a machine write clears (issue #149); a
|
||||
// raising Reader, which a Correction drops; then any check stamp at all.
|
||||
// An Acquisition reads as a machine read because it stamps
|
||||
// latest_checked_at exactly as a Poll does, so the two are
|
||||
// indistinguishable the moment it finishes; telling them apart would need
|
||||
// the column this project declines to add (spec #135), and the one
|
||||
// actionable case — acquired once, never read again — is already the
|
||||
// unchecked filter.
|
||||
if a.LatestCorrectedAt != 0 {
|
||||
v.Provenance = "correction"
|
||||
} else if a.RaisedByReader {
|
||||
v.Provenance = "sighting"
|
||||
} else if a.LatestCheckedAt != 0 {
|
||||
v.Provenance = "machine read"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// correctedAge is the correction marker's text: "corrected <age> ago" while
|
||||
// the stamp is set, "" when zero — zero means never corrected, and the marker
|
||||
// must not read as history once a machine wrote the number.
|
||||
func correctedAge(now time.Time, at int64) string {
|
||||
if at == 0 {
|
||||
return ""
|
||||
}
|
||||
return "corrected " + since(now, time.UnixMilli(at))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// seriesDetailView derives the provenance line from the three stamps the
|
||||
// admin projection already carries: the correction stamp outranks a raising
|
||||
// Reader, which outranks a check stamp, and a Series with none of the three
|
||||
// renders no line at all — it was never read, and no actor class is true of
|
||||
// it. Acquisition stamps latest_checked_at exactly as a Poll does, so an
|
||||
// acquired value lands in the same "machine read" class (#152).
|
||||
func TestSeriesDetailViewProvenance(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a store.AdminSeries
|
||||
want string
|
||||
}{
|
||||
{"correction stamp", store.AdminSeries{LatestCorrectedAt: 1}, "correction"},
|
||||
{"raising reader only", store.AdminSeries{RaisedByReader: true}, "sighting"},
|
||||
{"check stamp only", store.AdminSeries{LatestCheckedAt: 1}, "machine read"},
|
||||
{"correction outranks sighting", store.AdminSeries{LatestCorrectedAt: 1, RaisedByReader: true}, "correction"},
|
||||
{"none of the three", store.AdminSeries{}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := (&Handler{}).seriesDetailView(tc.a).Provenance; got != tc.want {
|
||||
t.Fatalf("Provenance = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -653,6 +653,13 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.series .row-msg {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 6px;
|
||||
color: var(--danger-soft);
|
||||
font: 400 13px/1.4 var(--font-body);
|
||||
}
|
||||
|
||||
.admin-sheet .detail-back {
|
||||
display: inline-block;
|
||||
margin: 18px 0 0;
|
||||
@@ -720,6 +727,27 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.admin-sheet .dform input {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--field-line);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font: 500 14px var(--font-mono);
|
||||
outline: none;
|
||||
}
|
||||
/* Focus follows the chapter form's idiom — paper, not heat: a red border on
|
||||
a valid number field reads as "invalid". */
|
||||
.admin-sheet .dform input:focus {
|
||||
border-color: var(--paper);
|
||||
}
|
||||
.admin-sheet .dform .hint {
|
||||
margin: 0;
|
||||
color: var(--mute-2);
|
||||
font: 500 12px/1.4 var(--font-mono);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.admin-sheet .pausebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
|
||||
list row is one hop from it. Everything here is a Series-level fact plus
|
||||
the anonymous Reader count. The Check now control lands in its own .dform
|
||||
below the (empty) .detail-grid; the pending marker rides the meta line
|
||||
with the other marks. */}}
|
||||
the anonymous Reader count. Check now lands in its own .dform below the
|
||||
.detail-grid; the correction form is the grid's first column and the URL
|
||||
repair the second (issue #151). The pending and corrected markers ride the
|
||||
meta line with the other marks. */}}
|
||||
{{define "series-detail"}}
|
||||
<a class="ghost detail-back" href="/admin/series">← Series</a>
|
||||
<h1 class="detail-title">{{.Title}}</h1>
|
||||
@@ -10,26 +11,52 @@
|
||||
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
||||
{{else}}<div class="cover"></div>{{end}}
|
||||
{{template "series-detail-meta" .}}
|
||||
<div class="detail-grid"></div>
|
||||
<div class="detail-grid">
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/latest" hx-target="#detail-meta" hx-swap="outerHTML">
|
||||
<h3>Correct latest chapter</h3>
|
||||
<p class="hint">The next successful Poll overwrites this value.</p>
|
||||
<div class="field">
|
||||
<input type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required>
|
||||
<button type="submit" class="ghost">Set</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML">
|
||||
<h3>Repair series URL</h3>
|
||||
<p class="hint">The Poll fetches this address — storing is not verifying it. A Site-wide host change is a SQL migration, not two hundred forms.</p>
|
||||
<div class="field">
|
||||
<input type="url" name="series_url" value="{{.URL}}" required>
|
||||
<button type="submit" class="ghost">Set</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{if .CanPoll}}
|
||||
<div class="dform">
|
||||
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .CanRemove}}
|
||||
<div class="dform">
|
||||
<div class="field"><button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta" hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button></div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* series-detail-meta is the meta line, and the answer a Check now press on
|
||||
the detail page swaps into its place: the same marks, re-rendered after
|
||||
the stamp so the pending marker shows. */}}
|
||||
{{/* series-detail-meta is the meta line, and the answer a Check now or
|
||||
correction press on the detail page swaps into its place: the same marks,
|
||||
re-rendered after the stamp so the pending and corrected markers — and
|
||||
the provenance line beside the number — describe the value they sit
|
||||
next to. */}}
|
||||
{{define "series-detail-meta"}}
|
||||
<div class="detail-meta" id="detail-meta">
|
||||
<span>ch {{.Chapter}}</span>
|
||||
{{if .Provenance}}<span>{{.Provenance}}</span>{{end}}
|
||||
<span>checked {{.Checked}}</span>
|
||||
<span>{{.Readers}} readers</span>
|
||||
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
|
||||
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
|
||||
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
|
||||
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
|
||||
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
|
||||
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -4,7 +4,7 @@
|
||||
that can be bookmarked: the two selects submit the GET form, and the
|
||||
Library segment links and the pager preserve the filter and Site. */}}
|
||||
{{define "series-list"}}
|
||||
<form class="filterbar" method="get" action="/admin/series">
|
||||
<form class="filterbar" id="filterbar" method="get" action="/admin/series">
|
||||
<input type="hidden" name="kind" value="{{.Kind}}">
|
||||
<label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()">
|
||||
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
|
||||
@@ -19,7 +19,7 @@
|
||||
<a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a>
|
||||
</span>
|
||||
</form>
|
||||
<div class="listhead">{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
|
||||
{{template "series-list-head" .}}
|
||||
{{if .Rows}}
|
||||
<div class="tbl series">
|
||||
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
|
||||
@@ -48,6 +48,16 @@
|
||||
<span>{{.Age}}</span>
|
||||
<span class="c-rd">{{.Readers}}</span>
|
||||
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
|
||||
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}</span>
|
||||
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
|
||||
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* series-list-head is the list's heading — the count and the label are one
|
||||
fact. The removal answer renders it out of band (the OOB flag, like the
|
||||
chrome partials) so the heading never lies past the row that made it,
|
||||
and inline here it is the page's own heading. The id is the OOB swap's
|
||||
hook; hx-swap-oob sits on the element the answer carries. */}}
|
||||
{{define "series-list-head"}}
|
||||
<div class="listhead" id="series-listhead"{{if .OOB}} hx-swap-oob="true"{{end}}>{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user