diff --git a/backend/internal/web/templates/app.html b/backend/internal/web/templates/app.html
index 4fb0494..760c0cd 100644
--- a/backend/internal/web/templates/app.html
+++ b/backend/internal/web/templates/app.html
@@ -20,6 +20,14 @@
diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go
index a4faa1e..732afe7 100644
--- a/backend/internal/web/web.go
+++ b/backend/internal/web/web.go
@@ -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
diff --git a/backend/web_test.go b/backend/web_test.go
index e6c4550..0dd662c 100644
--- a/backend/web_test.go
+++ b/backend/web_test.go
@@ -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")
+ }
+}