Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b5b44454 | ||
|
|
b95c811898 |
@@ -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"`
|
||||
|
||||
@@ -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,6 +98,7 @@ 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)
|
||||
|
||||
@@ -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,16 @@ 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")
|
||||
}
|
||||
// Send push notifications to users who have this book in their library.
|
||||
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,116 @@ 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
|
||||
}
|
||||
|
||||
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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
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 });
|
||||
};
|
||||
@@ -121,6 +121,78 @@
|
||||
let paginatedContentEl = $state<HTMLDivElement | null>(null);
|
||||
let containerH = $state(0);
|
||||
|
||||
// ── Page slider popover ───────────────────────────────────────────────────
|
||||
let sliderOpen = $state(false);
|
||||
let sliderAnchorEl = $state<HTMLElement | null>(null);
|
||||
let sliderAnchorFocusEl = $state<HTMLElement | null>(null);
|
||||
|
||||
function toggleSlider(anchor: HTMLElement) {
|
||||
if (sliderOpen) { sliderOpen = false; return; }
|
||||
sliderAnchorEl = anchor;
|
||||
sliderOpen = true;
|
||||
}
|
||||
|
||||
function closeSliderOnOutside(e: MouseEvent) {
|
||||
if (!sliderOpen) return;
|
||||
const target = e.target as Node;
|
||||
if (sliderAnchorEl && sliderAnchorEl.contains(target)) return;
|
||||
if (sliderAnchorFocusEl && sliderAnchorFocusEl.contains(target)) return;
|
||||
sliderOpen = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
document.addEventListener('pointerdown', closeSliderOnOutside);
|
||||
return () => document.removeEventListener('pointerdown', closeSliderOnOutside);
|
||||
});
|
||||
|
||||
// ── Hold-to-repeat action ────────────────────────────────────────────────
|
||||
/**
|
||||
* Svelte action: fires `onrepeat()` repeatedly while the pointer is held.
|
||||
* First repeat fires after `delay` ms; subsequent repeats every `interval` ms.
|
||||
*/
|
||||
function holdRepeat(
|
||||
node: HTMLElement,
|
||||
params: { onrepeat: () => void; delay?: number; interval?: number }
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clear() {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
timer = setTimeout(() => {
|
||||
params.onrepeat();
|
||||
schedule(); // re-schedule at interval speed
|
||||
}, params.interval ?? 80);
|
||||
}
|
||||
|
||||
function start() {
|
||||
clear();
|
||||
timer = setTimeout(() => {
|
||||
params.onrepeat();
|
||||
schedule();
|
||||
}, params.delay ?? 400);
|
||||
}
|
||||
|
||||
node.addEventListener('pointerdown', start);
|
||||
node.addEventListener('pointerup', clear);
|
||||
node.addEventListener('pointercancel', clear);
|
||||
node.addEventListener('pointerleave', clear);
|
||||
|
||||
return {
|
||||
update(newParams: typeof params) { params = newParams; },
|
||||
destroy() {
|
||||
clear();
|
||||
node.removeEventListener('pointerdown', start);
|
||||
node.removeEventListener('pointerup', clear);
|
||||
node.removeEventListener('pointercancel', clear);
|
||||
node.removeEventListener('pointerleave', clear);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
|
||||
// Re-run when html, container refs, or mini-player visibility changes
|
||||
@@ -610,6 +682,7 @@
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex > 0) pageIndex--; }}
|
||||
disabled={pageIndex === 0}
|
||||
use:holdRepeat={{ onrepeat: () => { if (pageIndex > 0) pageIndex--; } }}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -617,13 +690,47 @@
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
<span class="text-sm text-(--color-muted) tabular-nums">
|
||||
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
|
||||
</span>
|
||||
|
||||
<!-- Counter — tap to open slider -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
bind:this={sliderAnchorEl}
|
||||
onclick={(e) => toggleSlider(e.currentTarget as HTMLElement)}
|
||||
class="px-3 py-1.5 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) tabular-nums transition-colors"
|
||||
aria-label="Jump to page"
|
||||
>
|
||||
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
|
||||
</button>
|
||||
|
||||
{#if sliderOpen}
|
||||
<div
|
||||
bind:this={sliderAnchorFocusEl}
|
||||
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
|
||||
bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-xl
|
||||
px-4 py-3 flex flex-col items-center gap-2"
|
||||
style="min-width: min(220px, 60vw);"
|
||||
>
|
||||
<span class="text-xs text-(--color-muted) tabular-nums">
|
||||
Page {pageIndex + 1} of {totalPages}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={totalPages - 1}
|
||||
value={pageIndex}
|
||||
oninput={(e) => { pageIndex = Number((e.target as HTMLInputElement).value); }}
|
||||
class="w-full accent-(--color-brand) cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
|
||||
disabled={pageIndex === totalPages - 1}
|
||||
use:holdRepeat={{ onrepeat: () => { if (pageIndex < totalPages - 1) pageIndex++; } }}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
|
||||
>
|
||||
Next
|
||||
@@ -710,6 +817,7 @@
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex > 0) pageIndex--; }}
|
||||
disabled={pageIndex === 0}
|
||||
use:holdRepeat={{ onrepeat: () => { if (pageIndex > 0) pageIndex--; } }}
|
||||
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
@@ -717,13 +825,46 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="px-2.5 py-2 tabular-nums text-(--color-muted) shrink-0 select-none">
|
||||
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
|
||||
</span>
|
||||
|
||||
<!-- Counter — tap to open slider -->
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => toggleSlider(e.currentTarget as HTMLElement)}
|
||||
class="px-2.5 py-2 tabular-nums text-(--color-muted) hover:text-(--color-text) transition-colors select-none"
|
||||
aria-label="Jump to page"
|
||||
>
|
||||
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
|
||||
</button>
|
||||
|
||||
{#if sliderOpen}
|
||||
<div
|
||||
bind:this={sliderAnchorFocusEl}
|
||||
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
|
||||
bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-xl
|
||||
px-4 py-3 flex flex-col items-center gap-2"
|
||||
style="min-width: min(220px, 60vw);"
|
||||
>
|
||||
<span class="text-xs text-(--color-muted) tabular-nums">
|
||||
Page {pageIndex + 1} of {totalPages}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={totalPages - 1}
|
||||
value={pageIndex}
|
||||
oninput={(e) => { pageIndex = Number((e.target as HTMLInputElement).value); }}
|
||||
class="w-full accent-(--color-brand) cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
|
||||
disabled={pageIndex === totalPages - 1}
|
||||
use:holdRepeat={{ onrepeat: () => { if (pageIndex < totalPages - 1) pageIndex++; } }}
|
||||
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
|
||||
aria-label="Next page"
|
||||
>
|
||||
|
||||
@@ -233,6 +233,96 @@
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
// ── Push notifications ────────────────────────────────────────────────────────
|
||||
type PushState = 'unsupported' | 'default' | 'subscribed' | 'denied' | 'loading';
|
||||
let pushState = $state<PushState>('unsupported');
|
||||
let pushError = $state('');
|
||||
|
||||
$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 +724,64 @@
|
||||
{/if}
|
||||
</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