push notifs
This commit is contained in:
parent
ab91a53b15
commit
49b71ae1c7
125
package-lock.json
generated
125
package-lock.json
generated
@ -8,10 +8,13 @@
|
|||||||
"name": "untitled2",
|
"name": "untitled2",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
"cra-template": "1.2.0",
|
"cra-template": "1.2.0",
|
||||||
"react": "19.0.0",
|
"express": "^4.21.2",
|
||||||
"react-dom": "19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-scripts": "5.0.1"
|
"react-dom": "^19.0.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@ -4672,6 +4675,17 @@
|
|||||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||||
"license": "MIT"
|
"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==",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ast-types-flow": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||||
@ -5058,6 +5072,11 @@
|
|||||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
|
||||||
|
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg=="
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
@ -5194,6 +5213,11 @@
|
|||||||
"node-int64": "^0.4.0"
|
"node-int64": "^0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@ -5794,6 +5818,18 @@
|
|||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||||
|
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cosmiconfig": {
|
"node_modules/cosmiconfig": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||||
@ -6650,6 +6686,14 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@ -7687,7 +7731,6 @@
|
|||||||
"version": "4.21.2",
|
"version": "4.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@ -8828,6 +8871,14 @@
|
|||||||
"entities": "^2.0.0"
|
"entities": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-deceiver": {
|
"node_modules/http-deceiver": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
|
||||||
@ -10829,6 +10880,25 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.0",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@ -16116,17 +16186,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.7.3",
|
"version": "4.9.5",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"node": ">=4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
@ -16421,6 +16490,44 @@
|
|||||||
"minimalistic-assert": "^1.0.0"
|
"minimalistic-assert": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"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.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||||
|
"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==",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
|
||||||
|
@ -3,10 +3,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
"cra-template": "1.2.0",
|
"cra-template": "1.2.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-scripts": "5.0.1"
|
"react-scripts": "5.0.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
@ -14,6 +17,7 @@
|
|||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
},
|
},
|
||||||
|
"proxy": "http://localhost:4000",
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"react-app",
|
"react-app",
|
||||||
|
45
public/service-worker.js
Normal file
45
public/service-worker.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/* public/service-worker.js
|
||||||
|
|
||||||
|
Listens to 'push' events and shows a notification.
|
||||||
|
This file MUST be served via HTTPS or on localhost.
|
||||||
|
*/
|
||||||
|
|
||||||
|
self.addEventListener('push', function(event) {
|
||||||
|
if (!event.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = event.data.json(); // {title, body}
|
||||||
|
const title = payload.title || 'Timer Notification';
|
||||||
|
const body = payload.body || 'Time is up!';
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: body,
|
||||||
|
icon: '/icon.png', // optional
|
||||||
|
badge: '/badge.png' // optional
|
||||||
|
// any other notification options
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle notification click
|
||||||
|
self.addEventListener('notificationclick', function(event) {
|
||||||
|
event.notification.close();
|
||||||
|
// Focus or open the site
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type:'window' }).then( windowClients => {
|
||||||
|
// if we already have a window open, focus it
|
||||||
|
for (let client of windowClients) {
|
||||||
|
if (client.url.includes('/PointTracker') && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// otherwise, open a new one
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
253
server.js
253
server.js
@ -1,102 +1,124 @@
|
|||||||
/*
|
/*
|
||||||
server.js
|
server.js
|
||||||
A single Node/Express server that:
|
|
||||||
1) Manages timers, logs, admin routes (same logic as before).
|
Node/Express + web-push sample.
|
||||||
2) Serves the React build from "client/build".
|
- Timer logic: 15 min usage => ring => optional 2 min => ignore or finish up
|
||||||
|
- Weekly reset for base arrays (soloUsed, togetherUsed)
|
||||||
|
- Push Subscriptions in memory
|
||||||
|
- Serves React build from "client/build"
|
||||||
|
|
||||||
|
Setup Steps:
|
||||||
|
1) npm install express cors path web-push
|
||||||
|
2) Generate VAPID keys: "npx web-push generate-vapid-keys"
|
||||||
|
3) Replace placeholders below
|
||||||
|
4) "npm run build" in your client folder, so "client/build" exists
|
||||||
|
5) node server.js => listens on port 4000
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path'); // for serving the React build
|
const path = require('path');
|
||||||
|
const webPush = require('web-push');
|
||||||
|
|
||||||
|
// -- REPLACE WITH YOUR ACTUAL VAPID KEYS! --
|
||||||
|
const VAPID_PUBLIC_KEY = 'BCn73fh1YZV3rFbK9H234he4nNWhhEKnYiQ_UZ1U0nxR6Q6cKvTG6v05Uyq7KXh0FxwjPOV3jHR3DPLqF5Lyfm4';
|
||||||
|
const VAPID_PRIVATE_KEY = 'ysmxNfkY_V0CVBwL0UJb1BeYl0dgrF4vw09cNWfFW-M';
|
||||||
|
|
||||||
|
// Configure web-push
|
||||||
|
webPush.setVapidDetails(
|
||||||
|
'mailto:youremail@example.com',
|
||||||
|
VAPID_PUBLIC_KEY,
|
||||||
|
VAPID_PRIVATE_KEY
|
||||||
|
);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
// ----- CONFIG -----
|
// In-memory push subscription store
|
||||||
const PORT = 80;
|
// For a real app, store in DB
|
||||||
|
let pushSubscriptions = [];
|
||||||
|
|
||||||
// Default base counts
|
/* ------------------------------------------------------------------
|
||||||
|
TIMER / WEEKLY RESET LOGIC
|
||||||
|
------------------------------------------------------------------ */
|
||||||
const SOLO_BASE_COUNT = 5;
|
const SOLO_BASE_COUNT = 5;
|
||||||
const TOGETHER_BASE_COUNT = 5;
|
const TOGETHER_BASE_COUNT = 5;
|
||||||
|
|
||||||
// Passwords
|
|
||||||
const POINTS_PASSWORD = 'ParentsOnly25'; // for adding points
|
|
||||||
const ADMIN_PASSWORD = 'Bubb75i-i'; // for admin panel
|
|
||||||
|
|
||||||
// Timer durations (ms)
|
|
||||||
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
|
||||||
const TWO_MINUTES = 2 * 60 * 1000;
|
|
||||||
|
|
||||||
// ----- IN-MEMORY STATE -----
|
|
||||||
// Start with 5 false for each
|
|
||||||
let soloUsed = Array(SOLO_BASE_COUNT).fill(false);
|
let soloUsed = Array(SOLO_BASE_COUNT).fill(false);
|
||||||
let togetherUsed = Array(TOGETHER_BASE_COUNT).fill(false);
|
let togetherUsed = Array(TOGETHER_BASE_COUNT).fill(false);
|
||||||
|
|
||||||
// Logs: e.g. { type:'solo'|'together'|'movie'|'episode', index, usedAt:'ISOString' }
|
|
||||||
let logs = [];
|
let logs = [];
|
||||||
|
|
||||||
let lastReset = null;
|
let lastReset = null;
|
||||||
|
|
||||||
// Timer state
|
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
||||||
|
const TWO_MINUTES = 2 * 60 * 1000;
|
||||||
|
|
||||||
let timerRunning = false;
|
let timerRunning = false;
|
||||||
let timeRemaining = 0;
|
let timeRemaining = 0;
|
||||||
let isPaused = false;
|
let isPaused = false;
|
||||||
let activeUsage = null; // e.g. { category:'solo'|'together', index }
|
let activeUsage = null; // e.g. { category:'solo'|'together', index:number }
|
||||||
let ringing = false;
|
let ringing = false;
|
||||||
|
|
||||||
// -----------------------------
|
// Weekly reset to set all usage to false if new Monday
|
||||||
// Weekly Reset Logic
|
|
||||||
// -----------------------------
|
|
||||||
function getMonday(d = new Date()) {
|
function getMonday(d = new Date()) {
|
||||||
const day = d.getDay(); // 0=Sun,1=Mon,...
|
const day = d.getDay(); // 0=Sun,1=Mon,...
|
||||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
d.setDate(diff);
|
d.setDate(diff);
|
||||||
return d.toISOString().split('T')[0];
|
return d.toISOString().split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetIfNewWeek() {
|
function resetIfNewWeek() {
|
||||||
const currentMonday = getMonday();
|
const currentMonday = getMonday();
|
||||||
if (!lastReset || lastReset !== currentMonday) {
|
if (!lastReset || lastReset !== currentMonday) {
|
||||||
// Keep array lengths, reset all to false
|
|
||||||
soloUsed = soloUsed.map(() => false);
|
soloUsed = soloUsed.map(() => false);
|
||||||
togetherUsed = togetherUsed.map(() => false);
|
togetherUsed = togetherUsed.map(() => false);
|
||||||
|
|
||||||
lastReset = currentMonday;
|
lastReset = currentMonday;
|
||||||
console.log('[Server] Weekly reset triggered:', currentMonday);
|
console.log('[Server] Weekly reset triggered on Monday =>', currentMonday);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement the timer
|
// Timer loop => decrement
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (timerRunning && !isPaused && timeRemaining > 0) {
|
if (timerRunning && !isPaused && timeRemaining > 0) {
|
||||||
timeRemaining -= 1000;
|
timeRemaining -= 1000;
|
||||||
if (timeRemaining <= 0) {
|
if (timeRemaining <= 0) {
|
||||||
// If finishing a 15-min => ring; if finishing 2-min => just end
|
// Timer ended
|
||||||
timerRunning = false;
|
timerRunning = false;
|
||||||
timeRemaining = 0;
|
timeRemaining = 0;
|
||||||
|
// If we ended a 15-min block => ring
|
||||||
// If we aren't already ringing, that means we ended the 15-min block => ring
|
|
||||||
if (!ringing) {
|
if (!ringing) {
|
||||||
ringing = true;
|
ringing = true;
|
||||||
console.log('[Server] 15-minute timer ended => ringing=true');
|
console.log('[Server] 15-minute ended => ringing=true');
|
||||||
|
// Send push notification to all
|
||||||
|
sendPushToAll('Timer Ended!', 'Time is up! Tap for details.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// -----------------------------
|
// Send push to all subscribed
|
||||||
// EXPRESS / MIDDLEWARE
|
function sendPushToAll(title, body) {
|
||||||
// -----------------------------
|
console.log('[Server] Sending push to', pushSubscriptions.length, 'subscribers');
|
||||||
app.use(cors());
|
const payload = JSON.stringify({ title, body });
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Weekly reset check on each request
|
pushSubscriptions.forEach((sub) => {
|
||||||
|
webPush.sendNotification(sub, payload).catch((err) => {
|
||||||
|
console.error('[Push Error]', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware => check weekly reset on each request
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
resetIfNewWeek();
|
resetIfNewWeek();
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------
|
/* ------------------------------------------------------------------
|
||||||
// API ROUTES
|
API ROUTES
|
||||||
// -----------------------------
|
------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
// GET /state => return current usage/timer
|
||||||
app.get('/state', (req, res) => {
|
app.get('/state', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
soloUsed,
|
soloUsed,
|
||||||
@ -111,63 +133,50 @@ app.get('/state', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Single bubble => 15-min timer
|
// POST /use-bubble => single bubble => 15 min
|
||||||
app.post('/use-bubble', (req, res) => {
|
app.post('/use-bubble', (req, res) => {
|
||||||
const { category, index } = req.body;
|
|
||||||
|
|
||||||
if (timerRunning && !ringing && timeRemaining > 0) {
|
if (timerRunning && !ringing && timeRemaining > 0) {
|
||||||
return res.status(400).json({ error: 'A timer is already active.' });
|
return res.status(400).json({ error: 'A timer is already active.' });
|
||||||
}
|
}
|
||||||
// If ringing => auto-ignore
|
// If currently ringing => ignore
|
||||||
if (ringing) {
|
if (ringing) {
|
||||||
timerRunning = false;
|
timerRunning = false;
|
||||||
timeRemaining = 0;
|
timeRemaining = 0;
|
||||||
isPaused = false;
|
isPaused = false;
|
||||||
activeUsage = null;
|
activeUsage = null;
|
||||||
ringing = false;
|
ringing = false;
|
||||||
console.log('[Server] Timer was ringing => auto-ignored, new usage starts.');
|
console.log('[Server] Timer was ringing => auto-ignored => new usage starts.');
|
||||||
}
|
}
|
||||||
|
const { category, index } = req.body;
|
||||||
|
let arr = category === 'solo' ? soloUsed : category === 'together' ? togetherUsed : null;
|
||||||
|
if (!arr) return res.status(400).json({ error: 'Invalid category' });
|
||||||
|
if (index < 0 || index >= arr.length) return res.status(400).json({ error: 'Index out of range' });
|
||||||
|
if (arr[index]) return res.status(400).json({ error: 'That bubble is already used.' });
|
||||||
|
|
||||||
let arr;
|
|
||||||
if (category === 'solo') arr = soloUsed;
|
|
||||||
else if (category === 'together') arr = togetherUsed;
|
|
||||||
else return res.status(400).json({ error: 'Invalid category' });
|
|
||||||
|
|
||||||
if (index < 0 || index >= arr.length) {
|
|
||||||
return res.status(400).json({ error: 'Index out of range' });
|
|
||||||
}
|
|
||||||
if (arr[index] === true) {
|
|
||||||
return res.status(400).json({ error: 'That bubble is already used.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark used
|
|
||||||
arr[index] = true;
|
arr[index] = true;
|
||||||
const usedAt = new Date().toISOString();
|
logs.push({ type: category, index, usedAt: new Date().toISOString() });
|
||||||
logs.push({ type: category, index, usedAt });
|
|
||||||
|
|
||||||
// Start 15-min
|
|
||||||
timerRunning = true;
|
timerRunning = true;
|
||||||
timeRemaining = FIFTEEN_MINUTES;
|
timeRemaining = FIFTEEN_MINUTES;
|
||||||
isPaused = false;
|
isPaused = false;
|
||||||
activeUsage = { category, index };
|
activeUsage = { category, index };
|
||||||
console.log(`[Server] ${category} #${index + 1} => 15-min started`);
|
res.json({ success: true });
|
||||||
return res.json({ success: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check password quickly
|
// check password
|
||||||
app.post('/check-password', (req, res) => {
|
app.post('/check-password', (req, res) => {
|
||||||
const { password } = req.body;
|
const { password } = req.body;
|
||||||
if (password !== POINTS_PASSWORD) {
|
if (password !== 'mySecretPassword') {
|
||||||
return res.status(403).json({ error: 'Incorrect password.' });
|
return res.status(403).json({ error: 'Incorrect password.' });
|
||||||
}
|
}
|
||||||
return res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add points => appends "amount" new false items to solo/together
|
// add-points => append new false items
|
||||||
app.post('/add-points', (req, res) => {
|
app.post('/add-points', (req, res) => {
|
||||||
const { password, category, amount } = req.body;
|
const { password, category, amount } = req.body;
|
||||||
if (password !== POINTS_PASSWORD) {
|
if (password !== 'mySecretPassword') {
|
||||||
return res.status(403).json({ error: 'Incorrect password.' });
|
return res.status(403).json({ error: 'Wrong password' });
|
||||||
}
|
}
|
||||||
if (!['solo', 'together'].includes(category)) {
|
if (!['solo', 'together'].includes(category)) {
|
||||||
return res.status(400).json({ error: 'Invalid category' });
|
return res.status(400).json({ error: 'Invalid category' });
|
||||||
@ -175,19 +184,18 @@ app.post('/add-points', (req, res) => {
|
|||||||
if (typeof amount !== 'number' || amount < 1 || amount > 4) {
|
if (typeof amount !== 'number' || amount < 1 || amount > 4) {
|
||||||
return res.status(400).json({ error: 'Amount must be 1..4' });
|
return res.status(400).json({ error: 'Amount must be 1..4' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category === 'solo') {
|
if (category === 'solo') {
|
||||||
for (let i = 0; i < amount; i++) {
|
for (let i = 0; i < amount; i++) {
|
||||||
soloUsed.push(false);
|
soloUsed.push(false);
|
||||||
}
|
}
|
||||||
console.log(`[Server] +${amount} appended to soloUsed => length now ${soloUsed.length}`);
|
console.log(`[Server] +${amount} appended to soloUsed => length now`, soloUsed.length);
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < amount; i++) {
|
for (let i = 0; i < amount; i++) {
|
||||||
togetherUsed.push(false);
|
togetherUsed.push(false);
|
||||||
}
|
}
|
||||||
console.log(`[Server] +${amount} appended to togetherUsed => length now ${togetherUsed.length}`);
|
console.log(`[Server] +${amount} appended to togetherUsed => length now`, togetherUsed.length);
|
||||||
}
|
}
|
||||||
return res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pause/Resume
|
// Pause/Resume
|
||||||
@ -200,10 +208,10 @@ app.post('/pause-resume', (req, res) => {
|
|||||||
}
|
}
|
||||||
isPaused = !isPaused;
|
isPaused = !isPaused;
|
||||||
console.log('[Server] Timer pause =>', isPaused);
|
console.log('[Server] Timer pause =>', isPaused);
|
||||||
return res.json({ success: true, isPaused });
|
res.json({ success: true, isPaused });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Finish-up => only if ringing
|
// finish-up => only if ringing
|
||||||
app.post('/finish-up', (req, res) => {
|
app.post('/finish-up', (req, res) => {
|
||||||
if (!ringing) {
|
if (!ringing) {
|
||||||
return res.status(400).json({ error: 'Not currently ringing. 2-min only after 15-min ends.' });
|
return res.status(400).json({ error: 'Not currently ringing. 2-min only after 15-min ends.' });
|
||||||
@ -213,10 +221,10 @@ app.post('/finish-up', (req, res) => {
|
|||||||
isPaused = false;
|
isPaused = false;
|
||||||
ringing = false;
|
ringing = false;
|
||||||
console.log('[Server] 2-min finish-up started');
|
console.log('[Server] 2-min finish-up started');
|
||||||
return res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cancel finish-up
|
// cancel-finish-up
|
||||||
app.post('/cancel-finish-up', (req, res) => {
|
app.post('/cancel-finish-up', (req, res) => {
|
||||||
if (!timerRunning || ringing || timeRemaining > TWO_MINUTES) {
|
if (!timerRunning || ringing || timeRemaining > TWO_MINUTES) {
|
||||||
return res.status(400).json({ error: 'Not currently in finish-up timer.' });
|
return res.status(400).json({ error: 'Not currently in finish-up timer.' });
|
||||||
@ -226,10 +234,10 @@ app.post('/cancel-finish-up', (req, res) => {
|
|||||||
isPaused = false;
|
isPaused = false;
|
||||||
activeUsage = null;
|
activeUsage = null;
|
||||||
console.log('[Server] 2-min finish-up canceled');
|
console.log('[Server] 2-min finish-up canceled');
|
||||||
return res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ignore ring => if ringing
|
// ignore-ring
|
||||||
app.post('/ignore-ring', (req, res) => {
|
app.post('/ignore-ring', (req, res) => {
|
||||||
if (!ringing) {
|
if (!ringing) {
|
||||||
return res.status(400).json({ error: 'Not currently ringing.' });
|
return res.status(400).json({ error: 'Not currently ringing.' });
|
||||||
@ -240,10 +248,10 @@ app.post('/ignore-ring', (req, res) => {
|
|||||||
activeUsage = null;
|
activeUsage = null;
|
||||||
ringing = false;
|
ringing = false;
|
||||||
console.log('[Server] Ring ignored => no 2-min started');
|
console.log('[Server] Ring ignored => no 2-min started');
|
||||||
return res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Movie => user picks 4
|
// use-movie
|
||||||
app.post('/use-movie', (req, res) => {
|
app.post('/use-movie', (req, res) => {
|
||||||
const { chosenPoints } = req.body;
|
const { chosenPoints } = req.body;
|
||||||
if (!Array.isArray(chosenPoints) || chosenPoints.length !== 4) {
|
if (!Array.isArray(chosenPoints) || chosenPoints.length !== 4) {
|
||||||
@ -252,21 +260,17 @@ app.post('/use-movie', (req, res) => {
|
|||||||
for (const p of chosenPoints) {
|
for (const p of chosenPoints) {
|
||||||
let arr = p.category==='solo'?soloUsed: p.category==='together'?togetherUsed:null;
|
let arr = p.category==='solo'?soloUsed: p.category==='together'?togetherUsed:null;
|
||||||
if (!arr) return res.status(400).json({ error:'Invalid category in chosen points.' });
|
if (!arr) return res.status(400).json({ error:'Invalid category in chosen points.' });
|
||||||
if (arr[p.index] === true) {
|
if (arr[p.index] === true) return res.status(400).json({ error:'A chosen point is already used.' });
|
||||||
return res.status(400).json({ error: 'A chosen point is already used.' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const p of chosenPoints) {
|
for (const p of chosenPoints) {
|
||||||
let arr = p.category==='solo'?soloUsed: togetherUsed;
|
let arr = p.category==='solo'?soloUsed: togetherUsed;
|
||||||
arr[p.index] = true;
|
arr[p.index] = true;
|
||||||
}
|
}
|
||||||
const usedAt = new Date().toISOString();
|
logs.push({ type:'movie', index:null, usedAt:new Date().toISOString() });
|
||||||
logs.push({ type: 'movie', index: null, usedAt });
|
res.json({ success: true });
|
||||||
console.log('[Server] Movie => used 4 chosen points at', usedAt);
|
|
||||||
return res.json({ success: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Episode => user picks 2
|
// use-episode
|
||||||
app.post('/use-episode', (req, res) => {
|
app.post('/use-episode', (req, res) => {
|
||||||
const { chosenPoints } = req.body;
|
const { chosenPoints } = req.body;
|
||||||
if (!Array.isArray(chosenPoints) || chosenPoints.length !== 2) {
|
if (!Array.isArray(chosenPoints) || chosenPoints.length !== 2) {
|
||||||
@ -275,49 +279,72 @@ app.post('/use-episode', (req, res) => {
|
|||||||
for (const p of chosenPoints) {
|
for (const p of chosenPoints) {
|
||||||
let arr = p.category==='solo'?soloUsed: p.category==='together'?togetherUsed:null;
|
let arr = p.category==='solo'?soloUsed: p.category==='together'?togetherUsed:null;
|
||||||
if (!arr) return res.status(400).json({ error:'Invalid category in chosen points.' });
|
if (!arr) return res.status(400).json({ error:'Invalid category in chosen points.' });
|
||||||
if (arr[p.index] === true) {
|
if (arr[p.index]) return res.status(400).json({ error:'A chosen point is already used.' });
|
||||||
return res.status(400).json({ error: 'A chosen point is already used.' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const p of chosenPoints) {
|
for (const p of chosenPoints) {
|
||||||
let arr = p.category==='solo'?soloUsed: togetherUsed;
|
let arr = p.category==='solo'?soloUsed: togetherUsed;
|
||||||
arr[p.index]=true;
|
arr[p.index]=true;
|
||||||
}
|
}
|
||||||
const usedAt = new Date().toISOString();
|
logs.push({ type:'episode', index:null, usedAt:new Date().toISOString()});
|
||||||
logs.push({ type: 'episode', index: null, usedAt });
|
res.json({ success:true });
|
||||||
console.log('[Server] Episode => used 2 points at', usedAt);
|
|
||||||
return res.json({ success: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----- ADMIN -----
|
/* ------------------------------------------------------------------
|
||||||
|
PUSH NOTIFICATION ROUTES
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
// POST /subscribe => client sends { subscription }
|
||||||
|
app.post('/subscribe', (req, res)=>{
|
||||||
|
const sub = req.body.subscription;
|
||||||
|
// Check if already in pushSubscriptions?
|
||||||
|
pushSubscriptions.push(sub);
|
||||||
|
console.log('[Server] New subscription => total', pushSubscriptions.length);
|
||||||
|
res.json({ success:true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /unsubscribe => remove from array
|
||||||
|
app.post('/unsubscribe', (req, res)=>{
|
||||||
|
const sub = req.body.subscription;
|
||||||
|
pushSubscriptions = pushSubscriptions.filter(s => s.endpoint!==sub.endpoint);
|
||||||
|
console.log('[Server] Unsubscribe => total', pushSubscriptions.length);
|
||||||
|
res.json({ success:true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
ADMIN
|
||||||
|
------------------------------------------------------------------ */
|
||||||
app.post('/admin/login',(req,res)=>{
|
app.post('/admin/login',(req,res)=>{
|
||||||
const { password }=req.body;
|
const { password }=req.body;
|
||||||
if (password !== ADMIN_PASSWORD) {
|
if(password!=='adminSecret'){
|
||||||
return res.status(403).json({ error:'Wrong admin password' });
|
return res.status(403).json({ error:'Wrong admin password' });
|
||||||
}
|
}
|
||||||
return res.json({ success: true });
|
res.json({ success:true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/admin/clear-logs',(req,res)=>{
|
app.post('/admin/clear-logs',(req,res)=>{
|
||||||
logs=[];
|
logs=[];
|
||||||
console.log('[Server] Logs cleared by admin');
|
console.log('[Server] Logs cleared by admin');
|
||||||
res.json({ success:true });
|
res.json({ success:true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/admin/reset-usage',(req,res)=>{
|
app.post('/admin/reset-usage',(req,res)=>{
|
||||||
soloUsed=soloUsed.map(()=>false);
|
soloUsed=soloUsed.map(()=>false);
|
||||||
togetherUsed=togetherUsed.map(()=>false);
|
togetherUsed=togetherUsed.map(()=>false);
|
||||||
console.log('[Server] Usage reset by admin => all set false, arrays kept');
|
console.log('[Server] Usage reset by admin => all set false');
|
||||||
res.json({ success:true });
|
res.json({ success:true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/admin/remove-all-earned',(req,res)=>{
|
app.post('/admin/remove-all-earned',(req,res)=>{
|
||||||
// keep only first 5 in each array
|
// revert arrays to length 5
|
||||||
soloUsed.splice(5);
|
soloUsed.splice(5);
|
||||||
togetherUsed.splice(5);
|
togetherUsed.splice(5);
|
||||||
console.log('[Server] All earned points removed => each array back to length 5');
|
console.log('[Server] All earned points removed => each array length=5');
|
||||||
res.json({ success:true });
|
res.json({ success:true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// For demonstration, add/remove 1 from soloUsed
|
||||||
app.post('/admin/add-earned',(req,res)=>{
|
app.post('/admin/add-earned',(req,res)=>{
|
||||||
soloUsed.push(false);
|
soloUsed.push(false);
|
||||||
console.log('[Server] Admin +1 to soloUsed => length now', soloUsed.length);
|
console.log('[Server] Admin +1 to soloUsed => length', soloUsed.length);
|
||||||
res.json({ success:true });
|
res.json({ success:true });
|
||||||
});
|
});
|
||||||
app.post('/admin/remove-earned',(req,res)=>{
|
app.post('/admin/remove-earned',(req,res)=>{
|
||||||
@ -325,9 +352,10 @@ app.post('/admin/remove-earned', (req, res) => {
|
|||||||
return res.status(400).json({ error:'No bubble to remove in soloUsed' });
|
return res.status(400).json({ error:'No bubble to remove in soloUsed' });
|
||||||
}
|
}
|
||||||
soloUsed.pop();
|
soloUsed.pop();
|
||||||
console.log('[Server] Admin removed last from soloUsed => length now', soloUsed.length);
|
console.log('[Server] Admin removed last => length', soloUsed.length);
|
||||||
res.json({ success:true });
|
res.json({ success:true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/admin/clear-timer',(req,res)=>{
|
app.post('/admin/clear-timer',(req,res)=>{
|
||||||
timerRunning=false;
|
timerRunning=false;
|
||||||
timeRemaining=0;
|
timeRemaining=0;
|
||||||
@ -337,6 +365,7 @@ app.post('/admin/clear-timer', (req, res) => {
|
|||||||
console.log('[Server] Timer cleared by admin');
|
console.log('[Server] Timer cleared by admin');
|
||||||
res.json({ success:true });
|
res.json({ success:true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/admin/expire-timer',(req,res)=>{
|
app.post('/admin/expire-timer',(req,res)=>{
|
||||||
if(timeRemaining>1){
|
if(timeRemaining>1){
|
||||||
timeRemaining=1;
|
timeRemaining=1;
|
||||||
@ -345,17 +374,19 @@ app.post('/admin/expire-timer', (req, res) => {
|
|||||||
res.json({ success:true });
|
res.json({ success:true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------
|
/* ------------------------------------------------------------------
|
||||||
// SERVE REACT BUILD
|
SERVE REACT BUILD
|
||||||
// -----------------------------
|
------------------------------------------------------------------ */
|
||||||
app.use(express.static(path.join(__dirname,'build')));
|
app.use(express.static(path.join(__dirname,'build')));
|
||||||
|
|
||||||
// Catch-all to serve index.html for any unknown route
|
|
||||||
app.get('*',(req,res)=>{
|
app.get('*',(req,res)=>{
|
||||||
res.sendFile(path.join(__dirname,'build','index.html'));
|
res.sendFile(path.join(__dirname,'build','index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server
|
/* ------------------------------------------------------------------
|
||||||
|
START SERVER
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
const PORT = 4000;
|
||||||
app.listen(PORT, ()=>{
|
app.listen(PORT, ()=>{
|
||||||
console.log(`[Server] Listening on port ${PORT}`);
|
console.log(`[Server] Listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
458
src/App.js
458
src/App.js
@ -1,68 +1,75 @@
|
|||||||
|
// src/App.js
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
const SERVER_URL = 'http://10.1.0.100:80';
|
// Replace with your actual public VAPID key
|
||||||
|
const VAPID_PUBLIC_KEY = 'BCn73fh1YZV3rFbK9H234he4nNWhhEKnYiQ_UZ1U0nxR6Q6cKvTG6v05Uyq7KXh0FxwjPOV3jHR3DPLqF5Lyfm4';
|
||||||
|
|
||||||
export default function App() {
|
function App() {
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// SERVER STATE
|
// BASIC STATE & MESSAGING
|
||||||
|
// -----------------------------
|
||||||
|
const [statusMsg, setStatusMsg] = useState('');
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// SERVER TIMER STATE
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
const [soloUsed, setSoloUsed] = useState([]);
|
const [soloUsed, setSoloUsed] = useState([]);
|
||||||
const [togetherUsed, setTogetherUsed] = useState([]);
|
const [togetherUsed, setTogetherUsed] = useState([]);
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
|
|
||||||
const [timerRunning, setTimerRunning] = useState(false);
|
const [timerRunning, setTimerRunning] = useState(false);
|
||||||
const [timeRemaining, setTimeRemaining] = useState(0);
|
const [timeRemaining, setTimeRemaining] = useState(0);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [ringing, setRinging] = useState(false);
|
const [ringing, setRinging] = useState(false);
|
||||||
|
|
||||||
// Admin
|
// For managing “Movie” (4 points) or “Episode” (2 points)
|
||||||
const [adminMode, setAdminMode] = useState(false);
|
|
||||||
|
|
||||||
// For status/error messages
|
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
|
||||||
|
|
||||||
// For Movie/Episode selection
|
|
||||||
const [chooseMovieMode, setChooseMovieMode] = useState(false);
|
const [chooseMovieMode, setChooseMovieMode] = useState(false);
|
||||||
const [chooseEpisodeMode, setChooseEpisodeMode] = useState(false);
|
const [chooseEpisodeMode, setChooseEpisodeMode] = useState(false);
|
||||||
const [chosen, setChosen] = useState([]); // e.g. [{ category:'solo', index }, ...]
|
const [chosenPoints, setChosenPoints] = useState([]); // e.g. [{ category:'solo', index:2 }, ...]
|
||||||
|
|
||||||
// -----------------------------
|
// For the multi-step “Add Points” flow
|
||||||
// "ADD POINTS" MULTI-STEP UI
|
|
||||||
// -----------------------------
|
|
||||||
// Step 1 => enter password + immediate check
|
|
||||||
// Step 2 => pick category
|
|
||||||
// Step 3 => pick amount
|
|
||||||
const [showAddPointsOverlay, setShowAddPointsOverlay] = useState(false);
|
const [showAddPointsOverlay, setShowAddPointsOverlay] = useState(false);
|
||||||
const [addPointsStep, setAddPointsStep] = useState(1);
|
const [addPointsStep, setAddPointsStep] = useState(1); // 1 => password, 2 => category, 3 => amount
|
||||||
const [pointsPassword, setPointsPassword] = useState('');
|
const [pointsPassword, setPointsPassword] = useState('');
|
||||||
const [pointsCategory, setPointsCategory] = useState(''); // 'solo' or 'together'
|
const [pointsCategory, setPointsCategory] = useState('');
|
||||||
const [pointsAmount, setPointsAmount] = useState(1);
|
const [pointsAmount, setPointsAmount] = useState(1);
|
||||||
|
|
||||||
// Audio ref for the alarm
|
// Admin mode
|
||||||
|
const [adminMode, setAdminMode] = useState(false);
|
||||||
|
|
||||||
|
// Audio ref for local “alarm” (only if the tab is foreground)
|
||||||
const alarmRef = useRef(null);
|
const alarmRef = useRef(null);
|
||||||
|
|
||||||
// Poll server
|
// -----------------------------
|
||||||
|
// ON MOUNT: FETCH STATE
|
||||||
|
// & REGISTER PUSH
|
||||||
|
// -----------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Poll the server for timer state
|
||||||
fetchState();
|
fetchState();
|
||||||
const id = setInterval(fetchState, 1000);
|
const intervalId = setInterval(fetchState, 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(intervalId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// If ringing changes => play/stop audio
|
useEffect(() => {
|
||||||
|
// Register service worker + request push
|
||||||
|
registerSWAndSubscribeToPush();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// If ringing changes, try to play local alarm (if tab is foreground)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ringing) {
|
if (ringing) {
|
||||||
playAlarm();
|
playLocalAlarm();
|
||||||
} else {
|
} else {
|
||||||
stopAlarm();
|
stopLocalAlarm();
|
||||||
}
|
}
|
||||||
}, [ringing]);
|
}, [ringing]);
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// FETCH STATE
|
// FUNCTIONS
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
async function fetchState() {
|
async function fetchState() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${SERVER_URL}/state`);
|
const res = await fetch('/state');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSoloUsed(data.soloUsed || []);
|
setSoloUsed(data.soloUsed || []);
|
||||||
setTogetherUsed(data.togetherUsed || []);
|
setTogetherUsed(data.togetherUsed || []);
|
||||||
@ -72,195 +79,217 @@ export default function App() {
|
|||||||
setIsPaused(data.isPaused);
|
setIsPaused(data.isPaused);
|
||||||
setRinging(data.ringing);
|
setRinging(data.ringing);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Fetch state error:', err);
|
||||||
setStatusMessage('Error: cannot reach server');
|
setStatusMsg('Error: cannot reach server');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ALARM
|
// ----- Local alarm (only if foreground) -----
|
||||||
function playAlarm() {
|
function playLocalAlarm() {
|
||||||
if (alarmRef.current) {
|
if (!alarmRef.current) return;
|
||||||
alarmRef.current.loop = true;
|
alarmRef.current.loop = true;
|
||||||
alarmRef.current.currentTime = 0;
|
alarmRef.current.currentTime = 0;
|
||||||
alarmRef.current.play().catch(e => {
|
alarmRef.current.play().catch((err) => {
|
||||||
console.log('Alarm might be blocked until user interacts:', e);
|
console.log('Alarm play might be blocked until user interacts:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
function stopLocalAlarm() {
|
||||||
function stopAlarm() {
|
if (!alarmRef.current) return;
|
||||||
if (alarmRef.current) {
|
|
||||||
alarmRef.current.pause();
|
alarmRef.current.pause();
|
||||||
alarmRef.current.currentTime = 0;
|
alarmRef.current.currentTime = 0;
|
||||||
alarmRef.current.loop = false;
|
alarmRef.current.loop = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// SINGLE BUBBLE => 15 MIN
|
// ----- Single bubble => 15-min -----
|
||||||
async function handleBubbleClick(category, index) {
|
async function handleBubbleClick(category, index) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${SERVER_URL}/use-bubble`, {
|
const res = await fetch('/use-bubble', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ category, index }),
|
body: JSON.stringify({ category, index }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setStatusMessage(`Error: ${data.error}`);
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage(`Used 1 ${category} (#${index+1}) => 15-min timer`);
|
setStatusMsg(`Using 1 ${category} bubble => 15-min timer started`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStatusMessage('Error using bubble');
|
setStatusMsg('Error using bubble');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TIMER
|
// ----- Pause/Resume -----
|
||||||
async function handlePauseResume() {
|
async function handlePauseResume() {
|
||||||
try {
|
try {
|
||||||
const res=await fetch(`${SERVER_URL}/pause-resume`,{ method:'POST' });
|
const res = await fetch('/pause-resume', { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
if (data.error) {
|
||||||
else setStatusMessage(`Timer paused => ${data.isPaused}`);
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
|
} else {
|
||||||
|
setStatusMsg(`Timer paused => ${data.isPaused}`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStatusMessage('Error pause/resume');
|
setStatusMsg('Error: pause/resume');
|
||||||
}
|
|
||||||
}
|
|
||||||
async function handleFinishUp() {
|
|
||||||
try {
|
|
||||||
const res=await fetch(`${SERVER_URL}/finish-up`,{method:'POST'});
|
|
||||||
const data=await res.json();
|
|
||||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
|
||||||
else setStatusMessage('2-min finish-up started');
|
|
||||||
} catch(err){
|
|
||||||
console.error(err);
|
|
||||||
setStatusMessage('Error finish-up');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function handleCancelFinishUp(){
|
|
||||||
try {
|
|
||||||
const res=await fetch(`${SERVER_URL}/cancel-finish-up`,{ method:'POST'});
|
|
||||||
const data=await res.json();
|
|
||||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
|
||||||
else setStatusMessage('Finish-up canceled');
|
|
||||||
} catch(err){
|
|
||||||
console.error(err);
|
|
||||||
setStatusMessage('Error cancel finish-up');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function handleIgnoreRing(){
|
|
||||||
try {
|
|
||||||
const res=await fetch(`${SERVER_URL}/ignore-ring`,{ method:'POST'});
|
|
||||||
const data=await res.json();
|
|
||||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
|
||||||
else setStatusMessage('Ignored ring (no 2-min started)');
|
|
||||||
} catch(err){
|
|
||||||
console.error(err);
|
|
||||||
setStatusMessage('Error ignoring ring');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MOVIE / EPISODE
|
// ----- Finish Up (2 min) -----
|
||||||
|
async function handleFinishUp() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/finish-up', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
|
} else {
|
||||||
|
setStatusMsg('2-minute finish-up started');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setStatusMsg('Error finishing up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Cancel Finish-Up -----
|
||||||
|
async function handleCancelFinishUp() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/cancel-finish-up', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
|
} else {
|
||||||
|
setStatusMsg('Finish-up canceled');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setStatusMsg('Error canceling finish-up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Ignore ring -----
|
||||||
|
async function handleIgnoreRing() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/ignore-ring', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
|
} else {
|
||||||
|
setStatusMsg('Ring ignored, no 2-min started');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setStatusMsg('Error ignoring ring');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Movie/Episode usage -----
|
||||||
function startChooseMovie() {
|
function startChooseMovie() {
|
||||||
setChooseMovieMode(true);
|
setChooseMovieMode(true);
|
||||||
setChooseEpisodeMode(false);
|
setChooseEpisodeMode(false);
|
||||||
setChosen([]);
|
setChosenPoints([]);
|
||||||
setStatusMessage('Select exactly 4 points for a movie');
|
setStatusMsg('Select exactly 4 points for a Movie');
|
||||||
}
|
}
|
||||||
function startChooseEpisode() {
|
function startChooseEpisode() {
|
||||||
setChooseEpisodeMode(true);
|
setChooseEpisodeMode(true);
|
||||||
setChooseMovieMode(false);
|
setChooseMovieMode(false);
|
||||||
setChosen([]);
|
setChosenPoints([]);
|
||||||
setStatusMessage('Select exactly 2 points for an episode');
|
setStatusMsg('Select exactly 2 points for an Episode');
|
||||||
}
|
}
|
||||||
function toggleChosen(category, index) {
|
function toggleChosen(category, index) {
|
||||||
const found=chosen.find(c=>c.category===category && c.index===index);
|
const found = chosenPoints.find(
|
||||||
|
(c) => c.category === category && c.index === index
|
||||||
|
);
|
||||||
if (found) {
|
if (found) {
|
||||||
setChosen(chosen.filter(c=> c!==found));
|
setChosenPoints(chosenPoints.filter((c) => c !== found));
|
||||||
} else {
|
} else {
|
||||||
setChosen([...chosen, { category,index }]);
|
setChosenPoints([...chosenPoints, { category, index }]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function submitMovie() {
|
async function submitMovie() {
|
||||||
if(chosen.length!==4){
|
if (chosenPoints.length !== 4) {
|
||||||
setStatusMessage('Must pick exactly 4 points for a movie');
|
setStatusMsg('Must pick exactly 4 points for a movie');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res=await fetch(`${SERVER_URL}/use-movie`,{
|
const res = await fetch('/use-movie', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body:JSON.stringify({ chosenPoints: chosen }),
|
body: JSON.stringify({ chosenPoints }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
if (data.error) {
|
||||||
else setStatusMessage('Movie => used chosen 4 points');
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
|
} else {
|
||||||
|
setStatusMsg('Movie => used chosen 4 points');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStatusMessage('Error submitting movie');
|
setStatusMsg('Error submitting movie');
|
||||||
}
|
}
|
||||||
setChooseMovieMode(false);
|
setChooseMovieMode(false);
|
||||||
setChosen([]);
|
setChosenPoints([]);
|
||||||
}
|
}
|
||||||
async function submitEpisode() {
|
async function submitEpisode() {
|
||||||
if(chosen.length!==2){
|
if (chosenPoints.length !== 2) {
|
||||||
setStatusMessage('Must pick exactly 2 points for an episode');
|
setStatusMsg('Must pick exactly 2 points for an Episode');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res=await fetch(`${SERVER_URL}/use-episode`,{
|
const res = await fetch('/use-episode', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body:JSON.stringify({ chosenPoints: chosen }),
|
body: JSON.stringify({ chosenPoints }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
if (data.error) {
|
||||||
else setStatusMessage('Episode => used chosen 2 points');
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
|
} else {
|
||||||
|
setStatusMsg('Episode => used chosen 2 points');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStatusMessage('Error submitting episode');
|
setStatusMsg('Error submitting episode');
|
||||||
}
|
}
|
||||||
setChooseEpisodeMode(false);
|
setChooseEpisodeMode(false);
|
||||||
setChosen([]);
|
setChosenPoints([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADD POINTS => 3 step flow
|
// ----- Add Points (3-step flow) -----
|
||||||
const [passwordChecked, setPasswordChecked] = useState(false); // if true => step2
|
|
||||||
function openAddPointsFlow() {
|
function openAddPointsFlow() {
|
||||||
setShowAddPointsOverlay(true);
|
setShowAddPointsOverlay(true);
|
||||||
setAddPointsStep(1);
|
setAddPointsStep(1);
|
||||||
setPointsPassword('');
|
setPointsPassword('');
|
||||||
setPointsCategory('');
|
setPointsCategory('');
|
||||||
setPointsAmount(1);
|
setPointsAmount(1);
|
||||||
setPasswordChecked(false);
|
|
||||||
}
|
}
|
||||||
function closeAddPointsFlow() {
|
function closeAddPointsFlow() {
|
||||||
setShowAddPointsOverlay(false);
|
setShowAddPointsOverlay(false);
|
||||||
}
|
}
|
||||||
// Step 1: user enters password => we check immediately
|
|
||||||
|
// Step 1: check password immediately
|
||||||
async function checkPointsPassword() {
|
async function checkPointsPassword() {
|
||||||
if (!pointsPassword) {
|
if (!pointsPassword) {
|
||||||
setStatusMessage('Please enter a password');
|
setStatusMsg('Please enter a password');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res=await fetch(`${SERVER_URL}/check-password`,{
|
const res = await fetch('/check-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password: pointsPassword }),
|
body: JSON.stringify({ password: pointsPassword }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setStatusMessage(`Error: ${data.error}`);
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
} else {
|
} else {
|
||||||
// password correct => go to step2
|
setStatusMsg('Password ok, pick category next');
|
||||||
setPasswordChecked(true);
|
|
||||||
setAddPointsStep(2);
|
setAddPointsStep(2);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStatusMessage('Error checking password');
|
setStatusMsg('Error checking password');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Step 2 => pick category
|
// Step 2 => pick category
|
||||||
@ -269,92 +298,152 @@ export default function App() {
|
|||||||
setAddPointsStep(3);
|
setAddPointsStep(3);
|
||||||
}
|
}
|
||||||
// Step 3 => pick amount => do /add-points
|
// Step 3 => pick amount => do /add-points
|
||||||
async function pickAddPointsAmount(amt){
|
async function pickAddPointsAmount(amount) {
|
||||||
setPointsAmount(amt);
|
setPointsAmount(amount);
|
||||||
// Now call POST /add-points
|
|
||||||
try {
|
try {
|
||||||
const res=await fetch(`${SERVER_URL}/add-points`,{
|
const res = await fetch('/add-points', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
password: pointsPassword,
|
password: pointsPassword,
|
||||||
category: pointsCategory,
|
category: pointsCategory,
|
||||||
amount: amt
|
amount,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
if (data.error) {
|
||||||
else setStatusMessage(`+${amt} points added to ${pointsCategory}`);
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
|
} else {
|
||||||
|
setStatusMsg(`+${amount} points added to ${pointsCategory}`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStatusMessage('Error adding points');
|
setStatusMsg('Error adding points');
|
||||||
}
|
}
|
||||||
setShowAddPointsOverlay(false);
|
setShowAddPointsOverlay(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADMIN
|
// ----- ADMIN -----
|
||||||
async function handleAdminLogin() {
|
async function handleAdminLogin() {
|
||||||
const pw = prompt('Enter admin password:');
|
const pw = prompt('Enter admin password:');
|
||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
try {
|
try {
|
||||||
const res=await fetch(`${SERVER_URL}/admin/login`,{
|
const res = await fetch('/admin/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password: pw }),
|
body: JSON.stringify({ password: pw }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
if (data.error) {
|
||||||
else {
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
|
} else {
|
||||||
setAdminMode(true);
|
setAdminMode(true);
|
||||||
setStatusMessage('Admin mode enabled');
|
setStatusMsg('Admin mode enabled');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStatusMessage('Error admin login');
|
setStatusMsg('Error admin login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function handleAdminLogout() {
|
function handleAdminLogout() {
|
||||||
setAdminMode(false);
|
setAdminMode(false);
|
||||||
setStatusMessage('Admin mode disabled');
|
setStatusMsg('Admin mode disabled');
|
||||||
}
|
}
|
||||||
async function adminRequest(path) {
|
async function adminRequest(path) {
|
||||||
try {
|
try {
|
||||||
const res=await fetch(`${SERVER_URL}/admin/${path}`,{ method:'POST'});
|
const res = await fetch(`/admin/${path}`, { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
if (data.error) {
|
||||||
else setStatusMessage(`Admin => ${path} success`);
|
setStatusMsg(`Error: ${data.error}`);
|
||||||
|
} else {
|
||||||
|
setStatusMsg(`Admin => ${path} success`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStatusMessage(`Error admin ${path}`);
|
setStatusMsg(`Error admin ${path}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RENDER BUBBLES
|
// -----------------------------
|
||||||
|
// PUSH NOTIFICATIONS SETUP
|
||||||
|
// -----------------------------
|
||||||
|
async function registerSWAndSubscribeToPush() {
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
console.log('Service workers not supported in this browser.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Register custom service worker from /public folder
|
||||||
|
const reg = await navigator.serviceWorker.register('/service-worker.js');
|
||||||
|
console.log('Service Worker registered:', reg);
|
||||||
|
|
||||||
|
// Request Notification permission
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
console.log('Notification permission not granted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe
|
||||||
|
const subscription = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||||
|
});
|
||||||
|
console.log('Push subscription:', subscription);
|
||||||
|
|
||||||
|
// Send subscription to server
|
||||||
|
await fetch('/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ subscription }),
|
||||||
|
});
|
||||||
|
console.log('Subscribed to push notifications!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SW registration or push subscription failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function urlBase64ToUint8Array(base64String) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// RENDER BUBBLES (solo/together)
|
||||||
|
// -----------------------------
|
||||||
function renderBubbles(usedArr, category) {
|
function renderBubbles(usedArr, category) {
|
||||||
return usedArr.map((val, idx) => {
|
return usedArr.map((val, idx) => {
|
||||||
// If chosen => highlight
|
const isSelected = chosenPoints.find(
|
||||||
const isChosen = chosen.find(c=> c.category===category && c.index===idx);
|
(c) => c.category === category && c.index === idx
|
||||||
const circleColor = val ? '#8B0000' : '#4caf50'; // used => dark red, unused => green
|
);
|
||||||
|
const bubbleColor = val ? '#8B0000' : '#4caf50'; // used => dark red, unused => green
|
||||||
// Outline color for base vs appended
|
// Outline color for base vs appended
|
||||||
let outline = (idx < 5) ? '2px solid #5f7b99' : '2px solid #7f5f99';
|
let outline = (idx < 5) ? '2px solid #5f7b99' : '2px solid #7f5f99';
|
||||||
if(isChosen) outline='3px solid yellow';
|
if (isSelected) outline = '3px solid yellow';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
style={{
|
style={{
|
||||||
width:30, height:30,
|
width: 30,
|
||||||
|
height: 30,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
backgroundColor: circleColor,
|
backgroundColor: bubbleColor,
|
||||||
margin: 5,
|
margin: 5,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: outline,
|
border: outline,
|
||||||
boxSizing:'border-box'
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (val) {
|
if (val) {
|
||||||
setStatusMessage('That bubble is already used.');
|
setStatusMsg('That bubble is already used.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (chooseMovieMode || chooseEpisodeMode) {
|
if (chooseMovieMode || chooseEpisodeMode) {
|
||||||
@ -368,10 +457,10 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Are we in 2-min finish-up?
|
// Are we in the 2-min “finish-up”?
|
||||||
const inFinishUp = timerRunning && !ringing && timeRemaining>0 && timeRemaining<=120000;
|
const inFinishUp = timerRunning && !ringing && timeRemaining>0 && timeRemaining<=120000;
|
||||||
|
|
||||||
// Time formatting
|
// Format time
|
||||||
function formatTime(ms){
|
function formatTime(ms){
|
||||||
if(ms<=0) return '00:00';
|
if(ms<=0) return '00:00';
|
||||||
const totalSec=Math.floor(ms/1000);
|
const totalSec=Math.floor(ms/1000);
|
||||||
@ -379,13 +468,14 @@ export default function App() {
|
|||||||
const ss=totalSec%60;
|
const ss=totalSec%60;
|
||||||
return `${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
|
return `${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
|
||||||
}
|
}
|
||||||
|
// Format logs
|
||||||
function formatLogDate(iso){
|
function formatLogDate(iso){
|
||||||
const d=new Date(iso);
|
const d=new Date(iso);
|
||||||
const w=d.toLocaleDateString('en-US',{ weekday:'long' });
|
const w=d.toLocaleDateString('en-US',{ weekday:'long' });
|
||||||
const day=d.getDate();
|
const day=d.getDate();
|
||||||
const ord=getOrdinal(day);
|
const ord=getOrdinal(day);
|
||||||
const time=d.toLocaleTimeString('en-US',{ hour12:true,hour:'numeric',minute:'2-digit'});
|
const timeStr=d.toLocaleTimeString('en-US',{ hour12:true,hour:'numeric',minute:'2-digit' });
|
||||||
return `${w} ${day}${ord}, ${time}`;
|
return `${w} ${day}${ord}, ${timeStr}`;
|
||||||
}
|
}
|
||||||
function getOrdinal(n){
|
function getOrdinal(n){
|
||||||
const s=['th','st','nd','rd'];
|
const s=['th','st','nd','rd'];
|
||||||
@ -393,13 +483,16 @@ export default function App() {
|
|||||||
return s[(v-20)%10]||s[v]||s[0];
|
return s[(v-20)%10]||s[v]||s[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
// RENDER
|
// RENDER
|
||||||
|
// -----------------------------
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<h1>Mylin's Points</h1>
|
<h1>Screen-Time App (Push Notifications)</h1>
|
||||||
|
|
||||||
{statusMessage && <div style={styles.statusBox}>{statusMessage}</div>}
|
{statusMsg && <div style={styles.statusBox}>{statusMsg}</div>}
|
||||||
|
|
||||||
|
{/* Audio for local ring (foreground only) */}
|
||||||
<audio ref={alarmRef} src="/alarm.mp3" />
|
<audio ref={alarmRef} src="/alarm.mp3" />
|
||||||
|
|
||||||
{/* SOLO */}
|
{/* SOLO */}
|
||||||
@ -418,7 +511,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Add Points button => 3-step overlay */}
|
{/* Add Points (3-step) */}
|
||||||
<button style={styles.button} onClick={openAddPointsFlow}>
|
<button style={styles.button} onClick={openAddPointsFlow}>
|
||||||
Add Points
|
Add Points
|
||||||
</button>
|
</button>
|
||||||
@ -427,31 +520,36 @@ export default function App() {
|
|||||||
<section style={{marginTop:20}}>
|
<section style={{marginTop:20}}>
|
||||||
{chooseMovieMode?(
|
{chooseMovieMode?(
|
||||||
<div>
|
<div>
|
||||||
<p>Select exactly 4 points => Movie</p>
|
<p>Select 4 points for a Movie</p>
|
||||||
<button style={styles.button} onClick={submitMovie}>Submit Movie</button>
|
<button style={styles.button} onClick={submitMovie}>Submit Movie</button>
|
||||||
<button style={styles.button} onClick={()=> {
|
<button style={styles.button} onClick={()=> {
|
||||||
setChooseMovieMode(false);
|
setChooseMovieMode(false);
|
||||||
setChosen([]);
|
setChosenPoints([]);
|
||||||
}}>Cancel</button>
|
}}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
):(
|
):(
|
||||||
<button style={styles.button} onClick={startChooseMovie}>Movie (4 points)</button>
|
<button style={styles.button} onClick={startChooseMovie}>
|
||||||
|
Movie (4 points)
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chooseEpisodeMode?(
|
{chooseEpisodeMode?(
|
||||||
<div>
|
<div>
|
||||||
<p>Select exactly 2 points => Episode</p>
|
<p>Select 2 points for an Episode</p>
|
||||||
<button style={styles.button} onClick={submitEpisode}>Submit Episode</button>
|
<button style={styles.button} onClick={submitEpisode}>Submit Episode</button>
|
||||||
<button style={styles.button} onClick={()=> {
|
<button style={styles.button} onClick={()=> {
|
||||||
setChooseEpisodeMode(false);
|
setChooseEpisodeMode(false);
|
||||||
setChosen([]);
|
setChosenPoints([]);
|
||||||
}}>Cancel</button>
|
}}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
):(
|
):(
|
||||||
<button style={styles.button} onClick={startChooseEpisode}>Episode (2 points)</button>
|
<button style={styles.button} onClick={startChooseEpisode}>
|
||||||
|
Episode (2 points)
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Timer */}
|
{/* Timer display */}
|
||||||
<section style={{marginTop:30}}>
|
<section style={{marginTop:30}}>
|
||||||
{(timerRunning || timeRemaining>0) && (
|
{(timerRunning || timeRemaining>0) && (
|
||||||
<div>
|
<div>
|
||||||
@ -464,12 +562,8 @@ export default function App() {
|
|||||||
{ringing && (
|
{ringing && (
|
||||||
<div style={{marginTop:10}}>
|
<div style={{marginTop:10}}>
|
||||||
<p style={{color:'yellow'}}>Timer ended! Alarm is ringing!</p>
|
<p style={{color:'yellow'}}>Timer ended! Alarm is ringing!</p>
|
||||||
<button style={styles.button} onClick={handleFinishUp}>
|
<button style={styles.button} onClick={handleFinishUp}>Finish Up (2 min)</button>
|
||||||
Finish Up (2 min)
|
<button style={styles.button} onClick={handleIgnoreRing}>Ignore</button>
|
||||||
</button>
|
|
||||||
<button style={styles.button} onClick={handleIgnoreRing}>
|
|
||||||
Ignore
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{inFinishUp && (
|
{inFinishUp && (
|
||||||
@ -493,10 +587,10 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ADD POINTS OVERLAY */}
|
{/* Add Points Overlay */}
|
||||||
{showAddPointsOverlay && (
|
{showAddPointsOverlay && (
|
||||||
<div style={styles.overlay}>
|
<div style={styles.overlay}>
|
||||||
<div style={styles.addPointsContainer}>
|
<div style={styles.addPointsDialog}>
|
||||||
{addPointsStep===1 && (
|
{addPointsStep===1 && (
|
||||||
<>
|
<>
|
||||||
<h3>Step 1: Enter Password</h3>
|
<h3>Step 1: Enter Password</h3>
|
||||||
@ -509,12 +603,14 @@ export default function App() {
|
|||||||
<button style={styles.button} onClick={checkPointsPassword}>
|
<button style={styles.button} onClick={checkPointsPassword}>
|
||||||
Check Password
|
Check Password
|
||||||
</button>
|
</button>
|
||||||
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
|
<button
|
||||||
|
style={{...styles.button, backgroundColor:'#999'}}
|
||||||
|
onClick={closeAddPointsFlow}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{addPointsStep===2 && (
|
{addPointsStep===2 && (
|
||||||
<>
|
<>
|
||||||
<h3>Step 2: Choose Category</h3>
|
<h3>Step 2: Choose Category</h3>
|
||||||
@ -526,12 +622,14 @@ export default function App() {
|
|||||||
Together
|
Together
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
|
<button
|
||||||
|
style={{...styles.button, backgroundColor:'#999'}}
|
||||||
|
onClick={closeAddPointsFlow}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{addPointsStep===3 && (
|
{addPointsStep===3 && (
|
||||||
<>
|
<>
|
||||||
<h3>Step 3: How many points?</h3>
|
<h3>Step 3: How many points?</h3>
|
||||||
@ -546,7 +644,10 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
|
<button
|
||||||
|
style={{...styles.button, backgroundColor:'#999'}}
|
||||||
|
onClick={closeAddPointsFlow}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@ -561,7 +662,6 @@ export default function App() {
|
|||||||
<h2>Admin Panel</h2>
|
<h2>Admin Panel</h2>
|
||||||
<button style={styles.adminButton} onClick={()=>adminRequest('clear-logs')}>Clear Logs</button>
|
<button style={styles.adminButton} onClick={()=>adminRequest('clear-logs')}>Clear Logs</button>
|
||||||
<button style={styles.adminButton} onClick={()=>adminRequest('reset-usage')}>Reset Usage</button>
|
<button style={styles.adminButton} onClick={()=>adminRequest('reset-usage')}>Reset Usage</button>
|
||||||
{/* The new remove-all-earned points button */}
|
|
||||||
<button style={styles.adminButton} onClick={()=>adminRequest('remove-all-earned')}>
|
<button style={styles.adminButton} onClick={()=>adminRequest('remove-all-earned')}>
|
||||||
Remove All Earned
|
Remove All Earned
|
||||||
</button>
|
</button>
|
||||||
@ -569,7 +669,9 @@ export default function App() {
|
|||||||
<button style={styles.adminButton} onClick={()=>adminRequest('remove-earned')}>-1 (SoloUsed)</button>
|
<button style={styles.adminButton} onClick={()=>adminRequest('remove-earned')}>-1 (SoloUsed)</button>
|
||||||
<button style={styles.adminButton} onClick={()=>adminRequest('clear-timer')}>Clear Timer</button>
|
<button style={styles.adminButton} onClick={()=>adminRequest('clear-timer')}>Clear Timer</button>
|
||||||
<button style={styles.adminButton} onClick={()=>adminRequest('expire-timer')}>Expire Timer (1s)</button>
|
<button style={styles.adminButton} onClick={()=>adminRequest('expire-timer')}>Expire Timer (1s)</button>
|
||||||
<button style={styles.adminButton} onClick={handleAdminLogout}>Logout Admin</button>
|
<button style={styles.adminButton} onClick={handleAdminLogout}>
|
||||||
|
Logout Admin
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
):(
|
):(
|
||||||
<div style={{marginTop:30}}>
|
<div style={{marginTop:30}}>
|
||||||
@ -626,7 +728,7 @@ const styles = {
|
|||||||
alignItems:'center',
|
alignItems:'center',
|
||||||
zIndex:9999
|
zIndex:9999
|
||||||
},
|
},
|
||||||
addPointsContainer: {
|
addPointsDialog: {
|
||||||
backgroundColor:'#333',
|
backgroundColor:'#333',
|
||||||
padding:20,
|
padding:20,
|
||||||
borderRadius:8,
|
borderRadius:8,
|
||||||
@ -665,3 +767,5 @@ const styles = {
|
|||||||
cursor:'pointer'
|
cursor:'pointer'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user