Compare commits

...

10 Commits

Author SHA1 Message Date
root
adacd8944b fix: AudioPlayer chapter picker highlights audioStore.chapter (playing) not page chapter prop
All checks were successful
Release / Test backend (push) Successful in 59s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 6m24s
Release / Gitea Release (push) Successful in 28s
2026-04-11 23:13:24 +05:00
root
ea58dab71c fix: hamburger backdrop starts below header so menu items are not blurred
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m58s
Release / Docker (push) Successful in 6m31s
Release / Gitea Release (push) Successful in 37s
2026-04-11 18:39:32 +05:00
root
cf3a3ad910 feat: add backdrop blur overlay when mobile hamburger menu is open
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m46s
Release / Docker (push) Successful in 6m5s
Release / Gitea Release (push) Successful in 34s
2026-04-11 17:30:55 +05:00
root
8660c675b6 fix: suppress mini-bar for float/minimal player styles; persist float position
All checks were successful
Release / Test backend (push) Successful in 1m0s
Release / Check ui (push) Successful in 1m42s
Release / Docker (push) Successful in 5m55s
Release / Gitea Release (push) Successful in 39s
2026-04-11 17:20:09 +05:00
root
1f4d67dc77 fix: player float mode now works; add minimal player style
All checks were successful
Release / Test backend (push) Successful in 52s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 6m29s
Release / Gitea Release (push) Successful in 35s
Float mode was broken because AudioPlayer was unmounted the moment
audioStore.active became true — exactly when the float overlay needs
to render. Fix: keep AudioPlayer mounted in float and minimal modes
regardless of audioStore.active; only standard mode shows the
'Controls below' message.

Adds a third 'minimal' style: a compact single-row bar (skip ±,
play/pause, seek, time) with no voice picker or chapter browser.
Voice picker and chapter button are hidden in the idle pill too.

Settings UI updated to show all three options with a live
description of what each style does.
2026-04-11 16:00:46 +05:00
root
b0e23cb50a feat: floating scroll nav buttons in scroll reader mode
All checks were successful
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Successful in 1m38s
Release / Docker (push) Successful in 6m29s
Release / Gitea Release (push) Successful in 31s
Up/down chevron buttons fixed to the bottom-right of the viewport.
At the top of the chapter the up button becomes a Prev chapter link;
at the bottom the down button becomes an amber Next chapter link.
Hidden in focus mode (uses its own pill). Lifts above the audio
mini-player when it is active.
2026-04-11 15:52:14 +05:00
root
1e886a705d feat: notifications modal, admin dedup, and in-app notification preferences
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 6m22s
Release / Gitea Release (push) Successful in 35s
- Replace bell dropdown with full-screen NotificationsModal (mirrors SearchModal pattern)
- Notifications visible to all logged-in users (not just admin)
- Admin users excluded from new-chapter fan-out (dedup vs Scrape Complete notification)
- Users with notify_new_chapters=false opted out of new-chapter in-app notifications
- Toggle in profile page to enable/disable in-app new-chapter notifications
- PATCH /api/profile endpoint to save notification preferences
- User-facing /notifications page (admin redirects to /admin/notifications)
2026-04-11 15:31:37 +05:00
root
19b5b44454 feat: hold-to-repeat page buttons and tap-counter slider in paginated reader
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m47s
Release / Docker (push) Successful in 6m8s
Release / Gitea Release (push) Successful in 36s
2026-04-11 15:13:34 +05:00
root
b95c811898 feat: web push notifications for new chapters
All checks were successful
Release / Test backend (push) Successful in 4m12s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 5m46s
Release / Gitea Release (push) Successful in 35s
- Service worker (src/service-worker.ts) handles push events and
  notification clicks, navigating to the book page on tap
- Web app manifest (manifest.webmanifest) linked in app.html
- Profile page: push notification toggle (subscribe/unsubscribe)
  using the browser Notification + PushManager API with VAPID
- API route POST/DELETE /api/push-subscription proxies to backend
- Go backend: push_subscriptions PocketBase collection storage
  methods (SavePushSubscription, DeletePushSubscription,
  ListPushSubscriptionsByBook) in storage/store.go
- handlers_push.go: GET vapid-public-key, POST/DELETE subscription
- webpush package: VAPID-signed sends via webpush-go, SendToBook
  fans out to all users who have the book in their library
- Runner fires push to subscribers whenever ChaptersScraped > 0
  after a successful book scrape
- Config: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT env vars
- domain.ScrapeResult gets a Slug field; orchestrator populates it
2026-04-11 14:59:21 +05:00
root
3a9f3b773e fix: reduce log noise during catalogue/book scrapes
All checks were successful
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Successful in 5m57s
Release / Gitea Release (push) Successful in 32s
Demote per-book and per-chapter-list-page Info logs to Debug — these
fire hundreds of times per catalogue run and drown out meaningful signals:
- orchestrator: RunBook starting (per book)
- metadata saved (per book)
- chapter list fetched (per book)
- scraping chapter list page N (per pagination page per book)

The 'book scrape finished' summary log (with scraped/skipped/errors
counters) remains at Info — it is the useful signal per book.
2026-04-11 12:39:41 +05:00
28 changed files with 1711 additions and 157 deletions

View File

@@ -34,6 +34,7 @@ import (
"github.com/libnovel/backend/internal/runner"
"github.com/libnovel/backend/internal/storage"
"github.com/libnovel/backend/internal/taskqueue"
"github.com/libnovel/backend/internal/webpush"
)
// version and commit are set at build time via -ldflags.
@@ -190,6 +191,15 @@ func run() error {
log.Info("runner: poll mode — using PocketBase for task dispatch")
}
// ── Web Push ─────────────────────────────────────────────────────────────
var pushSender *webpush.Sender
if cfg.VAPID.PublicKey != "" && cfg.VAPID.PrivateKey != "" {
pushSender = webpush.New(cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey, cfg.VAPID.Subject, log)
log.Info("runner: web push notifications enabled")
} else {
log.Info("runner: VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY not set — push notifications disabled")
}
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: store,
@@ -207,6 +217,8 @@ func run() error {
CFAI: cfaiClient,
LibreTranslate: ltClient,
Notifier: store,
WebPush: pushSender,
Store: store,
Log: log,
}
r := runner.New(rCfg, deps)

View File

