rebrand: MangaBM → BookmarkManager, add novel library support #15
@@ -20,6 +20,14 @@
|
|||||||
<div class="sheet">
|
<div class="sheet">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
|
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
|
||||||
|
{{/* Plain full-page links, not htmx swaps: switching library replaces the
|
||||||
|
tab row and the chrome, which is a page, not a fragment. */}}
|
||||||
|
<nav class="libswitch" aria-label="Library">
|
||||||
|
<a href="/?tab=all" class="{{if eq .Lib "manga"}}active{{end}}"
|
||||||
|
{{if eq .Lib "manga"}}aria-current="page"{{end}}>Manga</a>
|
||||||
|
<a href="/?lib=novel&tab=all" class="{{if eq .Lib "novel"}}active{{end}}"
|
||||||
|
{{if eq .Lib "novel"}}aria-current="page"{{end}}>Novels</a>
|
||||||
|
</nav>
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<button type="submit" class="ghost">Log out</button>
|
<button type="submit" class="ghost">Log out</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -38,27 +46,31 @@
|
|||||||
navigation, not an ARIA tablist — aria-current carries "which bucket am
|
navigation, not an ARIA tablist — aria-current carries "which bucket am
|
||||||
I in" without owing a tabpanel contract we do not implement. */}}
|
I in" without owing a tabpanel contract we do not implement. */}}
|
||||||
<nav class="tabs" aria-label="Bookmark buckets">
|
<nav class="tabs" aria-label="Bookmark buckets">
|
||||||
<a href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
|
<a href="{{.PageURL "all"}}" class="{{if eq .Tab "all"}}active{{end}}"
|
||||||
{{if eq .Tab "all"}}aria-current="page"{{end}}
|
{{if eq .Tab "all"}}aria-current="page"{{end}}
|
||||||
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML"
|
hx-get="{{.ListURL "all"}}" hx-target="#list" hx-swap="innerHTML"
|
||||||
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
|
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="setActiveTab(this)">All</a>
|
||||||
<a href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
{{/* The one bucket novels do not have: without a poller-fed "what is out
|
||||||
|
that I have not read", the tab would only ever restate All. */}}
|
||||||
|
{{if eq .Lib "manga"}}
|
||||||
|
<a href="{{.PageURL "new"}}" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
||||||
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
||||||
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
|
hx-get="{{.ListURL "new"}}" hx-target="#list" hx-swap="innerHTML"
|
||||||
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated
|
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="setActiveTab(this)">Updated
|
||||||
{{template "newcount" .}}</a>
|
{{template "newcount" .}}</a>
|
||||||
<a href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
|
{{end}}
|
||||||
|
<a href="{{.PageURL "fav"}}" class="{{if eq .Tab "fav"}}active{{end}}"
|
||||||
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
||||||
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
|
hx-get="{{.ListURL "fav"}}" hx-target="#list" hx-swap="innerHTML"
|
||||||
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||||
<a href="/?tab=archived" class="{{if eq .Tab "archived"}}active{{end}}"
|
<a href="{{.PageURL "archived"}}" class="{{if eq .Tab "archived"}}active{{end}}"
|
||||||
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
||||||
hx-get="/ui/list?tab=archived" hx-target="#list" hx-swap="innerHTML"
|
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
|
||||||
hx-push-url="/?tab=archived" hx-on::after-request="setActiveTab(this)">Archived</a>
|
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||||
<a href="/?tab=finished" class="{{if eq .Tab "finished"}}active{{end}}"
|
<a href="{{.PageURL "finished"}}" class="{{if eq .Tab "finished"}}active{{end}}"
|
||||||
{{if eq .Tab "finished"}}aria-current="page"{{end}}
|
{{if eq .Tab "finished"}}aria-current="page"{{end}}
|
||||||
hx-get="/ui/list?tab=finished" hx-target="#list" hx-swap="innerHTML"
|
hx-get="{{.ListURL "finished"}}" hx-target="#list" hx-swap="innerHTML"
|
||||||
hx-push-url="/?tab=finished" hx-on::after-request="setActiveTab(this)">Finished</a>
|
hx-push-url="{{.PageURL "finished"}}" hx-on::after-request="setActiveTab(this)">Finished</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ type Handler struct {
|
|||||||
|
|
||||||
// listView is what every list-rendering template receives.
|
// listView is what every list-rendering template receives.
|
||||||
type listView struct {
|
type listView struct {
|
||||||
|
// Lib is the library this view renders: store.KindManga or store.KindNovel.
|
||||||
|
// Manga is the default and carries no query parameter, so every pre-novel
|
||||||
|
// URL keeps meaning exactly what it did.
|
||||||
|
Lib string
|
||||||
Tab string // "all", "fav", or "new"
|
Tab string // "all", "fav", or "new"
|
||||||
Recent []store.Bookmark
|
Recent []store.Bookmark
|
||||||
Items []store.Bookmark
|
Items []store.Bookmark
|
||||||
@@ -53,6 +57,23 @@ type listView struct {
|
|||||||
OOB bool
|
OOB bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PageURL and ListURL are the two link shapes every tab needs. Building them
|
||||||
|
// here rather than concatenating in the template is what keeps the library
|
||||||
|
// parameter from being dropped on one link out of ten.
|
||||||
|
func (v listView) PageURL(tab string) string {
|
||||||
|
if v.Lib == store.KindNovel {
|
||||||
|
return "/?lib=novel&tab=" + tab
|
||||||
|
}
|
||||||
|
return "/?tab=" + tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v listView) ListURL(tab string) string {
|
||||||
|
if v.Lib == store.KindNovel {
|
||||||
|
return "/ui/list?lib=novel&tab=" + tab
|
||||||
|
}
|
||||||
|
return "/ui/list?tab=" + tab
|
||||||
|
}
|
||||||
|
|
||||||
// loginView is what the login template receives.
|
// loginView is what the login template receives.
|
||||||
type loginView struct {
|
type loginView struct {
|
||||||
Error string
|
Error string
|
||||||
@@ -146,7 +167,7 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.render(w, http.StatusOK, "login", loginView{})
|
h.render(w, http.StatusOK, "login", loginView{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
view, err := h.buildListView(libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("index: %v", err)
|
log.Printf("index: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -167,6 +188,26 @@ func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []sto
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kindOf reads a bookmark's library. A row cached or written before the kind
|
||||||
|
// column existed has none; every one of those is manga, which is what the
|
||||||
|
// column default says too.
|
||||||
|
func kindOf(b store.Bookmark) string {
|
||||||
|
if b.Kind == "" {
|
||||||
|
return store.KindManga
|
||||||
|
}
|
||||||
|
return b.Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// libOf normalises the query parameter. Anything that is not the novel library
|
||||||
|
// is the manga one, so a typo lands on the default page rather than an empty
|
||||||
|
// list.
|
||||||
|
func libOf(q string) string {
|
||||||
|
if q == store.KindNovel {
|
||||||
|
return store.KindNovel
|
||||||
|
}
|
||||||
|
return store.KindManga
|
||||||
|
}
|
||||||
|
|
||||||
// buildListView loads the list once and derives both the tab-filtered items and
|
// buildListView loads the list once and derives both the tab-filtered items and
|
||||||
// the recent strip from it.
|
// the recent strip from it.
|
||||||
//
|
//
|
||||||
@@ -174,11 +215,20 @@ func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []sto
|
|||||||
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
||||||
// archived favourite therefore shows only under Archived: Favourites means
|
// archived favourite therefore shows only under Archived: Favourites means
|
||||||
// "favourites I am currently reading".
|
// "favourites I am currently reading".
|
||||||
func (h *Handler) buildListView(tab string) (listView, error) {
|
func (h *Handler) buildListView(lib, tab string) (listView, error) {
|
||||||
all, err := h.store.List() // already ordered updated_at DESC
|
all, err := h.store.List() // already ordered updated_at DESC
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return listView{}, err
|
return listView{}, err
|
||||||
}
|
}
|
||||||
|
// Narrow to one library first: reading, withNew and recent all derive from
|
||||||
|
// this slice, so doing it later would let the other library's rows into the
|
||||||
|
// strip and the Updated badge.
|
||||||
|
all = filterBookmarks(all, func(b store.Bookmark) bool { return kindOf(b) == lib })
|
||||||
|
|
||||||
|
// Novels do not offer an Updated tab, so a hand-typed one lands on All.
|
||||||
|
if lib == store.KindNovel && tab == "new" {
|
||||||
|
tab = "all"
|
||||||
|
}
|
||||||
reading := filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusReading })
|
reading := filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusReading })
|
||||||
|
|
||||||
withNew := filterBookmarks(reading, func(b store.Bookmark) bool { return b.HasNewChapter() })
|
withNew := filterBookmarks(reading, func(b store.Bookmark) bool { return b.HasNewChapter() })
|
||||||
@@ -213,11 +263,11 @@ func (h *Handler) buildListView(tab string) (listView, error) {
|
|||||||
recent = recent[:RecentCount]
|
recent = recent[:RecentCount]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
return listView{Lib: lib, Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||||
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
view, err := h.buildListView(libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui list: %v", err)
|
log.Printf("ui list: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -241,6 +291,17 @@ func currentTab(r *http.Request) string {
|
|||||||
return u.Query().Get("tab")
|
return u.Query().Get("tab")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentLib is the library the reader is looking at, read from htmx's own
|
||||||
|
// header for the same reason currentTab is: out-of-band chrome must be rebuilt
|
||||||
|
// for that view rather than for the default one.
|
||||||
|
func currentLib(r *http.Request) string {
|
||||||
|
u, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
||||||
|
if err != nil {
|
||||||
|
return store.KindManga
|
||||||
|
}
|
||||||
|
return libOf(u.Query().Get("lib"))
|
||||||
|
}
|
||||||
|
|
||||||
// writeChromeOOB appends the regions that live outside #list — the recent
|
// writeChromeOOB appends the regions that live outside #list — the recent
|
||||||
// strip, the Updated badge and the action key — as out-of-band swaps, so a
|
// strip, the Updated badge and the action key — as out-of-band swaps, so a
|
||||||
// mutation cannot leave them describing the library as it was before the tap.
|
// mutation cannot leave them describing the library as it was before the tap.
|
||||||
@@ -248,7 +309,11 @@ func currentTab(r *http.Request) string {
|
|||||||
// Archive for Restore.
|
// Archive for Restore.
|
||||||
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||||
view.OOB = true
|
view.OOB = true
|
||||||
for _, name := range []string{"recent", "newcount", "keyrow"} {
|
names := []string{"recent", "keyrow"}
|
||||||
|
if view.Lib == store.KindManga {
|
||||||
|
names = append(names, "newcount")
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
||||||
// The card is already written; stale chrome beats a torn response.
|
// The card is already written; stale chrome beats a torn response.
|
||||||
log.Printf("render %s oob: %v", name, err)
|
log.Printf("render %s oob: %v", name, err)
|
||||||
@@ -260,7 +325,7 @@ func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
|||||||
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
||||||
// mutation and appends it to the response.
|
// mutation and appends it to the response.
|
||||||
func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
||||||
view, err := h.buildListView(currentTab(r))
|
view, err := h.buildListView(currentLib(r), currentTab(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui chrome: %v", err)
|
log.Printf("ui chrome: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -840,3 +840,127 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
|||||||
t.Fatalf("archiving did not clear the Updated badge out of band: %q", body)
|
t.Fatalf("archiving did not clear the Updated badge out of band: %q", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedLibraries puts one manga and one novel row in the store.
|
||||||
|
func seedLibraries(t *testing.T, st *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
seed(t, st, store.Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
|
Title: "Solo Leveling", Kind: store.KindManga, UpdatedAt: 2_000_000,
|
||||||
|
})
|
||||||
|
seed(t, st, store.Bookmark{
|
||||||
|
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
||||||
|
SeriesID: "a-will-eternal", Title: "A Will Eternal",
|
||||||
|
Kind: store.KindNovel, UpdatedAt: 1_000_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibrariesAreDisjoint(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, st := newWebTestServer(t, cfg)
|
||||||
|
seedLibraries(t, st)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name, path, want, absent string
|
||||||
|
}{
|
||||||
|
{"manga is the default", "/ui/list?tab=all", "Solo Leveling", "A Will Eternal"},
|
||||||
|
{"novel is opt-in", "/ui/list?lib=novel&tab=all", "A Will Eternal", "Solo Leveling"},
|
||||||
|
{"unknown lib falls back to manga", "/ui/list?lib=comics&tab=all", "Solo Leveling", "A Will Eternal"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, tc.path, nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, tc.want) {
|
||||||
|
t.Fatalf("%s missing from %s", tc.want, tc.path)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, tc.absent) {
|
||||||
|
t.Fatalf("%s leaked into %s", tc.absent, tc.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row written before the kind column existed has none. It is manga.
|
||||||
|
func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, st := newWebTestServer(t, cfg)
|
||||||
|
seed(t, st, store.Bookmark{
|
||||||
|
Key: "asura:legacy", Site: "asura", SeriesID: "legacy",
|
||||||
|
Title: "Legacy Series", UpdatedAt: 1_000_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=all", nil))
|
||||||
|
if !strings.Contains(rr.Body.String(), "Legacy Series") {
|
||||||
|
t.Fatal("a row with no kind must appear in the manga library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNovelPageOmitsUpdatedTab(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, st := newWebTestServer(t, cfg)
|
||||||
|
seedLibraries(t, st)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?lib=novel&tab=all", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if strings.Contains(body, "tab=new") {
|
||||||
|
t.Fatal("novel page must not offer the Updated tab")
|
||||||
|
}
|
||||||
|
// html/template escapes & to & inside an attribute value, so that — not
|
||||||
|
// the raw URL — is what lands in the body. htmx and the browser both decode
|
||||||
|
// it on read, so only the assertion has to know.
|
||||||
|
for _, want := range []string{
|
||||||
|
"/?lib=novel&tab=fav",
|
||||||
|
"/?lib=novel&tab=archived",
|
||||||
|
"/?lib=novel&tab=finished",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("novel page missing tab link %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `class="libswitch"`) {
|
||||||
|
t.Fatal("novel page missing the library switch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, st := newWebTestServer(t, cfg)
|
||||||
|
seedLibraries(t, st)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?tab=all", nil))
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "/?tab=new") {
|
||||||
|
t.Fatal("manga page must keep the Updated tab")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "lib=novel&tab=new") {
|
||||||
|
t.Fatal("the Updated tab must never be emitted for the novel library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tab=new is not offered for novels, so a hand-typed one must land on All
|
||||||
|
// rather than an empty page.
|
||||||
|
func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, st := newWebTestServer(t, cfg)
|
||||||
|
seedLibraries(t, st)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?lib=novel&tab=new", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "A Will Eternal") {
|
||||||
|
t.Fatal("novel tab=new should render the novel All list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user