Compare commits

...

7 Commits

Author SHA1 Message Date
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
root
6776d9106f fix: catalogue job always shows 0 counters after cancel/finish
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 6m5s
Release / Gitea Release (push) Successful in 32s
Two bugs fixed in runScrapeTask / runCatalogueTask:

1. FinishScrapeTask was called with the task's own context, which is
   already cancelled when the task is stopped. The PATCH to PocketBase
   failed silently, leaving all counters at their initial zero values.
   Fix: use a fresh context.WithTimeout(Background, 15s) for the write.

2. BooksFound was double-counted: RunBook already sets BooksFound=1 on
   success, but the accumulation loop added an extra +1 unconditionally,
   reporting 2 books per successful scrape.
   Fix: result.BooksFound += bookResult.BooksFound  (drop the + 1).
2026-04-11 12:33:30 +05:00
root
ada7de466a perf: remove voice picker from profile, parallelize server load
All checks were successful
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 1m52s
Release / Docker (push) Successful in 5m58s
Release / Gitea Release (push) Successful in 33s
Remove the TTS voice section from the profile page — it fetched
/api/voices on every mount, blocking paint for the full round-trip.
Voice selection lives on the chapter page where voices are already loaded.

Rewrite the server load to run avatar, sessions+stats, and reading history
all concurrently via Promise.allSettled instead of sequentially, cutting
SSR latency by ~2-3x on the profile route.
2026-04-11 10:41:35 +05:00
root
c91dd20c8c refactor: clean up profile page UI — remove decorative icons
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m51s
Release / Docker (push) Successful in 6m21s
Release / Gitea Release (push) Successful in 36s
Remove all decorative SVG icons (checkmarks, chevrons, stars, fire,
external-link arrows, empty-state illustrations). Replace icon-only
interactive elements with text (avatar hover shows 'Edit', voice sample
buttons show 'Play'/'Stop', danger zone toggle shows 'Open'/'Close').
Replace SVG avatar placeholder with the user's initial. Strip emoji
from stats cards and genre chips. Tighten playback toggle descriptions.
2026-04-11 10:21:14 +05:00
26 changed files with 1560 additions and 363 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
@@ -505,7 +513,11 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
log.Warn("runner: unknown task kind")
}
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
// Use a fresh context for the final write so a cancelled task context doesn't
// prevent the result counters from being persisted to PocketBase.
finishCtx, finishCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer finishCancel()
if err := r.deps.Consumer.FinishScrapeTask(finishCtx, task.ID, result); err != nil {
log.Error("runner: FinishScrapeTask failed", "err", err)
}
@@ -527,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",
@@ -551,7 +587,7 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o
TargetURL: entry.URL,
}
bookResult := o.RunBook(ctx, bookTask)
result.BooksFound += bookResult.BooksFound + 1
result.BooksFound += bookResult.BooksFound
result.ChaptersScraped += bookResult.ChaptersScraped
result.ChaptersSkipped += bookResult.ChaptersSkipped
result.Errors += bookResult.Errors

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

