Archived and finished buckets, userscript nav chips #4
@@ -3,6 +3,7 @@
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
backend/server
|
backend/server
|
||||||
|
backend/backend
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
graphify-out/
|
graphify-out/
|
||||||
plans/
|
plans/
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
|||||||
value now differs, moving `updated_at` and reordering the list. This is a
|
value now differs, moving `updated_at` and reordering the list. This is a
|
||||||
known, accepted limitation for a single-user deployment, not a bug to fix.
|
known, accepted limitation for a single-user deployment, not a bug to fix.
|
||||||
- **`updated_at` drives list order, so it moves only on real reading progress:** the server applies its timestamp when the row is new or `last_chapter_num` changes, and otherwise keeps the stored value — favouriting a series or recording a newly published chapter must not reorder the list. `PUT` therefore returns the row **as stored**, and clients must adopt that response rather than their own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
|
- **`updated_at` drives list order, so it moves only on real reading progress:** the server applies its timestamp when the row is new or `last_chapter_num` changes, and otherwise keeps the stored value — favouriting a series or recording a newly published chapter must not reorder the list. `PUT` therefore returns the row **as stored**, and clients must adopt that response rather than their own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
|
||||||
|
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
|
||||||
|
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
||||||
|
their own tab — not in All, Updated, Favourites, or the recent strip. The
|
||||||
|
poller keeps checking archived series and skips finished ones. `finished` is
|
||||||
|
settable only from the web UI; `PUT /bookmarks/{key}` rejects it with 400.
|
||||||
|
**An empty incoming status means "keep the stored one"** — resolved on the
|
||||||
|
`VALUES` side of `Store.Upsert`, not in the conflict clause, because
|
||||||
|
`excluded.*` is the post-evaluation row and a default applied there would
|
||||||
|
wipe the bucket on every PUT from a client that predates the column. See
|
||||||
|
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
||||||
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
|
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
|
||||||
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
|
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
|
||||||
(gates the browser UI; unset disables it),
|
(gates the browser UI; unset disables it),
|
||||||
@@ -68,7 +78,10 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
|||||||
1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
|
1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
|
||||||
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback.
|
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback.
|
||||||
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
||||||
4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS (critical on mobile).
|
4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS
|
||||||
|
(critical on mobile). Three tabs (All / Favourites / Archived) and a row of
|
||||||
|
link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG
|
||||||
|
block next to `API_BASE`.
|
||||||
5. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices).
|
5. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices).
|
||||||
|
|
||||||
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
||||||
|
|||||||
@@ -148,6 +148,15 @@ desktop for faster testing — install the same file unchanged.
|
|||||||
- **Favourites**: the ☆ on any row toggles it; the **★ Favourites** tab narrows
|
- **Favourites**: the ☆ on any row toggles it; the **★ Favourites** tab narrows
|
||||||
the list. Favourited series still appear under **All**. The flag syncs, so it
|
the list. Favourited series still appear under **All**. The flag syncs, so it
|
||||||
follows you across devices; the chosen tab does not persist.
|
follows you across devices; the chosen tab does not persist.
|
||||||
|
- **Archive**: the **Archive** button on any row parks a series — it drops out
|
||||||
|
of **All** and **★ Favourites** and moves to the **Archived** tab. The server
|
||||||
|
keeps checking it for new chapters, so it is worth coming back to. Archiving
|
||||||
|
does not touch read progress, and reading an archived series leaves it
|
||||||
|
archived.
|
||||||
|
- **Finished**: series you have completed live in a **Finished** tab in the web
|
||||||
|
UI only. It is set there and nowhere else — the API rejects the value — and
|
||||||
|
finished series are hidden from every userscript tab and are no longer polled
|
||||||
|
for new chapters.
|
||||||
- Bookmarks made on Asura appear when the panel is opened on Demonic, and vice
|
- Bookmarks made on Asura appear when the panel is opened on Demonic, and vice
|
||||||
versa — the backend is the shared store.
|
versa — the backend is the shared store.
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty status is "no opinion" and Upsert keeps the stored bucket.
|
||||||
|
// Finishing a series is a web-UI decision, so the JSON API refuses it
|
||||||
|
// rather than trusting every client to leave it alone.
|
||||||
|
switch b.Status {
|
||||||
|
case "", statusReading, statusArchived:
|
||||||
|
case statusFinished:
|
||||||
|
http.Error(w, "status "+statusFinished+" can only be set from the web UI",
|
||||||
|
http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Candidate timestamp, not a decision: Upsert keeps the stored one unless
|
// Candidate timestamp, not a decision: Upsert keeps the stored one unless
|
||||||
// reading progress actually moved. Any client value is ignored.
|
// reading progress actually moved. Any client value is ignored.
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
b.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
|||||||
@@ -148,6 +148,13 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
|
|
||||||
// Re-read: the row may have been updated or deleted while the fetch was in
|
// Re-read: the row may have been updated or deleted while the fetch was in
|
||||||
// flight, and writing b back wholesale would undo that.
|
// flight, and writing b back wholesale would undo that.
|
||||||
|
//
|
||||||
|
// ponytail: non-transactional read-modify-write, wrap Get+Upsert in a tx if
|
||||||
|
// this ever runs for more than one user. A client PUT that commits between
|
||||||
|
// these two statements is lost to the stale re-read — reverting read
|
||||||
|
// progress or a status change, and moving updated_at because the stored
|
||||||
|
// value now differs. Accepted for a single-user deployment: the window is
|
||||||
|
// milliseconds and the loser is one poll cycle.
|
||||||
cur, found, err := p.store.Get(b.Key)
|
cur, found, err := p.store.Get(b.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("latest poll %q: reread: %v", b.Key, err)
|
log.Printf("latest poll %q: reread: %v", b.Key, err)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -113,3 +118,81 @@ func TestLoadLatestPollClampsAndFallsBack(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPutStatusValidation(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"empty is no opinion", "", http.StatusOK},
|
||||||
|
{"reading", "reading", http.StatusOK},
|
||||||
|
{"archived", "archived", http.StatusOK},
|
||||||
|
{"finished is web-only", "finished", http.StatusBadRequest},
|
||||||
|
{"garbage", "dropped", http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
body := fmt.Sprintf(`{"title":"Solo","status":%q}`, tc.status)
|
||||||
|
req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
||||||
|
strings.NewReader(body)))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != tc.want {
|
||||||
|
t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String())
|
||||||
|
}
|
||||||
|
if tc.want != http.StatusOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var got Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
want := tc.status
|
||||||
|
if want == "" {
|
||||||
|
want = "reading"
|
||||||
|
}
|
||||||
|
if got.Status != want {
|
||||||
|
t.Fatalf("stored status = %q, want %q", got.Status, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PUT that omits the status field entirely (what a userscript build
|
||||||
|
// predating the column sends) is the actual preserve path — the "" case
|
||||||
|
// above only exercises the fresh-INSERT default and never touches an
|
||||||
|
// existing bucket. This must both keep the archived bucket and still apply
|
||||||
|
// the chapter progress carried in the same request.
|
||||||
|
func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
|
||||||
|
seed := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
||||||
|
strings.NewReader(`{"title":"Solo","status":"archived"}`))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(seed))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("seed status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
||||||
|
strings.NewReader(`{"title":"Solo","last_chapter":"12","last_chapter_num":12}`))
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(req))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var got Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != "archived" {
|
||||||
|
t.Fatalf("stored status = %q, want %q", got.Status, "archived")
|
||||||
|
}
|
||||||
|
if got.LastChapterNum != 12 {
|
||||||
|
t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,10 +131,19 @@ button, .primary, [role="tab"] {
|
|||||||
|
|
||||||
.search:focus-visible { outline: 2px solid var(--accent); }
|
.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"] {
|
.tabs [role="tab"] {
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
|
padding-inline: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
+44
-4
@@ -27,6 +27,10 @@ type Bookmark struct {
|
|||||||
LatestChapter string `json:"latest_chapter"`
|
LatestChapter string `json:"latest_chapter"`
|
||||||
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
|
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
|
||||||
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
|
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
|
||||||
|
// Status is the lifecycle bucket: reading, archived, or finished.
|
||||||
|
// Archived series stay polled for new chapters; finished ones do not.
|
||||||
|
// Empty on the way in means "no opinion" — see Upsert.
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasNewChapter reports whether the site has published past the read point.
|
// HasNewChapter reports whether the site has published past the read point.
|
||||||
@@ -45,6 +49,13 @@ func (b Bookmark) ContinueURL() string {
|
|||||||
return b.SeriesURL
|
return b.SeriesURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
||||||
|
const (
|
||||||
|
statusReading = "reading"
|
||||||
|
statusArchived = "archived"
|
||||||
|
statusFinished = "finished"
|
||||||
|
)
|
||||||
|
|
||||||
const schema = `
|
const schema = `
|
||||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -60,6 +71,7 @@ CREATE TABLE IF NOT EXISTS bookmarks (
|
|||||||
latest_chapter TEXT NOT NULL DEFAULT '',
|
latest_chapter TEXT NOT NULL DEFAULT '',
|
||||||
latest_chapter_num REAL,
|
latest_chapter_num REAL,
|
||||||
latest_checked_at INTEGER NOT NULL DEFAULT 0,
|
latest_checked_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'reading',
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);`
|
);`
|
||||||
|
|
||||||
@@ -73,11 +85,14 @@ var addedColumns = []struct{ name, ddl string }{
|
|||||||
// sorts first so a new bookmark is picked up on the next tick with no
|
// sorts first so a new bookmark is picked up on the next tick with no
|
||||||
// special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked.
|
// special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked.
|
||||||
{"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`},
|
{"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`},
|
||||||
|
// Lifecycle bucket. The DEFAULT backfills every pre-existing row as
|
||||||
|
// 'reading', so there is no separate migration step.
|
||||||
|
{"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`},
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmarkColumns = `key, site, series_id, title, series_url, cover,
|
const bookmarkColumns = `key, site, series_id, title, series_url, cover,
|
||||||
last_chapter, last_chapter_num, last_chapter_url,
|
last_chapter, last_chapter_num, last_chapter_url,
|
||||||
favorite, latest_chapter, latest_chapter_num, updated_at`
|
favorite, latest_chapter, latest_chapter_num, updated_at, status`
|
||||||
|
|
||||||
// Store is the SQLite-backed bookmark store.
|
// Store is the SQLite-backed bookmark store.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -156,13 +171,14 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
|||||||
b Bookmark
|
b Bookmark
|
||||||
title, seriesURL, cover sql.NullString
|
title, seriesURL, cover sql.NullString
|
||||||
lastChapter, lastChapterURL, latestChapter sql.NullString
|
lastChapter, lastChapterURL, latestChapter sql.NullString
|
||||||
|
status sql.NullString
|
||||||
lastChapterNum, latestChapterNum sql.NullFloat64
|
lastChapterNum, latestChapterNum sql.NullFloat64
|
||||||
favorite sql.NullInt64
|
favorite sql.NullInt64
|
||||||
)
|
)
|
||||||
if err := scan(
|
if err := scan(
|
||||||
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
|
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
|
||||||
&lastChapter, &lastChapterNum, &lastChapterURL,
|
&lastChapter, &lastChapterNum, &lastChapterURL,
|
||||||
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt,
|
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Bookmark{}, err
|
return Bookmark{}, err
|
||||||
}
|
}
|
||||||
@@ -177,6 +193,13 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
|||||||
if latestChapterNum.Valid {
|
if latestChapterNum.Valid {
|
||||||
b.LatestChapterNum = &latestChapterNum.Float64
|
b.LatestChapterNum = &latestChapterNum.Float64
|
||||||
}
|
}
|
||||||
|
// A NULL, empty, or unrecognised bucket (e.g. a hand-edited row) would
|
||||||
|
// leave the row in no list at all, so anything outside the three known
|
||||||
|
// buckets reads as the default rather than being passed through.
|
||||||
|
b.Status = status.String
|
||||||
|
if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished {
|
||||||
|
b.Status = statusReading
|
||||||
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,9 +265,19 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
|||||||
// IS NOT is SQLite's null-safe comparison. Within DO UPDATE, a bare column
|
// IS NOT is SQLite's null-safe comparison. Within DO UPDATE, a bare column
|
||||||
// is the stored row and excluded.* is the incoming one; a brand-new key
|
// is the stored row and excluded.* is the incoming one; a brand-new key
|
||||||
// never reaches this clause, so it keeps the fresh timestamp from VALUES.
|
// never reaches this clause, so it keeps the fresh timestamp from VALUES.
|
||||||
|
//
|
||||||
|
// The status column resolves on the VALUES side, not in the conflict
|
||||||
|
// clause: excluded.* is the row *after* these expressions are evaluated,
|
||||||
|
// so a default applied there would look identical to a real 'reading' and
|
||||||
|
// would overwrite an archived row on every PUT from a client that knows
|
||||||
|
// nothing about the column. Resolved once here, an empty incoming status
|
||||||
|
// means "keep what is stored", and only a brand-new row falls through to
|
||||||
|
// the literal default. The subquery runs inside this transaction, so it
|
||||||
|
// sees the row this statement is about to conflict with.
|
||||||
if _, err := tx.Exec(`
|
if _, err := tx.Exec(`
|
||||||
INSERT INTO bookmarks (`+bookmarkColumns+`)
|
INSERT INTO bookmarks (`+bookmarkColumns+`)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
|
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading'))
|
||||||
ON CONFLICT(key) DO UPDATE SET
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
site=excluded.site, series_id=excluded.series_id, title=excluded.title,
|
site=excluded.site, series_id=excluded.series_id, title=excluded.title,
|
||||||
series_url=excluded.series_url, cover=excluded.cover,
|
series_url=excluded.series_url, cover=excluded.cover,
|
||||||
@@ -253,6 +286,7 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
|||||||
favorite=excluded.favorite,
|
favorite=excluded.favorite,
|
||||||
latest_chapter=excluded.latest_chapter,
|
latest_chapter=excluded.latest_chapter,
|
||||||
latest_chapter_num=excluded.latest_chapter_num,
|
latest_chapter_num=excluded.latest_chapter_num,
|
||||||
|
status=excluded.status,
|
||||||
updated_at=CASE
|
updated_at=CASE
|
||||||
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
|
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
|
||||||
THEN excluded.updated_at
|
THEN excluded.updated_at
|
||||||
@@ -260,7 +294,8 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
|||||||
END`,
|
END`,
|
||||||
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
|
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
|
||||||
b.LastChapter, b.LastChapterNum, b.LastChapterURL,
|
b.LastChapter, b.LastChapterNum, b.LastChapterURL,
|
||||||
b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt); err != nil {
|
b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt,
|
||||||
|
b.Status, b.Key); err != nil {
|
||||||
return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err)
|
return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,10 +328,15 @@ func (s *Store) Delete(key string) error {
|
|||||||
//
|
//
|
||||||
// Bookmarks with no series_url are skipped — there is nothing to fetch, which
|
// Bookmarks with no series_url are skipped — there is nothing to fetch, which
|
||||||
// is the same filter the userscript applies at L452.
|
// is the same filter the userscript applies at L452.
|
||||||
|
//
|
||||||
|
// Finished series are excluded: nothing more is coming, so fetching them only
|
||||||
|
// burns requests. Archived ones are deliberately still polled — knowing what a
|
||||||
|
// shelved series is up to is the whole reason for archiving instead of deleting.
|
||||||
func (s *Store) DueForLatestCheck(cutoffMs int64, limit int) ([]Bookmark, error) {
|
func (s *Store) DueForLatestCheck(cutoffMs int64, limit int) ([]Bookmark, error) {
|
||||||
rows, err := s.db.Query(`SELECT `+bookmarkColumns+`
|
rows, err := s.db.Query(`SELECT `+bookmarkColumns+`
|
||||||
FROM bookmarks
|
FROM bookmarks
|
||||||
WHERE series_url IS NOT NULL AND series_url <> ''
|
WHERE series_url IS NOT NULL AND series_url <> ''
|
||||||
|
AND status IS NOT 'finished'
|
||||||
AND latest_checked_at <= ?
|
AND latest_checked_at <= ?
|
||||||
ORDER BY latest_checked_at ASC
|
ORDER BY latest_checked_at ASC
|
||||||
LIMIT ?`, cutoffMs, limit)
|
LIMIT ?`, cutoffMs, limit)
|
||||||
|
|||||||
@@ -722,3 +722,186 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
|||||||
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
|
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
stored, err := store.Upsert(Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
|
UpdatedAt: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upsert: %v", err)
|
||||||
|
}
|
||||||
|
if stored.Status != statusReading {
|
||||||
|
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty status means "no opinion" — a userscript build that predates the
|
||||||
|
// column must not reset the bucket on every progress PUT.
|
||||||
|
func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
base := Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
|
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
if _, err := store.Upsert(base); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Status = ""
|
||||||
|
base.LastChapterNum = 12
|
||||||
|
stored, err := store.Upsert(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upsert: %v", err)
|
||||||
|
}
|
||||||
|
if stored.Status != statusArchived {
|
||||||
|
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the poller's read-modify-write in latest.go: Get the current row,
|
||||||
|
// mutate only the latest-chapter fields, and Upsert the whole struct back.
|
||||||
|
// cur.Status comes from Get (never empty — see scanBookmark), so it must
|
||||||
|
// round-trip through Upsert unchanged rather than being reset.
|
||||||
|
func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
base := Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
|
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
if _, err := store.Upsert(base); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cur, found, err := store.Get(base.Key)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("Get: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
num := 7.0
|
||||||
|
cur.LatestChapter = "7"
|
||||||
|
cur.LatestChapterNum = &num
|
||||||
|
cur.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
stored, err := store.Upsert(cur)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upsert: %v", err)
|
||||||
|
}
|
||||||
|
if stored.Status != statusArchived {
|
||||||
|
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
base := Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
|
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
if _, err := store.Upsert(base); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Status = statusReading
|
||||||
|
stored, err := store.Upsert(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upsert: %v", err)
|
||||||
|
}
|
||||||
|
if stored.Status != statusReading {
|
||||||
|
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A status change is not reading progress, so the list must not reorder.
|
||||||
|
func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
base := Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
|
LastChapter: "45", LastChapterNum: 45,
|
||||||
|
UpdatedAt: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
first, err := store.Upsert(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Status = statusArchived
|
||||||
|
base.UpdatedAt = first.UpdatedAt + 60_000
|
||||||
|
stored, err := store.Upsert(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upsert: %v", err)
|
||||||
|
}
|
||||||
|
if stored.UpdatedAt != first.UpdatedAt {
|
||||||
|
t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A database written before the column existed must gain it, with every
|
||||||
|
// pre-existing row landing in the reading bucket.
|
||||||
|
func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "legacy.db")
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
CREATE TABLE bookmarks (
|
||||||
|
key TEXT PRIMARY KEY, site TEXT NOT NULL, series_id TEXT NOT NULL,
|
||||||
|
title TEXT, series_url TEXT, cover TEXT,
|
||||||
|
last_chapter TEXT, last_chapter_num REAL, last_chapter_url TEXT,
|
||||||
|
updated_at INTEGER NOT NULL);
|
||||||
|
INSERT INTO bookmarks (key, site, series_id, updated_at)
|
||||||
|
VALUES ('asura:old', 'asura', 'old', 1)`); err != nil {
|
||||||
|
t.Fatalf("seed legacy: %v", err)
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
store, err := OpenStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
b, ok, err := store.Get("asura:old")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if b.Status != statusReading {
|
||||||
|
t.Fatalf("Status = %q, want %q", b.Status, statusReading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archiving is the reason to keep polling — the point is to come back to a
|
||||||
|
// series that has moved on. A finished series has nothing left to publish.
|
||||||
|
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
for _, tc := range []struct{ key, status string }{
|
||||||
|
{"asura:reading", statusReading},
|
||||||
|
{"asura:archived", statusArchived},
|
||||||
|
{"asura:finished", statusFinished},
|
||||||
|
} {
|
||||||
|
if _, err := store.Upsert(Bookmark{
|
||||||
|
Key: tc.key, Site: "asura", SeriesID: tc.key,
|
||||||
|
SeriesURL: "https://asurascans.com/comics/" + tc.key,
|
||||||
|
Status: tc.status, UpdatedAt: time.Now().UnixMilli(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed %s: %v", tc.key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := store.DueForLatestCheck(time.Now().UnixMilli(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, b := range due {
|
||||||
|
got[b.Key] = true
|
||||||
|
}
|
||||||
|
if !got["asura:reading"] || !got["asura:archived"] {
|
||||||
|
t.Fatalf("due = %v, want reading and archived present", got)
|
||||||
|
}
|
||||||
|
if got["asura:finished"] {
|
||||||
|
t.Fatal("due included a finished series")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
|
<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-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
|
||||||
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
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>
|
</nav>
|
||||||
|
|
||||||
{{if .Recent}}
|
{{if .Recent}}
|
||||||
|
|||||||
@@ -38,6 +38,48 @@
|
|||||||
<path d="M13.8 7.2l3 3"/>
|
<path d="M13.8 7.2l3 3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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"
|
<button class="icon danger" title="Remove" aria-label="Remove"
|
||||||
onclick="toggleConfirmRow('{{.Key}}')">
|
onclick="toggleConfirmRow('{{.Key}}')">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
<p class="empty">No favourites yet. Star a series to pin it here.</p>
|
<p class="empty">No favourites yet. Star a series to pin it here.</p>
|
||||||
{{else if eq .Tab "new"}}
|
{{else if eq .Tab "new"}}
|
||||||
<p class="empty">Nothing new. Every series is caught up to its latest chapter.</p>
|
<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}}
|
{{else}}
|
||||||
<p class="empty">
|
<p class="empty">
|
||||||
Nothing here yet. Bookmarks appear once the userscript records a chapter.
|
Nothing here yet. Bookmarks appear once the userscript records a chapter.
|
||||||
|
|||||||
+63
-16
@@ -79,6 +79,7 @@ func (h *webHandler) register(mux *http.ServeMux) {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList))
|
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList))
|
||||||
mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite))
|
mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite))
|
||||||
|
mux.HandleFunc("POST /ui/bookmarks/{key}/status", h.requireSession(h.uiStatus))
|
||||||
mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
|
mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
|
||||||
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
|
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
|
||||||
}
|
}
|
||||||
@@ -145,38 +146,51 @@ func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.render(w, http.StatusOK, "app", view)
|
h.render(w, http.StatusOK, "app", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterBookmarks returns the subset keep reports true for, preserving order.
|
||||||
|
// It always returns a non-nil slice so an empty tab renders its empty state.
|
||||||
|
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
|
||||||
|
out := []Bookmark{}
|
||||||
|
for _, b := range all {
|
||||||
|
if keep(b) {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// 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 strip always reflects overall recency, not the
|
// the recent strip from it.
|
||||||
// active tab, so it is built before filtering.
|
//
|
||||||
|
// Archived and finished series appear in their own tab and nowhere else — not
|
||||||
|
// 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 *webHandler) buildListView(tab string) (listView, error) {
|
func (h *webHandler) buildListView(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
|
||||||
}
|
}
|
||||||
|
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
|
||||||
|
|
||||||
recent := all
|
// The strip reflects overall reading recency, not the active tab.
|
||||||
|
recent := reading
|
||||||
if len(recent) > recentCount {
|
if len(recent) > recentCount {
|
||||||
recent = recent[:recentCount]
|
recent = recent[:recentCount]
|
||||||
}
|
}
|
||||||
|
|
||||||
items := all
|
var items []Bookmark
|
||||||
switch tab {
|
switch tab {
|
||||||
case "fav":
|
case "fav":
|
||||||
items = []Bookmark{}
|
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
|
||||||
for _, b := range all {
|
|
||||||
if b.Favorite {
|
|
||||||
items = append(items, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "new":
|
case "new":
|
||||||
items = []Bookmark{}
|
items = filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
|
||||||
for _, b := range all {
|
case "archived":
|
||||||
if b.HasNewChapter() {
|
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
|
||||||
items = append(items, b)
|
case "finished":
|
||||||
}
|
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
tab = "all"
|
tab = "all"
|
||||||
|
items = reading
|
||||||
}
|
}
|
||||||
return listView{Tab: tab, Recent: recent, Items: items}, nil
|
return listView{Tab: tab, Recent: recent, Items: items}, nil
|
||||||
}
|
}
|
||||||
@@ -248,6 +262,13 @@ func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bo
|
|||||||
// saveAndRenderCard upserts and renders the row as stored. Upsert decides
|
// saveAndRenderCard upserts and renders the row as stored. Upsert decides
|
||||||
// whether updated_at moves, so the argument's timestamp is only a candidate and
|
// whether updated_at moves, so the argument's timestamp is only a candidate and
|
||||||
// the response must come from the return value.
|
// the response must come from the return value.
|
||||||
|
//
|
||||||
|
// ponytail: the swapped card stays put even when its new status no longer
|
||||||
|
// matches the active tab, add an hx-swap-oob list refresh if that reads as a
|
||||||
|
// bug rather than as feedback. Archiving from the All tab leaves the card on
|
||||||
|
// screen until the next list load. The alternative costs a full list round
|
||||||
|
// trip on every toggle, and the card visibly showing its new state is the
|
||||||
|
// feedback the user needs.
|
||||||
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
|
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
|
||||||
stored, err := h.store.Upsert(b)
|
stored, err := h.store.Upsert(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -270,6 +291,32 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.saveAndRenderCard(w, b)
|
h.saveAndRenderCard(w, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
|
||||||
|
// a series can be marked finished — the JSON API refuses that value, so the
|
||||||
|
// userscript cannot set it even by accident.
|
||||||
|
//
|
||||||
|
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
||||||
|
// list does not reorder.
|
||||||
|
func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
b, ok := h.loadForMutation(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch s := r.PostFormValue("status"); s {
|
||||||
|
case statusReading, statusArchived, statusFinished:
|
||||||
|
b.Status = s
|
||||||
|
default:
|
||||||
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
h.saveAndRenderCard(w, b)
|
||||||
|
}
|
||||||
|
|
||||||
// uiChapter forces the read chapter to a value the user typed.
|
// uiChapter forces the read chapter to a value the user typed.
|
||||||
//
|
//
|
||||||
// Writing the number also clears last_chapter_url: that URL points at the
|
// Writing the number also clears last_chapter_url: that URL points at the
|
||||||
|
|||||||
+219
-2
@@ -308,8 +308,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
|||||||
body := rr.Body.String()
|
body := rr.Body.String()
|
||||||
|
|
||||||
want := `hx-target="[id='card-asura:solo']"`
|
want := `hx-target="[id='card-asura:solo']"`
|
||||||
if strings.Count(body, want) != 3 {
|
if strings.Count(body, want) != 5 {
|
||||||
t.Fatalf("body has %d occurrences of %s, want 3 (favorite button, delete button, chapter form)",
|
t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)",
|
||||||
strings.Count(body, want), want)
|
strings.Count(body, want), want)
|
||||||
}
|
}
|
||||||
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
|
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
|
||||||
@@ -513,3 +513,220 @@ func TestUIListNewTab(t *testing.T) {
|
|||||||
t.Fatal("new tab included a series already caught up")
|
t.Fatal("new tab included a series already caught up")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedStatusRows puts one series in each bucket, the archived one also
|
||||||
|
// favourited and with a new chapter out, so a leak into any reading-bucket tab
|
||||||
|
// shows up as a failure rather than passing by accident.
|
||||||
|
func seedStatusRows(t *testing.T, store *Store) {
|
||||||
|
t.Helper()
|
||||||
|
// floatPtr already exists in store_test.go — same package, reuse it.
|
||||||
|
rows := []Bookmark{
|
||||||
|
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
|
||||||
|
Status: statusReading, LastChapterNum: 10, Favorite: true,
|
||||||
|
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
|
||||||
|
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
||||||
|
Status: statusArchived, LastChapterNum: 5, Favorite: true,
|
||||||
|
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
||||||
|
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
||||||
|
Status: statusFinished, LastChapterNum: 200, Favorite: true},
|
||||||
|
}
|
||||||
|
for _, b := range rows {
|
||||||
|
b.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
if _, err := store.Upsert(b); err != nil {
|
||||||
|
t.Fatalf("seed %s: %v", b.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, store := newWebTestServer(t, cfg)
|
||||||
|
seedStatusRows(t, store)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
tab string
|
||||||
|
want, dontWant []string
|
||||||
|
}{
|
||||||
|
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||||||
|
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||||||
|
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||||||
|
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}},
|
||||||
|
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, w := range tc.want {
|
||||||
|
if !strings.Contains(body, w) {
|
||||||
|
t.Fatalf("tab %s missing %s", tc.tab, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range tc.dontWant {
|
||||||
|
if strings.Contains(body, d) {
|
||||||
|
t.Fatalf("tab %s leaked %s", tc.tab, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentStripExcludesArchivedAndFinished(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, store := newWebTestServer(t, cfg)
|
||||||
|
seedStatusRows(t, store)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/?tab=archived", nil)
|
||||||
|
req.AddCookie(sessionCookie(t, cfg))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
strip := rr.Body.String()
|
||||||
|
if i := strings.Index(strip, `id="list"`); i >= 0 {
|
||||||
|
strip = strip[:i] // everything above the list is the recent section
|
||||||
|
}
|
||||||
|
if strings.Contains(strip, "ArchivedOne") || strings.Contains(strip, "FinishedOne") {
|
||||||
|
t.Fatal("recent strip included an archived or finished series")
|
||||||
|
}
|
||||||
|
if !strings.Contains(strip, "ReadingOne") {
|
||||||
|
t.Fatal("recent strip dropped the reading series")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{"status": {status}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/"+key+"/status",
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(sessionCookie(t, cfg))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIStatusSetsBucket(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, store := newWebTestServer(t, cfg)
|
||||||
|
seedStatusRows(t, store)
|
||||||
|
|
||||||
|
for _, want := range []string{statusArchived, statusFinished, statusReading} {
|
||||||
|
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
b, ok, err := store.Get("asura:reading")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if b.Status != want {
|
||||||
|
t.Fatalf("stored status = %q, want %q", b.Status, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, store := newWebTestServer(t, cfg)
|
||||||
|
seedStatusRows(t, store)
|
||||||
|
|
||||||
|
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
b, _, _ := store.Get("asura:reading")
|
||||||
|
if b.Status != statusReading {
|
||||||
|
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIStatusRequiresSession(t *testing.T) {
|
||||||
|
srv, store := newWebTestServer(t, webConfig())
|
||||||
|
seedStatusRows(t, store)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
||||||
|
strings.NewReader("status=archived"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, store := newWebTestServer(t, cfg)
|
||||||
|
seedStatusRows(t, store)
|
||||||
|
|
||||||
|
before, _, _ := store.Get("asura:reading")
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
after, _, _ := store.Get("asura:reading")
|
||||||
|
if after.UpdatedAt != before.UpdatedAt {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Manga Bookmark Sync
|
// @name Manga Bookmark Sync
|
||||||
// @namespace mangabm
|
// @namespace mangabm
|
||||||
// @version 1.2.0
|
// @version 1.3.0
|
||||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||||
// @author you
|
// @author you
|
||||||
// @match https://asuracomic.net/*
|
// @match https://asuracomic.net/*
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
||||||
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
|
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
|
||||||
|
const WEB_BASE = "https://manga.violetcrown.my.id"; // the browser UI, for the panel's nav chips
|
||||||
|
|
||||||
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
||||||
const CACHE_KEY = "mangabm:cache";
|
const CACHE_KEY = "mangabm:cache";
|
||||||
@@ -270,11 +271,17 @@
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPut(key, obj) {
|
// Only an explicit archive/restore has an opinion about the bucket. Every
|
||||||
|
// other write omits `status`, so the server keeps the stored one — otherwise
|
||||||
|
// a cached value would resend "finished" (which the API rejects with 400) or
|
||||||
|
// silently un-archive a series archived on another device.
|
||||||
|
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
||||||
|
const body = Object.assign({}, obj);
|
||||||
|
if (!sendStatus) delete body.status;
|
||||||
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify(obj),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -325,6 +332,12 @@
|
|||||||
saveCache(state.list);
|
saveCache(state.list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A list cached by an older version has no status field, and a row the
|
||||||
|
// server defaulted has "reading" — both mean the same thing here.
|
||||||
|
function statusOf(b) {
|
||||||
|
return b.status || "reading";
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Mutations (optimistic: update UI/cache first, then sync)
|
// Mutations (optimistic: update UI/cache first, then sync)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -415,6 +428,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archive parks a series: it leaves All and Favourites but the server keeps
|
||||||
|
// polling it for new chapters. "finished" is deliberately not reachable from
|
||||||
|
// here — the API rejects that value, it is a web-UI decision.
|
||||||
|
async function toggleArchive(key) {
|
||||||
|
const existing = state.byKey[key];
|
||||||
|
if (!existing) return;
|
||||||
|
const next = statusOf(existing) === "archived" ? "reading" : "archived";
|
||||||
|
const bm = Object.assign({}, existing, { status: next });
|
||||||
|
upsertLocal(bm);
|
||||||
|
render();
|
||||||
|
try {
|
||||||
|
const saved = await apiPut(bm.key, bm, { sendStatus: true });
|
||||||
|
upsertLocal(saved); // adopt the stored row: the server owns updated_at
|
||||||
|
render();
|
||||||
|
toast(next === "archived" ? "Archived" : "Back in your list");
|
||||||
|
} catch (e) {
|
||||||
|
toast("Archive failed — retry when online", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Records the newest chapter a site has published. Silent: this fires from
|
// Records the newest chapter a site has published. Silent: this fires from
|
||||||
// page visits and background checks the user did not ask for, and it never
|
// page visits and background checks the user did not ask for, and it never
|
||||||
// reorders the list — updated_at is a candidate the server discards unless
|
// reorders the list — updated_at is a candidate the server discards unless
|
||||||
@@ -462,6 +495,7 @@
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const due = state.list
|
const due = state.list
|
||||||
.filter((b) => b.site === site && b.series_url)
|
.filter((b) => b.site === site && b.series_url)
|
||||||
|
.filter((b) => statusOf(b) !== "finished")
|
||||||
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
||||||
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
||||||
.slice(0, LATEST_CHECK_BATCH);
|
.slice(0, LATEST_CHECK_BATCH);
|
||||||
@@ -551,7 +585,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
let root, panelOpen = false;
|
let root, panelOpen = false;
|
||||||
let activeTab = "all"; // "all" | "favorites"; resets each page, by design
|
let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design
|
||||||
|
|
||||||
function buildUI() {
|
function buildUI() {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
@@ -580,7 +614,7 @@
|
|||||||
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
||||||
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
||||||
|
|
||||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"]]) {
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||||
root.getElementById(id).addEventListener("click", () => {
|
root.getElementById(id).addEventListener("click", () => {
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
render();
|
render();
|
||||||
@@ -752,22 +786,28 @@
|
|||||||
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." }));
|
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bookmark list. Favorites only narrows what is drawn — state.list keeps
|
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
||||||
// every bookmark, so favouriting one never removes it from "All".
|
// Archived rows are hidden from All and Favourites, and finished ones —
|
||||||
const favorites = activeTab === "favorites";
|
// which only the web UI can set — are hidden from every tab.
|
||||||
root.getElementById("tabAll").classList.toggle("active", !favorites);
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||||
root.getElementById("tabFav").classList.toggle("active", favorites);
|
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
||||||
|
}
|
||||||
|
|
||||||
const listEl = root.getElementById("list");
|
const listEl = root.getElementById("list");
|
||||||
listEl.innerHTML = "";
|
listEl.innerHTML = "";
|
||||||
const items = favorites ? state.list.filter((b) => b.favorite) : state.list;
|
const items = state.list.filter((b) => {
|
||||||
|
const s = statusOf(b);
|
||||||
|
if (activeTab === "archived") return s === "archived";
|
||||||
|
if (s !== "reading") return false;
|
||||||
|
return activeTab !== "favorites" || b.favorite;
|
||||||
|
});
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
listEl.appendChild(
|
const empty = {
|
||||||
el("div", {
|
favorites: "No favourites yet.",
|
||||||
class: "empty",
|
archived: "Nothing archived.",
|
||||||
text: favorites ? "No favourites yet." : "No bookmarks yet.",
|
all: "No bookmarks yet.",
|
||||||
})
|
}[activeTab];
|
||||||
);
|
listEl.appendChild(el("div", { class: "empty", text: empty }));
|
||||||
}
|
}
|
||||||
for (const b of items) {
|
for (const b of items) {
|
||||||
listEl.appendChild(renderItem(b));
|
listEl.appendChild(renderItem(b));
|
||||||
@@ -798,6 +838,11 @@
|
|||||||
title: b.favorite ? "Remove from favourites" : "Add to favourites",
|
title: b.favorite ? "Remove from favourites" : "Add to favourites",
|
||||||
onclick: () => toggleFavorite(b.key),
|
onclick: () => toggleFavorite(b.key),
|
||||||
}),
|
}),
|
||||||
|
el("button", {
|
||||||
|
class: "btn small",
|
||||||
|
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
|
||||||
|
onclick: () => toggleArchive(b.key),
|
||||||
|
}),
|
||||||
el("a", { class: "btn small", href: cont, text: "Continue" }),
|
el("a", { class: "btn small", href: cont, text: "Continue" }),
|
||||||
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
|
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
|
||||||
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
|
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
|
||||||
@@ -909,10 +954,16 @@
|
|||||||
<span>Manga Bookmarks</span>
|
<span>Manga Bookmarks</span>
|
||||||
<button id="closeBtn" aria-label="Close">✕</button>
|
<button id="closeBtn" aria-label="Close">✕</button>
|
||||||
</header>
|
</header>
|
||||||
|
<div id="nav">
|
||||||
|
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a>
|
||||||
|
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
||||||
|
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
||||||
|
</div>
|
||||||
<section id="context"></section>
|
<section id="context"></section>
|
||||||
<div id="tabs" role="tablist">
|
<div id="tabs" role="tablist">
|
||||||
<button id="tabAll" class="tab active" role="tab">All</button>
|
<button id="tabAll" class="tab active" role="tab">All</button>
|
||||||
<button id="tabFav" class="tab" role="tab">★ Favourites</button>
|
<button id="tabFav" class="tab" role="tab">★ Favourites</button>
|
||||||
|
<button id="tabArc" class="tab" role="tab">Archived</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="list"></div>
|
<div id="list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -960,6 +1011,13 @@
|
|||||||
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
|
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
|
||||||
}
|
}
|
||||||
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
|
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
|
||||||
|
#nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; }
|
||||||
|
.chip {
|
||||||
|
background: #2a2a33; color: #c4b5fd; text-decoration: none;
|
||||||
|
padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip:active { opacity: .8; }
|
||||||
#context {
|
#context {
|
||||||
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user