Compare commits
3
Commits
pwa
...
littleFixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57864e00da | ||
|
|
0bbf2ab56f | ||
|
|
26a17b5a4c |
No files matched your search
Generated
+101
-31
@@ -31,7 +31,8 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"typeorm": "^0.3.28"
|
||||
"typeorm": "^0.3.28",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
@@ -234,7 +236,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -2160,7 +2161,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -2331,7 +2331,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz",
|
||||
"integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"file-type": "21.3.0",
|
||||
"iterare": "1.2.1",
|
||||
@@ -2379,7 +2378,6 @@
|
||||
"integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nuxt/opencollective": "0.4.1",
|
||||
"fast-safe-stringify": "2.1.1",
|
||||
@@ -2456,7 +2454,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz",
|
||||
"integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cors": "2.8.6",
|
||||
"express": "5.2.1",
|
||||
@@ -2904,7 +2901,6 @@
|
||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"@types/json-schema": "*"
|
||||
@@ -3030,7 +3026,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
|
||||
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3143,6 +3138,16 @@
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@@ -3205,7 +3210,6 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -3894,7 +3898,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3984,7 +3987,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -4199,6 +4201,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -4401,6 +4415,12 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -4469,7 +4489,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4789,7 +4808,6 @@
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@@ -4846,15 +4864,13 @@
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-validator": {
|
||||
"version": "0.14.3",
|
||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
||||
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/validator": "^13.15.3",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
@@ -5662,7 +5678,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5723,7 +5738,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -6208,6 +6222,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
@@ -6744,6 +6759,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -7221,7 +7245,6 @@
|
||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "30.2.0",
|
||||
"@jest/types": "30.2.0",
|
||||
@@ -8544,6 +8567,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -9292,7 +9321,6 @@
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
@@ -9421,7 +9449,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
|
||||
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.0",
|
||||
"pg-pool": "^3.11.0",
|
||||
@@ -9705,7 +9732,6 @@
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9795,7 +9821,8 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
@@ -9946,8 +9973,7 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
@@ -10092,7 +10118,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -10980,7 +11005,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -11322,7 +11346,6 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -11495,7 +11518,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz",
|
||||
"integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sqltools/formatter": "^1.2.5",
|
||||
"ansis": "^4.2.0",
|
||||
@@ -11702,7 +11724,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11996,6 +12017,47 @@
|
||||
"defaults": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.105.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
|
||||
@@ -12090,6 +12152,7 @@
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
@@ -12108,6 +12171,7 @@
|
||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
@@ -12121,6 +12185,7 @@
|
||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^4.1.1"
|
||||
@@ -12135,6 +12200,7 @@
|
||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
@@ -12144,7 +12210,8 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/webpack/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
@@ -12152,6 +12219,7 @@
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -12162,6 +12230,7 @@
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -12175,6 +12244,7 @@
|
||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"ajv": "^8.9.0",
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"dev:local": "cp -f .env.local .env && nest start --watch",
|
||||
"dev:reset-env": "cp -f .env.docker .env",
|
||||
"seed:admin": "ts-node scripts/seed-admin.ts",
|
||||
"seed:categories": "ts-node scripts/seed-categories.ts"
|
||||
"seed:categories": "ts-node scripts/seed-categories.ts",
|
||||
"generate-vapid": "ts-node scripts/generate-vapid-keys.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
@@ -49,7 +50,8 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"typeorm": "^0.3.28"
|
||||
"typeorm": "^0.3.28",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -61,6 +63,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as webpush from 'web-push';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const envPath = path.join(__dirname, '..', '.env');
|
||||
|
||||
function generateVapidKeys(): void {
|
||||
console.log('Generating VAPID keys...');
|
||||
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
|
||||
let envContent = '';
|
||||
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
}
|
||||
|
||||
const lines = envContent.split('\n');
|
||||
|
||||
const vapidSubject = `VAPID_SUBJECT=mailto:contact@placebo.mk`;
|
||||
const vapidPublicKey = `VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`;
|
||||
const vapidPrivateKey = `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`;
|
||||
|
||||
const updatedLines = lines.filter(
|
||||
(line) =>
|
||||
!line.startsWith('VAPID_SUBJECT=') &&
|
||||
!line.startsWith('VAPID_PUBLIC_KEY=') &&
|
||||
!line.startsWith('VAPID_PRIVATE_KEY='),
|
||||
);
|
||||
|
||||
const nonEmptyLines = updatedLines.filter((line) => line.trim() !== '');
|
||||
|
||||
const newEnvContent =
|
||||
nonEmptyLines.join('\n') +
|
||||
(nonEmptyLines.length > 0 ? '\n' : '') +
|
||||
`${vapidSubject}\n${vapidPublicKey}\n${vapidPrivateKey}\n`;
|
||||
|
||||
fs.writeFileSync(envPath, newEnvContent);
|
||||
|
||||
console.log('VAPID keys generated and added to .env file:');
|
||||
console.log(` Public Key: ${vapidKeys.publicKey}`);
|
||||
console.log(` Private Key: ${vapidKeys.privateKey}`);
|
||||
console.log('\nKeep your private key secret!');
|
||||
}
|
||||
|
||||
generateVapidKeys();
|
||||
@@ -10,6 +10,7 @@ import { UserModule } from './modules/users/user.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CommentModule } from './modules/comment/comment.module';
|
||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||
import { PushModule } from './modules/push/push.module';
|
||||
import {
|
||||
Article,
|
||||
Author,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
Reaction,
|
||||
} from './modules/entities';
|
||||
import { ShareEvent } from './modules/analytics/analytics.entity';
|
||||
import { PushSubscriptionEntity } from './modules/push/push-subscription.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,6 +46,7 @@ import { ShareEvent } from './modules/analytics/analytics.entity';
|
||||
Comment,
|
||||
Reaction,
|
||||
ShareEvent,
|
||||
PushSubscriptionEntity,
|
||||
],
|
||||
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
|
||||
logging: process.env.DATABASE_LOGGING === 'true',
|
||||
@@ -55,6 +58,7 @@ import { ShareEvent } from './modules/analytics/analytics.entity';
|
||||
AuthModule,
|
||||
CommentModule,
|
||||
AnalyticsModule,
|
||||
PushModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -4,11 +4,13 @@ import { ArticlesService } from './articles.service';
|
||||
import { ArticlesController } from './articles.controller';
|
||||
import { Article, Author, Category } from './entities';
|
||||
import { StrapiModule } from './strapi.module';
|
||||
import { PushModule } from './push/push.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Article, Author, Category]),
|
||||
forwardRef(() => StrapiModule),
|
||||
forwardRef(() => PushModule),
|
||||
],
|
||||
controllers: [ArticlesController],
|
||||
providers: [ArticlesService],
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Logger,
|
||||
Inject,
|
||||
forwardRef,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
UpdateArticleDto,
|
||||
FindArticlesDto,
|
||||
} from './articles.dto';
|
||||
import { PushService } from './push/push.service';
|
||||
|
||||
@Injectable()
|
||||
export class ArticlesService {
|
||||
@@ -24,6 +26,9 @@ export class ArticlesService {
|
||||
private readonly articleRepository: Repository<Article>,
|
||||
@Inject(forwardRef(() => StrapiService))
|
||||
private readonly strapiService: StrapiService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => PushService))
|
||||
private readonly pushService?: PushService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateArticleDto): Promise<Article> {
|
||||
@@ -183,6 +188,7 @@ export class ArticlesService {
|
||||
status: ArticleStatus = ArticleStatus.PUBLISHED,
|
||||
): Promise<Article> {
|
||||
const article = await this.findOne(id);
|
||||
const wasDraft = article.status === ArticleStatus.DRAFT;
|
||||
article.status = status;
|
||||
const savedArticle = await this.articleRepository.save(article);
|
||||
|
||||
@@ -202,6 +208,21 @@ export class ArticlesService {
|
||||
}
|
||||
}
|
||||
|
||||
// Send push notification for newly published articles
|
||||
if (wasDraft && status === ArticleStatus.PUBLISHED && this.pushService) {
|
||||
try {
|
||||
await this.pushService.notifyNewArticle(
|
||||
savedArticle.title,
|
||||
savedArticle.slug,
|
||||
);
|
||||
this.logger.log(
|
||||
`Push notification sent for article: ${savedArticle.title}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to send push notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return savedArticle;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ import { LiveBlogService } from './live-blog.service';
|
||||
import { LiveBlogController } from './live-blog.controller';
|
||||
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
|
||||
import { StrapiModule } from './strapi.module';
|
||||
import { PushModule } from './push/push.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
|
||||
EventEmitterModule.forRoot(),
|
||||
forwardRef(() => StrapiModule),
|
||||
forwardRef(() => PushModule),
|
||||
],
|
||||
controllers: [LiveBlogController],
|
||||
providers: [LiveBlogService],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
OnModuleInit,
|
||||
Inject,
|
||||
forwardRef,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
CreateLiveBlogUpdateDto,
|
||||
UpdateLiveBlogUpdateDto,
|
||||
} from './articles.dto';
|
||||
import { PushService } from './push/push.service';
|
||||
|
||||
interface SseClient {
|
||||
id: string;
|
||||
@@ -55,6 +57,9 @@ export class LiveBlogService implements OnModuleInit {
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
@Inject(forwardRef(() => StrapiService))
|
||||
private readonly strapiService: StrapiService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => PushService))
|
||||
private readonly pushService?: PushService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -364,6 +369,22 @@ export class LiveBlogService implements OnModuleInit {
|
||||
update: savedUpdate,
|
||||
});
|
||||
|
||||
// Send push notification for live blog updates (only for live blogs)
|
||||
if (liveBlogEntity.status === LiveBlogStatus.LIVE && this.pushService) {
|
||||
try {
|
||||
await this.pushService.notifyLiveBlogUpdate(
|
||||
liveBlogEntity.title,
|
||||
liveBlogEntity.slug,
|
||||
savedUpdate.content,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Push notification sent for live blog update: ${liveBlogEntity.title}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to send push notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return savedUpdate;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('push_subscriptions')
|
||||
export class PushSubscriptionEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text', unique: true })
|
||||
endpoint: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
p256dh: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
auth: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
userId: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PushService } from './push.service';
|
||||
import { SubscribeDto, UnsubscribeDto, SendNotificationDto } from './push.dto';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { UserRole } from '../entities';
|
||||
|
||||
@Controller('push')
|
||||
@UseGuards(RolesGuard)
|
||||
export class PushController {
|
||||
constructor(private readonly pushService: PushService) {}
|
||||
|
||||
@Public()
|
||||
@Get('vapid-public-key')
|
||||
getVapidPublicKey(): { publicKey: string | null } {
|
||||
return { publicKey: this.pushService.getPublicKey() };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('subscribe')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> {
|
||||
console.log('Received push subscription:', {
|
||||
endpoint: dto.endpoint?.substring(0, 50) + '...',
|
||||
hasP256dh: !!dto.p256dh,
|
||||
hasAuth: !!dto.auth,
|
||||
userId: dto.userId,
|
||||
});
|
||||
await this.pushService.subscribe(dto);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Delete('unsubscribe')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async unsubscribe(@Body() dto: UnsubscribeDto): Promise<void> {
|
||||
await this.pushService.unsubscribe(dto);
|
||||
}
|
||||
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('stats')
|
||||
getStats(): Promise<{ totalSubscribers: number }> {
|
||||
return this.pushService.getStats();
|
||||
}
|
||||
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
@Post('send')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendNotification(
|
||||
@Body() dto: SendNotificationDto,
|
||||
): Promise<{ sent: number; failed: number }> {
|
||||
return this.pushService.sendToAll({
|
||||
title: dto.title,
|
||||
body: dto.body,
|
||||
url: dto.url,
|
||||
tag: 'admin-notification',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class SubscribeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
endpoint: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
p256dh: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
auth: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class UnsubscribeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export class SendNotificationDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
body: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
url?: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PushController } from './push.controller';
|
||||
import { PushService } from './push.service';
|
||||
import { PushSubscriptionEntity } from './push-subscription.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PushSubscriptionEntity])],
|
||||
controllers: [PushController],
|
||||
providers: [PushService],
|
||||
exports: [PushService],
|
||||
})
|
||||
export class PushModule {}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as webpush from 'web-push';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PushSubscriptionEntity } from './push-subscription.entity';
|
||||
import { SubscribeDto, UnsubscribeDto } from './push.dto';
|
||||
|
||||
export interface PushPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
url?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushService implements OnModuleInit {
|
||||
private readonly logger = new Logger(PushService.name);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@InjectRepository(PushSubscriptionEntity)
|
||||
private subscriptionRepo: Repository<PushSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
|
||||
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
|
||||
const subject = this.configService.get<string>(
|
||||
'VAPID_SUBJECT',
|
||||
'mailto:contact@placebo.mk',
|
||||
);
|
||||
|
||||
if (!publicKey || !privateKey) {
|
||||
this.logger.warn(
|
||||
'VAPID keys not configured. Push notifications will not work. Run: npm run generate-vapid',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||
this.logger.log('VAPID keys configured successfully');
|
||||
}
|
||||
|
||||
getPublicKey(): string | null {
|
||||
return this.configService.get<string>('VAPID_PUBLIC_KEY') ?? null;
|
||||
}
|
||||
|
||||
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
|
||||
this.logger.log('Subscribing push notification...');
|
||||
this.logger.debug(`Endpoint: ${dto.endpoint?.substring(0, 50)}...`);
|
||||
|
||||
const existing = await this.subscriptionRepo.findOne({
|
||||
where: { endpoint: dto.endpoint },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
this.logger.log('Subscription already exists, updating...');
|
||||
if (dto.userId && existing.userId !== dto.userId) {
|
||||
existing.userId = dto.userId;
|
||||
return this.subscriptionRepo.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
this.logger.log('Creating new subscription...');
|
||||
const subscription = this.subscriptionRepo.create({
|
||||
endpoint: dto.endpoint,
|
||||
p256dh: dto.p256dh,
|
||||
auth: dto.auth,
|
||||
userId: dto.userId ?? null,
|
||||
});
|
||||
|
||||
const saved = await this.subscriptionRepo.save(subscription);
|
||||
this.logger.log(`Subscription saved with ID: ${saved.id}`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async unsubscribe(dto: UnsubscribeDto): Promise<void> {
|
||||
await this.subscriptionRepo.delete({ endpoint: dto.endpoint });
|
||||
}
|
||||
|
||||
async sendToAll(
|
||||
payload: PushPayload,
|
||||
): Promise<{ sent: number; failed: number }> {
|
||||
const subscriptions = await this.subscriptionRepo.find();
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return { sent: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const notification = JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
icon: payload.icon ?? '/icons/icon-192.png',
|
||||
badge: payload.badge ?? '/icons/badge-72.png',
|
||||
url: payload.url ?? '/',
|
||||
tag: payload.tag,
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map(async (sub) => {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
},
|
||||
},
|
||||
notification,
|
||||
{
|
||||
TTL: 86400,
|
||||
},
|
||||
);
|
||||
return { success: true, id: sub.id };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
this.logger.warn(`Push failed for ${sub.id}: ${error.message}`);
|
||||
}
|
||||
if (
|
||||
error instanceof webpush.WebPushError &&
|
||||
(error.statusCode === 410 || error.statusCode === 404)
|
||||
) {
|
||||
await this.subscriptionRepo.delete({ id: sub.id });
|
||||
this.logger.log(`Removed invalid subscription: ${sub.id}`);
|
||||
}
|
||||
return { success: false, id: sub.id };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
sent++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Push sent: ${sent}, failed: ${failed}`);
|
||||
return { sent, failed };
|
||||
}
|
||||
|
||||
async notifyNewArticle(
|
||||
articleTitle: string,
|
||||
articleSlug: string,
|
||||
): Promise<void> {
|
||||
await this.sendToAll({
|
||||
title: 'Нови вести! 📰',
|
||||
body: articleTitle,
|
||||
url: `/article/${articleSlug}`,
|
||||
tag: 'new-article',
|
||||
});
|
||||
}
|
||||
|
||||
async notifyLiveBlogUpdate(
|
||||
blogTitle: string,
|
||||
blogSlug: string,
|
||||
updatePreview: string,
|
||||
): Promise<void> {
|
||||
const body =
|
||||
updatePreview.length > 100
|
||||
? updatePreview.substring(0, 97) + '...'
|
||||
: updatePreview;
|
||||
|
||||
await this.sendToAll({
|
||||
title: `${blogTitle} 📡`,
|
||||
body,
|
||||
url: `/live-blog/${blogSlug}`,
|
||||
tag: `live-blog-${blogSlug}`,
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(): Promise<{ totalSubscribers: number }> {
|
||||
const totalSubscribers = await this.subscriptionRepo.count();
|
||||
return { totalSubscribers };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
import { usePushStats, useSendPushNotification } from '@/queries/push';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bell, Send, Users, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
export function PushNotificationManager() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [result, setResult] = useState<{ sent: number; failed: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: stats, isLoading: loadingStats } = usePushStats();
|
||||
const sendMutation = useSendPushNotification();
|
||||
|
||||
const handleSendTest = async () => {
|
||||
if (!title.trim() || !body.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await sendMutation.mutateAsync({
|
||||
title: title.trim(),
|
||||
body: body.trim(),
|
||||
url: url.trim() || undefined,
|
||||
});
|
||||
setResult(result);
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => setShowSuccess(false), 5000);
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = title.trim().length > 0 && body.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-brutal bg-card p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Bell className="h-6 w-6 text-accent" />
|
||||
<h2 className="text-2xl font-display uppercase">Push Notifications</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<div className="border-brutal-sm bg-background p-5 h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-body text-sm uppercase tracking-wider text-muted-foreground">
|
||||
Subscribers
|
||||
</span>
|
||||
</div>
|
||||
{loadingStats ? (
|
||||
<div className="text-4xl font-display animate-pulse">...</div>
|
||||
) : (
|
||||
<div className="text-5xl font-display">
|
||||
{stats?.totalSubscribers ?? 0}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs font-body text-muted-foreground mt-2">
|
||||
Active push subscribers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<div className="border-brutal-sm bg-background p-5">
|
||||
<h3 className="font-body text-sm uppercase tracking-wider text-muted-foreground mb-4">
|
||||
Send Notification
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter notification title..."
|
||||
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Body
|
||||
</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Enter notification message..."
|
||||
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none resize-none"
|
||||
rows={3}
|
||||
maxLength={150}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||
URL (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="/article/slug or /live-blogs/slug"
|
||||
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 pt-2">
|
||||
<div className="text-xs font-body text-muted-foreground">
|
||||
{title.length}/50 chars • {body.length}/150 chars
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="brutalAccent"
|
||||
onClick={handleSendTest}
|
||||
disabled={!isValid || sendMutation.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{sendMutation.isPending ? 'Sending...' : 'Send to All'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSuccess && result && (
|
||||
<div className="mt-4 p-3 border-2 border-green-500 bg-green-500/10 flex items-start gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-body text-sm font-medium">
|
||||
Notification sent!
|
||||
</p>
|
||||
<p className="font-body text-xs text-muted-foreground">
|
||||
Delivered: {result.sent} • Failed: {result.failed}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendMutation.isError && (
|
||||
<div className="mt-4 p-3 border-2 border-red-500 bg-red-500/10 flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
<p className="font-body text-sm">
|
||||
Failed to send notification. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 border-2 border-foreground/20 bg-foreground/5">
|
||||
<p className="font-body text-xs text-muted-foreground">
|
||||
<strong>Note:</strong> Notifications are sent to all subscribers with
|
||||
push enabled. Use sparingly to avoid spam. Articles and live blog
|
||||
updates are automatically pushed when published.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { format } from 'date-fns';
|
||||
import { mk } from 'date-fns/locale';
|
||||
import { PushNotificationManager } from '@/components/admin/PushNotificationManager';
|
||||
|
||||
export function AdminDashboardComponent() {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
@@ -167,32 +168,42 @@ export function AdminDashboardComponent() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
||||
{liveBlogs.filter((b) => b.status === 'live').length || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||
Активни live блогови
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{articles.filter(a => a.status === 'published').length || 0}
|
||||
{articles.filter((a) => a.status === 'published').length || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||
Објавени написи
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{liveBlogs.filter(b => b.isPinned).length || 0}
|
||||
{liveBlogs.filter((b) => b.isPinned).length || 0}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||
Закачени live блогови
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-brutal bg-card p-5">
|
||||
<div className="text-4xl font-display">
|
||||
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||
</div>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p>
|
||||
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||
Вкупни прегледи
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showArchived && <PushNotificationManager />}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="border-brutal bg-card">
|
||||
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
||||
|
||||
@@ -827,3 +827,41 @@ export async function getUserReaction(
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Push Notification Admin Types
|
||||
export interface PushStats {
|
||||
totalSubscribers: number;
|
||||
}
|
||||
|
||||
export interface SendNotificationResult {
|
||||
sent: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface SendNotificationDto {
|
||||
title: string;
|
||||
body: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// Push Notification Admin API Functions
|
||||
export async function fetchPushStats(): Promise<PushStats> {
|
||||
const response = await authFetch(`${API_BASE_URL}/push/stats`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch push stats');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function sendPushNotification(
|
||||
dto: SendNotificationDto,
|
||||
): Promise<SendNotificationResult> {
|
||||
const response = await authFetch(`${API_BASE_URL}/push/send`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send push notification');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../lib/api';
|
||||
|
||||
export function usePushStats() {
|
||||
return useQuery({
|
||||
queryKey: ['push', 'stats'],
|
||||
queryFn: api.fetchPushStats,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendPushNotification() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.sendPushNotification,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['push', 'stats'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Generated
+108
-49
@@ -42,7 +42,8 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"typeorm": "^0.3.28"
|
||||
"typeorm": "^0.3.28",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -54,6 +55,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
@@ -5249,10 +5251,6 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"license": "ISC"
|
||||
},
|
||||
"backend/node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"license": "ISC"
|
||||
@@ -6520,13 +6518,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"license": "ISC",
|
||||
@@ -7610,10 +7601,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"backend/node_modules/schema-utils": {
|
||||
"version": "3.3.0",
|
||||
"dev": true,
|
||||
@@ -14119,16 +14106,6 @@
|
||||
"version": "2.0.6",
|
||||
"license": "MIT"
|
||||
},
|
||||
"cms/cms/node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"cms/cms/node_modules/ast-types": {
|
||||
"version": "0.16.1",
|
||||
"license": "MIT",
|
||||
@@ -14254,10 +14231,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"cms/cms/node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"cms/cms/node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"license": "ISC"
|
||||
@@ -17240,10 +17213,6 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"cms/cms/node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"license": "ISC"
|
||||
},
|
||||
"cms/cms/node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"license": "ISC"
|
||||
@@ -18716,10 +18685,6 @@
|
||||
"webpack": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"cms/cms/node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"license": "ISC"
|
||||
},
|
||||
"cms/cms/node_modules/minimalistic-crypto-utils": {
|
||||
"version": "1.0.1",
|
||||
"license": "MIT"
|
||||
@@ -18737,13 +18702,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"cms/cms/node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"cms/cms/node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"license": "ISC",
|
||||
@@ -21241,10 +21199,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"cms/cms/node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"cms/cms/node_modules/sanitize-html": {
|
||||
"version": "2.13.0",
|
||||
"license": "MIT",
|
||||
@@ -27112,12 +27066,31 @@
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -27142,6 +27115,18 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/backend": {
|
||||
"resolved": "backend",
|
||||
"link": true
|
||||
@@ -27170,6 +27155,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -27563,6 +27554,28 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -27583,6 +27596,12 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -28632,6 +28651,21 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -29024,6 +29058,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -29377,6 +29417,25 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-d975e299'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.ek3n41mv3u8"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.StaleWhileRevalidate({
|
||||
"cacheName": "api-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 86400
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||
import { Bell, X } from 'lucide-react';
|
||||
|
||||
const DISMISSED_KEY = 'push-banner-dismissed';
|
||||
const SHOW_DELAY = 3000;
|
||||
|
||||
function getInitiallyDismissed(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!localStorage.getItem(DISMISSED_KEY);
|
||||
}
|
||||
|
||||
export function NotificationBanner() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(getInitiallyDismissed);
|
||||
const { isSupported, isSubscribed, isLoading, subscribe } =
|
||||
usePushNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSupported && !isSubscribed && !isDismissed) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, SHOW_DELAY);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isSupported, isSubscribed, isDismissed]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
localStorage.setItem(DISMISSED_KEY, 'true');
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
console.log('handleSubscribe called');
|
||||
console.log('isSupported:', isSupported);
|
||||
console.log('isSubscribed:', isSubscribed);
|
||||
|
||||
try {
|
||||
const success = await subscribe();
|
||||
console.log('subscribe() returned:', success);
|
||||
if (success) {
|
||||
setIsVisible(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('handleSubscribe error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVisible || isSubscribed || isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-sm animate-slide-in-right">
|
||||
<div className="flex items-start gap-3 rounded-lg border-2 border-foreground bg-card p-4 shadow-brutal">
|
||||
<div className="flex-shrink-0">
|
||||
<Bell className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-body text-sm font-medium uppercase tracking-wider">
|
||||
Останете информирани
|
||||
</p>
|
||||
<p className="mt-1 font-body text-xs text-muted-foreground">
|
||||
Добијте известувања за нови статии и live ажурирања
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleSubscribe}
|
||||
disabled={isLoading}
|
||||
className="border-2 border-foreground bg-foreground px-3 py-1.5 font-body text-xs font-medium uppercase tracking-wider text-background transition-all hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Се вклучува...' : 'Вклучи'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="border-2 border-foreground px-3 py-1.5 font-body text-xs font-medium uppercase tracking-wider transition-all hover:bg-muted"
|
||||
>
|
||||
Не сега
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Затвори"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
getVapidPublicKey,
|
||||
subscribeToPush,
|
||||
unsubscribeFromPush,
|
||||
urlBase64ToUint8Array,
|
||||
} from '@/lib/push-api';
|
||||
import type { PushSubscriptionData } from '@/lib/push-api';
|
||||
|
||||
const STORAGE_KEY = 'push-notification-preference';
|
||||
|
||||
function checkPushSupport(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialPermission(): NotificationPermission | 'not-supported' {
|
||||
if (!checkPushSupport()) return 'not-supported';
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
export interface UsePushNotificationsReturn {
|
||||
isSupported: boolean;
|
||||
isSubscribed: boolean;
|
||||
isLoading: boolean;
|
||||
permissionState: NotificationPermission | 'not-supported';
|
||||
subscribe: () => Promise<boolean>;
|
||||
unsubscribe: () => Promise<boolean>;
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
}
|
||||
|
||||
export function usePushNotifications(): UsePushNotificationsReturn {
|
||||
const [isSupported] = useState(checkPushSupport);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [permissionState, setPermissionState] = useState(getInitialPermission);
|
||||
const hasCheckedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('usePushNotifications - isSupported:', checkPushSupport());
|
||||
console.log('usePushNotifications - permissionState:', getInitialPermission());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupported || hasCheckedRef.current) return;
|
||||
hasCheckedRef.current = true;
|
||||
|
||||
console.log('Checking existing push subscription...');
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => registration.pushManager.getSubscription())
|
||||
.then((subscription) => {
|
||||
console.log('Existing subscription:', subscription);
|
||||
setIsSubscribed(!!subscription);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error checking subscription:', error);
|
||||
});
|
||||
}, [isSupported]);
|
||||
|
||||
const requestPermission =
|
||||
useCallback(async (): Promise<NotificationPermission> => {
|
||||
if (!isSupported) return 'not-supported';
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
setPermissionState(permission);
|
||||
return permission;
|
||||
}, [isSupported]);
|
||||
|
||||
const subscribe = useCallback(async (): Promise<boolean> => {
|
||||
if (!isSupported) return false;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const permission = await requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.log('Push permission denied');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const publicKey = await getVapidPublicKey();
|
||||
if (!publicKey) {
|
||||
console.error('VAPID public key not available');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Got VAPID public key, subscribing...');
|
||||
|
||||
// Check if service worker is already ready
|
||||
console.log('Checking service worker status...');
|
||||
const swController = navigator.serviceWorker.controller;
|
||||
console.log('Service worker controller:', swController);
|
||||
|
||||
// Wait for service worker to be ready with timeout
|
||||
const registration = await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise<ServiceWorkerRegistration>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Service worker ready timeout after 10s'));
|
||||
}, 10000);
|
||||
}),
|
||||
]);
|
||||
console.log('Service worker ready:', registration);
|
||||
|
||||
if (!registration.pushManager) {
|
||||
console.error('PushManager not available in service worker registration');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
console.log('Creating new push subscription...');
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
console.log('Push subscription created');
|
||||
} else {
|
||||
console.log('Existing push subscription found');
|
||||
}
|
||||
|
||||
const subJson = subscription.toJSON();
|
||||
console.log('Subscription JSON:', subJson);
|
||||
|
||||
if (
|
||||
!subJson.endpoint ||
|
||||
!subJson.keys?.p256dh ||
|
||||
!subJson.keys?.auth
|
||||
) {
|
||||
console.error('Invalid subscription data:', subJson);
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = await subscribeToPush(subJson as PushSubscriptionData);
|
||||
console.log('Subscribe to push result:', success);
|
||||
|
||||
if (success) {
|
||||
setIsSubscribed(true);
|
||||
localStorage.setItem(STORAGE_KEY, 'subscribed');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push notifications:', error);
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
}, [isSupported, requestPermission]);
|
||||
|
||||
const unsubscribe = useCallback(async (): Promise<boolean> => {
|
||||
if (!isSupported) return false;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
await unsubscribeFromPush(subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
setIsSubscribed(false);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setIsLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error unsubscribing from push notifications:', error);
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isSubscribed,
|
||||
isLoading,
|
||||
permissionState,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
requestPermission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VapidPublicKeyResponse {
|
||||
publicKey: string | null;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
export async function getVapidPublicKey(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/push/vapid-public-key`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch VAPID public key');
|
||||
}
|
||||
const data: VapidPublicKeyResponse = await response.json();
|
||||
return data.publicKey;
|
||||
} catch (error) {
|
||||
console.error('Error fetching VAPID public key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeToPush(
|
||||
subscription: PushSubscriptionData,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
console.log('Sending subscription to server:', {
|
||||
endpoint: subscription.endpoint,
|
||||
hasKeys: !!subscription.keys,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
};
|
||||
|
||||
console.log('Payload:', payload);
|
||||
console.log('API URL:', `${API_BASE_URL}/push/subscribe`);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
console.log('Subscribe response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Subscribe failed:', errorText);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(
|
||||
endpoint: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error unsubscribing from push:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
+19
-1
@@ -5,12 +5,30 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { router } from './routes'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { initializeTheme } from './lib/theme'
|
||||
import 'virtual:pwa-register'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
initializeTheme()
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
// Register service worker
|
||||
const updateSW = registerSW({
|
||||
onNeedRefresh() {
|
||||
if (confirm('New content available. Reload?')) {
|
||||
updateSW(true)
|
||||
}
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('App ready to work offline')
|
||||
},
|
||||
onRegistered(registration) {
|
||||
console.log('SW Registered:', registration?.scope)
|
||||
},
|
||||
onRegisterError(error) {
|
||||
console.error('SW registration error:', error)
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Header } from './components/layout/Header'
|
||||
import { HeroArticle } from './components/home/HeroArticle'
|
||||
import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar'
|
||||
import { LatestArticlesGrid } from './components/home/LatestArticlesGrid'
|
||||
import { NotificationBanner } from './components/ui/notification-banner'
|
||||
import { Button } from './components/ui/button'
|
||||
import { Zap, Search, Users } from 'lucide-react'
|
||||
import './styles.css'
|
||||
@@ -38,6 +39,8 @@ const rootRoute = createRootRoute({
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<NotificationBanner />
|
||||
|
||||
<footer className="border-t-4 border-foreground bg-foreground text-background">
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
|
||||
+3
-2
@@ -32,9 +32,10 @@ self.addEventListener('push', (event: PushEvent) => {
|
||||
const title = data.title ?? 'Placebo.mk'
|
||||
const options = {
|
||||
body: data.body ?? 'New update available',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
icon: data.icon ?? '/icons/icon-192.svg',
|
||||
badge: data.badge ?? '/icons/badge-72.svg',
|
||||
data: data.url ?? '/',
|
||||
tag: data.tag,
|
||||
}
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options))
|
||||
|
||||
@@ -44,6 +44,7 @@ export default defineConfig({
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user