@@ -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(() => {
@@ -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) -->
@@ -1222,6 +1138,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

@@ -121,6 +121,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
@@ -610,6 +682,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 +690,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
@@ -710,6 +817,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 +825,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"
>

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';
@@ -15,54 +16,71 @@ export const load: PageServerLoad = async ({ locals }) => {
redirect(302, '/login');
}
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
let email: string | null = null;
let polarCustomerId: string | null = null;
let stats: Awaited<ReturnType<typeof getUserStats>> | null = null;
// Fetch avatar — MinIO first, fall back to OAuth provider picture
let avatarUrl: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
email = record?.email ?? null;
polarCustomerId = record?.polar_customer_id ?? null;
} catch (e) {
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
}
try {
[sessions, stats] = await Promise.all([
listUserSessions(locals.user.id),
getUserStats(locals.sessionId, locals.user.id)
]);
} catch (e) {
log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
}
// Reading history — last 50 progress entries with book metadata
let history: { slug: string; chapter: number; updated: string; title: string; cover: string | null }[] = [];
try {
const progress = await allProgress(locals.sessionId, locals.user.id);
// Helper: fetch reading history (progress → books, sequential by necessity)
async function fetchHistory() {
const progress = await allProgress(locals.sessionId, locals.user!.id);
const recent = progress.slice(0, 50);
const books = await getBooksBySlugs(new Set(recent.map((p) => p.slug)));
const bookMap = new Map(books.map((b) => [b.slug, b]));
history = recent.map((p) => ({
return recent.map((p) => ({
slug: p.slug,
chapter: p.chapter,
updated: p.updated,
title: bookMap.get(p.slug)?.title ?? p.slug,
cover: bookMap.get(p.slug)?.cover ?? null
}));
} catch (e) {
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(e) });
}
// Helper: fetch avatar/email/polarCustomerId (getUserByUsername → resolveAvatarUrl)
async function fetchUserRecord() {
const record = await getUserByUsername(locals.user!.username);
const avatarUrl = await resolveAvatarUrl(locals.user!.id, record?.avatar_url);
return {
avatarUrl,
email: record?.email ?? null,
polarCustomerId: record?.polar_customer_id ?? null
};
}
// 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, freshUserResult] = await Promise.allSettled([
fetchUserRecord(),
listUserSessions(locals.user.id),
getUserStats(locals.sessionId, locals.user.id),
fetchHistory(),
fetchFreshUser()
]);
if (userRecord.status === 'rejected')
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(userRecord.reason) });
if (sessionsResult.status === 'rejected')
log.warn('profile', 'sessions fetch failed (non-fatal)', { err: String(sessionsResult.reason) });
if (statsResult.status === 'rejected')
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 =
sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
const stats =
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

