feat(web): archived and finished tabs with per-card status controls

This commit is contained in:
2026-07-27 13:06:11 +07:00
parent 8579f5a0ed
commit 4d396a3582
5 changed files with 120 additions and 4 deletions
+11 -2
View File
@@ -131,10 +131,19 @@ button, .primary, [role="tab"] {
.search:focus-visible { outline: 2px solid var(--accent); }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
/* Five buttons no longer divide a phone's width legibly, so the row scrolls
sideways instead of squeezing. The scrollbar is hidden: on mobile there is
none anyway, and on desktop it would sit under the tab labels. */
.tabs {
display: flex; gap: 8px; margin-bottom: 16px;
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar { display: none; }
.tabs [role="tab"] {
flex: 1;
flex: 1 0 auto;
padding-inline: 14px;
white-space: nowrap;
display: grid;
place-items: center;
background: var(--surface);
+6
View File
@@ -31,6 +31,12 @@
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{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 role="tab" href="/?tab=archived" class="{{if eq .Tab "archived"}}active{{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 role="tab" href="/?tab=finished" class="{{if eq .Tab "finished"}}active{{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>
</nav>
{{if .Recent}}
+42
View File
@@ -38,6 +38,48 @@
<path d="M13.8 7.2l3 3"/>
</svg>
</button>
{{if eq .Status "finished"}}
<button class="icon on" title="Restore to reading" aria-label="Restore to reading"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
</svg>
</button>
{{else}}
{{if eq .Status "archived"}}
<button class="icon on" title="Restore to reading" aria-label="Restore to reading"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
</svg>
</button>
{{else}}
<button class="icon" title="Archive" aria-label="Archive"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"archived"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M3.5 6.4h17v3.2h-17zM5 9.6h14V19H5zM10 13h4"/>
</svg>
</button>
{{end}}
<button class="icon" title="Mark finished" aria-label="Mark finished"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4.5 12.5l5 5 10-11"/>
</svg>
</button>
{{end}}
<button class="icon danger" title="Remove" aria-label="Remove"
onclick="toggleConfirmRow('{{.Key}}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
+4
View File
@@ -5,6 +5,10 @@
<p class="empty">No favourites yet. Star a series to pin it here.</p>
{{else if eq .Tab "new"}}
<p class="empty">Nothing new. Every series is caught up to its latest chapter.</p>
{{else if eq .Tab "archived"}}
<p class="empty">Nothing archived. Shelve a series to park it here — it keeps getting checked for new chapters.</p>
{{else if eq .Tab "finished"}}
<p class="empty">Nothing finished yet.</p>
{{else}}
<p class="empty">
Nothing here yet. Bookmarks appear once the userscript records a chapter.
+57 -2
View File
@@ -308,8 +308,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
body := rr.Body.String()
want := `hx-target="[id='card-asura:solo']"`
if strings.Count(body, want) != 3 {
t.Fatalf("body has %d occurrences of %s, want 3 (favorite button, delete button, chapter form)",
if strings.Count(body, want) != 5 {
t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)",
strings.Count(body, want), want)
}
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
@@ -675,3 +675,58 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
}
}
func TestCardShowsStatusControls(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
cases := []struct {
tab string
want, dontWant []string
}{
// A series being read can be shelved or completed, not restored.
{"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil},
// An archived one can come back or be completed.
{"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil},
// A finished one can only come back.
{"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
}
for _, tc := range cases {
t.Run(tc.tab, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
req.AddCookie(sessionCookie(t, cfg))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
body := rr.Body.String()
for _, w := range tc.want {
if !strings.Contains(body, w) {
t.Fatalf("tab %s missing control %s", tc.tab, w)
}
}
for _, d := range tc.dontWant {
if strings.Contains(body, d) {
t.Fatalf("tab %s offered %s", tc.tab, d)
}
}
})
}
}
func TestAppRendersNewTabs(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, cfg))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
for _, want := range []string{`href="/?tab=archived"`, `href="/?tab=finished"`} {
if !strings.Contains(rr.Body.String(), want) {
t.Fatalf("app page missing %s", want)
}
}
}