v2.6.50: notifications overhaul, fix blank page, fix chapter review loading
- svelte.config.js: paths.relative=false so CSS uses absolute /_app/ paths (fixes blank home page after redirect)
- ai-jobs: fix openReview() mutating stale alias r instead of $state review — was causing 'Loading results...' to never resolve for chapter-names/image-gen/description
- notifications bell: redesign with All/Unread tabs, per-item dismiss (×), mark-all-read, clear-all, 'View all' footer link
- /admin/notifications: new dedicated full-page notifications view
- api/notifications proxy: add PATCH (mark-all-read) and DELETE (clear-all, dismiss) handlers
- runner: add CreateNotification calls on success/failure in runScrapeTask, runAudioTask, runTranslationTask
- storage/import.go: real PDF (dslipak/pdf) and EPUB (archive/zip + x/net/html) parsing replacing stubs
- translation admin page: stream jobs Promise instead of blocking navigation
- store.go: DeleteNotification, ClearAllNotifications, MarkAllNotificationsRead methods
- handlers_notifications.go + server.go: PATCH /api/notifications, DELETE /api/notifications, DELETE /api/notifications/{id}
This commit is contained in:
@@ -12,8 +12,10 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/dslipak/pdf v0.0.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/getsentry/sentry-go v0.43.0 // indirect
|
github.com/getsentry/sentry-go v0.43.0 // indirect
|
||||||
@@ -23,16 +25,21 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||||
|
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||||
|
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||||
|
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||||
github.com/hibiken/asynq v0.26.0 // indirect
|
github.com/hibiken/asynq v0.26.0 // indirect
|
||||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d // indirect
|
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d // indirect
|
||||||
github.com/klauspost/compress v1.18.2 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/meilisearch/meilisearch-go v0.36.1 // indirect
|
github.com/meilisearch/meilisearch-go v0.36.1 // indirect
|
||||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -61,6 +68,7 @@ require (
|
|||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/image v0.32.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
@@ -68,5 +76,6 @@ require (
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||||
google.golang.org/grpc v1.79.2 // indirect
|
google.golang.org/grpc v1.79.2 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
|||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dslipak/pdf v0.0.2 h1:djAvcM5neg9Ush+zR6QXB+VMJzR6TdnX766HPIg1JmI=
|
||||||
|
github.com/dslipak/pdf v0.0.2/go.mod h1:2L3SnkI9cQwnAS9gfPz2iUoLC0rUZwbucpbKi5R1mUo=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
@@ -29,6 +33,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||||
|
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||||
|
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||||
|
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
|
||||||
|
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
||||||
|
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
|
||||||
|
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
|
||||||
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
|
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
|
||||||
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
|
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
|
||||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d h1:Ld5m8EIK5QVOq/owOexKIbETij3skACg4eU1pArHsrw=
|
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d h1:Ld5m8EIK5QVOq/owOexKIbETij3skACg4eU1pArHsrw=
|
||||||
@@ -40,6 +50,8 @@ github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4O
|
|||||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
|
github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
|
||||||
github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
|
github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
|
||||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
@@ -50,8 +62,12 @@ github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRi
|
|||||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
|
||||||
|
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||||
@@ -120,6 +136,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
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/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
@@ -139,5 +157,7 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
|||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -7,6 +7,63 @@ import (
|
|||||||
"github.com/libnovel/backend/internal/storage"
|
"github.com/libnovel/backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleDismissNotification handles DELETE /api/notifications/{id}.
|
||||||
|
func (s *Server) handleDismissNotification(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "notification id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store, ok := s.deps.Producer.(*storage.Store)
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := store.DeleteNotification(r.Context(), id); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 0, map[string]any{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClearAllNotifications handles DELETE /api/notifications?user_id=...
|
||||||
|
func (s *Server) handleClearAllNotifications(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store, ok := s.deps.Producer.(*storage.Store)
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := store.ClearAllNotifications(r.Context(), userID); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 0, map[string]any{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMarkAllNotificationsRead handles PATCH /api/notifications?user_id=...
|
||||||
|
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store, ok := s.deps.Producer.(*storage.Store)
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := store.MarkAllNotificationsRead(r.Context(), userID); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 0, map[string]any{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
type notification struct {
|
type notification struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
|||||||
@@ -251,7 +251,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
|
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
|
||||||
|
mux.HandleFunc("PATCH /api/notifications", s.handleMarkAllNotificationsRead)
|
||||||
mux.HandleFunc("PATCH /api/notifications/{id}", s.handleMarkNotificationRead)
|
mux.HandleFunc("PATCH /api/notifications/{id}", s.handleMarkNotificationRead)
|
||||||
|
mux.HandleFunc("DELETE /api/notifications", s.handleClearAllNotifications)
|
||||||
|
mux.HandleFunc("DELETE /api/notifications/{id}", s.handleDismissNotification)
|
||||||
|
|
||||||
// Voices list
|
// Voices list
|
||||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||||
|
|||||||
@@ -503,9 +503,21 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
|||||||
if result.ErrorMessage != "" {
|
if result.ErrorMessage != "" {
|
||||||
r.tasksFailed.Add(1)
|
r.tasksFailed.Add(1)
|
||||||
span.SetStatus(codes.Error, result.ErrorMessage)
|
span.SetStatus(codes.Error, result.ErrorMessage)
|
||||||
|
if r.deps.Notifier != nil {
|
||||||
|
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||||
|
"Scrape Failed",
|
||||||
|
fmt.Sprintf("Scrape task (%s) failed: %s", task.Kind, result.ErrorMessage),
|
||||||
|
"/admin/tasks")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
r.tasksCompleted.Add(1)
|
r.tasksCompleted.Add(1)
|
||||||
span.SetStatus(codes.Ok, "")
|
span.SetStatus(codes.Ok, "")
|
||||||
|
if r.deps.Notifier != nil {
|
||||||
|
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||||
|
"Scrape Complete",
|
||||||
|
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
|
||||||
|
"/admin/tasks")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("runner: scrape task finished",
|
log.Info("runner: scrape task finished",
|
||||||
@@ -585,6 +597,12 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
|||||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||||
}
|
}
|
||||||
|
if r.deps.Notifier != nil {
|
||||||
|
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||||
|
"Audio Failed",
|
||||||
|
fmt.Sprintf("Ch.%d of %s (%s): %s", task.Chapter, task.Slug, task.Voice, msg),
|
||||||
|
fmt.Sprintf("/books/%s", task.Slug))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
|
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
|
||||||
@@ -649,6 +667,12 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
|||||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||||
}
|
}
|
||||||
|
if r.deps.Notifier != nil {
|
||||||
|
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||||
|
"Audio Ready",
|
||||||
|
fmt.Sprintf("Ch.%d of %s (%s) is ready", task.Chapter, task.Slug, task.Voice),
|
||||||
|
fmt.Sprintf("/books/%s", task.Slug))
|
||||||
|
}
|
||||||
log.Info("runner: audio task finished", "key", key)
|
log.Info("runner: audio task finished", "key", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ func (r *Runner) runTranslationTask(ctx context.Context, task domain.Translation
|
|||||||
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
||||||
log.Error("runner: FinishTranslationTask failed", "err", err)
|
log.Error("runner: FinishTranslationTask failed", "err", err)
|
||||||
}
|
}
|
||||||
|
if r.deps.Notifier != nil {
|
||||||
|
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||||
|
"Translation Failed",
|
||||||
|
fmt.Sprintf("Ch.%d of %s (%s): %s", task.Chapter, task.Slug, task.Lang, msg),
|
||||||
|
fmt.Sprintf("/books/%s", task.Slug))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: LibreTranslate must be configured.
|
// Guard: LibreTranslate must be configured.
|
||||||
@@ -93,5 +99,11 @@ func (r *Runner) runTranslationTask(ctx context.Context, task domain.Translation
|
|||||||
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
||||||
log.Error("runner: FinishTranslationTask failed", "err", err)
|
log.Error("runner: FinishTranslationTask failed", "err", err)
|
||||||
}
|
}
|
||||||
|
if r.deps.Notifier != nil {
|
||||||
|
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||||
|
"Translation Ready",
|
||||||
|
fmt.Sprintf("Ch.%d of %s translated to %s", task.Chapter, task.Slug, task.Lang),
|
||||||
|
fmt.Sprintf("/books/%s", task.Slug))
|
||||||
|
}
|
||||||
log.Info("runner: translation task finished", "key", key)
|
log.Info("runner: translation task finished", "key", key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dslipak/pdf"
|
||||||
"github.com/libnovel/backend/internal/bookstore"
|
"github.com/libnovel/backend/internal/bookstore"
|
||||||
|
"github.com/libnovel/backend/internal/domain"
|
||||||
minio "github.com/minio/minio-go/v7"
|
minio "github.com/minio/minio-go/v7"
|
||||||
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// chapterHeadingRE matches common chapter heading patterns:
|
||||||
chapterPattern = regexp.MustCompile(`(?i)chapter\s+(\d+)|The\s+Eminence\s+in\s+Shadow\s+(\d+)\s*-\s*(\d+)`)
|
// "Chapter 1", "Chapter 1:", "Chapter 1 -", "CHAPTER ONE", "1.", "Part 1", etc.
|
||||||
)
|
var chapterHeadingRE = regexp.MustCompile(
|
||||||
|
`(?i)^(?:chapter|ch\.?|part|episode|book)\s+(\d+|[ivxlcdm]+)\b|^\d{1,4}[\.\)]\s+\S`)
|
||||||
|
|
||||||
type importer struct {
|
type importer struct {
|
||||||
mc *minioClient
|
mc *minioClient
|
||||||
@@ -42,73 +49,453 @@ func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if fileType == "pdf" {
|
if fileType == "pdf" {
|
||||||
return i.parsePDF(data)
|
return parsePDF(data)
|
||||||
}
|
}
|
||||||
return i.parseEPUB(data)
|
return parseEPUB(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *importer) parsePDF(data []byte) ([]bookstore.Chapter, error) {
|
// ── PDF parsing ───────────────────────────────────────────────────────────────
|
||||||
return nil, errors.New("PDF parsing not yet implemented - requires external library")
|
|
||||||
|
func parsePDF(data []byte) ([]bookstore.Chapter, error) {
|
||||||
|
r, err := pdf.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract per-page text so we can detect chapter boundaries.
|
||||||
|
numPages := r.NumPage()
|
||||||
|
if numPages == 0 {
|
||||||
|
return nil, fmt.Errorf("PDF has no pages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect full text first with page markers so we can split by chapter.
|
||||||
|
var sb strings.Builder
|
||||||
|
fonts := make(map[string]*pdf.Font)
|
||||||
|
for i := 1; i <= numPages; i++ {
|
||||||
|
page := r.Page(i)
|
||||||
|
if page.V.IsNull() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, err := page.GetPlainText(fonts)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(text)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractChaptersFromText(sb.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *importer) parseEPUB(data []byte) ([]bookstore.Chapter, error) {
|
// ── EPUB parsing ──────────────────────────────────────────────────────────────
|
||||||
return nil, errors.New("EPUB parsing not yet implemented - requires external library")
|
|
||||||
}
|
func parseEPUB(data []byte) ([]bookstore.Chapter, error) {
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open EPUB zip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Read META-INF/container.xml → find rootfile (content.opf path).
|
||||||
|
opfPath, err := epubRootfilePath(zr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("epub container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse content.opf → spine order of chapter files.
|
||||||
|
spineFiles, titleMap, err := epubSpine(zr, opfPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("epub spine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(spineFiles) == 0 {
|
||||||
|
return nil, fmt.Errorf("EPUB spine is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base directory of the OPF file for resolving relative hrefs.
|
||||||
|
opfDir := ""
|
||||||
|
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
|
||||||
|
opfDir = opfPath[:idx+1]
|
||||||
|
}
|
||||||
|
|
||||||
// extractChaptersFromText is a helper that splits raw text into chapters.
|
|
||||||
// Used as a fallback when the PDF parser library returns raw text.
|
|
||||||
func extractChaptersFromText(text string) []bookstore.Chapter {
|
|
||||||
var chapters []bookstore.Chapter
|
var chapters []bookstore.Chapter
|
||||||
var currentChapter *bookstore.Chapter
|
for i, href := range spineFiles {
|
||||||
|
fullPath := opfDir + href
|
||||||
|
content, err := epubFileContent(zr, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := htmlToText(content)
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := titleMap[href]
|
||||||
|
if title == "" {
|
||||||
|
title = fmt.Sprintf("Chapter %d", i+1)
|
||||||
|
}
|
||||||
|
chapters = append(chapters, bookstore.Chapter{
|
||||||
|
Number: i + 1,
|
||||||
|
Title: title,
|
||||||
|
Content: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chapters) == 0 {
|
||||||
|
return nil, fmt.Errorf("no readable chapters found in EPUB")
|
||||||
|
}
|
||||||
|
return chapters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// epubRootfilePath parses META-INF/container.xml and returns the full-path
|
||||||
|
// of the OPF package document.
|
||||||
|
func epubRootfilePath(zr *zip.Reader) (string, error) {
|
||||||
|
f := zipFile(zr, "META-INF/container.xml")
|
||||||
|
if f == nil {
|
||||||
|
return "", fmt.Errorf("META-INF/container.xml not found")
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
doc, err := html.Parse(rc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var path string
|
||||||
|
var walk func(*html.Node)
|
||||||
|
walk = func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "rootfile") {
|
||||||
|
for _, a := range n.Attr {
|
||||||
|
if strings.EqualFold(a.Key, "full-path") {
|
||||||
|
path = a.Val
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
walk(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(doc)
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return "", fmt.Errorf("rootfile full-path not found in container.xml")
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// epubSpine parses the OPF document and returns the spine item hrefs in order,
|
||||||
|
// plus a map from href → nav title (if available from NCX/NAV).
|
||||||
|
func epubSpine(zr *zip.Reader, opfPath string) ([]string, map[string]string, error) {
|
||||||
|
f := zipFile(zr, opfPath)
|
||||||
|
if f == nil {
|
||||||
|
return nil, nil, fmt.Errorf("OPF file %q not found in EPUB", opfPath)
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
opfData, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build id→href map from <manifest>.
|
||||||
|
idToHref := make(map[string]string)
|
||||||
|
// Also keep a href→navTitle map (populated from NCX later).
|
||||||
|
hrefTitle := make(map[string]string)
|
||||||
|
|
||||||
|
// Parse OPF XML with html.Parse (handles malformed XML too).
|
||||||
|
doc, _ := html.Parse(bytes.NewReader(opfData))
|
||||||
|
|
||||||
|
var manifestItems []struct{ id, href, mediaType string }
|
||||||
|
var spineIdrefs []string
|
||||||
|
var ncxID string
|
||||||
|
|
||||||
|
var walk func(*html.Node)
|
||||||
|
walk = func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode {
|
||||||
|
tag := strings.ToLower(n.Data)
|
||||||
|
switch tag {
|
||||||
|
case "item":
|
||||||
|
var id, href, mt string
|
||||||
|
for _, a := range n.Attr {
|
||||||
|
switch strings.ToLower(a.Key) {
|
||||||
|
case "id":
|
||||||
|
id = a.Val
|
||||||
|
case "href":
|
||||||
|
href = a.Val
|
||||||
|
case "media-type":
|
||||||
|
mt = a.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id != "" && href != "" {
|
||||||
|
manifestItems = append(manifestItems, struct{ id, href, mediaType string }{id, href, mt})
|
||||||
|
idToHref[id] = href
|
||||||
|
}
|
||||||
|
case "itemref":
|
||||||
|
for _, a := range n.Attr {
|
||||||
|
if strings.ToLower(a.Key) == "idref" {
|
||||||
|
spineIdrefs = append(spineIdrefs, a.Val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "spine":
|
||||||
|
for _, a := range n.Attr {
|
||||||
|
if strings.ToLower(a.Key) == "toc" {
|
||||||
|
ncxID = a.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
walk(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(doc)
|
||||||
|
|
||||||
|
// Build ordered spine href list.
|
||||||
|
var spineHrefs []string
|
||||||
|
for _, idref := range spineIdrefs {
|
||||||
|
if href, ok := idToHref[idref]; ok {
|
||||||
|
spineHrefs = append(spineHrefs, href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no explicit spine, fall back to all XHTML items in manifest order.
|
||||||
|
if len(spineHrefs) == 0 {
|
||||||
|
sort.Slice(manifestItems, func(i, j int) bool {
|
||||||
|
return manifestItems[i].href < manifestItems[j].href
|
||||||
|
})
|
||||||
|
for _, it := range manifestItems {
|
||||||
|
mt := strings.ToLower(it.mediaType)
|
||||||
|
if strings.Contains(mt, "html") || strings.HasSuffix(strings.ToLower(it.href), ".html") || strings.HasSuffix(strings.ToLower(it.href), ".xhtml") {
|
||||||
|
spineHrefs = append(spineHrefs, it.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get chapter titles from NCX (toc.ncx).
|
||||||
|
opfDir := ""
|
||||||
|
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
|
||||||
|
opfDir = opfPath[:idx+1]
|
||||||
|
}
|
||||||
|
if ncxHref, ok := idToHref[ncxID]; ok {
|
||||||
|
ncxPath := opfDir + ncxHref
|
||||||
|
if ncxFile := zipFile(zr, ncxPath); ncxFile != nil {
|
||||||
|
if ncxRC, err := ncxFile.Open(); err == nil {
|
||||||
|
defer ncxRC.Close()
|
||||||
|
parseNCXTitles(ncxRC, hrefTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spineHrefs, hrefTitle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNCXTitles extracts navPoint label→src mappings from a toc.ncx.
|
||||||
|
func parseNCXTitles(r io.Reader, out map[string]string) {
|
||||||
|
doc, err := html.Parse(r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect navPoints: each has a <navLabel><text>…</text></navLabel> and
|
||||||
|
// a <content src="…"/> child.
|
||||||
|
var walk func(*html.Node)
|
||||||
|
walk = func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "navpoint") {
|
||||||
|
var label, src string
|
||||||
|
var inner func(*html.Node)
|
||||||
|
inner = func(c *html.Node) {
|
||||||
|
if c.Type == html.ElementNode {
|
||||||
|
if strings.EqualFold(c.Data, "text") && label == "" {
|
||||||
|
if c.FirstChild != nil && c.FirstChild.Type == html.TextNode {
|
||||||
|
label = strings.TrimSpace(c.FirstChild.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(c.Data, "content") {
|
||||||
|
for _, a := range c.Attr {
|
||||||
|
if strings.EqualFold(a.Key, "src") {
|
||||||
|
// Strip fragment identifier (#...).
|
||||||
|
src = strings.SplitN(a.Val, "#", 2)[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for child := c.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
inner(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inner(n)
|
||||||
|
if label != "" && src != "" {
|
||||||
|
out[src] = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
walk(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// epubFileContent returns the raw bytes of a file inside the EPUB zip.
|
||||||
|
func epubFileContent(zr *zip.Reader, path string) ([]byte, error) {
|
||||||
|
f := zipFile(zr, path)
|
||||||
|
if f == nil {
|
||||||
|
return nil, fmt.Errorf("file %q not in EPUB", path)
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
return io.ReadAll(rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zipFile finds a file by name (case-insensitive) in a zip.Reader.
|
||||||
|
func zipFile(zr *zip.Reader, name string) *zip.File {
|
||||||
|
nameLower := strings.ToLower(name)
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if strings.ToLower(f.Name) == nameLower {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlToText converts HTML/XHTML content to plain text suitable for storage.
|
||||||
|
func htmlToText(data []byte) string {
|
||||||
|
doc, err := html.Parse(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
var walk func(*html.Node)
|
||||||
|
walk = func(n *html.Node) {
|
||||||
|
if n.Type == html.TextNode {
|
||||||
|
text := strings.TrimSpace(n.Data)
|
||||||
|
if text != "" {
|
||||||
|
sb.WriteString(text)
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n.Type == html.ElementNode {
|
||||||
|
switch strings.ToLower(n.Data) {
|
||||||
|
case "p", "div", "br", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr":
|
||||||
|
// Block-level: ensure newline before content.
|
||||||
|
if sb.Len() > 0 {
|
||||||
|
s := sb.String()
|
||||||
|
if s[len(s)-1] != '\n' {
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "script", "style", "head":
|
||||||
|
// Skip entirely.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
walk(c)
|
||||||
|
}
|
||||||
|
if n.Type == html.ElementNode {
|
||||||
|
switch strings.ToLower(n.Data) {
|
||||||
|
case "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr":
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(doc)
|
||||||
|
|
||||||
|
// Collapse multiple blank lines.
|
||||||
|
lines := strings.Split(sb.String(), "\n")
|
||||||
|
var out []string
|
||||||
|
blanks := 0
|
||||||
|
for _, l := range lines {
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
if l == "" {
|
||||||
|
blanks++
|
||||||
|
if blanks <= 1 {
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blanks = 0
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chapter segmentation (shared by PDF and plain-text paths) ─────────────────
|
||||||
|
|
||||||
|
// extractChaptersFromText splits a block of plain text into chapters by
|
||||||
|
// detecting heading lines that match chapterHeadingRE.
|
||||||
|
// Falls back to paragraph-splitting when no headings are found.
|
||||||
|
func extractChaptersFromText(text string) []bookstore.Chapter {
|
||||||
lines := strings.Split(text, "\n")
|
lines := strings.Split(text, "\n")
|
||||||
chapterNum := 0
|
|
||||||
|
type segment struct {
|
||||||
|
title string
|
||||||
|
number int
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments []segment
|
||||||
|
var cur *segment
|
||||||
|
chNum := 0
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if len(line) < 3 {
|
if chapterHeadingRE.MatchString(line) {
|
||||||
continue
|
if cur != nil {
|
||||||
}
|
segments = append(segments, *cur)
|
||||||
|
|
||||||
matches := chapterPattern.FindStringSubmatch(line)
|
|
||||||
if matches != nil {
|
|
||||||
if currentChapter != nil && currentChapter.Content != "" {
|
|
||||||
chapters = append(chapters, *currentChapter)
|
|
||||||
}
|
}
|
||||||
chapterNum++
|
chNum++
|
||||||
if matches[1] != "" {
|
// Try to parse the explicit chapter number from the heading.
|
||||||
chapterNum, _ = fmt.Sscanf(matches[1], "%d", &chapterNum)
|
if m := regexp.MustCompile(`\d+`).FindString(line); m != "" {
|
||||||
|
if n, err := strconv.Atoi(m); err == nil && n > 0 && n < 100000 {
|
||||||
|
chNum = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
currentChapter = &bookstore.Chapter{
|
cur = &segment{title: line, number: chNum}
|
||||||
Number: chapterNum,
|
} else if cur != nil && line != "" {
|
||||||
Title: line,
|
cur.lines = append(cur.lines, line)
|
||||||
Content: "",
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentChapter != nil {
|
|
||||||
if currentChapter.Content != "" {
|
|
||||||
currentChapter.Content += " "
|
|
||||||
}
|
|
||||||
currentChapter.Content += line
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if cur != nil {
|
||||||
if currentChapter != nil && currentChapter.Content != "" {
|
segments = append(segments, *cur)
|
||||||
chapters = append(chapters, *currentChapter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no chapters found via regex, try splitting by double newlines
|
// Require segments to have meaningful content (>= 100 chars).
|
||||||
|
var chapters []bookstore.Chapter
|
||||||
|
for _, seg := range segments {
|
||||||
|
content := strings.Join(seg.lines, "\n")
|
||||||
|
if len(strings.TrimSpace(content)) < 50 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chapters = append(chapters, bookstore.Chapter{
|
||||||
|
Number: seg.number,
|
||||||
|
Title: seg.title,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: no headings found — split by double newlines (paragraph blocks).
|
||||||
if len(chapters) == 0 {
|
if len(chapters) == 0 {
|
||||||
paragraphs := strings.Split(text, "\n\n")
|
paragraphs := strings.Split(text, "\n\n")
|
||||||
for i, para := range paragraphs {
|
n := 0
|
||||||
|
for _, para := range paragraphs {
|
||||||
para = strings.TrimSpace(para)
|
para = strings.TrimSpace(para)
|
||||||
if len(para) > 50 {
|
if len(para) > 100 {
|
||||||
|
n++
|
||||||
chapters = append(chapters, bookstore.Chapter{
|
chapters = append(chapters, bookstore.Chapter{
|
||||||
Number: i + 1,
|
Number: n,
|
||||||
Title: fmt.Sprintf("Chapter %d", i+1),
|
Title: fmt.Sprintf("Chapter %d", n),
|
||||||
Content: para,
|
Content: para,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,28 +504,31 @@ func extractChaptersFromText(text string) []bookstore.Chapter {
|
|||||||
return chapters
|
return chapters
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestChapters stores extracted chapters for a book via BookWriter.
|
// ── Chapter ingestion ─────────────────────────────────────────────────────────
|
||||||
// This is called by the runner after extracting chapters from PDF/EPUB.
|
|
||||||
|
// IngestChapters stores extracted chapters for a book.
|
||||||
|
// Each chapter is written as a markdown file in the chapters MinIO bucket
|
||||||
|
// and its index record is upserted in PocketBase via WriteChapter.
|
||||||
func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
|
func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
|
||||||
// For now, store each chapter as plain text in MinIO (similar to scraped chapters)
|
|
||||||
// The BookWriter interface expects markdown, so we'll store the content as-is
|
|
||||||
for _, ch := range chapters {
|
for _, ch := range chapters {
|
||||||
content := fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
|
var mdContent string
|
||||||
if ch.Title != "" {
|
if ch.Title != "" && ch.Title != fmt.Sprintf("Chapter %d", ch.Number) {
|
||||||
content = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
|
mdContent = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
|
||||||
|
} else {
|
||||||
|
mdContent = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
|
||||||
}
|
}
|
||||||
key := fmt.Sprintf("books/%s/chapters/%d.md", slug, ch.Number)
|
domainCh := domain.Chapter{
|
||||||
if err := s.mc.putObject(ctx, "books", key, "text/markdown", []byte(content)); err != nil {
|
Ref: domain.ChapterRef{Number: ch.Number, Title: ch.Title},
|
||||||
return fmt.Errorf("put chapter %d: %w", ch.Number, err)
|
Text: mdContent,
|
||||||
|
}
|
||||||
|
if err := s.WriteChapter(ctx, slug, domainCh); err != nil {
|
||||||
|
return fmt.Errorf("ingest chapter %d: %w", ch.Number, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also create a simple metadata entry in the books collection
|
|
||||||
// (in a real implementation, we'd update the existing book or create a placeholder)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetImportObjectKey returns the MinIO object key for an uploaded import file.
|
// GetImportObjectKey returns the MinIO object key for an uploaded import file.
|
||||||
func GetImportObjectKey(filename string) string {
|
func GetImportObjectKey(filename string) string {
|
||||||
return fmt.Sprintf("imports/%s", filename)
|
return fmt.Sprintf("imports/%s", filename)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -708,6 +708,48 @@ func (s *Store) MarkNotificationRead(ctx context.Context, id string) error {
|
|||||||
map[string]any{"read": true})
|
map[string]any{"read": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteNotification deletes a single notification by ID.
|
||||||
|
func (s *Store) DeleteNotification(ctx context.Context, id string) error {
|
||||||
|
return s.pb.delete(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAllNotifications deletes all notifications for a user.
|
||||||
|
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
|
||||||
|
filter := fmt.Sprintf("user_id='%s'", userID)
|
||||||
|
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ClearAllNotifications 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/notifications/records/%s", rec.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllNotificationsRead marks all notifications for a user as read.
|
||||||
|
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
|
||||||
|
filter := fmt.Sprintf("user_id='%s'&&read=false", userID)
|
||||||
|
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MarkAllNotificationsRead list: %w", err)
|
||||||
|
}
|
||||||
|
for _, raw := range items {
|
||||||
|
var rec struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||||
|
_ = s.pb.patch(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", rec.ID),
|
||||||
|
map[string]any{"read": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CancelTask(ctx context.Context, id string) error {
|
func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||||
// Try scraping_tasks first, then audio_jobs, then translation_jobs.
|
// Try scraping_tasks first, then audio_jobs, then translation_jobs.
|
||||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
|
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
// Notifications
|
// Notifications
|
||||||
let notificationsOpen = $state(false);
|
let notificationsOpen = $state(false);
|
||||||
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
|
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
|
||||||
|
let notifFilter = $state<'all' | 'unread'>('all');
|
||||||
async function loadNotifications() {
|
async function loadNotifications() {
|
||||||
if (!data.user) return;
|
if (!data.user) return;
|
||||||
try {
|
try {
|
||||||
@@ -42,8 +43,31 @@
|
|||||||
notifications = notifications.map(n => n.id === id ? {...n, read: true} : n);
|
notifications = notifications.map(n => n.id === id ? {...n, read: true} : n);
|
||||||
} catch (e) { console.error('mark read:', e); }
|
} catch (e) { console.error('mark read:', e); }
|
||||||
}
|
}
|
||||||
|
async function markAllRead() {
|
||||||
|
if (!data.user) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications?user_id=' + data.user.id, { method: 'PATCH' });
|
||||||
|
notifications = notifications.map(n => ({ ...n, read: true }));
|
||||||
|
} catch (e) { console.error('mark all read:', e); }
|
||||||
|
}
|
||||||
|
async function dismissNotification(id: string) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications/' + id, { method: 'DELETE' });
|
||||||
|
notifications = notifications.filter(n => n.id !== id);
|
||||||
|
} catch (e) { console.error('dismiss notification:', e); }
|
||||||
|
}
|
||||||
|
async function clearAllNotifications() {
|
||||||
|
if (!data.user) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications?user_id=' + data.user.id, { method: 'DELETE' });
|
||||||
|
notifications = [];
|
||||||
|
} catch (e) { console.error('clear notifications:', e); }
|
||||||
|
}
|
||||||
$effect(() => { if (data.user) loadNotifications(); });
|
$effect(() => { if (data.user) loadNotifications(); });
|
||||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||||
|
const filteredNotifications = $derived(
|
||||||
|
notifFilter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||||
|
);
|
||||||
|
|
||||||
// Close search on navigation
|
// Close search on navigation
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -579,21 +603,84 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if notificationsOpen}
|
{#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 max-h-96 overflow-y-auto">
|
<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]">
|
||||||
{#if notifications.length === 0}
|
<!-- Header -->
|
||||||
<div class="p-4 text-center text-(--color-muted) text-sm">No notifications</div>
|
<div class="flex items-center justify-between px-3 pt-3 pb-2 shrink-0">
|
||||||
{:else}
|
<span class="text-sm font-semibold">Notifications</span>
|
||||||
{#each notifications as n}
|
<div class="flex items-center gap-1">
|
||||||
<a
|
{#if unreadCount > 0}
|
||||||
href={n.link || '/admin/import'}
|
<button
|
||||||
onclick={() => { markRead(n.id); notificationsOpen = false; }}
|
type="button"
|
||||||
class="block p-3 border-b border-(--color-border)/50 hover:bg-(--color-surface-3) {n.read ? 'opacity-60' : ''}"
|
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)"
|
||||||
<div class="text-sm font-medium">{n.title}</div>
|
>Mark all read</button>
|
||||||
<div class="text-xs text-(--color-muted) mt-0.5">{n.message}</div>
|
{/if}
|
||||||
</a>
|
{#if notifications.length > 0}
|
||||||
{/each}
|
<button
|
||||||
{/if}
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,22 +155,24 @@
|
|||||||
};
|
};
|
||||||
review = r;
|
review = r;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
||||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||||
|
|
||||||
r.pattern = payload.pattern ?? '';
|
review = {
|
||||||
r.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
|
...r,
|
||||||
r.loading = false;
|
pattern: payload.pattern ?? '',
|
||||||
} catch (e) {
|
titles: (payload.results ?? []).map((t: ProposedTitle) => ({ ...t })),
|
||||||
r.loading = false;
|
loading: false
|
||||||
r.error = String(e);
|
};
|
||||||
}
|
} catch (e) {
|
||||||
} else if (job.kind === 'image-gen') {
|
review = { ...r, loading: false, error: String(e) };
|
||||||
|
}
|
||||||
|
} else if (job.kind === 'image-gen') {
|
||||||
const r: ImageGenReview = {
|
const r: ImageGenReview = {
|
||||||
kind: 'image-gen',
|
kind: 'image-gen',
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
@@ -189,38 +191,40 @@
|
|||||||
};
|
};
|
||||||
review = r;
|
review = r;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
let payload: {
|
let payload: {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
chapter?: number;
|
chapter?: number;
|
||||||
content_type?: string;
|
content_type?: string;
|
||||||
image_b64?: string;
|
image_b64?: string;
|
||||||
bytes?: number;
|
bytes?: number;
|
||||||
} = {};
|
} = {};
|
||||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||||
|
|
||||||
if (!payload.image_b64) {
|
if (!payload.image_b64) {
|
||||||
r.error = 'No image in job payload.';
|
review = { ...r, error: 'No image in job payload.', loading: false };
|
||||||
r.loading = false;
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
r.imageType = payload.type ?? 'cover';
|
|
||||||
r.chapter = payload.chapter ?? 0;
|
|
||||||
r.prompt = payload.prompt ?? '';
|
|
||||||
r.contentType = payload.content_type ?? 'image/png';
|
|
||||||
r.bytes = payload.bytes ?? 0;
|
|
||||||
r.imageSrc = `data:${r.contentType};base64,${payload.image_b64}`;
|
|
||||||
r.loading = false;
|
|
||||||
} catch (e) {
|
|
||||||
r.loading = false;
|
|
||||||
r.error = String(e);
|
|
||||||
}
|
}
|
||||||
} else if (job.kind === 'description') {
|
const contentType = payload.content_type ?? 'image/png';
|
||||||
|
review = {
|
||||||
|
...r,
|
||||||
|
imageType: payload.type ?? 'cover',
|
||||||
|
chapter: payload.chapter ?? 0,
|
||||||
|
prompt: payload.prompt ?? '',
|
||||||
|
contentType,
|
||||||
|
bytes: payload.bytes ?? 0,
|
||||||
|
imageSrc: `data:${contentType};base64,${payload.image_b64}`,
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
review = { ...r, loading: false, error: String(e) };
|
||||||
|
}
|
||||||
|
} else if (job.kind === 'description') {
|
||||||
const r: DescriptionReview = {
|
const r: DescriptionReview = {
|
||||||
kind: 'description',
|
kind: 'description',
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
@@ -236,28 +240,30 @@
|
|||||||
};
|
};
|
||||||
review = r;
|
review = r;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
let payload: {
|
let payload: {
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
old_description?: string;
|
old_description?: string;
|
||||||
new_description?: string;
|
new_description?: string;
|
||||||
} = {};
|
} = {};
|
||||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||||
|
|
||||||
r.instructions = payload.instructions ?? '';
|
review = {
|
||||||
r.oldDescription = payload.old_description ?? '';
|
...r,
|
||||||
r.newDescription = payload.new_description ?? '';
|
instructions: payload.instructions ?? '',
|
||||||
r.loading = false;
|
oldDescription: payload.old_description ?? '',
|
||||||
} catch (e) {
|
newDescription: payload.new_description ?? '',
|
||||||
r.loading = false;
|
loading: false
|
||||||
r.error = String(e);
|
};
|
||||||
}
|
} catch (e) {
|
||||||
|
review = { ...r, loading: false, error: String(e) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeReview() {
|
function closeReview() {
|
||||||
review = null;
|
review = null;
|
||||||
|
|||||||
16
ui/src/routes/admin/notifications/+page.server.ts
Normal file
16
ui/src/routes/admin/notifications/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
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/admin/notifications/+page.svelte
Normal file
127
ui/src/routes/admin/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 — Admin</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 || '/admin'}
|
||||||
|
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>
|
||||||
@@ -9,16 +9,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
redirect(302, '/');
|
redirect(302, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [books, jobs] = await Promise.all([
|
// Stream jobs — navigation is instant, list populates shortly after.
|
||||||
listBookSlugs().catch((e): Awaited<ReturnType<typeof listBookSlugs>> => {
|
const jobs = listTranslationJobs().catch((e): TranslationJob[] => {
|
||||||
log.warn('admin/translation', 'failed to load book slugs', { err: String(e) });
|
log.warn('admin/translation', 'failed to load translation jobs', { err: String(e) });
|
||||||
return [];
|
return [];
|
||||||
}),
|
});
|
||||||
listTranslationJobs().catch((e): TranslationJob[] => {
|
|
||||||
log.warn('admin/translation', 'failed to load translation jobs', { err: String(e) });
|
// Books list is needed immediately for the enqueue form, but use cache so
|
||||||
return [];
|
// it's fast on repeat visits.
|
||||||
})
|
const books = await listBookSlugs().catch((e): Awaited<ReturnType<typeof listBookSlugs>> => {
|
||||||
]);
|
log.warn('admin/translation', 'failed to load book slugs', { err: String(e) });
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
return { books, jobs };
|
return { books, jobs };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
import type { TranslationJob } from '$lib/server/pocketbase';
|
import type { TranslationJob } from '$lib/server/pocketbase';
|
||||||
@@ -7,11 +6,11 @@
|
|||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
let jobs = $state<TranslationJob[]>(untrack(() => data.jobs));
|
let jobs = $state<TranslationJob[]>([]);
|
||||||
|
|
||||||
// Keep in sync on server reloads
|
// Resolve streamed promise; re-runs on server reloads (invalidateAll)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
jobs = data.jobs;
|
Promise.resolve(data.jobs).then((resolved) => { jobs = resolved; });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||||
|
|||||||
@@ -8,4 +8,22 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
const res = await backendFetch('/api/notifications?user_id=' + userId);
|
const res = await backendFetch('/api/notifications?user_id=' + userId);
|
||||||
const data = await res.json().catch(() => ({ notifications: [] }));
|
const data = await res.json().catch(() => ({ notifications: [] }));
|
||||||
return json(data);
|
return json(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// PATCH /api/notifications?user_id=<id> — mark all read
|
||||||
|
export const PATCH: RequestHandler = async ({ url }) => {
|
||||||
|
const userId = url.searchParams.get('user_id');
|
||||||
|
if (!userId) throw error(400, 'user_id required');
|
||||||
|
const res = await backendFetch('/api/notifications?user_id=' + userId, { method: 'PATCH' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return json(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE /api/notifications?user_id=<id> — clear all
|
||||||
|
export const DELETE: RequestHandler = async ({ url }) => {
|
||||||
|
const userId = url.searchParams.get('user_id');
|
||||||
|
if (!userId) throw error(400, 'user_id required');
|
||||||
|
const res = await backendFetch('/api/notifications?user_id=' + userId, { method: 'DELETE' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return json(data);
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ import { json, error } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { backendFetch } from '$lib/server/scraper';
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
|
||||||
const userId = url.searchParams.get('user_id');
|
|
||||||
if (!userId) throw error(400, 'user_id required');
|
|
||||||
const res = await backendFetch('/api/notifications?user_id=' + userId);
|
|
||||||
const data = await res.json().catch(() => ({ notifications: [] }));
|
|
||||||
return json(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async ({ params }) => {
|
export const PATCH: RequestHandler = async ({ params }) => {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
if (!id) throw error(400, 'id required');
|
if (!id) throw error(400, 'id required');
|
||||||
const res = await backendFetch('/api/notifications/' + id, { method: 'PATCH' });
|
const res = await backendFetch('/api/notifications/' + id, { method: 'PATCH' });
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
return json(data);
|
return json(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DELETE /api/notifications/[id] — dismiss a single notification
|
||||||
|
export const DELETE: RequestHandler = async ({ params }) => {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) throw error(400, 'id required');
|
||||||
|
const res = await backendFetch('/api/notifications/' + id, { method: 'DELETE' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return json(data);
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import adapter from '@sveltejs/adapter-node';
|
|||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter()
|
adapter: adapter(),
|
||||||
|
paths: {
|
||||||
|
relative: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user