@@ -24,6 +24,7 @@ require (
)
require (
github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect

View File

@@ -1,3 +1,5 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -33,10 +35,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -112,6 +116,7 @@ github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
@@ -154,18 +159,80 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=

View File

@@ -0,0 +1,87 @@
package backend
import (
"encoding/json"
"net/http"
"os"
"github.com/libnovel/backend/internal/storage"
)
// handleGetVAPIDPublicKey handles GET /api/push-subscriptions/vapid-public-key.
// Returns the VAPID public key so the SvelteKit frontend can subscribe browsers.
func (s *Server) handleGetVAPIDPublicKey(w http.ResponseWriter, r *http.Request) {
key := os.Getenv("VAPID_PUBLIC_KEY")
if key == "" {
jsonError(w, http.StatusServiceUnavailable, "push notifications not configured")
return
}
writeJSON(w, 0, map[string]string{"public_key": key})
}
// handleSavePushSubscription handles POST /api/push-subscriptions.
// Registers a new browser push subscription for the authenticated user.
func (s *Server) handleSavePushSubscription(w http.ResponseWriter, r *http.Request) {
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
var body struct {
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
P256DH string `json:"p256dh"`
Auth string `json:"auth"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.UserID == "" || body.Endpoint == "" || body.P256DH == "" || body.Auth == "" {
jsonError(w, http.StatusBadRequest, "user_id, endpoint, p256dh and auth are required")
return
}
if err := store.SavePushSubscription(r.Context(), storage.PushSubscription{
UserID: body.UserID,
Endpoint: body.Endpoint,
P256DH: body.P256DH,
Auth: body.Auth,
}); err != nil {
jsonError(w, http.StatusInternalServerError, "save push subscription: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}
// handleDeletePushSubscription handles DELETE /api/push-subscriptions.
// Removes a push subscription by endpoint for the given user.
func (s *Server) handleDeletePushSubscription(w http.ResponseWriter, r *http.Request) {
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
var body struct {
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.UserID == "" || body.Endpoint == "" {
jsonError(w, http.StatusBadRequest, "user_id and endpoint are required")
return
}
if err := store.DeletePushSubscription(r.Context(), body.UserID, body.Endpoint); err != nil {
jsonError(w, http.StatusInternalServerError, "delete push subscription: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}

View File

@@ -270,6 +270,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("DELETE /api/notifications", s.handleClearAllNotifications)
mux.HandleFunc("DELETE /api/notifications/{id}", s.handleDismissNotification)
// Web Push subscriptions
mux.HandleFunc("GET /api/push-subscriptions/vapid-public-key", s.handleGetVAPIDPublicKey)
mux.HandleFunc("POST /api/push-subscriptions", s.handleSavePushSubscription)
mux.HandleFunc("DELETE /api/push-subscriptions", s.handleDeletePushSubscription)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)

View File

@@ -123,6 +123,19 @@ type Redis struct {
Password string
}
// VAPID holds Web Push VAPID key pair for browser push notifications.
// Generate a pair once with: go run ./cmd/genkeys (or use the web-push CLI).
// The public key is exposed via GET /api/push-subscriptions/vapid-public-key
// and embedded in the SvelteKit app via PUBLIC_VAPID_PUBLIC_KEY.
type VAPID struct {
// PublicKey is the base64url-encoded VAPID public key (65 bytes, uncompressed EC P-256).
PublicKey string
// PrivateKey is the base64url-encoded VAPID private key (32 bytes).
PrivateKey string
// Subject is the mailto: or https: URL used as the VAPID subscriber contact.
Subject string
}
// Runner holds settings specific to the runner/worker binary.
type Runner struct {
// PollInterval is how often the runner checks PocketBase for pending tasks.
@@ -172,6 +185,7 @@ type Config struct {
Meilisearch Meilisearch
Valkey Valkey
Redis Redis
VAPID VAPID
// LogLevel is one of "debug", "info", "warn", "error".
LogLevel string
}
@@ -258,6 +272,12 @@ func Load() Config {
Addr: envOr("REDIS_ADDR", ""),
Password: envOr("REDIS_PASSWORD", ""),
},
VAPID: VAPID{
PublicKey: envOr("VAPID_PUBLIC_KEY", ""),
PrivateKey: envOr("VAPID_PRIVATE_KEY", ""),
Subject: envOr("VAPID_SUBJECT", "mailto:admin@libnovel.cc"),
},
}
}

View File

@@ -126,6 +126,8 @@ type ScrapeTask struct {
// ScrapeResult is the outcome reported by the runner after finishing a ScrapeTask.
type ScrapeResult struct {
// Slug is the book slug that was scraped. Empty for catalogue tasks.
Slug string `json:"slug,omitempty"`
BooksFound int `json:"books_found"`
ChaptersScraped int `json:"chapters_scraped"`
ChaptersSkipped int `json:"chapters_skipped"`

View File

@@ -241,7 +241,7 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string, upTo in
}
pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page)
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
s.log.Debug("scraping chapter list", "page", page, "url", pageURL)
raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 6*time.Second)
if err != nil {

View File

@@ -68,7 +68,7 @@ func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log
// Returns a ScrapeResult with counters. The result's ErrorMessage is non-empty
// if the run failed at the metadata or chapter-list level.
func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) domain.ScrapeResult {
o.log.Info("orchestrator: RunBook starting",
o.log.Debug("orchestrator: RunBook starting",
"task_id", task.ID,
"kind", task.Kind,
"url", task.TargetURL,
@@ -90,6 +90,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
result.Errors++
return result
}
result.Slug = meta.Slug
if err := o.store.WriteMetadata(ctx, meta); err != nil {
o.log.Error("metadata write failed", "slug", meta.Slug, "err", err)
@@ -97,13 +98,14 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
result.Errors++
} else {
result.BooksFound = 1
result.Slug = meta.Slug
// Fire optional post-metadata hook (e.g. Meilisearch indexing).
if o.postMetadata != nil {
o.postMetadata(ctx, meta)
}
}
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
o.log.Debug("metadata saved", "slug", meta.Slug, "title", meta.Title)
// ── Step 2: Chapter list ──────────────────────────────────────────────────
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL, task.ToChapter)
@@ -114,7 +116,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
return result
}
o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
o.log.Debug("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
// Persist chapter refs (without text) so the index exists early.
if wErr := o.store.WriteChapterRefs(ctx, meta.Slug, refs); wErr != nil {

View File

@@ -36,7 +36,9 @@ import (
"github.com/libnovel/backend/internal/orchestrator"
"github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/scraper"
"github.com/libnovel/backend/internal/storage"
"github.com/libnovel/backend/internal/taskqueue"
"github.com/libnovel/backend/internal/webpush"
"github.com/prometheus/client_golang/prometheus"
)
@@ -130,6 +132,12 @@ type Dependencies struct {
ChapterIngester ChapterIngester
// Notifier creates notifications for users.
Notifier Notifier
// WebPush sends browser push notifications to subscribed users.
// If nil, push notifications are disabled.
WebPush *webpush.Sender
// Store is the underlying *storage.Store; used for push subscription lookups.
// Only needed when WebPush is non-nil.
Store *storage.Store
// SearchIndex indexes books in Meilisearch after scraping.
// If nil a no-op is used.
SearchIndex meili.Client
@@ -531,6 +539,30 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
"/admin/tasks")
}
// Fan-out in-app new-chapter notification to all users who have this book
// in their library. Runs in background so it doesn't block the task loop.
if r.deps.Store != nil && result.ChaptersScraped > 0 &&
result.Slug != "" && task.Kind != "catalogue" {
go func() {
notifyCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
title := result.Slug
_ = r.deps.Store.NotifyUsersWithBook(notifyCtx, result.Slug,
"New chapters available",
fmt.Sprintf("%d new chapter(s) added to %s", result.ChaptersScraped, title),
"/books/"+result.Slug)
}()
}
// Send Web Push notifications to subscribed browsers.
if r.deps.WebPush != nil && r.deps.Store != nil &&
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{
Title: "New chapter available",
Body: fmt.Sprintf("%d new chapter(s) added", result.ChaptersScraped),
URL: "/books/" + result.Slug,
Icon: "/icon-192.png",
})
}
}
log.Info("runner: scrape task finished",

View File

@@ -1526,3 +1526,169 @@ func parseAIJob(raw json.RawMessage) (domain.AIJob, error) {
HeartbeatAt: parseT(r.HeartbeatAt),
}, nil
}
// ── Push subscriptions ────────────────────────────────────────────────────────
// PushSubscription holds the Web Push subscription data for a single browser.
type PushSubscription struct {
ID string
UserID string
Endpoint string
P256DH string
Auth string
}
// SavePushSubscription upserts a Web Push subscription for a user.
// If a record with the same endpoint already exists it is updated in place.
func (s *Store) SavePushSubscription(ctx context.Context, sub PushSubscription) error {
filter := fmt.Sprintf("endpoint=%q", sub.Endpoint)
existing, err := s.pb.listAll(ctx, "push_subscriptions", filter, "")
if err != nil {
return fmt.Errorf("SavePushSubscription list: %w", err)
}
payload := map[string]any{
"user_id": sub.UserID,
"endpoint": sub.Endpoint,
"p256dh": sub.P256DH,
"auth": sub.Auth,
}
if len(existing) > 0 {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(existing[0], &rec) == nil && rec.ID != "" {
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/push_subscriptions/records/%s", rec.ID), payload)
}
}
return s.pb.post(ctx, "/api/collections/push_subscriptions/records", payload, nil)
}
// DeletePushSubscription removes a Web Push subscription by endpoint.
func (s *Store) DeletePushSubscription(ctx context.Context, userID, endpoint string) error {
filter := fmt.Sprintf("user_id=%q&&endpoint=%q", userID, endpoint)
items, err := s.pb.listAll(ctx, "push_subscriptions", filter, "")
if err != nil {
return fmt.Errorf("DeletePushSubscription list: %w", err)
}
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
_ = s.pb.delete(ctx, fmt.Sprintf("/api/collections/push_subscriptions/records/%s", rec.ID))
}
}
return nil
}
// ListPushSubscriptionsByBook returns all push subscriptions belonging to users
// who have the given book slug in their library (user_library collection).
func (s *Store) ListPushSubscriptionsByBook(ctx context.Context, slug string) ([]PushSubscription, error) {
// Find all users who have this book in their library
libFilter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
libItems, err := s.pb.listAll(ctx, "user_library", libFilter, "")
if err != nil {
return nil, fmt.Errorf("ListPushSubscriptionsByBook list library: %w", err)
}
// Collect unique user IDs
seen := make(map[string]bool)
var userIDs []string
for _, raw := range libItems {
var rec struct {
UserID string `json:"user_id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] {
seen[rec.UserID] = true
userIDs = append(userIDs, rec.UserID)
}
}
if len(userIDs) == 0 {
return nil, nil
}
// Build OR filter for push_subscriptions
parts := make([]string, len(userIDs))
for i, uid := range userIDs {
parts[i] = fmt.Sprintf("user_id=%q", uid)
}
subFilter := strings.Join(parts, "||")
subItems, err := s.pb.listAll(ctx, "push_subscriptions", subFilter, "")
if err != nil {
return nil, fmt.Errorf("ListPushSubscriptionsByBook list subs: %w", err)
}
subs := make([]PushSubscription, 0, len(subItems))
for _, raw := range subItems {
var rec struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
P256DH string `json:"p256dh"`
Auth string `json:"auth"`
}
if json.Unmarshal(raw, &rec) == nil && rec.Endpoint != "" {
subs = append(subs, PushSubscription{
ID: rec.ID,
UserID: rec.UserID,
Endpoint: rec.Endpoint,
P256DH: rec.P256DH,
Auth: rec.Auth,
})
}
}
return subs, nil
}
// NotifyUsersWithBook creates an in-app notification for every logged-in user
// who has slug in their library. Errors for individual users are logged but
// do not abort the loop. Returns the number of notifications created.
func (s *Store) NotifyUsersWithBook(ctx context.Context, slug, title, message, link string) int {
userIDs, err := s.ListUserIDsWithBook(ctx, slug)
if err != nil || len(userIDs) == 0 {
return 0
}
var n int
for _, uid := range userIDs {
if createErr := s.CreateNotification(ctx, uid, title, message, link); createErr == nil {
n++
}
}
return n
}
// who have slug in their user_library. Used to fan-out new-chapter notifications.
// Admin users and users who have opted out of in-app new-chapter notifications
// (notify_new_chapters=false on app_users) are excluded.
func (s *Store) ListUserIDsWithBook(ctx context.Context, slug string) ([]string, error) {
// Collect user IDs to skip: admins + opted-out users.
skipIDs := make(map[string]bool)
excludedItems, err := s.pb.listAll(ctx, "app_users", `role="admin"||notify_new_chapters=false`, "")
if err == nil {
for _, raw := range excludedItems {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
skipIDs[rec.ID] = true
}
}
}
filter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
items, err := s.pb.listAll(ctx, "user_library", filter, "")
if err != nil {
return nil, fmt.Errorf("ListUserIDsWithBook: %w", err)
}
seen := make(map[string]bool)
var ids []string
for _, raw := range items {
var rec struct {
UserID string `json:"user_id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] && !skipIDs[rec.UserID] {
seen[rec.UserID] = true
ids = append(ids, rec.UserID)
}
}
return ids, nil
}

View File

@@ -0,0 +1,147 @@
// Package webpush sends Web Push notifications using the VAPID protocol.
package webpush
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync"
webpushgo "github.com/SherClockHolmes/webpush-go"
"github.com/libnovel/backend/internal/storage"
)
// Payload is the JSON body delivered to the browser service worker.
type Payload struct {
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
}
// Sender sends Web Push notifications to subscribed browsers.
type Sender struct {
vapidPublic string
vapidPrivate string
subject string
log *slog.Logger
}
// New returns a Sender configured with the given VAPID key pair.
// subject should be a mailto: or https: contact URL per the VAPID spec.
func New(vapidPublic, vapidPrivate, subject string, log *slog.Logger) *Sender {
if log == nil {
log = slog.Default()
}
return &Sender{
vapidPublic: vapidPublic,
vapidPrivate: vapidPrivate,
subject: subject,
log: log,
}
}
// Enabled returns true when VAPID keys are configured.
func (s *Sender) Enabled() bool {
return s.vapidPublic != "" && s.vapidPrivate != ""
}
// Send delivers payload to all provided subscriptions concurrently.
// Errors for individual subscriptions are logged but do not abort other sends.
// Returns the number of successful sends.
func (s *Sender) Send(ctx context.Context, subs []storage.PushSubscription, p Payload) int {
if !s.Enabled() || len(subs) == 0 {
return 0
}
body, err := json.Marshal(p)
if err != nil {
s.log.Error("webpush: marshal payload", "err", err)
return 0
}
var (
wg sync.WaitGroup
mu sync.Mutex
success int
)
for _, sub := range subs {
sub := sub
wg.Add(1)
go func() {
defer wg.Done()
resp, err := webpushgo.SendNotificationWithContext(ctx, body, &webpushgo.Subscription{
Endpoint: sub.Endpoint,
Keys: webpushgo.Keys{
P256dh: sub.P256DH,
Auth: sub.Auth,
},
}, &webpushgo.Options{
VAPIDPublicKey: s.vapidPublic,
VAPIDPrivateKey: s.vapidPrivate,
Subscriber: s.subject,
TTL: 86400,
})
if err != nil {
s.log.Warn("webpush: send failed", "endpoint", truncate(sub.Endpoint, 60), "err", err)
return
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode >= 400 {
s.log.Warn("webpush: push service returned error",
"endpoint", truncate(sub.Endpoint, 60),
"status", resp.StatusCode)
return
}
mu.Lock()
success++
mu.Unlock()
}()
}
wg.Wait()
return success
}
// SendToBook sends a push notification to all subscribers of the given book.
// store is used to list subscriptions for the book's library followers.
func (s *Sender) SendToBook(ctx context.Context, store *storage.Store, slug string, p Payload) {
if !s.Enabled() {
return
}
subs, err := store.ListPushSubscriptionsByBook(ctx, slug)
if err != nil {
s.log.Warn("webpush: list push subscriptions", "slug", slug, "err", err)
return
}
if len(subs) == 0 {
return
}
n := s.Send(ctx, subs, p)
s.log.Info("webpush: sent chapter notification",
"slug", slug,
"recipients", n,
"total_subs", len(subs),
)
}
// GenerateVAPIDKeys generates a new VAPID key pair and prints them.
// Useful for one-off key generation during setup.
func GenerateVAPIDKeys() (public, private string, err error) {
private, public, err = webpushgo.GenerateVAPIDKeys()
if err != nil {
return "", "", fmt.Errorf("generate VAPID keys: %w", err)
}
return public, private, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" />

View File

@@ -170,6 +170,21 @@ class AudioStore {
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
}
/**
* When true the persistent mini-bar in +layout.svelte is hidden.
* Set by the chapter reader page when playerStyle is 'float' or 'minimal'
* so the in-page player is the sole control surface.
* Cleared when leaving the chapter page (page destroy / onDestroy effect).
*/
suppressMiniBar = $state(false);
/**
* Position of the draggable float overlay (bottom-right anchor offsets).
* Stored here (module singleton) so the position survives chapter navigation.
* x > 0 = moved left; y > 0 = moved up.
*/
floatPos = $state({ x: 0, y: 0 });
/** True when the currently loaded track matches slug+chapter */
isCurrentChapter(slug: string, chapter: number): boolean {
return this.slug === slug && this.chapter === chapter;

View File

@@ -71,8 +71,11 @@
voices?: Voice[];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired?: () => void;
/** Visual style of the player card. 'standard' = inline card; 'float' = draggable overlay. */
playerStyle?: 'standard' | 'float';
/** Visual style of the player card.
* 'standard' = full inline card with voice/chapter controls;
* 'minimal' = compact single-row bar (play + seek + time only);
* 'float' = draggable overlay anchored bottom-right. */
playerStyle?: 'standard' | 'minimal' | 'float';
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
wordCount?: number;
}
@@ -942,19 +945,18 @@
}
// ── Float player drag state ──────────────────────────────────────────────
/** Position of the floating overlay (bottom-right anchor by default). */
let floatPos = $state({ x: 0, y: 0 });
// floatPos lives on audioStore (singleton) so position survives chapter navigation.
let floatDragging = $state(false);
let floatDragStart = $state({ mx: 0, my: 0, ox: 0, oy: 0 });
function onFloatPointerDown(e: PointerEvent) {
floatDragging = true;
floatDragStart = { mx: e.clientX, my: e.clientY, ox: floatPos.x, oy: floatPos.y };
floatDragStart = { mx: e.clientX, my: e.clientY, ox: audioStore.floatPos.x, oy: audioStore.floatPos.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onFloatPointerMove(e: PointerEvent) {
if (!floatDragging) return;
floatPos = {
audioStore.floatPos = {
x: floatDragStart.ox + (e.clientX - floatDragStart.mx),
y: floatDragStart.oy + (e.clientY - floatDragStart.my)
};
@@ -1034,7 +1036,8 @@
</svg>
</button>
<!-- Track info -->
<!-- Track info (hidden in minimal style) -->
{#if playerStyle !== 'minimal'}
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
{m.reader_play_narration()}
@@ -1091,9 +1094,10 @@
{/if}
</div>
</div>
{/if}
<!-- Chapters button (right side) -->
{#if chapters.length > 0}
<!-- Chapters button (right side, hidden in minimal style) -->
{#if chapters.length > 0 && playerStyle !== 'minimal'}
<button
type="button"
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
@@ -1167,6 +1171,64 @@
{:else}
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
{#if !(playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active)}
{#if playerStyle === 'minimal' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
<!-- ── Minimal style: compact bar — seek + play/pause + skip + time ────────── -->
<div class="px-3 py-2.5 flex items-center gap-2">
<!-- Skip back 15s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
title="-15s"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
</svg>
</button>
<!-- Play/pause -->
<button
type="button"
onclick={() => { audioStore.toggleRequest++; }}
class="flex-shrink-0 w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all"
>
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
{:else}
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
title="+30s"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
</svg>
</button>
<!-- Seek bar -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="none"
class="flex-1 h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer"
onclick={seekFromBar}
>
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
</div>
<!-- Time -->
<span class="flex-shrink-0 text-[11px] tabular-nums text-(--color-muted)">
{formatTime(audioStore.currentTime)}<span class="opacity-40">/</span>{formatDuration(audioStore.duration)}
</span>
</div>
{:else}
<div class="p-4">
<div class="flex items-center justify-end gap-2 mb-3">
<!-- Chapter picker button -->
@@ -1372,6 +1434,7 @@
</div>
{/if}
{/if}
{/if}
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
Rendered as a top-level sibling (outside all player containers) so that
@@ -1380,7 +1443,7 @@
{#if showChapterPanel && audioStore.chapters.length > 0}
<ChapterPickerOverlay
chapters={audioStore.chapters}
activeChapter={chapter}
activeChapter={audioStore.chapter}
zIndex="z-[60]"
onselect={playChapter}
onclose={() => { showChapterPanel = false; }}
@@ -1395,8 +1458,8 @@
<div
class="fixed z-[55] select-none"
style="
bottom: calc(4.5rem + {-floatPos.y}px);
right: calc(1rem + {-floatPos.x}px);
bottom: calc(1rem + {-audioStore.floatPos.y}px);
right: calc(1rem + {-audioStore.floatPos.x}px);
touch-action: none;
"
>

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import { browser } from '$app/environment';
import { cn } from '$lib/utils';
interface Notification {
id: string;
title: string;
message: string;
link: string;
read: boolean;
}
interface Props {
notifications: Notification[];
userId: string;
isAdmin: boolean;
onclose: () => void;
onMarkRead: (id: string) => void;
onMarkAllRead: () => void;
onDismiss: (id: string) => void;
onClearAll: () => void;
}
let {
notifications,
userId,
isAdmin,
onclose,
onMarkRead,
onMarkAllRead,
onDismiss,
onClearAll,
}: Props = $props();
let filter = $state<'all' | 'unread'>('all');
const filtered = $derived(
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
);
const unreadCount = $derived(notifications.filter(n => !n.read).length);
// Body scroll lock + Escape to close
$effect(() => {
if (browser) {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}
});
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
const viewAllHref = $derived(isAdmin ? '/admin/notifications' : '/notifications');
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[70] flex flex-col"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<!-- Modal panel — slides down from top -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
style="max-height: 100svh;"
onpointerdown={(e) => e.stopPropagation()}
>
<!-- Header row -->
<div class="flex items-center justify-between px-4 py-3 border-b border-(--color-border) shrink-0">
<div class="flex items-center gap-3">
<span class="text-base font-semibold text-(--color-text)">Notifications</span>
{#if unreadCount > 0}
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
{unreadCount}
</span>
{/if}
</div>
<div class="flex items-center gap-1">
{#if unreadCount > 0}
<button
type="button"
onclick={onMarkAllRead}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
>Mark all read</button>
{/if}
{#if notifications.length > 0}
<button
type="button"
onclick={onClearAll}
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
>Clear all</button>
{/if}
<button
type="button"
onclick={onclose}
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close notifications"
>
Cancel
</button>
</div>
</div>
<!-- Filter tabs -->
<div class="flex gap-0 px-4 py-2 border-b border-(--color-border)/60 shrink-0">
<button
type="button"
onclick={() => filter = 'all'}
class={cn(
'text-xs px-3 py-1.5 rounded-l border border-(--color-border) transition-colors',
filter === 'all'
? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
>All ({notifications.length})</button>
<button
type="button"
onclick={() => filter = 'unread'}
class={cn(
'text-xs px-3 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors',
filter === 'unread'
? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
>Unread ({unreadCount})</button>
</div>
<!-- Scrollable list -->
<div class="flex-1 overflow-y-auto overscroll-contain min-h-0">
{#if filtered.length === 0}
<div class="py-16 text-center text-(--color-muted) text-sm">
{filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
</div>
{:else}
{#each filtered as n (n.id)}
<div class={cn(
'flex items-start gap-1 border-b border-(--color-border)/40 last:border-0 hover:bg-(--color-surface-2) group transition-colors',
n.read && 'opacity-60'
)}>
<a
href={n.link || (isAdmin ? '/admin' : '/')}
onclick={() => { onMarkRead(n.id); onclose(); }}
class="flex-1 px-4 py-3.5 min-w-0"
>
<div class="flex items-center gap-1.5">
{#if !n.read}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
{/if}
<span class="text-sm font-semibold text-(--color-text) truncate">{n.title}</span>
</div>
<p class="text-sm text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</p>
</a>
<button
type="button"
onclick={() => onDismiss(n.id)}
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{/each}
{/if}
</div>
<!-- Footer -->
<div class="px-4 py-3 border-t border-(--color-border)/40 shrink-0">
<a
href={viewAllHref}
onclick={onclose}
class="block text-center text-sm text-(--color-muted) hover:text-(--color-brand) transition-colors"
>View all notifications</a>
</div>
</div>
</div>

View File

@@ -95,6 +95,7 @@ export interface User {
oauth_id?: string;
polar_customer_id?: string;
polar_subscription_id?: string;
notify_new_chapters?: boolean;
}
// ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -1481,6 +1482,25 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
}
}
/**
* Update a user's notification preferences (stored on app_users record).
*/
export async function updateUserNotificationPrefs(
userId: string,
prefs: { notify_new_chapters?: boolean }
): Promise<void> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(prefs)
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`updateUserNotificationPrefs failed: ${res.status} ${body}`);
}
}
// ─── Comments ─────────────────────────────────────────────────────────────────
export interface PBBookComment {

View File

@@ -13,6 +13,7 @@
import { locales, getLocale } from '$lib/paraglide/runtime.js';
import ListeningMode from '$lib/components/ListeningMode.svelte';
import SearchModal from '$lib/components/SearchModal.svelte';
import NotificationsModal from '$lib/components/NotificationsModal.svelte';
import { fly, fade } from 'svelte/transition';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -26,7 +27,6 @@
// Notifications
let notificationsOpen = $state(false);
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
let notifFilter = $state<'all' | 'unread'>('all');
async function loadNotifications() {
if (!data.user) return;
try {
@@ -65,9 +65,6 @@
}
$effect(() => { if (data.user) loadNotifications(); });
const unreadCount = $derived(notifications.filter(n => !n.read).length);
const filteredNotifications = $derived(
notifFilter === 'unread' ? notifications.filter(n => !n.read) : notifications
);
// Close search on navigation
$effect(() => {
@@ -515,7 +512,7 @@
style="display:none"
></audio>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active && !audioStore.suppressMiniBar}>
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
{#if navigating}
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
@@ -588,12 +585,12 @@
</button>
{/if}
<!-- Notifications bell -->
{#if data.user?.role === 'admin'}
<div class="relative">
<!-- Notifications bell -->
{#if data.user}
<div class="relative">
<button
type="button"
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; }}
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
title="Notifications"
class="flex items-center justify-center w-8 h-8 rounded transition-colors {notificationsOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'} relative"
>
@@ -604,87 +601,6 @@
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
{/if}
</button>
{#if notificationsOpen}
<div class="absolute right-0 top-full mt-1 w-80 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl z-50 flex flex-col max-h-[28rem]">
<!-- Header -->
<div class="flex items-center justify-between px-3 pt-3 pb-2 shrink-0">
<span class="text-sm font-semibold">Notifications</span>
<div class="flex items-center gap-1">
{#if unreadCount > 0}
<button
type="button"
onclick={markAllRead}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
>Mark all read</button>
{/if}
{#if notifications.length > 0}
<button
type="button"
onclick={clearAllNotifications}
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
>Clear all</button>
{/if}
</div>
</div>
<!-- Filter tabs -->
<div class="flex gap-0 px-3 pb-2 shrink-0">
<button
type="button"
onclick={() => notifFilter = 'all'}
class="text-xs px-2.5 py-1 rounded-l border border-(--color-border) transition-colors {notifFilter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>All ({notifications.length})</button>
<button
type="button"
onclick={() => notifFilter = 'unread'}
class="text-xs px-2.5 py-1 rounded-r border border-l-0 border-(--color-border) transition-colors {notifFilter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>Unread ({unreadCount})</button>
</div>
<!-- List -->
<div class="overflow-y-auto flex-1 min-h-0">
{#if filteredNotifications.length === 0}
<div class="p-4 text-center text-(--color-muted) text-sm">
{notifFilter === 'unread' ? 'No unread notifications' : 'No notifications'}
</div>
{:else}
{#each filteredNotifications as n (n.id)}
<div class="flex items-start gap-1 border-b border-(--color-border)/40 hover:bg-(--color-surface-3) group {n.read ? 'opacity-60' : ''}">
<a
href={n.link || '/admin'}
onclick={() => { markRead(n.id); notificationsOpen = false; }}
class="flex-1 p-3 min-w-0"
>
<div class="flex items-center gap-1.5">
{#if !n.read}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
{/if}
<span class="text-sm font-medium truncate">{n.title}</span>
</div>
<div class="text-xs text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</div>
</a>
<button
type="button"
onclick={() => dismissNotification(n.id)}
class="shrink-0 p-2.5 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
title="Dismiss"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{/each}
{/if}
</div>
<!-- Footer -->
<div class="px-3 py-2 border-t border-(--color-border)/40 shrink-0">
<a
href="/admin/notifications"
onclick={() => notificationsOpen = false}
class="block text-center text-xs text-(--color-muted) hover:text-(--color-brand) transition-colors"
>View all notifications</a>
</div>
</div>
{/if}
</div>
{/if}
<!-- Theme dropdown (desktop) -->
@@ -829,14 +745,24 @@
</Button>
</div>
<!-- Click-outside overlay for dropdowns -->
{#if langMenuOpen || userMenuOpen}
<div
class="fixed inset-0 z-40"
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
aria-hidden="true"
></div>
{/if}
<!-- Click-outside overlay for dropdowns -->
{#if langMenuOpen || userMenuOpen}
<div
class="fixed inset-0 z-40"
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
aria-hidden="true"
></div>
{/if}
<!-- Backdrop for mobile hamburger menu -->
{#if menuOpen}
<div
class="fixed top-14 inset-x-0 bottom-0 z-40 sm:hidden"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={() => { menuOpen = false; }}
aria-hidden="true"
></div>
{/if}
{:else}
<div class="ml-auto">
<a
@@ -1043,7 +969,7 @@
</div>
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
{#if audioStore.active}
{#if audioStore.active && !audioStore.suppressMiniBar}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
<!-- Generation progress bar (sits at very top of the bar) -->
@@ -1222,6 +1148,20 @@
<SearchModal onclose={() => { searchOpen = false; }} />
{/if}
<!-- Notifications modal — full-screen, shown for all logged-in users -->
{#if notificationsOpen && data.user}
<NotificationsModal
notifications={notifications}
userId={data.user.id}
isAdmin={data.user.role === 'admin'}
onclose={() => { notificationsOpen = false; }}
onMarkRead={markRead}
onMarkAllRead={markAllRead}
onDismiss={dismissNotification}
onClearAll={clearAllNotifications}
/>
{/if}
<svelte:window onkeydown={(e) => {
// Don't intercept when typing in an input/textarea
const tag = (e.target as HTMLElement).tagName;

View File

@@ -179,17 +179,16 @@
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
</a>
<button
type="button"
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
<a
href="/books/{heroBook.book.slug}"
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
title="Listen to narration"
title="Book info"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
</svg>
Listen
</button>
Info
</a>
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}

View File

@@ -1,8 +1,43 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { deleteUserAccount } from '$lib/server/pocketbase';
import { deleteUserAccount, updateUserNotificationPrefs } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* PATCH /api/profile
*
* Update mutable profile preferences (currently: notification preferences).
* Body: { notify_new_chapters?: boolean }
*/
export const PATCH: RequestHandler = async ({ locals, request }) => {
if (!locals.user) error(401, 'Not authenticated');
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON');
}
const prefs: { notify_new_chapters?: boolean } = {};
if (typeof body.notify_new_chapters === 'boolean') {
prefs.notify_new_chapters = body.notify_new_chapters;
}
if (Object.keys(prefs).length === 0) {
error(400, 'No valid preferences provided');
}
try {
await updateUserNotificationPrefs(locals.user.id, prefs);
} catch (e) {
log.error('profile', 'PATCH /api/profile failed', { userId: locals.user.id, err: String(e) });
error(500, { message: 'Failed to update preferences. Please try again.' });
}
return json({ ok: true });
};
/**
* DELETE /api/profile
*

View File

@@ -0,0 +1,63 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
/**
* POST /api/push-subscription
* Registers a browser push subscription for the current user.
* Body: { endpoint, keys: { p256dh, auth } }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Login required');
const body = await request.json().catch(() => null);
if (!body?.endpoint || !body?.keys?.p256dh || !body?.keys?.auth) {
throw error(400, 'Invalid push subscription object');
}
const res = await backendFetch('/api/push-subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: locals.user.id,
endpoint: body.endpoint,
p256dh: body.keys.p256dh,
auth: body.keys.auth,
}),
});
if (!res.ok) {
const msg = await res.text().catch(() => 'backend error');
throw error(res.status, msg);
}
return json({ success: true });
};
/**
* DELETE /api/push-subscription
* Unregisters a push subscription by endpoint.
* Body: { endpoint }
*/
export const DELETE: RequestHandler = async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Login required');
const body = await request.json().catch(() => null);
if (!body?.endpoint) throw error(400, 'endpoint required');
const res = await backendFetch('/api/push-subscriptions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: locals.user.id,
endpoint: body.endpoint,
}),
});
if (!res.ok) {
const msg = await res.text().catch(() => 'backend error');
throw error(res.status, msg);
}
return json({ success: true });
};

View File

@@ -52,7 +52,7 @@
type LineSpacing = 'compact' | 'normal' | 'relaxed';
type ReadWidth = 'narrow' | 'normal' | 'wide';
type ParaStyle = 'spaced' | 'indented';
type PlayerStyle = 'standard' | 'float';
type PlayerStyle = 'standard' | 'minimal' | 'float';
/** Controls how many lines fit on a page by adjusting the container height offset. */
type PageLines = 'less' | 'normal' | 'more';
@@ -100,6 +100,33 @@
document.documentElement.style.setProperty('--reading-max-width', READ_WIDTHS[layout.readWidth]);
});
// ── Suppress mini-bar for float / minimal player styles ──────────────────────
// The in-page player is the sole control surface for these styles; the layout
// mini-bar would be a duplicate. Clear on page destroy so the mini-bar returns
// on other pages (library, catalogue, etc.) where audio may still be playing.
$effect(() => {
audioStore.suppressMiniBar = layout.playerStyle === 'float' || layout.playerStyle === 'minimal';
return () => { audioStore.suppressMiniBar = false; };
});
// ── Persist float overlay position across reloads ─────────────────────────────
const FLOAT_POS_KEY = 'reader_float_pos_v1';
if (browser) {
try {
const saved = localStorage.getItem(FLOAT_POS_KEY);
if (saved) {
const p = JSON.parse(saved) as { x: number; y: number };
if (typeof p.x === 'number' && typeof p.y === 'number') audioStore.floatPos = p;
}
} catch { /* ignore */ }
}
$effect(() => {
const pos = audioStore.floatPos;
if (browser && (pos.x !== 0 || pos.y !== 0)) {
try { localStorage.setItem(FLOAT_POS_KEY, JSON.stringify(pos)); } catch { /* ignore */ }
}
});
// ── Scroll progress bar ──────────────────────────────────────────────────────
let scrollProgress = $state(0);
@@ -121,6 +148,78 @@
let paginatedContentEl = $state<HTMLDivElement | null>(null);
let containerH = $state(0);
// ── Page slider popover ───────────────────────────────────────────────────
let sliderOpen = $state(false);
let sliderAnchorEl = $state<HTMLElement | null>(null);
let sliderAnchorFocusEl = $state<HTMLElement | null>(null);
function toggleSlider(anchor: HTMLElement) {
if (sliderOpen) { sliderOpen = false; return; }
sliderAnchorEl = anchor;
sliderOpen = true;
}
function closeSliderOnOutside(e: MouseEvent) {
if (!sliderOpen) return;
const target = e.target as Node;
if (sliderAnchorEl && sliderAnchorEl.contains(target)) return;
if (sliderAnchorFocusEl && sliderAnchorFocusEl.contains(target)) return;
sliderOpen = false;
}
$effect(() => {
if (!browser) return;
document.addEventListener('pointerdown', closeSliderOnOutside);
return () => document.removeEventListener('pointerdown', closeSliderOnOutside);
});
// ── Hold-to-repeat action ────────────────────────────────────────────────
/**
* Svelte action: fires `onrepeat()` repeatedly while the pointer is held.
* First repeat fires after `delay` ms; subsequent repeats every `interval` ms.
*/
function holdRepeat(
node: HTMLElement,
params: { onrepeat: () => void; delay?: number; interval?: number }
) {
let timer: ReturnType<typeof setTimeout> | null = null;
function clear() {
if (timer !== null) { clearTimeout(timer); timer = null; }
}
function schedule() {
timer = setTimeout(() => {
params.onrepeat();
schedule(); // re-schedule at interval speed
}, params.interval ?? 80);
}
function start() {
clear();
timer = setTimeout(() => {
params.onrepeat();
schedule();
}, params.delay ?? 400);
}
node.addEventListener('pointerdown', start);
node.addEventListener('pointerup', clear);
node.addEventListener('pointercancel', clear);
node.addEventListener('pointerleave', clear);
return {
update(newParams: typeof params) { params = newParams; },
destroy() {
clear();
node.removeEventListener('pointerdown', start);
node.removeEventListener('pointerup', clear);
node.removeEventListener('pointercancel', clear);
node.removeEventListener('pointerleave', clear);
}
};
}
$effect(() => {
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
// Re-run when html, container refs, or mini-player visibility changes
@@ -524,27 +623,27 @@
</button>
{#if audioExpanded}
<div class="border border-t-0 border-(--color-border) rounded-b-lg overflow-hidden">
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active}
<!-- Mini-player is already playing this chapter — don't duplicate controls -->
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span>Controls are in the player bar below.</span>
</div>
{:else}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
chapterTitle={cleanTitle}
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
wordCount={wordCount}
onProRequired={() => { audioProRequired = true; }}
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active && layout.playerStyle === 'standard'}
<!-- Mini-player is already playing this chapter — don't duplicate controls (standard/minimal mode) -->
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span>Controls are in the player bar below.</span>
</div>
{:else}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
chapterTitle={cleanTitle}
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
wordCount={wordCount}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
</div>
@@ -610,6 +709,7 @@
type="button"
onclick={() => { if (pageIndex > 0) pageIndex--; }}
disabled={pageIndex === 0}
use:holdRepeat={{ onrepeat: () => { if (pageIndex > 0) pageIndex--; } }}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -617,13 +717,47 @@
</svg>
Prev
</button>
<span class="text-sm text-(--color-muted) tabular-nums">
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
</span>
<!-- Counter — tap to open slider -->
<div class="relative">
<button
type="button"
bind:this={sliderAnchorEl}
onclick={(e) => toggleSlider(e.currentTarget as HTMLElement)}
class="px-3 py-1.5 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) tabular-nums transition-colors"
aria-label="Jump to page"
>
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
</button>
{#if sliderOpen}
<div
bind:this={sliderAnchorFocusEl}
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-xl
px-4 py-3 flex flex-col items-center gap-2"
style="min-width: min(220px, 60vw);"
>
<span class="text-xs text-(--color-muted) tabular-nums">
Page {pageIndex + 1} of {totalPages}
</span>
<input
type="range"
min="0"
max={totalPages - 1}
value={pageIndex}
oninput={(e) => { pageIndex = Number((e.target as HTMLInputElement).value); }}
class="w-full accent-(--color-brand) cursor-pointer"
/>
</div>
{/if}
</div>
<button
type="button"
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
disabled={pageIndex === totalPages - 1}
use:holdRepeat={{ onrepeat: () => { if (pageIndex < totalPages - 1) pageIndex++; } }}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
>
Next
@@ -686,9 +820,70 @@
</div>
{/if}
<!-- ── Scroll mode floating nav buttons ──────────────────────────────────── -->
{#if layout.readMode === 'scroll' && !layout.focusMode}
{@const atTop = scrollProgress <= 0.01}
{@const atBottom = scrollProgress >= 0.99}
<div class="fixed right-4 {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[5.5rem]' : 'bottom-8'} z-40 flex flex-col gap-2 transition-all">
<!-- Up button / Prev chapter -->
{#if atTop && data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-brand) hover:bg-(--color-surface-3) transition-colors"
title="Previous chapter"
aria-label="Previous chapter"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
</svg>
</a>
{:else}
<button
type="button"
onclick={() => window.scrollBy({ top: -Math.round(window.innerHeight * 0.85), behavior: 'smooth' })}
disabled={atTop}
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-20 disabled:cursor-default transition-colors"
title="Scroll up"
aria-label="Scroll up"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
</svg>
</button>
{/if}
<!-- Down button / Next chapter -->
{#if atBottom && data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-brand) shadow-lg text-black hover:bg-(--color-brand-dim) transition-colors"
title="Next chapter"
aria-label="Next chapter"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</a>
{:else}
<button
type="button"
onclick={() => window.scrollBy({ top: Math.round(window.innerHeight * 0.85), behavior: 'smooth' })}
disabled={atBottom && !data.next}
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-20 disabled:cursor-default transition-colors"
title="Scroll down"
aria-label="Scroll down"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{/if}
</div>
{/if}
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
{#if layout.focusMode}
<div class="fixed {audioStore.active ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
<div class="fixed {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
<div class="flex items-center divide-x divide-(--color-border) rounded-full bg-(--color-surface-2)/95 backdrop-blur border border-(--color-border) shadow-lg text-xs text-(--color-muted) overflow-hidden">
<!-- Prev chapter -->
{#if data.prev}
@@ -710,6 +905,7 @@
type="button"
onclick={() => { if (pageIndex > 0) pageIndex--; }}
disabled={pageIndex === 0}
use:holdRepeat={{ onrepeat: () => { if (pageIndex > 0) pageIndex--; } }}
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
aria-label="Previous page"
>
@@ -717,13 +913,46 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<span class="px-2.5 py-2 tabular-nums text-(--color-muted) shrink-0 select-none">
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
</span>
<!-- Counter — tap to open slider -->
<div class="relative shrink-0">
<button
type="button"
onclick={(e) => toggleSlider(e.currentTarget as HTMLElement)}
class="px-2.5 py-2 tabular-nums text-(--color-muted) hover:text-(--color-text) transition-colors select-none"
aria-label="Jump to page"
>
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
</button>
{#if sliderOpen}
<div
bind:this={sliderAnchorFocusEl}
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-xl
px-4 py-3 flex flex-col items-center gap-2"
style="min-width: min(220px, 60vw);"
>
<span class="text-xs text-(--color-muted) tabular-nums">
Page {pageIndex + 1} of {totalPages}
</span>
<input
type="range"
min="0"
max={totalPages - 1}
value={pageIndex}
oninput={(e) => { pageIndex = Number((e.target as HTMLInputElement).value); }}
class="w-full accent-(--color-brand) cursor-pointer"
/>
</div>
{/if}
</div>
<button
type="button"
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
disabled={pageIndex === totalPages - 1}
use:holdRepeat={{ onrepeat: () => { if (pageIndex < totalPages - 1) pageIndex++; } }}
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
aria-label="Next page"
>
@@ -967,7 +1196,7 @@
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
<div class="flex gap-1.5 flex-1">
{#each ([['standard', 'Standard'], ['float', 'Float']] as const) as [s, lbl]}
{#each ([['standard', 'Standard'], ['minimal', 'Minimal'], ['float', 'Float']] as const) as [s, lbl]}
<button
type="button"
onclick={() => setLayout('playerStyle', s)}
@@ -980,6 +1209,17 @@
{/each}
</div>
</div>
<div class="px-3 pb-2.5">
<p class="text-[11px] text-(--color-muted)/70 leading-snug">
{#if layout.playerStyle === 'standard'}
Full panel with voice picker and chapter browser.
{:else if layout.playerStyle === 'minimal'}
Compact bar: play/pause, seek, and time only.
{:else}
Draggable overlay — stays visible while you read.
{/if}
</p>
</div>
<!-- Speed -->
<div class="flex items-center gap-3 px-3 py-2.5">

View File

@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { backendFetch } from '$lib/server/scraper';
export const load: PageServerLoad = async ({ locals }) => {
// Admins have their own full notifications page
if (locals.user?.role === 'admin') {
redirect(302, '/admin/notifications');
}
const userId = locals.user!.id;
try {
const res = await backendFetch('/api/notifications?user_id=' + userId);
const data = await res.json().catch(() => ({ notifications: [] }));
return {
userId,
notifications: (data.notifications ?? []) as Array<{
id: string;
title: string;
message: string;
link: string;
read: boolean;
}>
};
} catch {
return { userId, notifications: [] };
}
};

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
type Notification = { id: string; title: string; message: string; link: string; read: boolean };
let notifications = $state<Notification[]>(data.notifications);
let filter = $state<'all' | 'unread'>('all');
let busy = $state(false);
const filtered = $derived(
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
);
const unreadCount = $derived(notifications.filter(n => !n.read).length);
async function markRead(id: string) {
await fetch('/api/notifications/' + id, { method: 'PATCH' }).catch(() => {});
notifications = notifications.map(n => n.id === id ? { ...n, read: true } : n);
}
async function dismiss(id: string) {
await fetch('/api/notifications/' + id, { method: 'DELETE' }).catch(() => {});
notifications = notifications.filter(n => n.id !== id);
}
async function markAllRead() {
busy = true;
try {
await fetch('/api/notifications?user_id=' + data.userId, { method: 'PATCH' });
notifications = notifications.map(n => ({ ...n, read: true }));
} finally { busy = false; }
}
async function clearAll() {
if (!confirm('Clear all notifications?')) return;
busy = true;
try {
await fetch('/api/notifications?user_id=' + data.userId, { method: 'DELETE' });
notifications = [];
} finally { busy = false; }
}
</script>
<svelte:head>
<title>Notifications</title>
</svelte:head>
<div class="max-w-2xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-xl font-semibold">Notifications</h1>
{#if unreadCount > 0}
<p class="text-sm text-(--color-muted) mt-0.5">{unreadCount} unread</p>
{/if}
</div>
<div class="flex gap-2">
{#if unreadCount > 0}
<button
type="button"
onclick={markAllRead}
disabled={busy}
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
>Mark all read</button>
{/if}
{#if notifications.length > 0}
<button
type="button"
onclick={clearAll}
disabled={busy}
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-red-400 hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
>Clear all</button>
{/if}
</div>
</div>
<!-- Filter tabs -->
<div class="flex gap-0 mb-4">
<button
type="button"
onclick={() => filter = 'all'}
class="text-sm px-4 py-1.5 rounded-l border border-(--color-border) transition-colors {filter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
>All ({notifications.length})</button>
<button
type="button"
onclick={() => filter = 'unread'}
class="text-sm px-4 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors {filter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
>Unread ({unreadCount})</button>
</div>
<!-- List -->
{#if filtered.length === 0}
<div class="py-16 text-center text-(--color-muted)">
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
</div>
{:else}
<div class="rounded-lg border border-(--color-border) overflow-hidden">
{#each filtered as n (n.id)}
<div class="flex items-start gap-2 border-b border-(--color-border)/40 last:border-b-0 hover:bg-(--color-surface-2) group transition-colors {n.read ? 'opacity-60' : ''}">
<a
href={n.link || '/'}
onclick={() => markRead(n.id)}
class="flex-1 p-4 min-w-0"
>
<div class="flex items-center gap-2">
{#if !n.read}
<span class="w-2 h-2 rounded-full bg-(--color-brand) shrink-0"></span>
{/if}
<span class="font-medium text-sm">{n.title}</span>
</div>
<p class="text-sm text-(--color-muted) mt-1">{n.message}</p>
</a>
<button
type="button"
onclick={() => dismiss(n.id)}
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -5,7 +5,8 @@ import {
getUserByUsername,
getUserStats,
allProgress,
getBooksBySlugs
getBooksBySlugs,
getUserById
} from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
@@ -41,12 +42,18 @@ export const load: PageServerLoad = async ({ locals }) => {
};
}
// Helper: fetch fresh user record (for notification prefs not in auth token)
async function fetchFreshUser() {
return getUserById(locals.user!.id);
}
// Run all three independent groups concurrently
const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([
const [userRecord, sessionsResult, statsResult, historyResult, freshUserResult] = await Promise.allSettled([
fetchUserRecord(),
listUserSessions(locals.user.id),
getUserStats(locals.sessionId, locals.user.id),
fetchHistory()
fetchHistory(),
fetchFreshUser()
]);
if (userRecord.status === 'rejected')
@@ -57,7 +64,6 @@ export const load: PageServerLoad = async ({ locals }) => {
log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) });
if (historyResult.status === 'rejected')
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
const { avatarUrl = null, email = null, polarCustomerId = null } =
userRecord.status === 'fulfilled' ? userRecord.value : {};
const sessions =
@@ -66,12 +72,15 @@ export const load: PageServerLoad = async ({ locals }) => {
statsResult.status === 'fulfilled' ? statsResult.value : null;
const history =
historyResult.status === 'fulfilled' ? historyResult.value : [];
const freshUser =
freshUserResult.status === 'fulfilled' ? freshUserResult.value : null;
return {
user: locals.user,
avatarUrl,
email,
polarCustomerId,
notifyNewChapters: freshUser?.notify_new_chapters ?? true,
stats: stats ?? {
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
booksPlanToRead: 0, booksDropped: 0, topGenres: [],

View File

@@ -233,6 +233,117 @@
} catch { return iso; }
}
// ── Push notifications ────────────────────────────────────────────────────────
type PushState = 'unsupported' | 'default' | 'subscribed' | 'denied' | 'loading';
let pushState = $state<PushState>('unsupported');
let pushError = $state('');
// ── In-app notifications ──────────────────────────────────────────────────────
let notifyNewChapters = $state(data.notifyNewChapters ?? true);
let notifyNewChaptersSaving = $state(false);
async function toggleNotifyNewChapters() {
notifyNewChaptersSaving = true;
const next = !notifyNewChapters;
try {
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notify_new_chapters: next })
});
if (res.ok) {
notifyNewChapters = next;
}
} catch { /* ignore */ } finally {
notifyNewChaptersSaving = false;
}
}
$effect(() => {
if (!browser) return;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
pushState = 'unsupported';
return;
}
// Check current permission / subscription state
(async () => {
const perm = Notification.permission;
if (perm === 'denied') { pushState = 'denied'; return; }
const reg = await navigator.serviceWorker.getRegistration('/');
if (!reg) { pushState = 'default'; return; }
const sub = await reg.pushManager.getSubscription();
pushState = sub ? 'subscribed' : 'default';
})();
});
async function subscribePush() {
pushError = '';
pushState = 'loading';
try {
// Fetch VAPID public key from backend
const keyRes = await fetch('/api/push-subscriptions/vapid-public-key');
if (!keyRes.ok) { pushError = 'Push notifications are not configured on this server.'; pushState = 'default'; return; }
const { public_key } = await keyRes.json() as { public_key: string };
// Register / get existing service worker
const reg = await navigator.serviceWorker.ready;
// Subscribe with VAPID
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
const json = sub.toJSON() as {
endpoint: string;
keys: { p256dh: string; auth: string };
};
const saveRes = await fetch('/api/push-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json),
});
if (!saveRes.ok) { pushError = 'Failed to save subscription. Please try again.'; pushState = 'default'; return; }
pushState = 'subscribed';
} catch (e) {
pushError = e instanceof Error ? e.message : 'Failed to enable notifications.';
pushState = Notification.permission === 'denied' ? 'denied' : 'default';
}
}
async function unsubscribePush() {
pushError = '';
pushState = 'loading';
try {
const reg = await navigator.serviceWorker.getRegistration('/');
const sub = await reg?.pushManager.getSubscription();
if (sub) {
await fetch('/api/push-subscription', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
await sub.unsubscribe();
}
pushState = 'default';
} catch (e) {
pushError = e instanceof Error ? e.message : 'Failed to disable notifications.';
pushState = 'subscribed';
}
}
/** Convert a base64url VAPID public key to a Uint8Array for PushManager.subscribe(). */
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr.buffer as ArrayBuffer;
}
function parseUA(ua: string): string {
if (!ua) return 'Unknown browser';
if (/Mobile/i.test(ua)) {
@@ -634,6 +745,97 @@
{/if}
</section>
<!-- ── In-app notifications ──────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-base font-semibold text-(--color-text)">In-app notifications</h2>
<p class="text-sm text-(--color-muted) mt-0.5">
{#if notifyNewChapters}
You'll receive a notification when new chapters are added to books in your library.
{:else}
In-app new-chapter notifications are disabled.
{/if}
</p>
</div>
<button
type="button"
onclick={toggleNotifyNewChapters}
disabled={notifyNewChaptersSaving}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50',
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'
)}
role="switch"
aria-checked={notifyNewChapters}
title={notifyNewChapters ? 'Turn off in-app notifications' : 'Turn on in-app notifications'}
>
<span class={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
notifyNewChapters ? 'translate-x-6' : 'translate-x-1'
)}></span>
</button>
</div>
</section>
<!-- ── Push notifications ────────────────────────────────────────────────── -->
{#if pushState !== 'unsupported'}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-base font-semibold text-(--color-text)">Push notifications</h2>
<p class="text-sm text-(--color-muted) mt-0.5">
{#if pushState === 'subscribed'}
You'll receive a push notification when new chapters are added to books in your library.
{:else if pushState === 'denied'}
Notifications are blocked by your browser. Change the permission in your browser settings.
{:else}
Get notified when new chapters arrive for books in your library.
{/if}
</p>
{#if pushError}
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
{/if}
</div>
<div class="shrink-0">
{#if pushState === 'subscribed'}
<button
type="button"
onclick={unsubscribePush}
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-sm font-medium transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
Turn off
</button>
{:else if pushState === 'denied'}
<span class="text-xs text-(--color-muted) italic">Blocked</span>
{:else}
<button
type="button"
onclick={subscribePush}
disabled={pushState === 'loading'}
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) disabled:opacity-60 text-sm font-semibold transition-colors"
>
{#if pushState === 'loading'}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
{/if}
Turn on
</button>
{/if}
</div>
</div>
</section>
{/if}
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
<section class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
<button

73
ui/src/service-worker.ts Normal file
View File

@@ -0,0 +1,73 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
declare let self: ServiceWorkerGlobalScope;
// ── Install / Activate ────────────────────────────────────────────────────────
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
// ── Push notifications ────────────────────────────────────────────────────────
interface PushPayload {
title: string;
body: string;
url?: string;
icon?: string;
badge?: string;
}
self.addEventListener('push', (event) => {
if (!event.data) return;
let payload: PushPayload;
try {
payload = event.data.json() as PushPayload;
} catch {
payload = { title: 'LibNovel', body: event.data.text() };
}
const options: NotificationOptions = {
body: payload.body,
icon: payload.icon ?? '/icon-192.png',
badge: payload.badge ?? '/favicon-32.png',
data: { url: payload.url ?? '/' },
// Show notification even when the app is focused
requireInteraction: false,
};
event.waitUntil(
self.registration.showNotification(payload.title, options)
);
});
// ── Notification click ────────────────────────────────────────────────────────
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url: string = (event.notification.data as { url?: string })?.url ?? '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus existing window if it has the target URL already open
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
// Otherwise open a new window
if (self.clients.openWindow) {
return self.clients.openWindow(url);
}
})
);
});

View File

@@ -0,0 +1,14 @@
{
"name": "LibNovel",
"short_name": "LibNovel",
"description": "Read and listen to web novels",
"start_url": "/",
"display": "standalone",
"background_color": "#18181b",
"theme_color": "#18181b",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}