Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adacd8944b | ||
|
|
ea58dab71c | ||
|
|
cf3a3ad910 | ||
|
|
8660c675b6 | ||
|
|
1f4d67dc77 | ||
|
|
b0e23cb50a | ||
|
|
1e886a705d | ||
|
|
19b5b44454 | ||
|
|
b95c811898 | ||
|
|
3a9f3b773e |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
87
backend/internal/backend/handlers_push.go
Normal file
87
backend/internal/backend/handlers_push.go
Normal 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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -36,7 +36,9 @@ import (
|
||||
"github.com/libnovel/backend/internal/orchestrator"
|
||||
"github.com/libnovel/backend/internal/pockettts"
|
||||
"github.com/libnovel/backend/internal/scraper"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
"github.com/libnovel/backend/internal/webpush"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
@@ -130,6 +132,12 @@ type Dependencies struct {
|
||||
ChapterIngester ChapterIngester
|
||||
// Notifier creates notifications for users.
|
||||
Notifier Notifier
|
||||
// WebPush sends browser push notifications to subscribed users.
|
||||
// If nil, push notifications are disabled.
|
||||
WebPush *webpush.Sender
|
||||
// Store is the underlying *storage.Store; used for push subscription lookups.
|
||||
// Only needed when WebPush is non-nil.
|
||||
Store *storage.Store
|
||||
// SearchIndex indexes books in Meilisearch after scraping.
|
||||
// If nil a no-op is used.
|
||||
SearchIndex meili.Client
|
||||
@@ -531,6 +539,30 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
|
||||
"/admin/tasks")
|
||||
}
|
||||
// Fan-out in-app new-chapter notification to all users who have this book
|
||||
// in their library. Runs in background so it doesn't block the task loop.
|
||||
if r.deps.Store != nil && result.ChaptersScraped > 0 &&
|
||||
result.Slug != "" && task.Kind != "catalogue" {
|
||||
go func() {
|
||||
notifyCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
title := result.Slug
|
||||
_ = r.deps.Store.NotifyUsersWithBook(notifyCtx, result.Slug,
|
||||
"New chapters available",
|
||||
fmt.Sprintf("%d new chapter(s) added to %s", result.ChaptersScraped, title),
|
||||
"/books/"+result.Slug)
|
||||
}()
|
||||
}
|
||||
// Send Web Push notifications to subscribed browsers.
|
||||
if r.deps.WebPush != nil && r.deps.Store != nil &&
|
||||
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
|
||||
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{
|
||||
Title: "New chapter available",
|
||||
Body: fmt.Sprintf("%d new chapter(s) added", result.ChaptersScraped),
|
||||
URL: "/books/" + result.Slug,
|
||||
Icon: "/icon-192.png",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("runner: scrape task finished",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
147
backend/internal/webpush/webpush.go
Normal file
147
backend/internal/webpush/webpush.go
Normal 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] + "..."
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -170,6 +170,21 @@ class AudioStore {
|
||||
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
|
||||
}
|
||||
|
||||
/**
|
||||
* When true the persistent mini-bar in +layout.svelte is hidden.
|
||||
* Set by the chapter reader page when playerStyle is 'float' or 'minimal'
|
||||
* so the in-page player is the sole control surface.
|
||||
* Cleared when leaving the chapter page (page destroy / onDestroy effect).
|
||||
*/
|
||||
suppressMiniBar = $state(false);
|
||||
|
||||
/**
|
||||
* Position of the draggable float overlay (bottom-right anchor offsets).
|
||||
* Stored here (module singleton) so the position survives chapter navigation.
|
||||
* x > 0 = moved left; y > 0 = moved up.
|
||||
*/
|
||||
floatPos = $state({ x: 0, y: 0 });
|
||||
|
||||
/** True when the currently loaded track matches slug+chapter */
|
||||
isCurrentChapter(slug: string, chapter: number): boolean {
|
||||
return this.slug === slug && this.chapter === chapter;
|
||||
|
||||
@@ -71,8 +71,11 @@
|
||||
voices?: Voice[];
|
||||
/** Called when the server returns 402 (free daily limit reached). */
|
||||
onProRequired?: () => void;
|
||||
/** Visual style of the player card. 'standard' = inline card; 'float' = draggable overlay. */
|
||||
playerStyle?: 'standard' | 'float';
|
||||
/** Visual style of the player card.
|
||||
* 'standard' = full inline card with voice/chapter controls;
|
||||
* 'minimal' = compact single-row bar (play + seek + time only);
|
||||
* 'float' = draggable overlay anchored bottom-right. */
|
||||
playerStyle?: 'standard' | 'minimal' | 'float';
|
||||
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
|
||||
wordCount?: number;
|
||||
}
|
||||
@@ -942,19 +945,18 @@
|
||||
}
|
||||
|
||||
// ── Float player drag state ──────────────────────────────────────────────
|
||||
/** Position of the floating overlay (bottom-right anchor by default). */
|
||||
let floatPos = $state({ x: 0, y: 0 });
|
||||
// floatPos lives on audioStore (singleton) so position survives chapter navigation.
|
||||
let floatDragging = $state(false);
|
||||
let floatDragStart = $state({ mx: 0, my: 0, ox: 0, oy: 0 });
|
||||
|
||||
function onFloatPointerDown(e: PointerEvent) {
|
||||
floatDragging = true;
|
||||
floatDragStart = { mx: e.clientX, my: e.clientY, ox: floatPos.x, oy: floatPos.y };
|
||||
floatDragStart = { mx: e.clientX, my: e.clientY, ox: audioStore.floatPos.x, oy: audioStore.floatPos.y };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onFloatPointerMove(e: PointerEvent) {
|
||||
if (!floatDragging) return;
|
||||
floatPos = {
|
||||
audioStore.floatPos = {
|
||||
x: floatDragStart.ox + (e.clientX - floatDragStart.mx),
|
||||
y: floatDragStart.oy + (e.clientY - floatDragStart.my)
|
||||
};
|
||||
@@ -1034,7 +1036,8 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Track info -->
|
||||
<!-- Track info (hidden in minimal style) -->
|
||||
{#if playerStyle !== 'minimal'}
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
|
||||
{m.reader_play_narration()}
|
||||
@@ -1091,9 +1094,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapters button (right side) -->
|
||||
{#if chapters.length > 0}
|
||||
<!-- Chapters button (right side, hidden in minimal style) -->
|
||||
{#if chapters.length > 0 && playerStyle !== 'minimal'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
|
||||
@@ -1167,6 +1171,64 @@
|
||||
{:else}
|
||||
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
|
||||
{#if !(playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active)}
|
||||
|
||||
{#if playerStyle === 'minimal' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
|
||||
<!-- ── Minimal style: compact bar — seek + play/pause + skip + time ────────── -->
|
||||
<div class="px-3 py-2.5 flex items-center gap-2">
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
||||
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
title="-15s"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play/pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.toggleRequest++; }}
|
||||
class="flex-shrink-0 w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all"
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
||||
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
title="+30s"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Seek bar -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="none"
|
||||
class="flex-1 h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer"
|
||||
onclick={seekFromBar}
|
||||
>
|
||||
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<span class="flex-shrink-0 text-[11px] tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)}<span class="opacity-40">/</span>{formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-end gap-2 mb-3">
|
||||
<!-- Chapter picker button -->
|
||||
@@ -1372,6 +1434,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
|
||||
Rendered as a top-level sibling (outside all player containers) so that
|
||||
@@ -1380,7 +1443,7 @@
|
||||
{#if showChapterPanel && audioStore.chapters.length > 0}
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={chapter}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[60]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterPanel = false; }}
|
||||
@@ -1395,8 +1458,8 @@
|
||||
<div
|
||||
class="fixed z-[55] select-none"
|
||||
style="
|
||||
bottom: calc(4.5rem + {-floatPos.y}px);
|
||||
right: calc(1rem + {-floatPos.x}px);
|
||||
bottom: calc(1rem + {-audioStore.floatPos.y}px);
|
||||
right: calc(1rem + {-audioStore.floatPos.x}px);
|
||||
touch-action: none;
|
||||
"
|
||||
>
|
||||
|
||||
184
ui/src/lib/components/NotificationsModal.svelte
Normal file
184
ui/src/lib/components/NotificationsModal.svelte
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
||||
import SearchModal from '$lib/components/SearchModal.svelte';
|
||||
import NotificationsModal from '$lib/components/NotificationsModal.svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
@@ -26,7 +27,6 @@
|
||||
// Notifications
|
||||
let notificationsOpen = $state(false);
|
||||
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
|
||||
let notifFilter = $state<'all' | 'unread'>('all');
|
||||
async function loadNotifications() {
|
||||
if (!data.user) return;
|
||||
try {
|
||||
@@ -65,9 +65,6 @@
|
||||
}
|
||||
$effect(() => { if (data.user) loadNotifications(); });
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
const filteredNotifications = $derived(
|
||||
notifFilter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||
);
|
||||
|
||||
// Close search on navigation
|
||||
$effect(() => {
|
||||
@@ -515,7 +512,7 @@
|
||||
style="display:none"
|
||||
></audio>
|
||||
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active && !audioStore.suppressMiniBar}>
|
||||
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
|
||||
{#if navigating}
|
||||
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
|
||||
@@ -588,12 +585,12 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="relative">
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user}
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; }}
|
||||
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
|
||||
title="Notifications"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors {notificationsOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'} relative"
|
||||
>
|
||||
@@ -604,87 +601,6 @@
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if notificationsOpen}
|
||||
<div class="absolute right-0 top-full mt-1 w-80 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl z-50 flex flex-col max-h-[28rem]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-3 pt-3 pb-2 shrink-0">
|
||||
<span class="text-sm font-semibold">Notifications</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if unreadCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={markAllRead}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
|
||||
>Mark all read</button>
|
||||
{/if}
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAllNotifications}
|
||||
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
|
||||
>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter tabs -->
|
||||
<div class="flex gap-0 px-3 pb-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => notifFilter = 'all'}
|
||||
class="text-xs px-2.5 py-1 rounded-l border border-(--color-border) transition-colors {notifFilter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>All ({notifications.length})</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => notifFilter = 'unread'}
|
||||
class="text-xs px-2.5 py-1 rounded-r border border-l-0 border-(--color-border) transition-colors {notifFilter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Unread ({unreadCount})</button>
|
||||
</div>
|
||||
<!-- List -->
|
||||
<div class="overflow-y-auto flex-1 min-h-0">
|
||||
{#if filteredNotifications.length === 0}
|
||||
<div class="p-4 text-center text-(--color-muted) text-sm">
|
||||
{notifFilter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredNotifications as n (n.id)}
|
||||
<div class="flex items-start gap-1 border-b border-(--color-border)/40 hover:bg-(--color-surface-3) group {n.read ? 'opacity-60' : ''}">
|
||||
<a
|
||||
href={n.link || '/admin'}
|
||||
onclick={() => { markRead(n.id); notificationsOpen = false; }}
|
||||
class="flex-1 p-3 min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if !n.read}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||
{/if}
|
||||
<span class="text-sm font-medium truncate">{n.title}</span>
|
||||
</div>
|
||||
<div class="text-xs text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</div>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => dismissNotification(n.id)}
|
||||
class="shrink-0 p-2.5 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="px-3 py-2 border-t border-(--color-border)/40 shrink-0">
|
||||
<a
|
||||
href="/admin/notifications"
|
||||
onclick={() => notificationsOpen = false}
|
||||
class="block text-center text-xs text-(--color-muted) hover:text-(--color-brand) transition-colors"
|
||||
>View all notifications</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Theme dropdown (desktop) -->
|
||||
@@ -829,14 +745,24 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Backdrop for mobile hamburger menu -->
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="fixed top-14 inset-x-0 bottom-0 z-40 sm:hidden"
|
||||
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
|
||||
onpointerdown={() => { menuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="ml-auto">
|
||||
<a
|
||||
@@ -1043,7 +969,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
|
||||
{#if audioStore.active}
|
||||
{#if audioStore.active && !audioStore.suppressMiniBar}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
||||
|
||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||
@@ -1222,6 +1148,20 @@
|
||||
<SearchModal onclose={() => { searchOpen = false; }} />
|
||||
{/if}
|
||||
|
||||
<!-- Notifications modal — full-screen, shown for all logged-in users -->
|
||||
{#if notificationsOpen && data.user}
|
||||
<NotificationsModal
|
||||
notifications={notifications}
|
||||
userId={data.user.id}
|
||||
isAdmin={data.user.role === 'admin'}
|
||||
onclose={() => { notificationsOpen = false; }}
|
||||
onMarkRead={markRead}
|
||||
onMarkAllRead={markAllRead}
|
||||
onDismiss={dismissNotification}
|
||||
onClearAll={clearAllNotifications}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<svelte:window onkeydown={(e) => {
|
||||
// Don't intercept when typing in an input/textarea
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
63
ui/src/routes/api/push-subscription/+server.ts
Normal file
63
ui/src/routes/api/push-subscription/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -52,7 +52,7 @@
|
||||
type LineSpacing = 'compact' | 'normal' | 'relaxed';
|
||||
type ReadWidth = 'narrow' | 'normal' | 'wide';
|
||||
type ParaStyle = 'spaced' | 'indented';
|
||||
type PlayerStyle = 'standard' | 'float';
|
||||
type PlayerStyle = 'standard' | 'minimal' | 'float';
|
||||
/** Controls how many lines fit on a page by adjusting the container height offset. */
|
||||
type PageLines = 'less' | 'normal' | 'more';
|
||||
|
||||
@@ -100,6 +100,33 @@
|
||||
document.documentElement.style.setProperty('--reading-max-width', READ_WIDTHS[layout.readWidth]);
|
||||
});
|
||||
|
||||
// ── Suppress mini-bar for float / minimal player styles ──────────────────────
|
||||
// The in-page player is the sole control surface for these styles; the layout
|
||||
// mini-bar would be a duplicate. Clear on page destroy so the mini-bar returns
|
||||
// on other pages (library, catalogue, etc.) where audio may still be playing.
|
||||
$effect(() => {
|
||||
audioStore.suppressMiniBar = layout.playerStyle === 'float' || layout.playerStyle === 'minimal';
|
||||
return () => { audioStore.suppressMiniBar = false; };
|
||||
});
|
||||
|
||||
// ── Persist float overlay position across reloads ─────────────────────────────
|
||||
const FLOAT_POS_KEY = 'reader_float_pos_v1';
|
||||
if (browser) {
|
||||
try {
|
||||
const saved = localStorage.getItem(FLOAT_POS_KEY);
|
||||
if (saved) {
|
||||
const p = JSON.parse(saved) as { x: number; y: number };
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') audioStore.floatPos = p;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
$effect(() => {
|
||||
const pos = audioStore.floatPos;
|
||||
if (browser && (pos.x !== 0 || pos.y !== 0)) {
|
||||
try { localStorage.setItem(FLOAT_POS_KEY, JSON.stringify(pos)); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Scroll progress bar ──────────────────────────────────────────────────────
|
||||
let scrollProgress = $state(0);
|
||||
|
||||
@@ -121,6 +148,78 @@
|
||||
let paginatedContentEl = $state<HTMLDivElement | null>(null);
|
||||
let containerH = $state(0);
|
||||
|
||||
// ── Page slider popover ───────────────────────────────────────────────────
|
||||
let sliderOpen = $state(false);
|
||||
let sliderAnchorEl = $state<HTMLElement | null>(null);
|
||||
let sliderAnchorFocusEl = $state<HTMLElement | null>(null);
|
||||
|
||||
function toggleSlider(anchor: HTMLElement) {
|
||||
if (sliderOpen) { sliderOpen = false; return; }
|
||||
sliderAnchorEl = anchor;
|
||||
sliderOpen = true;
|
||||
}
|
||||
|
||||
function closeSliderOnOutside(e: MouseEvent) {
|
||||
if (!sliderOpen) return;
|
||||
const target = e.target as Node;
|
||||
if (sliderAnchorEl && sliderAnchorEl.contains(target)) return;
|
||||
if (sliderAnchorFocusEl && sliderAnchorFocusEl.contains(target)) return;
|
||||
sliderOpen = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
document.addEventListener('pointerdown', closeSliderOnOutside);
|
||||
return () => document.removeEventListener('pointerdown', closeSliderOnOutside);
|
||||
});
|
||||
|
||||
// ── Hold-to-repeat action ────────────────────────────────────────────────
|
||||
/**
|
||||
* Svelte action: fires `onrepeat()` repeatedly while the pointer is held.
|
||||
* First repeat fires after `delay` ms; subsequent repeats every `interval` ms.
|
||||
*/
|
||||
function holdRepeat(
|
||||
node: HTMLElement,
|
||||
params: { onrepeat: () => void; delay?: number; interval?: number }
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clear() {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
timer = setTimeout(() => {
|
||||
params.onrepeat();
|
||||
schedule(); // re-schedule at interval speed
|
||||
}, params.interval ?? 80);
|
||||
}
|
||||
|
||||
function start() {
|
||||
clear();
|
||||
timer = setTimeout(() => {
|
||||
params.onrepeat();
|
||||
schedule();
|
||||
}, params.delay ?? 400);
|
||||
}
|
||||
|
||||
node.addEventListener('pointerdown', start);
|
||||
node.addEventListener('pointerup', clear);
|
||||
node.addEventListener('pointercancel', clear);
|
||||
node.addEventListener('pointerleave', clear);
|
||||
|
||||
return {
|
||||
update(newParams: typeof params) { params = newParams; },
|
||||
destroy() {
|
||||
clear();
|
||||
node.removeEventListener('pointerdown', start);
|
||||
node.removeEventListener('pointerup', clear);
|
||||
node.removeEventListener('pointercancel', clear);
|
||||
node.removeEventListener('pointerleave', clear);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
|
||||
// Re-run when html, container refs, or mini-player visibility changes
|
||||
@@ -524,27 +623,27 @@
|
||||
</button>
|
||||
{#if audioExpanded}
|
||||
<div class="border border-t-0 border-(--color-border) rounded-b-lg overflow-hidden">
|
||||
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active}
|
||||
<!-- Mini-player is already playing this chapter — don't duplicate controls -->
|
||||
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
|
||||
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span>Controls are in the player bar below.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={cleanTitle}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
playerStyle={layout.playerStyle}
|
||||
wordCount={wordCount}
|
||||
onProRequired={() => { audioProRequired = true; }}
|
||||
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active && layout.playerStyle === 'standard'}
|
||||
<!-- Mini-player is already playing this chapter — don't duplicate controls (standard/minimal mode) -->
|
||||
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
|
||||
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span>Controls are in the player bar below.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={cleanTitle}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
playerStyle={layout.playerStyle}
|
||||
wordCount={wordCount}
|
||||
onProRequired={() => { audioProRequired = true; }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -610,6 +709,7 @@
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex > 0) pageIndex--; }}
|
||||
disabled={pageIndex === 0}
|
||||
use:holdRepeat={{ onrepeat: () => { if (pageIndex > 0) pageIndex--; } }}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -617,13 +717,47 @@
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
<span class="text-sm text-(--color-muted) tabular-nums">
|
||||
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
|
||||
</span>
|
||||
|
||||
<!-- Counter — tap to open slider -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
bind:this={sliderAnchorEl}
|
||||
onclick={(e) => toggleSlider(e.currentTarget as HTMLElement)}
|
||||
class="px-3 py-1.5 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) tabular-nums transition-colors"
|
||||
aria-label="Jump to page"
|
||||
>
|
||||
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
|
||||
</button>
|
||||
|
||||
{#if sliderOpen}
|
||||
<div
|
||||
bind:this={sliderAnchorFocusEl}
|
||||
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
|
||||
bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-xl
|
||||
px-4 py-3 flex flex-col items-center gap-2"
|
||||
style="min-width: min(220px, 60vw);"
|
||||
>
|
||||
<span class="text-xs text-(--color-muted) tabular-nums">
|
||||
Page {pageIndex + 1} of {totalPages}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={totalPages - 1}
|
||||
value={pageIndex}
|
||||
oninput={(e) => { pageIndex = Number((e.target as HTMLInputElement).value); }}
|
||||
class="w-full accent-(--color-brand) cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
|
||||
disabled={pageIndex === totalPages - 1}
|
||||
use:holdRepeat={{ onrepeat: () => { if (pageIndex < totalPages - 1) pageIndex++; } }}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
|
||||
>
|
||||
Next
|
||||
@@ -686,9 +820,70 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Scroll mode floating nav buttons ──────────────────────────────────── -->
|
||||
{#if layout.readMode === 'scroll' && !layout.focusMode}
|
||||
{@const atTop = scrollProgress <= 0.01}
|
||||
{@const atBottom = scrollProgress >= 0.99}
|
||||
<div class="fixed right-4 {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[5.5rem]' : 'bottom-8'} z-40 flex flex-col gap-2 transition-all">
|
||||
<!-- Up button / Prev chapter -->
|
||||
{#if atTop && data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-brand) hover:bg-(--color-surface-3) transition-colors"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => window.scrollBy({ top: -Math.round(window.innerHeight * 0.85), behavior: 'smooth' })}
|
||||
disabled={atTop}
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-20 disabled:cursor-default transition-colors"
|
||||
title="Scroll up"
|
||||
aria-label="Scroll up"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Down button / Next chapter -->
|
||||
{#if atBottom && data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-brand) shadow-lg text-black hover:bg-(--color-brand-dim) transition-colors"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => window.scrollBy({ top: Math.round(window.innerHeight * 0.85), behavior: 'smooth' })}
|
||||
disabled={atBottom && !data.next}
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-20 disabled:cursor-default transition-colors"
|
||||
title="Scroll down"
|
||||
aria-label="Scroll down"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
|
||||
{#if layout.focusMode}
|
||||
<div class="fixed {audioStore.active ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
|
||||
<div class="fixed {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
|
||||
<div class="flex items-center divide-x divide-(--color-border) rounded-full bg-(--color-surface-2)/95 backdrop-blur border border-(--color-border) shadow-lg text-xs text-(--color-muted) overflow-hidden">
|
||||
<!-- Prev chapter -->
|
||||
{#if data.prev}
|
||||
@@ -710,6 +905,7 @@
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex > 0) pageIndex--; }}
|
||||
disabled={pageIndex === 0}
|
||||
use:holdRepeat={{ onrepeat: () => { if (pageIndex > 0) pageIndex--; } }}
|
||||
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
@@ -717,13 +913,46 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="px-2.5 py-2 tabular-nums text-(--color-muted) shrink-0 select-none">
|
||||
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
|
||||
</span>
|
||||
|
||||
<!-- Counter — tap to open slider -->
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => toggleSlider(e.currentTarget as HTMLElement)}
|
||||
class="px-2.5 py-2 tabular-nums text-(--color-muted) hover:text-(--color-text) transition-colors select-none"
|
||||
aria-label="Jump to page"
|
||||
>
|
||||
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
|
||||
</button>
|
||||
|
||||
{#if sliderOpen}
|
||||
<div
|
||||
bind:this={sliderAnchorFocusEl}
|
||||
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
|
||||
bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-xl
|
||||
px-4 py-3 flex flex-col items-center gap-2"
|
||||
style="min-width: min(220px, 60vw);"
|
||||
>
|
||||
<span class="text-xs text-(--color-muted) tabular-nums">
|
||||
Page {pageIndex + 1} of {totalPages}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={totalPages - 1}
|
||||
value={pageIndex}
|
||||
oninput={(e) => { pageIndex = Number((e.target as HTMLInputElement).value); }}
|
||||
class="w-full accent-(--color-brand) cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
|
||||
disabled={pageIndex === totalPages - 1}
|
||||
use:holdRepeat={{ onrepeat: () => { if (pageIndex < totalPages - 1) pageIndex++; } }}
|
||||
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
|
||||
aria-label="Next page"
|
||||
>
|
||||
@@ -967,7 +1196,7 @@
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['standard', 'Standard'], ['float', 'Float']] as const) as [s, lbl]}
|
||||
{#each ([['standard', 'Standard'], ['minimal', 'Minimal'], ['float', 'Float']] as const) as [s, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('playerStyle', s)}
|
||||
@@ -980,6 +1209,17 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 pb-2.5">
|
||||
<p class="text-[11px] text-(--color-muted)/70 leading-snug">
|
||||
{#if layout.playerStyle === 'standard'}
|
||||
Full panel with voice picker and chapter browser.
|
||||
{:else if layout.playerStyle === 'minimal'}
|
||||
Compact bar: play/pause, seek, and time only.
|
||||
{:else}
|
||||
Draggable overlay — stays visible while you read.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
|
||||
28
ui/src/routes/notifications/+page.server.ts
Normal file
28
ui/src/routes/notifications/+page.server.ts
Normal 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: [] };
|
||||
}
|
||||
};
|
||||
127
ui/src/routes/notifications/+page.svelte
Normal file
127
ui/src/routes/notifications/+page.svelte
Normal 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>
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
getUserByUsername,
|
||||
getUserStats,
|
||||
allProgress,
|
||||
getBooksBySlugs
|
||||
getBooksBySlugs,
|
||||
getUserById
|
||||
} from '$lib/server/pocketbase';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
@@ -41,12 +42,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: fetch fresh user record (for notification prefs not in auth token)
|
||||
async function fetchFreshUser() {
|
||||
return getUserById(locals.user!.id);
|
||||
}
|
||||
|
||||
// Run all three independent groups concurrently
|
||||
const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([
|
||||
const [userRecord, sessionsResult, statsResult, historyResult, freshUserResult] = await Promise.allSettled([
|
||||
fetchUserRecord(),
|
||||
listUserSessions(locals.user.id),
|
||||
getUserStats(locals.sessionId, locals.user.id),
|
||||
fetchHistory()
|
||||
fetchHistory(),
|
||||
fetchFreshUser()
|
||||
]);
|
||||
|
||||
if (userRecord.status === 'rejected')
|
||||
@@ -57,7 +64,6 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) });
|
||||
if (historyResult.status === 'rejected')
|
||||
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
|
||||
|
||||
const { avatarUrl = null, email = null, polarCustomerId = null } =
|
||||
userRecord.status === 'fulfilled' ? userRecord.value : {};
|
||||
const sessions =
|
||||
@@ -66,12 +72,15 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
statsResult.status === 'fulfilled' ? statsResult.value : null;
|
||||
const history =
|
||||
historyResult.status === 'fulfilled' ? historyResult.value : [];
|
||||
const freshUser =
|
||||
freshUserResult.status === 'fulfilled' ? freshUserResult.value : null;
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
avatarUrl,
|
||||
email,
|
||||
polarCustomerId,
|
||||
notifyNewChapters: freshUser?.notify_new_chapters ?? true,
|
||||
stats: stats ?? {
|
||||
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
|
||||
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
|
||||
|
||||
@@ -233,6 +233,117 @@
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
// ── Push notifications ────────────────────────────────────────────────────────
|
||||
type PushState = 'unsupported' | 'default' | 'subscribed' | 'denied' | 'loading';
|
||||
let pushState = $state<PushState>('unsupported');
|
||||
let pushError = $state('');
|
||||
|
||||
// ── In-app notifications ──────────────────────────────────────────────────────
|
||||
let notifyNewChapters = $state(data.notifyNewChapters ?? true);
|
||||
let notifyNewChaptersSaving = $state(false);
|
||||
|
||||
async function toggleNotifyNewChapters() {
|
||||
notifyNewChaptersSaving = true;
|
||||
const next = !notifyNewChapters;
|
||||
try {
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notify_new_chapters: next })
|
||||
});
|
||||
if (res.ok) {
|
||||
notifyNewChapters = next;
|
||||
}
|
||||
} catch { /* ignore */ } finally {
|
||||
notifyNewChaptersSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
pushState = 'unsupported';
|
||||
return;
|
||||
}
|
||||
// Check current permission / subscription state
|
||||
(async () => {
|
||||
const perm = Notification.permission;
|
||||
if (perm === 'denied') { pushState = 'denied'; return; }
|
||||
const reg = await navigator.serviceWorker.getRegistration('/');
|
||||
if (!reg) { pushState = 'default'; return; }
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
pushState = sub ? 'subscribed' : 'default';
|
||||
})();
|
||||
});
|
||||
|
||||
async function subscribePush() {
|
||||
pushError = '';
|
||||
pushState = 'loading';
|
||||
try {
|
||||
// Fetch VAPID public key from backend
|
||||
const keyRes = await fetch('/api/push-subscriptions/vapid-public-key');
|
||||
if (!keyRes.ok) { pushError = 'Push notifications are not configured on this server.'; pushState = 'default'; return; }
|
||||
const { public_key } = await keyRes.json() as { public_key: string };
|
||||
|
||||
// Register / get existing service worker
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
|
||||
// Subscribe with VAPID
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key),
|
||||
});
|
||||
|
||||
const json = sub.toJSON() as {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
};
|
||||
|
||||
const saveRes = await fetch('/api/push-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(json),
|
||||
});
|
||||
if (!saveRes.ok) { pushError = 'Failed to save subscription. Please try again.'; pushState = 'default'; return; }
|
||||
|
||||
pushState = 'subscribed';
|
||||
} catch (e) {
|
||||
pushError = e instanceof Error ? e.message : 'Failed to enable notifications.';
|
||||
pushState = Notification.permission === 'denied' ? 'denied' : 'default';
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribePush() {
|
||||
pushError = '';
|
||||
pushState = 'loading';
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration('/');
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await fetch('/api/push-subscription', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
pushState = 'default';
|
||||
} catch (e) {
|
||||
pushError = e instanceof Error ? e.message : 'Failed to disable notifications.';
|
||||
pushState = 'subscribed';
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a base64url VAPID public key to a Uint8Array for PushManager.subscribe(). */
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
const arr = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
||||
return arr.buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
function parseUA(ua: string): string {
|
||||
if (!ua) return 'Unknown browser';
|
||||
if (/Mobile/i.test(ua)) {
|
||||
@@ -634,6 +745,97 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ── In-app notifications ──────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">In-app notifications</h2>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
{#if notifyNewChapters}
|
||||
You'll receive a notification when new chapters are added to books in your library.
|
||||
{:else}
|
||||
In-app new-chapter notifications are disabled.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleNotifyNewChapters}
|
||||
disabled={notifyNewChaptersSaving}
|
||||
class={cn(
|
||||
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50',
|
||||
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={notifyNewChapters}
|
||||
title={notifyNewChapters ? 'Turn off in-app notifications' : 'Turn on in-app notifications'}
|
||||
>
|
||||
<span class={cn(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
notifyNewChapters ? 'translate-x-6' : 'translate-x-1'
|
||||
)}></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Push notifications ────────────────────────────────────────────────── -->
|
||||
{#if pushState !== 'unsupported'}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Push notifications</h2>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
{#if pushState === 'subscribed'}
|
||||
You'll receive a push notification when new chapters are added to books in your library.
|
||||
{:else if pushState === 'denied'}
|
||||
Notifications are blocked by your browser. Change the permission in your browser settings.
|
||||
{:else}
|
||||
Get notified when new chapters arrive for books in your library.
|
||||
{/if}
|
||||
</p>
|
||||
{#if pushError}
|
||||
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
{#if pushState === 'subscribed'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={unsubscribePush}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
Turn off
|
||||
</button>
|
||||
{:else if pushState === 'denied'}
|
||||
<span class="text-xs text-(--color-muted) italic">Blocked</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={subscribePush}
|
||||
disabled={pushState === 'loading'}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) disabled:opacity-60 text-sm font-semibold transition-colors"
|
||||
>
|
||||
{#if pushState === 'loading'}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
{/if}
|
||||
Turn on
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
|
||||
<section class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
|
||||
<button
|
||||
|
||||
73
ui/src/service-worker.ts
Normal file
73
ui/src/service-worker.ts
Normal 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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
14
ui/static/manifest.webmanifest
Normal file
14
ui/static/manifest.webmanifest
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user