Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02ac3e5f7f | ||
|
|
40e9999e6a | ||
|
|
d989668650 | ||
|
|
b2c5c4a445 | ||
|
|
0fe3bc3057 | ||
|
|
e117ad52d3 | ||
|
|
928e52c604 |
@@ -53,5 +53,9 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner
|
||||
p.GET("/books/:id/pages/:n", h.Page)
|
||||
p.PUT("/books/:id/progress", h.PutProgress)
|
||||
p.GET("/progress", h.ListProgress)
|
||||
p.GET("/books/:id/bookmarks", h.ListBookmarks)
|
||||
p.POST("/books/:id/bookmarks", h.CreateBookmark)
|
||||
p.PATCH("/bookmarks/:id", h.PatchBookmark)
|
||||
p.DELETE("/bookmarks/:id", h.DeleteBookmark)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
const maxNoteRunes = 500
|
||||
|
||||
func bookmarkJSON(b store.Bookmark) gin.H {
|
||||
return gin.H{
|
||||
"id": b.ID, "library_id": b.LibraryID, "path": b.BookPath,
|
||||
"locator": json.RawMessage(b.Locator), "percent": b.Percent,
|
||||
"note": b.Note, "created_at": b.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *H) ListBookmarks(c *gin.Context) {
|
||||
b, ok := h.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, e := h.st.ListBookmarks(c, uid(c), b.LibraryID, b.Path)
|
||||
if e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, bookmarkJSON(r))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *H) CreateBookmark(c *gin.Context) {
|
||||
b, ok := h.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Locator json.RawMessage `json:"locator"`
|
||||
Percent float64 `json:"percent"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if e := c.ShouldBindJSON(&req); e != nil {
|
||||
err(c, http.StatusBadRequest, "bad_request", "json body required")
|
||||
return
|
||||
}
|
||||
if len(req.Locator) == 0 || string(req.Locator) == "null" || !json.Valid(req.Locator) {
|
||||
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
|
||||
return
|
||||
}
|
||||
if req.Percent < 0 || req.Percent > 1 {
|
||||
err(c, http.StatusBadRequest, "bad_request", "percent must be in [0,1]")
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(req.Note) > maxNoteRunes {
|
||||
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
|
||||
return
|
||||
}
|
||||
id, e := h.st.InsertBookmark(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent, req.Note)
|
||||
if e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, bookmarkJSON(store.Bookmark{
|
||||
ID: id, LibraryID: b.LibraryID, BookPath: b.Path,
|
||||
Locator: req.Locator, Percent: req.Percent, Note: req.Note, CreatedAt: time.Now(),
|
||||
}))
|
||||
}
|
||||
|
||||
func bookmarkID(c *gin.Context) (int64, bool) {
|
||||
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if e != nil {
|
||||
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (h *H) PatchBookmark(c *gin.Context) {
|
||||
id, ok := bookmarkID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if e := c.ShouldBindJSON(&req); e != nil {
|
||||
err(c, http.StatusBadRequest, "bad_request", "json body required")
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(req.Note) > maxNoteRunes {
|
||||
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
|
||||
return
|
||||
}
|
||||
updated, e := h.st.UpdateBookmarkNote(c, uid(c), id, req.Note)
|
||||
if e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
if !updated {
|
||||
err(c, http.StatusNotFound, "not_found", "no such bookmark")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "note": req.Note})
|
||||
}
|
||||
|
||||
func (h *H) DeleteBookmark(c *gin.Context) {
|
||||
id, ok := bookmarkID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deleted, e := h.st.DeleteBookmark(c, uid(c), id)
|
||||
if e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
err(c, http.StatusNotFound, "not_found", "no such bookmark")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBookmarkCRUDAndOwnership(t *testing.T) {
|
||||
st, sc, h, booksDir := setupAPI(t)
|
||||
tok := adminToken(t, h)
|
||||
lib, root := newLibrary(t, st, h, tok, booksDir, "bm")
|
||||
writeCBZ(t, root+"/x.cbz", 5)
|
||||
scanNow(t, sc, lib)
|
||||
bs := []map[string]any{}
|
||||
json.Unmarshal(do(h, "GET", "/api/books?q=x", tok, nil).Body.Bytes(), &bs)
|
||||
bid := itoa(bs[0]["id"])
|
||||
|
||||
w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"locator": map[string]int{"page": 3}, "percent": 0.6, "note": "伏笔"})
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("create %d %s", w.Code, w.Body)
|
||||
}
|
||||
var bm map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &bm)
|
||||
bmID := itoa(bm["id"])
|
||||
if bm["note"] != "伏笔" {
|
||||
t.Fatalf("echo: %s", w.Body)
|
||||
}
|
||||
|
||||
// 第二本书 + 第二条书签:列表按 percent 升序且不跨书泄漏
|
||||
writeCBZ(t, fmt.Sprintf("%s/y.cbz", root), 5)
|
||||
scanNow(t, sc, lib)
|
||||
bs = nil
|
||||
json.Unmarshal(do(h, "GET", "/api/books?q=y", tok, nil).Body.Bytes(), &bs)
|
||||
yid := itoa(bs[0]["id"])
|
||||
do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.2})
|
||||
arr := []map[string]any{}
|
||||
json.Unmarshal(do(h, "GET", "/api/books/"+bid+"/bookmarks", tok, nil).Body.Bytes(), &arr)
|
||||
if len(arr) != 2 || arr[0]["percent"].(float64) > arr[1]["percent"].(float64) {
|
||||
t.Fatalf("list order/scope: %v", arr)
|
||||
}
|
||||
if w := do(h, "GET", "/api/books/"+yid+"/bookmarks", tok, nil); strings.TrimSpace(w.Body.String()) != "[]" {
|
||||
t.Fatalf("other book leak: %s", w.Body)
|
||||
}
|
||||
|
||||
if w := do(h, "PATCH", "/api/bookmarks/"+bmID, tok, map[string]string{"note": "改了"}); w.Code != 200 {
|
||||
t.Fatalf("patch %d %s", w.Code, w.Body)
|
||||
}
|
||||
|
||||
// 校验:note 超长 / percent 越界 / locator 缺失
|
||||
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.5, "note": strings.Repeat("字", 501)}); w.Code != 400 {
|
||||
t.Fatalf("long note want 400 got %d", w.Code)
|
||||
}
|
||||
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"locator": map[string]int{"page": 1}, "percent": 1.5}); w.Code != 400 {
|
||||
t.Fatalf("percent want 400 got %d", w.Code)
|
||||
}
|
||||
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"percent": 0.5}); w.Code != 400 {
|
||||
t.Fatalf("locator required got %d", w.Code)
|
||||
}
|
||||
|
||||
// 所有权:member carl 看不见/改不了/删不了 alice 的书签
|
||||
do(h, "POST", "/api/users", tok, map[string]string{"username": "carl", "password": testPW, "role": "member"})
|
||||
lr := map[string]string{}
|
||||
json.Unmarshal(do(h, "POST", "/api/auth/login", "", map[string]string{"username": "carl", "password": testPW}).Body.Bytes(), &lr)
|
||||
bt := lr["token"]
|
||||
if w := do(h, "GET", "/api/books/"+bid+"/bookmarks", bt, nil); strings.TrimSpace(w.Body.String()) != "[]" {
|
||||
t.Fatalf("cross-user leak: %s", w.Body)
|
||||
}
|
||||
if w := do(h, "PATCH", "/api/bookmarks/"+bmID, bt, map[string]string{"note": "抢"}); w.Code != 404 {
|
||||
t.Fatalf("cross-user patch want 404 got %d", w.Code)
|
||||
}
|
||||
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, bt, nil); w.Code != 404 {
|
||||
t.Fatalf("cross-user delete want 404 got %d", w.Code)
|
||||
}
|
||||
|
||||
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 204 {
|
||||
t.Fatalf("delete %d", w.Code)
|
||||
}
|
||||
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 404 {
|
||||
t.Fatalf("re-delete want 404 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,11 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
||||
locator JSONB NOT NULL DEFAULT '{}', percent DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, library_id, book_path));
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id BIGINT NOT NULL, book_path TEXT NOT NULL,
|
||||
locator JSONB NOT NULL, percent DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now());
|
||||
CREATE INDEX IF NOT EXISTS bookmarks_user_book_idx ON bookmarks (user_id, library_id, book_path);
|
||||
|
||||
@@ -314,6 +314,57 @@ func (s *Store) ListProgress(ctx context.Context, userID int64) ([]Progress, err
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- bookmarks ----------
|
||||
|
||||
type Bookmark struct {
|
||||
ID int64
|
||||
LibraryID int64
|
||||
BookPath string
|
||||
Locator []byte
|
||||
Percent float64
|
||||
Note string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) {
|
||||
var id int64
|
||||
err := s.P.QueryRow(ctx,
|
||||
`INSERT INTO bookmarks (user_id, library_id, book_path, locator, percent, note)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
userID, libID, bookPath, locator, percent, note).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]Bookmark, error) {
|
||||
rows, err := s.P.Query(ctx,
|
||||
`SELECT id, library_id, book_path, locator, percent, note, created_at
|
||||
FROM bookmarks WHERE user_id=$1 AND library_id=$2 AND book_path=$3
|
||||
ORDER BY percent ASC, id ASC`, userID, libID, bookPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Bookmark
|
||||
for rows.Next() {
|
||||
var b Bookmark
|
||||
if err := rows.Scan(&b.ID, &b.LibraryID, &b.BookPath, &b.Locator, &b.Percent, &b.Note, &b.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) {
|
||||
res, err := s.P.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note)
|
||||
return res.RowsAffected() > 0, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) {
|
||||
res, err := s.P.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID)
|
||||
return res.RowsAffected() > 0, err
|
||||
}
|
||||
|
||||
func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) {
|
||||
var pr Progress
|
||||
err := s.P.QueryRow(ctx,
|
||||
|
||||
@@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
||||
|
||||
### Added / 新增
|
||||
|
||||
- API: per-user bookmarks — `GET/POST /api/books/:id/bookmarks` (locator+percent snapshot with optional ≤500-char note; list ordered by percent) and `PATCH/DELETE /api/bookmarks/:id`; not-yours uniformly 404. New `bookmarks` table keyed like progress, cleaned up with the user (no cascade on book delete, same precedent).
|
||||
- API:新增按用户隔离的书签——`GET/POST /api/books/:id/bookmarks`(存当前 locator+percent,备注可选、≤500 字,列表按进度升序)与 `PATCH/DELETE /api/bookmarks/:id`;不属于自己的一律 404。新 `bookmarks` 表与进度同款定位键,随用户删除而清(删书不级联,沿用既有先例)。
|
||||
|
||||
- API: CBZ page indexing now skips macOS packaging junk (`__MACOSX/…` and `._*` AppleDouble files), which used to land in the page list as ~163-byte black "pages"; `GET /api/books/:id/pages` additionally returns `chapters:[{title,start}]` derived from the archive's folder structure (e.g. 第1話…), so per-folder comics expose their real organization.
|
||||
- API:CBZ 页索引现会跳过 macOS 打包垃圾(`__MACOSX/…` 与 `._*` 资源叉文件),此前它们以 ~163 字节黑页混入页列表;`GET /api/books/:id/pages` 新增 `chapters:[{title,start}]`,按压缩包内目录结构(如 第1話…)给出真实章节。
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
||||
|
||||
### Added / 新增
|
||||
|
||||
- Bookmarks in all four readers: a 书签 button in the bar captures the current position (page / chapter+offset / EPUB CFI) with an optional note; the right-side panel lists them — click jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices.
|
||||
- 四类阅读器新增书签:工具条「书签」按钮把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;右侧书签面板点一下即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。
|
||||
|
||||
- Folder-structured comics gain a 目录 sidebar in the CBZ reader: each archive folder (第N話…) becomes a chapter that jumps to its first page, with 上一章/下一章 buttons, a current-chapter counter, and the active row highlighted and scrolled into view — flat archives show no extra UI.
|
||||
- 按目录组织的漫画在 CBZ 阅读器新增「目录」侧栏:压缩包内每个文件夹(第N話…)即一章,点击直达该话首页;工具条配上一章/下一章与当前章计数,目录内高亮当前章并自动居中;平铺无目录的包不显示多余控件。
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# 书签系统(备注+跳转)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 全格式阅读书签:捕获当前 locator+percent、可备注、列表一键跳转、行内改删。
|
||||
|
||||
**Architecture:** 新 `bookmarks` 表按 `(user_id, library_id, book_path)` 定位(与 progress 同款);REST 四端点 owner-scoped(他人 404);前端 `createProgressSaver` 顺带记录当前值供 `capture()`,共享组件 `Bookmarks.tsx` 在四个 reader 里以 `{btn, panel}` 两段挂载,seek 复用各 reader 现成的定位函数。
|
||||
|
||||
**Tech Stack:** Go 1.26 + gin + pgx(backend 容器内跑测试);React + TS + react-query + Tailwind(rd-* 类)。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-08-bookmarks-design.md`
|
||||
|
||||
**Global constraints:**
|
||||
- 测试执行:`docker exec -w /app booklib-api-1 go test -p 1 -count=1 ./...`(需 DATABASE_URL,容器已配);前端 `docker exec -w /app booklib-web-1 npm run -s check`
|
||||
- 用户可见变更必须同变更里写 changelog:后端→`docs/CHANGELOG.md`,前端→`docs/CHANGELOG_web.md`,英中相邻两行,不同条目空行隔,新版在上
|
||||
- locator 形状不变:cbz/pdf `{page:number}`(0 基),txt/md `{ch:number,scrollFraction:number}`,epub `{cfi:string}`
|
||||
- note 长度按 rune 计 ≤500
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端垂直切片(schema+store+handler+router+测试)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/db/schema.sql`(追加表)
|
||||
- Modify: `backend/internal/store/store.go`(progress 区之后加 Bookmark 方法)
|
||||
- Create: `backend/cmd/webui/handlers/bookmarks.go`
|
||||
- Modify: `backend/cmd/webui/api/router.go`(progress 路由旁)
|
||||
- Test: `backend/cmd/webui/handlers/bookmarks_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `store.Bookmark{ID,LibraryID,BookPath int64/string...,Locator []byte,Percent float64,Note string,CreatedAt time.Time}`;`InsertBookmark(ctx,userID,libID int64,bookPath string,locator []byte,percent float64,note string)(int64,error)`;`ListBookmarks(ctx,userID,libID int64,bookPath string)([]Bookmark,error)`;`UpdateBookmarkNote(ctx,userID,id int64,note string)(bool,error)`;`DeleteBookmark(ctx,userID,id int64)(bool,error)`;HTTP 四端点见下。
|
||||
|
||||
- [ ] **Step 1: 写失败测试** `bookmarks_test.go`(复用 setupAPI/adminToken/do/writeCBZ/scanNow/newLibrary/itoa 既有 helper;member 登录用 testPW)
|
||||
|
||||
```go
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBookmarkCRUDAndOwnership(t *testing.T) {
|
||||
st, sc, h, booksDir := setupAPI(t)
|
||||
tok := adminToken(t, h)
|
||||
lib, root := newLibrary(t, st, h, tok, booksDir, "bm")
|
||||
writeCBZ(t, root+"/x.cbz", 5)
|
||||
scanNow(t, sc, lib)
|
||||
bs := []map[string]any{}
|
||||
json.Unmarshal(do(h, "GET", "/api/books?q=x", tok, nil).Body.Bytes(), &bs)
|
||||
bid := itoa(bs[0]["id"])
|
||||
|
||||
w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"locator": map[string]int{"page": 3}, "percent": 0.6, "note": "伏笔"})
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("create %d %s", w.Code, w.Body)
|
||||
}
|
||||
var bm map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &bm)
|
||||
bmID := itoa(bm["id"])
|
||||
if bm["note"] != "伏笔" {
|
||||
t.Fatalf("echo: %s", w.Body)
|
||||
}
|
||||
// 第二本书 + 第二条书签,列表按 percent 升序且不跨书
|
||||
writeCBZ(t, fmt.Sprintf("%s/y.cbz", root), 5)
|
||||
scanNow(t, sc, lib)
|
||||
bs = nil
|
||||
json.Unmarshal(do(h, "GET", "/api/books?q=y", tok, nil).Body.Bytes(), &bs)
|
||||
yid := itoa(bs[0]["id"])
|
||||
do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.2})
|
||||
lst := struct{ N int; First float64 }{}
|
||||
w = do(h, "GET", "/api/books/"+bid+"/bookmarks", tok, nil)
|
||||
var arr []map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &arr)
|
||||
if len(arr) != 2 || arr[0]["percent"].(float64) > arr[1]["percent"].(float64) {
|
||||
t.Fatalf("list order/scope: %s", w.Body)
|
||||
}
|
||||
_ = lst
|
||||
if w := do(h, "GET", "/api/books/"+yid+"/bookmarks", tok, nil); strings.TrimSpace(w.Body.String()) != "[]" {
|
||||
t.Fatalf("other book leak: %s", w.Body)
|
||||
}
|
||||
// 改备注
|
||||
w = do(h, "PATCH", "/api/bookmarks/"+bmID, tok, map[string]string{"note": "改了"})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("patch %d %s", w.Code, w.Body)
|
||||
}
|
||||
// 校验:note 超长 / percent 越界 / locator 缺失
|
||||
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.5, "note": strings.Repeat("字", 501)}); w.Code != 400 {
|
||||
t.Fatalf("long note want 400 got %d", w.Code)
|
||||
}
|
||||
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"locator": map[string]int{"page": 1}, "percent": 1.5}); w.Code != 400 {
|
||||
t.Fatalf("percent want 400 got %d", w.Code)
|
||||
}
|
||||
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
|
||||
map[string]any{"percent": 0.5}); w.Code != 400 {
|
||||
t.Fatalf("locator required got %d", w.Code)
|
||||
}
|
||||
// 所有权:bob 看不见也改不了 alice 的书签
|
||||
do(h, "POST", "/api/users", tok, map[string]string{"username": "carl", "password": testPW, "role": "member"})
|
||||
w = do(h, "POST", "/api/auth/login", "", map[string]string{"username": "carl", "password": testPW})
|
||||
var lr map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &lr)
|
||||
bt := lr["token"]
|
||||
if w := do(h, "GET", "/api/books/"+bid+"/bookmarks", bt, nil); strings.TrimSpace(w.Body.String()) != "[]" {
|
||||
t.Fatalf("cross-user leak: %s", w.Body)
|
||||
}
|
||||
if w := do(h, "PATCH", "/api/bookmarks/"+bmID, bt, map[string]string{"note": "抢"}); w.Code != 404 {
|
||||
t.Fatalf("cross-user patch want 404 got %d", w.Code)
|
||||
}
|
||||
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, bt, nil); w.Code != 404 {
|
||||
t.Fatalf("cross-user delete want 404 got %d", w.Code)
|
||||
}
|
||||
// 本人删除 → 204,再删 404
|
||||
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 204 {
|
||||
t.Fatalf("delete %d", w.Code)
|
||||
}
|
||||
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 404 {
|
||||
t.Fatalf("re-delete want 404 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败(404/编译错)**
|
||||
|
||||
Run: `docker exec -w /app booklib-api-1 go test ./cmd/webui/handlers/ -run TestBookmark -count=1`
|
||||
Expected: FAIL(路由不存在)
|
||||
|
||||
- [ ] **Step 3: schema.sql 追加**(文件末尾)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id BIGINT NOT NULL,
|
||||
book_path TEXT NOT NULL,
|
||||
locator JSONB NOT NULL,
|
||||
percent DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now());
|
||||
CREATE INDEX IF NOT EXISTS bookmarks_user_book_idx ON bookmarks (user_id, library_id, book_path);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: store.go 四个方法 + handlers/bookmarks.go + router 四条**(签名见 Interfaces;JSON 键:`id,library_id,path,locator,percent,note,created_at`(RFC3339);PATCH 返回 200 `{id,note}`;book 解析用 `h.bookFromParam`;`uid(c)` 取用户;校验:percent∈[0,1]、`json.Valid(locator)` 且非空、`utf8.RuneCountInString(note)<=500`)
|
||||
|
||||
- [ ] **Step 5: 跑测试转绿 + 全包回归 vet**
|
||||
|
||||
Run: `docker exec -w /app booklib-api-1 go test ./cmd/webui/handlers/ -run TestBookmark -count=1 && docker exec -w /app booklib-api-1 go vet ./...`
|
||||
|
||||
- [ ] **Step 6: Commit** `feat(bookmarks): api+store — per-user CRUD, owner-scoped 404, percent-ordered list`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 前端管道(saver.capture + client API + types)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/progress.ts`、`frontend/src/lib/useProgress.ts`、`frontend/src/api/client.ts`、`frontend/src/api/types.ts`、`frontend/src/pages/Reader.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 HTTP 契约
|
||||
- Produces: `ProgressSaver.at(): {loc: Record<string,unknown>, pct: number} | null`;`useProgressSaver(bookId, seed?)` 返回 `ProgressSaver & { capture(): { locator: Record<string, unknown>; percent: number } }`;`ReaderProps.initialPercent?: number`;`api.listBookmarks/createBookmark/patchBookmark/deleteBookmark`;`types.Bookmark`
|
||||
|
||||
- [ ] **Step 1:** progress.ts 的 `createProgressSaver` 里加 `let cur`,report 时赋值,返回对象加 `at: () => cur`
|
||||
- [ ] **Step 2:** useProgress.ts:`ReaderProps` 加 `initialPercent?: number`;`useProgressSaver(bookId, seed?)` 包一层 `capture = () => saver.at() ?? { locator: seed?.locator ?? {}, percent: seed?.percent ?? 0 }`(at 优先,未动过页时回退到打开时的进度)
|
||||
- [ ] **Step 3:** client.ts 四方法(路径/体按 Task 1 契约);types.ts:
|
||||
|
||||
```ts
|
||||
export interface Bookmark {
|
||||
id: number;
|
||||
locator: Record<string, unknown>;
|
||||
percent: number;
|
||||
note: string;
|
||||
created_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** Reader.tsx 渲染 `<R>` 处传 `initialPercent={row?.percent}`
|
||||
- [ ] **Step 5:** `docker exec -w /app booklib-web-1 npm run -s check` 绿
|
||||
- [ ] **Step 6:** Commit `feat(bookmarks): client plumbing — saver capture + bookmark api`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 共享组件 + 四 reader 接线
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/Bookmarks.tsx`
|
||||
- Modify: `frontend/src/readers/CbzReader.tsx`(工具条+根,复用 toc 侧栏样式)、`TextReader.tsx`(TxtView+MdView 的 ReaderSheet children)、`PdfReader.tsx`(底部按钮排)、`EpubReader.tsx`(底部按钮排,根 div 加 relative)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2 的 `capture/api/types`
|
||||
- Produces: `useBookmarks({ book, capture, seek }): { btn: ReactNode; panel: ReactNode }`(hook 命名过 react-rules)— btn 塞工具条,panel 塞 relative 根容器末尾;内部含面板开合/表单状态与查询
|
||||
|
||||
- [ ] **Step 1: 写 Bookmarks.tsx**(要点:`useQuery(["bookmarks",book.id])`;添加=btn 点开小表单(input+确定/取消,空 note 合法)→ POST→invalidate;panel=目录同款(fixed 遮罩 + absolute 左侧 nav,`aria-label="书签"`):行按钮 `{Math.round(percent*100)}% {note||无备注} {日期}`,点击 `seek(locator)`+收起;✎ 行内编辑 note→PATCH;✕→DELETE→invalidate;toast 报错)
|
||||
- [ ] **Step 2: CbzReader**:`const bm = Bookmarks({book, capture: saver.capture, seek:(l)=>jump(Number(l.page)||0)})`(capture 需 useProgressSaver 已带 seed:`useProgressSaver(book.id, {locator: initialLocator, percent: initialPercent})`);btn 进底部按钮行,panel 进根 `</div>` 前
|
||||
- [ ] **Step 3: TextReader 两个 View**:`seek` 用既有 `goChapter(Number(l.ch))`+设 `scrollFraction`(照抄 initialLocator 恢复段逻辑,抽成函数复用);ReaderSheet children 放 btn,根放 panel;MdView 同(仅 scrollFraction seek)
|
||||
- [ ] **Step 4: PdfReader**:`seek:(l)=>{const p=Number(l.page); if(p>=0&&p<num) setPage(p)}`;btn 进底部 `<button>` 排;根容器加 `relative`,panel 进末尾
|
||||
- [ ] **Step 5: EpubReader**:`seek:(l)=>rendRef.current?.display(String(l.cfi))`(与初始恢复同款 then/catch 容错,坏 cfi 提示 toast);btn 进上一页/下一页排;根 grid 容器 relative
|
||||
- [ ] **Step 6:** `npm run -s check` + `npm run -s build` 绿;`vitest`(若存在)不红
|
||||
- [ ] **Step 7:** Commit `feat(bookmarks): shared Bookmarks panel + wired into all four readers`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 收尾(回归 + 真实点验 + changelog)
|
||||
|
||||
- [ ] **Step 1:** 全量:`go vet ./...` + `go test -p 1 -count=1 ./...`(容器内);`npm run -s check`
|
||||
- [ ] **Step 2: 环境自愈:** 测试清库后 `DELETE FROM users;...` → 重启 api seed admin → 重建 tv 库 → 浏览器手动点验:cbz 加书签(带备注)→ 翻页 → 打开书签列表点回跳;txt 跨章书签;pdf 页码书签
|
||||
- [ ] **Step 3: changelog:** `docs/CHANGELOG.md` Added(API 书签四端点一句 + 中文);`docs/CHANGELOG_web.md` Added(阅读器书签面板 英/中)
|
||||
- [ ] **Step 4:** Commit `chore(bookmarks): changelog + e2e verification`
|
||||
@@ -0,0 +1,77 @@
|
||||
# 书签系统(备注 + 跳转)设计 / Bookmarks (notes + jump-to) design
|
||||
|
||||
日期 2026-09-08。分支 `feat/bookmarks`。状态:已获用户批准(会话内确认)。
|
||||
|
||||
## 目标 / Goal
|
||||
|
||||
阅读任意格式的书时可「加书签」,书签携带备注,可从书签列表一键跳转(seek)到原位。
|
||||
Bookmarks with editable notes and one-click jump-to-position for all reader formats.
|
||||
|
||||
## 决策记录 / Decisions
|
||||
|
||||
- 个人可见(与 progress 同款 user 隔离),不做共享。用户选定。
|
||||
- 阅读器内闭环(工具条按钮 + 侧栏),不做全局书签页。用户选定。
|
||||
- 全格式(cbz/txt/md/pdf/epub):locator 存取/恢复机制四类 reader 均已存在,书签复用。用户选定。
|
||||
- 否决方案:书签数组塞 progress JSONB(整包重写、并发互踩);纯 localStorage(不跨设备、与服务端进度先例分裂)。
|
||||
- 删书不级联清书签:与 `reading_progress` 既有行为一致(孤儿行 JOIN 不中即无害)。
|
||||
|
||||
## 数据模型 / Schema
|
||||
|
||||
`backend/internal/db/schema.sql` 追加(新表,无迁移痛):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id BIGINT NOT NULL,
|
||||
book_path TEXT NOT NULL,
|
||||
locator JSONB NOT NULL,
|
||||
percent DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now());
|
||||
CREATE INDEX IF NOT EXISTS bookmarks_user_book_idx
|
||||
ON bookmarks (user_id, library_id, book_path);
|
||||
```
|
||||
|
||||
定位键与 progress 相同 `(user_id, library_id, book_path)`;`locator` 各格式形状不变
|
||||
(cbz/pdf `{page}`,txt/md `{ch,scrollFraction}`,epub `{cfi}`)。
|
||||
|
||||
## API(source of truth: `cmd/webui/api/router.go`)
|
||||
|
||||
| 方法/路径 | 语义 | 错误 |
|
||||
|---|---|---|
|
||||
| `GET /api/books/:id/bookmarks` | 本书书签,percent 升序 | book 不存在 404 |
|
||||
| `POST /api/books/:id/bookmarks` `{locator,percent,note?}` | 建,201 返回全字段 | locator 空/percent∉[0,1]/note>500 字符 → 400 |
|
||||
| `PATCH /api/bookmarks/:id` `{note}` | 仅改备注 | 非本人/不存在 → 404 |
|
||||
| `DELETE /api/bookmarks/:id` | 删,204 | 非本人/不存在 → 404 |
|
||||
|
||||
- (library_id, book_path) 由服务端从 :id 解析,客户端不传路径。
|
||||
- 全部登录可见(admin 不特殊);「非本人」统一 404,不泄露存在性。
|
||||
- store 层新增 `ListBookmarks/InsertBookmark/UpdateBookmarkNote/DeleteBookmark`,均带 user_id 条件。
|
||||
|
||||
## 前端 / Frontend
|
||||
|
||||
- `useProgressSaver` 暴露 `capture(): {locator, percent} | null`——每次 `report()` 记下最新值,
|
||||
打开时以该书已有进度(initialLocator/percent)播种。书签 = 当前进度快照 + 备注。
|
||||
- `components/Bookmarks.tsx` 共享组件(目录侧栏同款交互/样式 rd-*):
|
||||
- 工具条「书签」按钮 → 弹出备注输入(可空,Enter 保存) → POST → 刷新列表。
|
||||
- 侧栏行:`percent%` + 备注(空则省略) + 时间;点行 = `seek(locator)` 并收起;✎ 行内改备注;✕ 删除。
|
||||
- 各 reader 提供 `seek(locator)`:cbz `jump(page)` 已有;txt `goChapter+scrollFraction` 已有;
|
||||
epub 复用初始恢复路径(`display(cfi)`);pdf 补一个滚动到页的函数(~5 行)。
|
||||
|
||||
## 错误处理 / Errors
|
||||
|
||||
- 网络失败:toast 报错,列表不半更新(react-query invalidate 全书)。
|
||||
- 竞态(两端同时加):无幂等要求,各自成条。
|
||||
- percent 越界/locator 非法:400,前端表单先行禁用空提交。
|
||||
|
||||
## 测试 / Tests
|
||||
|
||||
- 后端 `handlers/bookmarks_test.go`:CRUD 全链路、他人 404、note 超长 400、book 不存在 404、
|
||||
percent 越界 400、列表排序。
|
||||
- 前端:`npm run check` 绿;真实浏览器点验(加/跳/改/删)。不为组件引入新测试框架。
|
||||
- 回归:`go vet` + `go test -p 1 ./...`。
|
||||
|
||||
## 不做 / Non-goals
|
||||
|
||||
全局书签页、书详情页书签区、去重、标签/分组、跨用户共享、书签导出。有真实需求再做。
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Book, Library, Me, ProgressRow, User } from "./types";
|
||||
import type { Book, Bookmark, Library, Me, ProgressRow, User } from "./types";
|
||||
|
||||
export const TOKEN_KEY = "booklib.token";
|
||||
export const LOGOUT_EVENT = "booklib:logout";
|
||||
@@ -177,5 +177,12 @@ export const api = {
|
||||
|
||||
putProgress: (id: number, locator: Record<string, unknown>, percent: number, keepalive = false) =>
|
||||
apiFetch<void>(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }),
|
||||
|
||||
listBookmarks: (id: number) => apiFetch<Bookmark[]>(`/books/${id}/bookmarks`),
|
||||
createBookmark: (id: number, locator: Record<string, unknown>, percent: number, note: string) =>
|
||||
apiFetch<Bookmark>(`/books/${id}/bookmarks`, { method: "POST", body: { locator, percent, note } }),
|
||||
patchBookmark: (id: number, note: string) =>
|
||||
apiFetch<void>(`/bookmarks/${id}`, { method: "PATCH", body: { note } }),
|
||||
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: "DELETE" }),
|
||||
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
|
||||
};
|
||||
|
||||
@@ -44,6 +44,14 @@ export interface ProgressRow {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
id: number;
|
||||
locator: Record<string, unknown>;
|
||||
percent: number;
|
||||
note: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
id: number;
|
||||
username: string;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { Book } from "../api/types";
|
||||
import { toast } from "./Toaster";
|
||||
|
||||
type Locator = Record<string, unknown>;
|
||||
|
||||
export function useBookmarks(opts: {
|
||||
book: Book;
|
||||
capture: () => { locator: Locator; percent: number };
|
||||
seek: (locator: Locator) => void;
|
||||
}): { btn: ReactNode; panel: ReactNode } {
|
||||
const { book, capture, seek } = opts;
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [editVal, setEditVal] = useState("");
|
||||
const q = useQuery({ queryKey: ["bookmarks", book.id], queryFn: () => api.listBookmarks(book.id) });
|
||||
const items = q.data ?? [];
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["bookmarks", book.id] });
|
||||
const fail = (e: unknown) => toast("err", e instanceof Error ? e.message : "书签操作失败");
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => {
|
||||
const { locator, percent } = capture();
|
||||
return api.createBookmark(book.id, locator, percent, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
setNote("");
|
||||
invalidate();
|
||||
toast("ok", "已加书签");
|
||||
},
|
||||
onError: fail,
|
||||
});
|
||||
const patch = useMutation({
|
||||
mutationFn: (v: { id: number; note: string }) => api.patchBookmark(v.id, v.note),
|
||||
onSuccess: () => {
|
||||
setEditId(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: fail,
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.deleteBookmark(id),
|
||||
onSuccess: invalidate,
|
||||
onError: fail,
|
||||
});
|
||||
|
||||
const btn = (
|
||||
<button
|
||||
className="rd-btn"
|
||||
aria-expanded={open}
|
||||
aria-controls="bm-panel"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
书签{items.length ? ` ${items.length}` : ""}
|
||||
</button>
|
||||
);
|
||||
|
||||
const panel = open ? (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={() => setOpen(false)} />
|
||||
<nav
|
||||
id="bm-panel"
|
||||
aria-label="书签"
|
||||
className="rd-divider absolute inset-y-0 right-0 z-40 flex w-72 flex-col border-l bg-[var(--rd-bg)] p-2 shadow-2xl"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
|
||||
placeholder="备注(可空)"
|
||||
maxLength={500}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !add.isPending) add.mutate();
|
||||
}}
|
||||
/>
|
||||
<button className="rd-btn shrink-0" disabled={add.isPending} onClick={() => add.mutate()}>
|
||||
加
|
||||
</button>
|
||||
</div>
|
||||
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
|
||||
{!q.isPending && items.length === 0 && (
|
||||
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
|
||||
)}
|
||||
{items.map((b) => (
|
||||
<li key={b.id}>
|
||||
{editId === b.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100"
|
||||
maxLength={500}
|
||||
value={editVal}
|
||||
onChange={(e) => setEditVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="rd-btn shrink-0"
|
||||
disabled={patch.isPending}
|
||||
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button className="rd-btn shrink-0" onClick={() => setEditId(null)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<button
|
||||
className="rd-row min-w-0 flex-1 truncate text-left"
|
||||
title={b.note || undefined}
|
||||
onClick={() => {
|
||||
seek(b.locator);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
|
||||
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="改备注"
|
||||
onClick={() => {
|
||||
setEditId(b.id);
|
||||
setEditVal(b.note);
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="删除书签"
|
||||
onClick={() => del.mutate(b.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return { btn, panel };
|
||||
}
|
||||
@@ -7,12 +7,14 @@ export type ProgressSend = (
|
||||
export interface ProgressSaver {
|
||||
report: (locator: Record<string, unknown>, percent: number) => void;
|
||||
flush: () => void;
|
||||
at: () => { loc: Record<string, unknown>; pct: number } | null;
|
||||
}
|
||||
|
||||
// spec §8:PUT 节流 5s(leading-edge + 尾部合并);关页兜底走 keepalive fetch
|
||||
//(spec 原文 sendBeacon,但 beacon 无法带 Authorization,后端 Bearer-only,语义等价替换)
|
||||
export function createProgressSaver(send: ProgressSend, intervalMs = 5000): ProgressSaver {
|
||||
let lastKey = "";
|
||||
let cur: { loc: Record<string, unknown>; pct: number } | null = null;
|
||||
let pending: { loc: Record<string, unknown>; pct: number } | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastAt = 0;
|
||||
@@ -31,6 +33,7 @@ export function createProgressSaver(send: ProgressSend, intervalMs = 5000): Prog
|
||||
|
||||
return {
|
||||
report(locator, percent) {
|
||||
cur = { loc: locator, pct: percent };
|
||||
pending = { loc: locator, pct: percent };
|
||||
if (timer) return; // 已有尾部定时器:只更新挂起值
|
||||
const t = Date.now();
|
||||
@@ -53,5 +56,6 @@ export function createProgressSaver(send: ProgressSend, intervalMs = 5000): Prog
|
||||
lastAt = Date.now();
|
||||
doSend(true);
|
||||
},
|
||||
at: () => cur,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ export interface Chrome {
|
||||
export interface ReaderProps {
|
||||
book: Book;
|
||||
initialLocator?: Record<string, unknown>;
|
||||
initialPercent?: number;
|
||||
chrome: Chrome;
|
||||
}
|
||||
|
||||
export function useProgressSaver(bookId: number): ProgressSaver {
|
||||
export function useProgressSaver(
|
||||
bookId: number,
|
||||
seed?: { locator?: Record<string, unknown>; percent?: number },
|
||||
): ProgressSaver & { capture: () => { locator: Record<string, unknown>; percent: number } } {
|
||||
const qc = useQueryClient();
|
||||
const saver = useMemo(
|
||||
() =>
|
||||
@@ -26,6 +30,10 @@ export function useProgressSaver(bookId: number): ProgressSaver {
|
||||
}),
|
||||
[bookId, qc],
|
||||
);
|
||||
const capture = () => {
|
||||
const cur = saver.at();
|
||||
return cur ? { locator: cur.loc, percent: cur.pct } : { locator: seed?.locator ?? {}, percent: seed?.percent ?? 0 };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onPageHide = () => saver.flush();
|
||||
@@ -41,5 +49,5 @@ export function useProgressSaver(bookId: number): ProgressSaver {
|
||||
};
|
||||
}, [saver]);
|
||||
|
||||
return saver;
|
||||
return Object.assign(saver, { capture });
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function Reader() {
|
||||
const R = READERS[book.format];
|
||||
if (!R) return <Msg text={`暂不支持的格式:${book.format}`} />;
|
||||
const row = (progQ.data ?? []).find((r) => r.library_id === book.library_id && r.path === book.path);
|
||||
return <R book={book} initialLocator={row?.locator} chrome={chrome} />;
|
||||
return <R book={book} initialLocator={row?.locator} initialPercent={row?.percent} chrome={chrome} />;
|
||||
})();
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import { api, formatPageUrl } from "../api/client";
|
||||
import { btn } from "../components/ui";
|
||||
import { useBookmarks } from "../components/Bookmarks";
|
||||
import { fetchObjectUrl } from "../lib/authImage";
|
||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||
import { PageHeights } from "../lib/virt";
|
||||
@@ -76,7 +77,7 @@ function PageImg({
|
||||
);
|
||||
}
|
||||
|
||||
export default function CbzReader({ book, initialLocator, chrome }: ReaderProps) {
|
||||
export default function CbzReader({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||||
const countQ = useQuery({
|
||||
queryKey: ["pages", book.id],
|
||||
queryFn: () => api.pageCount(book.pages_url ?? ""),
|
||||
@@ -87,7 +88,8 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
|
||||
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷)
|
||||
const [toc, setToc] = useState(false);
|
||||
const activeRow = useRef<HTMLButtonElement>(null);
|
||||
const saver = useProgressSaver(book.id);
|
||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => jump(Number(l.page) || 0) });
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(480);
|
||||
const [vh, setVh] = useState(600);
|
||||
@@ -291,6 +293,7 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
|
||||
>
|
||||
{MODE_LABEL[mode]}
|
||||
</button>
|
||||
{bm.btn}
|
||||
{chapters && (
|
||||
<>
|
||||
<button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
|
||||
@@ -348,6 +351,7 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
{bm.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { apiRaw } from "../api/client";
|
||||
import { useBookmarks } from "../components/Bookmarks";
|
||||
import { btn } from "../components/ui";
|
||||
import { toast } from "../components/Toaster";
|
||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||
|
||||
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
||||
@@ -26,10 +28,19 @@ async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> {
|
||||
return m.default as (data: Uint8Array) => EpubBookLike;
|
||||
}
|
||||
|
||||
export default function EpubReader({ book, initialLocator }: ReaderProps) {
|
||||
export default function EpubReader({ book, initialLocator, initialPercent }: ReaderProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const rendRef = useRef<EpubRendition | null>(null);
|
||||
const saver = useProgressSaver(book.id);
|
||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||
const bm = useBookmarks({
|
||||
book,
|
||||
capture: saver.capture,
|
||||
seek: (l) => {
|
||||
const cfi = String(l.cfi ?? "");
|
||||
if (!cfi) return;
|
||||
Promise.resolve(rendRef.current?.display(cfi)).catch(() => toast("err", "书签定位失败"));
|
||||
},
|
||||
});
|
||||
const [err, setErr] = useState("");
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const [ready, setReady] = useState(false);
|
||||
@@ -90,7 +101,7 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[1fr_auto]">
|
||||
<div className="relative grid h-full grid-rows-[1fr_auto]">
|
||||
<div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800">
|
||||
<div ref={hostRef} className="absolute inset-0" />
|
||||
{!ready && !err && <div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>}
|
||||
@@ -102,7 +113,9 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) {
|
||||
<button className={btn} onClick={() => rendRef.current?.next()}>
|
||||
下一页
|
||||
</button>
|
||||
{bm.btn}
|
||||
</div>
|
||||
{bm.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import { apiRaw } from "../api/client";
|
||||
import { useBookmarks } from "../components/Bookmarks";
|
||||
import { btn } from "../components/ui";
|
||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||
|
||||
@@ -12,14 +13,22 @@ pdfjs.GlobalWorkerOptions.workerPort = new Worker(
|
||||
|
||||
type RenderTask = { cancel(): void; promise: Promise<void> };
|
||||
|
||||
export default function PdfReader({ book, initialLocator }: ReaderProps) {
|
||||
export default function PdfReader({ book, initialLocator, initialPercent }: ReaderProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
|
||||
const docTaskRef = useRef<{ destroy(): Promise<void> } | null>(null);
|
||||
const taskRef = useRef<RenderTask | null>(null);
|
||||
const restored = useRef(false);
|
||||
const saver = useProgressSaver(book.id);
|
||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||
const bm = useBookmarks({
|
||||
book,
|
||||
capture: saver.capture,
|
||||
seek: (l) => {
|
||||
const p = Number(l.page);
|
||||
if (Number.isInteger(p) && p >= 0 && p < num) setPage(p);
|
||||
},
|
||||
});
|
||||
const [num, setNum] = useState(0);
|
||||
const [page, setPage] = useState(0); // 0 基
|
||||
const [err, setErr] = useState("");
|
||||
@@ -130,7 +139,7 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) {
|
||||
tabIndex={0}
|
||||
onKeyDown={onKey}
|
||||
aria-label="PDF 阅读器,可用左右方向键翻页"
|
||||
className="grid h-full grid-rows-[1fr_auto] focus:outline-none"
|
||||
className="relative grid h-full grid-rows-[1fr_auto] focus:outline-none"
|
||||
>
|
||||
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-stone-900/70 p-4">
|
||||
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
|
||||
@@ -151,7 +160,9 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) {
|
||||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
|
||||
末页
|
||||
</button>
|
||||
{bm.btn}
|
||||
</div>
|
||||
{bm.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { apiRaw } from "../api/client";
|
||||
import { useBookmarks } from "../components/Bookmarks";
|
||||
import { btn } from "../components/ui";
|
||||
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
||||
import { decodeText } from "../lib/encoding";
|
||||
@@ -130,9 +131,10 @@ export default function TextReader(props: ReaderProps) {
|
||||
return props.book.format === "md" ? <MdView {...props} /> : <TxtView {...props} />;
|
||||
}
|
||||
|
||||
function MdView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const saver = useProgressSaver(book.id);
|
||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => seek(Number(l.scrollFraction) || 0) });
|
||||
const restored = useRef(false);
|
||||
const pr = useReaderPrefs();
|
||||
const [frac, setFrac] = useState(0);
|
||||
@@ -168,7 +170,7 @@ function MdView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
if (err) return <LoadState text={err} onRetry={retry} />;
|
||||
if (text == null) return <LoadState text="正文加载中…" />;
|
||||
return (
|
||||
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||
<div
|
||||
ref={boxRef}
|
||||
onScroll={onScroll}
|
||||
@@ -181,15 +183,16 @@ function MdView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
||||
/>
|
||||
</div>
|
||||
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek} />}
|
||||
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek}>{bm.btn}</ReaderSheet>}
|
||||
{bm.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
||||
function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const saver = useProgressSaver(book.id);
|
||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||
const inited = useRef(false);
|
||||
const restoreFrac = useRef<number | null>(null);
|
||||
const targetCi = useRef(0);
|
||||
@@ -199,6 +202,11 @@ function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
const [toc, setToc] = useState(false);
|
||||
const pr = useReaderPrefs();
|
||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||
const bm = useBookmarks({
|
||||
book,
|
||||
capture: saver.capture,
|
||||
seek: (l) => goChapter(Number(l.ch) || 0, Number(l.scrollFraction) || 0),
|
||||
});
|
||||
|
||||
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
|
||||
|
||||
@@ -279,6 +287,7 @@ function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
</div>
|
||||
{chrome.on && (
|
||||
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
||||
{bm.btn}
|
||||
{chapters.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
@@ -330,6 +339,7 @@ function TxtView({ book, initialLocator, chrome }: ReaderProps) {
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
{bm.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user