feat(web): split the UI into manga and novel libraries
This commit is contained in:
@@ -20,6 +20,14 @@
|
||||
<div class="sheet">
|
||||
<header class="topbar">
|
||||
<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">
|
||||
<button type="submit" class="ghost">Log out</button>
|
||||
</form>
|
||||
@@ -38,27 +46,31 @@
|
||||
navigation, not an ARIA tablist — aria-current carries "which bucket am
|
||||
I in" without owing a tabpanel contract we do not implement. */}}
|
||||
<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}}
|
||||
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
|
||||
<a href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
||||
hx-get="{{.ListURL "all"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="setActiveTab(this)">All</a>
|
||||
{{/* 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}}
|
||||
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated
|
||||
hx-get="{{.ListURL "new"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="setActiveTab(this)">Updated
|
||||
{{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}}
|
||||
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||
<a href="/?tab=archived" class="{{if eq .Tab "archived"}}active{{end}}"
|
||||
hx-get="{{.ListURL "fav"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||
<a href="{{.PageURL "archived"}}" class="{{if eq .Tab "archived"}}active{{end}}"
|
||||
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=archived" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=archived" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||
<a href="/?tab=finished" class="{{if eq .Tab "finished"}}active{{end}}"
|
||||
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||
<a href="{{.PageURL "finished"}}" class="{{if eq .Tab "finished"}}active{{end}}"
|
||||
{{if eq .Tab "finished"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=finished" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=finished" hx-on::after-request="setActiveTab(this)">Finished</a>
|
||||
hx-get="{{.ListURL "finished"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "finished"}}" hx-on::after-request="setActiveTab(this)">Finished</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ type Handler struct {
|
||||
|
||||
// listView is what every list-rendering template receives.
|
||||
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"
|
||||
Recent []store.Bookmark
|
||||
Items []store.Bookmark
|
||||
@@ -53,6 +57,23 @@ type listView struct {
|
||||
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.
|
||||
type loginView struct {
|
||||
Error string
|
||||
@@ -146,7 +167,7 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, http.StatusOK, "login", loginView{})
|
||||
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 {
|
||||
log.Printf("index: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -167,6 +188,26 @@ func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []sto
|
||||
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
|
||||
// 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
|
||||
// archived favourite therefore shows only under Archived: Favourites means
|
||||
// "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
|
||||
if err != nil {
|
||||
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 })
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
log.Printf("ui list: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -241,6 +291,17 @@ func currentTab(r *http.Request) string {
|
||||
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
|
||||
// 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.
|
||||
@@ -248,7 +309,11 @@ func currentTab(r *http.Request) string {
|
||||
// Archive for Restore.
|
||||
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||
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 {
|
||||
// The card is already written; stale chrome beats a torn response.
|
||||
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
|
||||
// mutation and appends it to the response.
|
||||
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 {
|
||||
log.Printf("ui chrome: %v", err)
|
||||
return
|
||||
|
||||
@@ -840,3 +840,127 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
||||
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