@@ -6,7 +6,6 @@
import type { AudioMode } from '$lib/audio.svelte';
import { browser } from '$app/environment';
import { page } from '$app/state';
import type { Voice } from '$lib/types';
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
@@ -84,70 +83,6 @@
function handleCropCancel() { cropFile = null; }
// ── Voices ───────────────────────────────────────────────────────────────────
let voices = $state<Voice[]>([]);
let voicesLoaded = $state(false);
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
function voiceLabel(v: Voice): string {
if (v.engine === 'cfai') {
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
return speaker.replace(/\b\w/g, (c) => c.toUpperCase()) + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
}
if (v.engine === 'pocket-tts') {
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return name + (v.gender ? ` (${v.lang?.toUpperCase().replace('-','')} ${v.gender.toUpperCase()})` : '');
}
// Kokoro: "af_bella" → "Bella (US F)"
const langMap: Record<string, string> = {
af:'US', am:'US', bf:'UK', bm:'UK',
ef:'ES', em:'ES', ff:'FR',
hf:'IN', hm:'IN', 'if':'IT', im:'IT',
jf:'JP', jm:'JP', pf:'PT', pm:'PT', zf:'ZH', zm:'ZH',
};
const prefix = v.id.slice(0, 2);
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
const lang = langMap[prefix] ?? prefix.toUpperCase();
const gender = v.gender ? v.gender.toUpperCase() : '?';
return `${name} (${lang} ${gender})`;
}
$effect(() => {
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
.catch(() => { voicesLoaded = true; });
});
// Voice sample playback
let samplePlayingVoice = $state<string | null>(null);
let sampleAudio = $state<HTMLAudioElement | null>(null);
function stopSample() {
if (sampleAudio) { sampleAudio.pause(); sampleAudio.src = ''; sampleAudio = null; }
samplePlayingVoice = null;
}
async function toggleSample(voiceId: string) {
if (samplePlayingVoice === voiceId) { stopSample(); return; }
stopSample();
samplePlayingVoice = voiceId;
try {
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
if (res.status === 404) { samplePlayingVoice = null; return; }
if (!res.ok) throw new Error();
const { url } = await res.json() as { url: string };
const audio = new Audio(url);
sampleAudio = audio;
audio.onended = () => { if (samplePlayingVoice === voiceId) stopSample(); };
audio.onerror = () => { if (samplePlayingVoice === voiceId) stopSample(); };
await audio.play();
} catch { samplePlayingVoice = null; }
}
// ── Settings state ────────────────────────────────────────────────────────────
// All changes are written directly into audioStore / theme context.
// The layout's debounced $effect owns the single PUT /api/settings call.
@@ -192,7 +127,6 @@
function markSaved() {
saveStatus = 'saving';
clearTimeout(savedTimer);
// Give a tick for layout's effect to fire, then show ✓ Saved
savedTimer = setTimeout(() => {
saveStatus = 'saved';
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
@@ -218,11 +152,10 @@
}
if (!initialized) { initialized = true; return; }
void v; void sp; void an; void ac; void am; // keep subscriptions live
void v; void sp; void an; void ac; void am;
markSaved();
});
// Keep theme/font writes flowing into layout context when changed from selectors
$effect(() => { if (settingsCtx) settingsCtx.current = selectedTheme; });
$effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; });
$effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; });
@@ -283,7 +216,6 @@
deleteError = body.message ?? `Delete failed (${res.status}). Please try again.`;
return;
}
// Server deleted account — submit logout form to clear session cookie
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) logoutForm.submit();
} catch {
@@ -301,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)) {
@@ -331,12 +374,9 @@
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
{#if justSubscribed}
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4 flex items-start gap-3">
<svg class="w-5 h-5 text-(--color-brand) shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
<div>
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
</div>
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4">
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
</div>
{/if}
@@ -353,9 +393,9 @@
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-10 h-10 text-(--color-muted)" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
<span class="text-3xl font-bold text-(--color-muted) select-none">
{data.user.username.slice(0, 1).toUpperCase()}
</span>
</div>
{/if}
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
@@ -365,10 +405,7 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
{:else}
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span class="text-xs font-semibold text-white tracking-wide">Edit</span>
{/if}
</div>
</button>
@@ -380,8 +417,7 @@
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
{#if data.isPro}
<span class="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
{m.profile_plan_pro()}
</span>
{/if}
@@ -440,8 +476,6 @@
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
{#if checkoutLoading === 'monthly'}
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
{:else}
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{/if}
{m.profile_upgrade_monthly()}
</button>
@@ -467,9 +501,8 @@
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
</div>
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
class="shrink-0 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
</a>
</section>
{/if}
@@ -562,84 +595,6 @@
</div>
</div>
<!-- TTS voice — visual card picker grouped by engine -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_tts_voice()}</p>
{#if !voicesLoaded}
<div class="space-y-2">
{#each [1,2,3] as _}
<div class="h-10 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
{/each}
</div>
{:else if voices.length === 0}
<p class="text-sm text-(--color-muted) italic">No voices available.</p>
{:else}
<!-- Engine groups -->
{#each [
{ label: 'Kokoro (GPU)', voices: kokoroVoices },
{ label: 'Pocket TTS (CPU)', voices: pocketVoices },
{ label: 'Cloudflare AI', voices: cfaiVoices },
].filter(g => g.voices.length > 0) as group}
<div>
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest mb-2">{group.label}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{#each group.voices as v (v.id)}
{@const isSelected = audioStore.voice === v.id}
{@const isPlaying = samplePlayingVoice === v.id}
<!-- Use role=option div to avoid nested <button> inside <button> -->
<div
role="option"
aria-selected={isSelected}
tabindex="0"
onclick={() => { audioStore.voice = v.id; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); audioStore.voice = v.id; } }}
class={cn(
'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border text-sm transition-colors cursor-pointer select-none',
isSelected
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-text) hover:border-(--color-brand)/40'
)}
>
<div class="flex items-center gap-2 min-w-0">
{#if isSelected}
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{:else}
<span class="w-3.5 h-3.5 shrink-0"></span>
{/if}
<span class="truncate font-medium">{voiceLabel(v)}</span>
</div>
<!-- Sample play button -->
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleSample(v.id); }}
class={cn(
'shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-colors',
isPlaying
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'
)}
title={isPlaying ? 'Stop sample' : 'Play sample'}
>
{#if isPlaying}
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
{:else}
<svg class="w-3 h-3 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
</div>
{/each}
</div>
</div>
{/each}
{/if}
</div>
<!-- Playback speed -->
<div class="px-6 py-5 space-y-3">
<div class="flex items-center justify-between">
@@ -654,15 +609,15 @@
</div>
</div>
<!-- Playback toggles row -->
<div class="px-6 py-5 space-y-4">
<!-- Playback toggles -->
<div class="px-6 py-5 space-y-5">
<p class="text-sm font-medium text-(--color-text)">Playback</p>
<!-- Auto-advance -->
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm text-(--color-text)">{m.profile_auto_advance()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
<p class="text-xs text-(--color-muted) mt-0.5">Load the next chapter automatically when audio ends</p>
</div>
<button
type="button"
@@ -680,10 +635,10 @@
</div>
<!-- Announce chapter -->
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm text-(--color-text)">Announce chapter</p>
<p class="text-xs text-(--color-muted) mt-0.5">Read the chapter title aloud before auto-advancing</p>
<p class="text-xs text-(--color-muted) mt-0.5">Read the chapter title aloud before advancing</p>
</div>
<button
type="button"
@@ -701,22 +656,18 @@
</div>
<!-- Audio mode -->
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm text-(--color-text)">Audio mode</p>
<p class="text-xs text-(--color-muted) mt-0.5">
{#if audioStore.audioMode === 'stream'}
<strong class="text-(--color-text)">Stream</strong> — audio starts within seconds, saved in background
{:else}
<strong class="text-(--color-text)">Generate</strong> — wait for full audio before playing
{/if}
{audioStore.audioMode === 'stream' ? 'Stream — starts within seconds' : 'Generate — waits for full audio'}
{#if audioStore.voice.startsWith('cfai:')} <span class="text-(--color-border)">(not available for CF AI)</span>{/if}
</p>
</div>
<button
type="button"
onclick={() => {
audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream';
}}
aria-label="Toggle audio mode"
onclick={() => { audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream'; }}
disabled={audioStore.voice.startsWith('cfai:')}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
@@ -726,7 +677,6 @@
? 'bg-(--color-brand)'
: 'bg-(--color-surface-3) border border-(--color-border)'
)}
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : undefined}
>
<span class={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
@@ -795,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
@@ -806,12 +847,7 @@
<p class="text-sm font-semibold text-red-400">Danger zone</p>
<p class="text-xs text-(--color-muted) mt-0.5">Irreversible actions — proceed with care</p>
</div>
<svg
class={cn('w-4 h-4 text-(--color-muted) transition-transform', deleteConfirmOpen && 'rotate-180')}
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>
<span class="text-xs text-(--color-muted)">{deleteConfirmOpen ? 'Close' : 'Open'}</span>
</button>
{#if deleteConfirmOpen}
@@ -851,9 +887,6 @@
<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"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
Deleting…
{: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="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Delete my account
{/if}
</button>
@@ -871,10 +904,10 @@
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Reading Overview</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
{#each [
{ label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' },
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
{ label: 'Chapters Read', value: data.stats.totalChaptersRead },
{ label: 'Completed', value: data.stats.booksCompleted },
{ label: 'Reading', value: data.stats.booksReading },
{ label: 'Plan to Read', value: data.stats.booksPlanToRead },
] as stat}
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
@@ -888,21 +921,15 @@
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Activity</h2>
<div class="grid grid-cols-2 gap-3">
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
<div class="w-9 h-9 rounded-full bg-orange-500/15 flex items-center justify-center text-lg flex-shrink-0">🔥</div>
<div>
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
<p class="text-xs text-(--color-muted)">day streak</p>
</div>
<div class="bg-(--color-surface-3) rounded-lg p-4">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
<p class="text-xs text-(--color-muted) mt-1">day streak</p>
</div>
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
<div class="w-9 h-9 rounded-full bg-yellow-500/15 flex items-center justify-center text-lg flex-shrink-0"></div>
<div>
<p class="text-xl font-bold text-(--color-text) tabular-nums">
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
</p>
<p class="text-xs text-(--color-muted)">avg rating given</p>
</div>
<div class="bg-(--color-surface-3) rounded-lg p-4">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
</p>
<p class="text-xs text-(--color-muted) mt-1">avg rating given</p>
</div>
</div>
</section>
@@ -914,12 +941,11 @@
<div class="flex flex-wrap gap-2">
{#each data.stats.topGenres as genre, i}
<span class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium',
'px-3 py-1.5 rounded-full text-sm font-medium',
i === 0
? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30'
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'
)}>
{#if i === 0}<span class="text-xs">🏆</span>{/if}
{genre}
</span>
{/each}
@@ -940,10 +966,7 @@
{#if activeTab === 'history'}
<div class="space-y-2">
{#if data.history.length === 0}
<div class="py-12 text-center text-(--color-muted)">
<svg class="w-10 h-10 mx-auto mb-3 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="py-16 text-center text-(--color-muted)">
<p class="text-sm">No reading history yet.</p>
</div>
{:else}
@@ -956,11 +979,7 @@
{#if item.cover}
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div class="w-full h-full bg-(--color-surface-3)"></div>
{/if}
</div>
<div class="flex-1 min-w-0">

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" }
]
}