From d46502844397e0f798a897c6902139e1378a7a20 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 6 Aug 2026 03:19:49 +0700 Subject: [PATCH] feat(web): split the UI into manga and novel libraries --- backend/internal/web/templates/app.html | 42 +++++--- backend/internal/web/web.go | 77 +++++++++++++-- backend/web_test.go | 124 ++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 21 deletions(-) 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 @@

{{template "mark" .}}BookmarkManager

+ {{/* Plain full-page links, not htmx swaps: switching library replaces the + tab row and the chrome, which is a page, not a fragment. */}} +
@@ -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. */}}
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") + } +}