mySQL
@@ -1,33 +1,35 @@
|
||||
index.html,1725860405793,6310d84ffdca1cba888ffec82dec1bd853728243a85f931c7bbb1ea501234ed9
|
||||
robots.txt,1725860392506,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2
|
||||
logo192.png,1725860392504,f66564670b5bfe49f3f04a5b63107bae60f4a7736f1d3b83aa0115f7acf45150
|
||||
favicon.ico,1725860392503,1463be9bedc82e16f9364eec7a1f5152cbe56229401381eed65c1496cb884407
|
||||
asset-manifest.json,1725860405804,4e32ba450098f872b3a581873dc770a8b5c91d6d56f58e07f8ffe31b70c53ef7
|
||||
logo512.png,1725860392505,2356e0b814e58a349935c8de17226873d615d088a867d591fef48a64c80c9a2f
|
||||
static/media/exit.5b9b641b90012b1192056a8c7e1b8c45.svg,1725860405791,4ff12475566874f3f3d3be8e8c97ca14a7b9292a04481947610725c16bb1599d
|
||||
static/media/coffee.46aafd6dd9e4a1d3460e80ff9969dc6e.svg,1725860405791,f27933e7a7e8113c7a2be9eba7492922dd5918b77575f02a8ad595cb079e5547
|
||||
static/media/infinity.d099870a8639593717e3b17aea138c92.svg,1725860405791,70485bc15570a561994193d7e3e6b08a1f2de236248676255b871c8697e4c577
|
||||
manifest.json,1725860392505,4e1d3adad9ce7e17c0d13fac197f1c27091906f4ecf3909c9f51247bff345118
|
||||
static/media/cardo-greek-ext-400-normal.134b821611b16e279388.woff2,1725860405791,f47df12ed94c7e7bb8a2e37e21d10cde4a47fd41eeda611d924a55187ad01466
|
||||
static/media/cardo-latin-400-normal.4911b85d7a3867b3911f.woff2,1725860405791,8f43ac21350311e2925bf981b9fd8da34935abad28c7b1028c2ca2756af003f3
|
||||
static/media/cardo-greek-400-normal.79d37e879083ce714bd7.woff2,1725860405791,074bc81d8ca5a2111b391b121b56d17a67601361e97404d3d7792dbda1dbe5bd
|
||||
static/media/cardo-greek-400-normal.82ddcf833fd34281b75d.woff,1725860405791,3b4c19d6c84e7a572ee345b68fc874deeec43d4690c7d590a9029fddf3dbddf5
|
||||
static/media/bracket.910c02ed0a238012f80f22086064c5d9.svg,1725860405791,fc713f945dd7cd2ca06c86d392ebdea3a6cf04a3e009e72877aec5e3be787486
|
||||
static/media/cardo-greek-ext-400-normal.f2008e9d17e15badfffd.woff,1725860405791,f3e91ac60b6103eb6065b6d12d6adb656d95b1ab9904f98d15916b49849385ec
|
||||
static/js/main.4e103dc0.js.LICENSE.txt,1725860405805,fd222b36f412805419e026feba067f18ca36cf439c430ae6dfd4bf072d541304
|
||||
static/js/453.18d94f3f.chunk.js,1725860405805,0c85c1b7dd1771b07c17867a1b4cc9147aac500e2761b78e5f43c3af5a990b97
|
||||
static/js/453.18d94f3f.chunk.js.map,1725860405805,01852c42a7189dcf6095a7437716308dcf1058fbf9afeee17e1d113d8fc0016f
|
||||
static/css/main.f0edc1a0.css,1725860405805,1c87f7cfa7da9415d7b6abe70cf41d8705f1d751ee0f4d6b406baafbb9bcca8b
|
||||
static/css/main.f0edc1a0.css.map,1725860405805,1a40319fdab387a87e11b7c3aadf554d86722ada29a129ba316b832bcdbaefa0
|
||||
static/media/cardo-latin-ext-400-normal.d90efa9a5c52b57c81be.woff2,1725860405792,ac5c40b8f0f0170763306cbd56736ea93f3bc8a46bd69691097be496e3b9a604
|
||||
static/media/cardo-latin-400-normal.a3fc903c20de90e51735.woff,1725860405791,cf7c3f21e093e6af8f4490da55d497398a38ff9485afae511311013338b8fe82
|
||||
static/media/cardo-latin-ext-400-normal.448cff0b9a0afb700151.woff,1725860405791,b285c4d668a7c861ca15b7dd3e126f26f6c06449c41a2c823a10146b0550089c
|
||||
static/media/Blackmild Regular.5b441560e0d850038d0f.otf,1725860405791,60300d3ccf8a22a528b216703a9677022b27e7f05fe71f9768e58220c132a4c9
|
||||
static/media/Cinzel-VariableFont_wght.2d087b545bf495f3d86d.ttf,1725860405791,3394f58fb0427fbe8d8dcc90863079661330c57c41d22309fabdda6f9bbbf7e3
|
||||
static/media/Cardo-Regular.4ad75d4da97e1cf6655b.ttf,1725860405791,9441d454efe51434f20f9f848652531e4caa3ffa57a3c7d2caa08370b01b2c63
|
||||
static/media/card.1d7fa489536497609c42.png,1725860405805,aed32aacda18dd89590e88b93da24764ec4c8d3a94f9390d90b241fb88f20788
|
||||
static/js/main.4e103dc0.js,1725860405805,20a9e0c99d6ba25a839ea31b3fcceb3d71c1e6835061ddb862bc51a56f0cf0ca
|
||||
static/media/applogo.9a90eed8f247993fa372.png,1725860405805,162fdef5335b42be3160731c6f9472f17d8a47b1a91f0f14f2a4fcf3b46559cd
|
||||
static/media/titlecard.420823b8e1f5db1380e4.png,1725860405806,77bccf483368ffe9622e590756dac4c2011d125589802b193a9d9ac79f1ddf0b
|
||||
static/media/cardback.6c07d542c2853cbf82d3.png,1725860405806,4cd2749fe35df98d738b7504afe8469dbdd2ba23101544b81c9592be8c5321f4
|
||||
static/js/main.4e103dc0.js.map,1725860405807,61fad40e9da546f6b8440539286164bb2d5d301525c6149d772184ed61d7dc04
|
||||
robots.txt,1726876647544,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2
|
||||
manifest.json,1726876647544,4e1d3adad9ce7e17c0d13fac197f1c27091906f4ecf3909c9f51247bff345118
|
||||
index.html,1726876659230,354b28bc9760fd2b17eab90a8d858fc8e52f9564c0c63c37be8a99d181e133e7
|
||||
asset-manifest.json,1726876659241,afa5940bbd1bc504018bd4f1fddfedc7ec8465288067dd8d8f933e89e71b170d
|
||||
favicon.ico,1726876647541,6048ca490d966978cc92b22151024057c5643b3bc69f6cdf9856fba38e8877fa
|
||||
static/media/exit.5b9b641b90012b1192056a8c7e1b8c45.svg,1726876659228,4ff12475566874f3f3d3be8e8c97ca14a7b9292a04481947610725c16bb1599d
|
||||
static/media/infinity.d099870a8639593717e3b17aea138c92.svg,1726876659228,70485bc15570a561994193d7e3e6b08a1f2de236248676255b871c8697e4c577
|
||||
static/media/coffee.46aafd6dd9e4a1d3460e80ff9969dc6e.svg,1726876659228,f27933e7a7e8113c7a2be9eba7492922dd5918b77575f02a8ad595cb079e5547
|
||||
static/media/cardo-latin-400-normal.4911b85d7a3867b3911f.woff2,1726876659228,8f43ac21350311e2925bf981b9fd8da34935abad28c7b1028c2ca2756af003f3
|
||||
static/media/cardo-greek-400-normal.79d37e879083ce714bd7.woff2,1726876659228,074bc81d8ca5a2111b391b121b56d17a67601361e97404d3d7792dbda1dbe5bd
|
||||
static/media/cardo-greek-400-normal.82ddcf833fd34281b75d.woff,1726876659228,3b4c19d6c84e7a572ee345b68fc874deeec43d4690c7d590a9029fddf3dbddf5
|
||||
static/media/cardo-greek-ext-400-normal.134b821611b16e279388.woff2,1726876659228,f47df12ed94c7e7bb8a2e37e21d10cde4a47fd41eeda611d924a55187ad01466
|
||||
static/media/bracket.910c02ed0a238012f80f22086064c5d9.svg,1726876659228,fc713f945dd7cd2ca06c86d392ebdea3a6cf04a3e009e72877aec5e3be787486
|
||||
static/media/cardo-greek-ext-400-normal.f2008e9d17e15badfffd.woff,1726876659228,f3e91ac60b6103eb6065b6d12d6adb656d95b1ab9904f98d15916b49849385ec
|
||||
static/js/main.cce1692b.js.LICENSE.txt,1726876659241,fd222b36f412805419e026feba067f18ca36cf439c430ae6dfd4bf072d541304
|
||||
static/js/453.18d94f3f.chunk.js,1726876659241,0c85c1b7dd1771b07c17867a1b4cc9147aac500e2761b78e5f43c3af5a990b97
|
||||
static/js/453.18d94f3f.chunk.js.map,1726876659242,01852c42a7189dcf6095a7437716308dcf1058fbf9afeee17e1d113d8fc0016f
|
||||
static/css/main.59a52c37.css,1726876659242,cb1aee4c30f148e3e41a58b1f43c2b03814942da517af2d341db473565473725
|
||||
static/css/main.59a52c37.css.map,1726876659241,bcdda2feb46a3e91c65dcaabd8cc9bab2ee623d259e7fee95565b88ef3e7aa1d
|
||||
static/media/cardo-latin-ext-400-normal.d90efa9a5c52b57c81be.woff2,1726876659228,ac5c40b8f0f0170763306cbd56736ea93f3bc8a46bd69691097be496e3b9a604
|
||||
static/media/cardo-latin-400-normal.a3fc903c20de90e51735.woff,1726876659228,cf7c3f21e093e6af8f4490da55d497398a38ff9485afae511311013338b8fe82
|
||||
static/media/cardo-latin-ext-400-normal.448cff0b9a0afb700151.woff,1726876659228,b285c4d668a7c861ca15b7dd3e126f26f6c06449c41a2c823a10146b0550089c
|
||||
static/media/CyarenLogo.d899a3de152d4020e373.png,1726876659241,06d88a03004c107e8170e6995d20c27ef9383ff8ac1fd3c8432f119eb3b6431a
|
||||
static/media/Blackmild Regular.5b441560e0d850038d0f.otf,1726876659227,60300d3ccf8a22a528b216703a9677022b27e7f05fe71f9768e58220c132a4c9
|
||||
logo192.png,1726876647542,1a626b8669164ea8df1bc1b55b3fba2844f6e258f86204ebcadc2e283d8d7d0a
|
||||
static/media/Cinzel-VariableFont_wght.2d087b545bf495f3d86d.ttf,1726876659227,3394f58fb0427fbe8d8dcc90863079661330c57c41d22309fabdda6f9bbbf7e3
|
||||
static/media/logo.9ad0b2edab4ef5fc261b.png,1726876659241,cf3180ffbfd0da385f809b161518dd1f9ae74b33b803ae92ab1f689f518620a5
|
||||
static/media/Cardo-Regular.4ad75d4da97e1cf6655b.ttf,1726876659228,9441d454efe51434f20f9f848652531e4caa3ffa57a3c7d2caa08370b01b2c63
|
||||
static/media/card.1d7fa489536497609c42.png,1726876659241,aed32aacda18dd89590e88b93da24764ec4c8d3a94f9390d90b241fb88f20788
|
||||
logo512.png,1726876647543,3d55e276f6674ba35ec7f6da0170e3e7b3c0016d0a84ee80f11954f7ab341862
|
||||
static/js/main.cce1692b.js,1726876659243,38fa9b797c454b23fa57226e91c0299f19185c48629de59381d4937bd38f98ab
|
||||
static/media/applogo.9a90eed8f247993fa372.png,1726876659241,162fdef5335b42be3160731c6f9472f17d8a47b1a91f0f14f2a4fcf3b46559cd
|
||||
static/media/titlecard.420823b8e1f5db1380e4.png,1726876659243,77bccf483368ffe9622e590756dac4c2011d125589802b193a9d9ac79f1ddf0b
|
||||
static/media/cardback.6c07d542c2853cbf82d3.png,1726876659242,4cd2749fe35df98d738b7504afe8469dbdd2ba23101544b81c9592be8c5321f4
|
||||
static/js/main.cce1692b.js.map,1726876659242,c5543305e62b653f85b5c8d20534d0d3766509eecec9ea27d6e93ae218dfadea
|
||||
|
||||
1
.firebaseignore
Normal file
@@ -0,0 +1 @@
|
||||
**/*.xcf
|
||||
2
.gitignore
vendored
@@ -25,3 +25,5 @@ yarn-error.log*
|
||||
|
||||
# storybook
|
||||
*storybook.log
|
||||
|
||||
**/*.xcf
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
21:13:22.421 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
|
||||
21:13:22.572 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
|
||||
21:13:31.975 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - fibonacci-fold-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 121ms, init: 0ms
|
||||
21:13:32.035 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace fibonacci-fold-default-rtdb status Active to Active
|
||||
21:14:00.571 [FirebaseWorkerPool-1-2] WARN com.firebase.core.persistence.backend.dummy.EmulatorMetadataPersistence - Multiple projectIds are not recommended in single project mode. Requested
|
||||
22:11:37.220 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
|
||||
22:11:37.437 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
|
||||
22:11:46.657 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - fibonacci-fold-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 152ms, init: 0ms
|
||||
22:11:46.748 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace fibonacci-fold-default-rtdb status Active to Active
|
||||
22:11:51.139 [FirebaseWorkerPool-1-3] WARN com.firebase.core.persistence.backend.dummy.EmulatorMetadataPersistence - Multiple projectIds are not recommended in single project mode. Requested
|
||||
namespace fake-project-id, but the emulator is configured for fibonacci-fold-default-rtdb. To
|
||||
opt-out of single project mode add/set the singleProjectMode: false property in
|
||||
the firebase.json emulators config.
|
||||
21:14:00.575 [NamespaceSystem-akka.actor.default-dispatcher-10] INFO com.firebase.core.namespace.NamespaceActor - fake-project-id successfully activated FBKV (SurveyIdle(0)) wait: 1ms, init: 0ms
|
||||
22:11:51.147 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO com.firebase.core.namespace.NamespaceActor - fake-project-id successfully activated FBKV (SurveyIdle(0)) wait: 3ms, init: 0ms
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"rules": {
|
||||
".read": "auth != null",
|
||||
".write": "auth != null",
|
||||
".read": "auth != null && auth.provider == 'anonymous'",
|
||||
".write": "auth != null && auth.provider == 'anonymous'",
|
||||
"users": {
|
||||
"$uid": {
|
||||
".read": "$uid === auth.uid",
|
||||
".write": "$uid === auth.uid"
|
||||
".read": "$uid === auth.uid && auth.provider == 'anonymous'",
|
||||
".write": "$uid === auth.uid && auth.provider == 'anonymous'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.7.7",
|
||||
"crypto-js": "^4.2.0",
|
||||
"firebase": "^10.13.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
30
public/.htaccess
Normal file
@@ -0,0 +1,30 @@
|
||||
# DO NOT REMOVE. CLOUDLINUX PASSENGER CONFIGURATION BEGIN
|
||||
|
||||
PassengerAppRoot "/home/cyarsflu/nodejs_app"
|
||||
PassengerBaseURI "/"
|
||||
PassengerNodejs "/home/cyarsflu/nodevenv/nodejs_app/20/bin/node"
|
||||
PassengerAppType node
|
||||
PassengerStartupFile server.js
|
||||
|
||||
# DO NOT REMOVE. CLOUDLINUX PASSENGER CONFIGURATION END
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
# Allow API requests to be handled by the Node.js app directly
|
||||
RewriteCond %{REQUEST_URI} ^/api/ [NC]
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# Redirect all other routes to index.html for React frontend routing
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ /index.html [L]
|
||||
</IfModule>
|
||||
|
||||
# DO NOT REMOVE OR MODIFY. CLOUDLINUX ENV VARS CONFIGURATION BEGIN
|
||||
|
||||
<IfModule Litespeed>
|
||||
</IfModule>
|
||||
|
||||
# DO NOT REMOVE OR MODIFY. CLOUDLINUX ENV VARS CONFIGURATION END
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 17 KiB |
@@ -5,13 +5,10 @@
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<meta name="description" content="A Fibonacci Poker Voting App" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>React App</title>
|
||||
<title>Fibonacci's Fold</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 420 KiB |
25
server/app.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const app = express();
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Ensure that SESSION_SECRET is defined
|
||||
if (!process.env.SESSIONS_SECRET) {
|
||||
console.error('FATAL ERROR: SESSIONS_SECRET is not defined.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
session({
|
||||
secret: process.env.SESSIONS_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = app;
|
||||
151
server/authRoutes.js
Normal file
@@ -0,0 +1,151 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const passport = require('passport');
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('./db');
|
||||
const { generateUniqueId } = require('../shared/utils/crypto');
|
||||
|
||||
// Check user session (GET)
|
||||
router.get('/api/auth/session', (req, res) => {
|
||||
const token = req.cookies.token;
|
||||
|
||||
if (!token) {
|
||||
return res.json({ userId: null });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
res.json({ userId: decoded.userId });
|
||||
} catch (error) {
|
||||
console.error('Invalid token:', error);
|
||||
res.status(401).json({ userId: null, error: 'Invalid token' });
|
||||
}
|
||||
});
|
||||
|
||||
// Sign in anonymously (POST)
|
||||
router.post('/api/auth/signin-anonymous', async (req, res) => {
|
||||
const userId = generateUniqueId(); // Generate unique ID for anonymous user
|
||||
|
||||
try {
|
||||
// Insert the anonymous user into the database (no sensitive data, just ID)
|
||||
await db.query('INSERT INTO users (id, provider) VALUES (?, ?)', [
|
||||
userId,
|
||||
'anonymous',
|
||||
]);
|
||||
|
||||
// Generate JWT token for the anonymous user
|
||||
const token = jwt.sign(
|
||||
{ userId, provider: 'anonymous' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
// Set the JWT in an httpOnly cookie for better security
|
||||
res.cookie('token', token, { httpOnly: true });
|
||||
|
||||
res.status(200).json({ message: 'Anonymous user signed in' });
|
||||
} catch (error) {
|
||||
console.error('Error creating anonymous user:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/auth/refresh-token', (req, res) => {
|
||||
const refreshToken = req.cookies.refreshToken;
|
||||
if (!refreshToken) return res.status(403).send('Refresh token required');
|
||||
|
||||
// Verify the refresh token and issue a new JWT
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
const newToken = jwt.sign(
|
||||
{ userId: decoded.userId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
res.cookie('token', newToken, { httpOnly: true });
|
||||
res.json({ message: 'Token refreshed' });
|
||||
} catch (error) {
|
||||
res.status(403).send('Invalid refresh token');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Passport
|
||||
router.use(passport.initialize());
|
||||
|
||||
// Google OAuth Strategy
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: '/api/auth/google/callback',
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
const googleId = profile.id; // This is the unique ID from Google
|
||||
|
||||
try {
|
||||
// Insert the Google user into the database if they don't exist
|
||||
await db.query(
|
||||
'INSERT INTO users (id, provider) VALUES (?, ?) ON DUPLICATE KEY UPDATE id = id',
|
||||
[googleId, 'google']
|
||||
);
|
||||
|
||||
// Generate JWT token for the Google user
|
||||
const token = jwt.sign(
|
||||
{ userId: googleId, provider: 'google' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
// Pass the token back
|
||||
return done(null, token);
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Start Google OAuth login
|
||||
router.get(
|
||||
'/api/auth/google',
|
||||
passport.authenticate('google', { scope: ['profile', 'email'] })
|
||||
);
|
||||
|
||||
// Callback route for Google OAuth
|
||||
router.get(
|
||||
'/api/auth/google/callback',
|
||||
passport.authenticate('google', { failureRedirect: '/' }),
|
||||
(req, res) => {
|
||||
// After successful authentication, generate the JWT (which is in req.user)
|
||||
const token = req.user; // Passport's Google strategy should return the JWT
|
||||
|
||||
// Set the JWT in an httpOnly cookie
|
||||
res.cookie('token', token, { httpOnly: true });
|
||||
|
||||
// Redirect to the frontend dashboard or a secure page without the token in the URL
|
||||
res.redirect('/dashboard');
|
||||
}
|
||||
);
|
||||
|
||||
// Example of a protected route (no need for query token)
|
||||
router.get('/dashboard', (req, res) => {
|
||||
// Extract token from cookies (JWT is automatically sent as a cookie)
|
||||
const token = req.cookies.token;
|
||||
|
||||
if (!token) {
|
||||
return res.status(403).send('Token is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the token and decode it
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
res.send(`Welcome, user with ID: ${decoded.userId}`);
|
||||
} catch (err) {
|
||||
res.status(403).send('Invalid token');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
15
server/db.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Create a MySQL connection pool with promises
|
||||
const db = mysql.createPool({
|
||||
host: process.env.MYSQL_HOST,
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
});
|
||||
|
||||
module.exports = db;
|
||||
19
server/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "fibonacci-fold-server",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.18.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^2.3.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
113
server/server.js
Normal file
@@ -0,0 +1,113 @@
|
||||
const authRoutes = require('./authRoutes');
|
||||
const app = require('./app');
|
||||
const db = require('./db');
|
||||
|
||||
// Routes
|
||||
app.use(authRoutes); // Use the auth routes
|
||||
|
||||
// Create new session
|
||||
app.post('/api/session', async (req, res) => {
|
||||
const sessionId = req.body.sessionId;
|
||||
const query = 'INSERT INTO sessions (id) VALUES (?)';
|
||||
await db.query(query, [sessionId], (err, result) => {
|
||||
if (err) return res.status(500).json({ error: err });
|
||||
res.status(200).json({ sessionId });
|
||||
});
|
||||
});
|
||||
|
||||
// Add user to session
|
||||
app.post('/api/session/:sessionId/user', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const { userId, provider } = req.body;
|
||||
|
||||
// Insert the user into the `users` table if they don't already exist
|
||||
const userQuery = `
|
||||
INSERT INTO users (id, provider)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE id = id`; // If the user already exists, do nothing
|
||||
|
||||
// Insert the user into the `session_users` table for this session
|
||||
const sessionUserQuery = `
|
||||
INSERT INTO session_users (session_id, user_id, provider)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE session_id = session_id`; // If the user is already in the session, do nothing
|
||||
|
||||
// Insert the initial vote (-1) for the user in this session
|
||||
const voteQuery = `
|
||||
INSERT INTO votes (session_id, user_id, vote)
|
||||
VALUES (?, ?, -1)
|
||||
ON DUPLICATE KEY UPDATE vote = -1`; // If a vote already exists, reset it to -1
|
||||
|
||||
try {
|
||||
// Insert the user into the `users` table
|
||||
await db.query(userQuery, [userId, provider]);
|
||||
|
||||
// Add the user to the `session_users` table
|
||||
await db.query(sessionUserQuery, [sessionId, userId, provider]);
|
||||
|
||||
// Set the initial vote for the user in the session
|
||||
await db.query(voteQuery, [sessionId, userId]);
|
||||
|
||||
res.status(200).json({ userId });
|
||||
} catch (err) {
|
||||
console.error('Error adding user to session:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get users in a session
|
||||
app.get('/api/session/:sessionId/users', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const query = `
|
||||
SELECT users.id, users.username
|
||||
FROM votes
|
||||
JOIN users ON votes.user_id = users.id
|
||||
WHERE session_id = ?
|
||||
`;
|
||||
|
||||
try {
|
||||
const [results] = await db.query(query, [sessionId]);
|
||||
console.log('Query results:', results); // Log query result
|
||||
res.status(200).json(results);
|
||||
} catch (err) {
|
||||
console.error('Error fetching session users:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/session/:sessionId/votes/:userId', async (req, res) => {
|
||||
const { sessionId, userId } = req.params;
|
||||
const query = 'DELETE FROM votes WHERE session_id = ? AND user_id = ?';
|
||||
|
||||
try {
|
||||
const [result] = await db.query(query, [sessionId, userId]);
|
||||
res
|
||||
.status(200)
|
||||
.json({ success: true, message: 'Vote removed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error removing vote:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/session/:sessionId/users/:userId', async (req, res) => {
|
||||
const { sessionId, userId } = req.params;
|
||||
const query =
|
||||
'DELETE FROM session_users WHERE session_id = ? AND user_id = ?';
|
||||
|
||||
try {
|
||||
const [result] = await db.query(query, [sessionId, userId]);
|
||||
res
|
||||
.status(200)
|
||||
.json({ success: true, message: 'User removed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error removing user:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const PORT = process.env.PORT || 5000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
1267
server/stderr.log
Normal file
0
server/tmp/restart.txt
Normal file
14
shared/utils/crypto.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const CryptoJS = require('crypto-js');
|
||||
|
||||
// Generate a session ID hash
|
||||
const generateUniqueId = () => {
|
||||
const uuid = uuidv4();
|
||||
const hash = CryptoJS.SHA256(uuid).toString();
|
||||
// Use the first 28 characters of the hash
|
||||
return hash.substring(0, 28);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
generateUniqueId,
|
||||
};
|
||||
62
src/App.jsx
@@ -5,11 +5,13 @@ import {
|
||||
Route,
|
||||
Navigate,
|
||||
} from 'react-router-dom';
|
||||
import Lobby from './components/Lobby/Lobby';
|
||||
import VotingTable from './components/VotingTable/VotingTable';
|
||||
import UserSessionListener from './components/UserSessionListener/UserSessionListener';
|
||||
import { signInAnonymously, onAuthStateChanged } from 'firebase/auth';
|
||||
import { auth } from './services/firebase';
|
||||
import Lobby from './components/Lobby';
|
||||
import VotingTable from './components/VotingTable';
|
||||
import UserSessionListener from './components/UserSessionListener';
|
||||
import axios from 'axios';
|
||||
import cyarenLogo from './assets/images/CyarenLogo.png';
|
||||
import cardLogo from './assets/images/logo.png';
|
||||
import styles from './App.module.css';
|
||||
|
||||
const App = () => {
|
||||
const [userId, setUserId] = useState('');
|
||||
@@ -21,18 +23,29 @@ const App = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Sign in the user anonymously
|
||||
signInAnonymously(auth).catch((error) => {
|
||||
console.error('Anonymous sign-in error:', error);
|
||||
});
|
||||
|
||||
// Handle user state changes
|
||||
onAuthStateChanged(auth, (user) => {
|
||||
if (user) {
|
||||
console.log('User is signed in:', user);
|
||||
// Check if there's a user session by making a request to the backend
|
||||
axios
|
||||
.get('/api/auth/session')
|
||||
.then((response) => {
|
||||
const { userId } = response.data;
|
||||
if (userId) {
|
||||
setUserId(userId); // User is logged in
|
||||
} else {
|
||||
console.log('User is signed out');
|
||||
// If no user session exists, create an anonymous user session
|
||||
axios
|
||||
.post('/api/auth/signin-anonymous')
|
||||
.then((response) => {
|
||||
setUserId(response.data.userId);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error signing in anonymously:', error);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error checking user session:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
@@ -40,6 +53,8 @@ const App = () => {
|
||||
return (
|
||||
<Router>
|
||||
<UserSessionListener />
|
||||
<div className={styles.app}>
|
||||
<section className={styles.header}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/lobby/:sessionId?"
|
||||
@@ -52,6 +67,23 @@ const App = () => {
|
||||
/>
|
||||
<Route path="*" element={<Navigate to={`/lobby`} />} />
|
||||
</Routes>
|
||||
</section>
|
||||
<section className={styles.footer}>
|
||||
<div className={styles.logoEnvelope}>
|
||||
<img className={styles.cyaren} src={cyarenLogo} alt="cyaren-logo" />
|
||||
</div>
|
||||
<img
|
||||
className={`${styles.cardLogo} ${styles.moveLeft}`}
|
||||
src={cardLogo}
|
||||
alt="card-logo"
|
||||
/>
|
||||
<img
|
||||
className={`${styles.cardLogo} ${styles.moveRight}`}
|
||||
src={cardLogo}
|
||||
alt="card-logo"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
69
src/App.module.css
Normal file
@@ -0,0 +1,69 @@
|
||||
.app {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app section.header {
|
||||
flex: 1 auto;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app section.footer {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.app section.footer .logoEnvelope {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease-in-out,
|
||||
width 0.2s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app section.footer:hover .logoEnvelope {
|
||||
width: 140px;
|
||||
opacity: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cyaren {
|
||||
/* width: calc(10%); */
|
||||
width: 140px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.cardLogo {
|
||||
width: calc(2%);
|
||||
min-width: 30px;
|
||||
position: absolute;
|
||||
opacity: 0.1;
|
||||
transition:
|
||||
margin 0.2s ease-in-out,
|
||||
opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.app section.footer:hover .moveLeft {
|
||||
margin-right: 120px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app section.footer:hover .moveRight {
|
||||
margin-left: 120px;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1,14 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'blackmild-regular';
|
||||
src: url('/src/assets/fonts/Blackmild\ Regular/Blackmild\ Regular.otf')
|
||||
format('opentype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'cardo-regular';
|
||||
src: url('/src/assets/fonts/Cardo/Cardo-Regular.ttf') format('opentype');
|
||||
src: url('/src/assets/fonts/Cardo/Cardo-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@@ -16,7 +8,7 @@
|
||||
@font-face {
|
||||
font-family: 'cinzel';
|
||||
src: url('/src/assets/fonts/Cinzel/Cinzel-VariableFont_wght.ttf')
|
||||
format('opentype');
|
||||
format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
BIN
src/assets/images/CyarenLogo.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
9
src/assets/images/bracket.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="119" height="30" viewBox="0 0 119 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 21.5C9.13162 21.6574 8.56984 21.6774 8.56984 21.6774C8.56984 21.6774 10.8859 17.7503 14 22C16 25.5 11.0613 29.7046 6.70152 28.9176C-4.5 26.5 -0.12039 14.287 10 13.5C16.8508 13.3426 31.9248 23.4088 40.1768 23.7236C48.4287 24.0383 49.2074 18.5295 49.2074 17.4277C49.5 13 42.0001 11.2225 42 15C42.1558 17.3609 44.3807 17.8999 44.3807 17.8999C41.4224 19.3165 39.3983 16.3259 40.7995 13.0206C42.2008 9.7153 50.92 11.4467 50.92 16.3259C50.92 21.2052 49 25.5 40 26C31 26.5 20.403 17.1129 10.4382 16.0111C1.87485 15.2242 0 27 6.85715 27.5011C13.7143 28.0021 13.9583 21.5 11 21.5Z" fill="currentColor"/>
|
||||
<path d="M38.3105 17.6692C38.3105 17.6692 34.7779 15.843 34.0833 12.5105C33.772 11.0171 35.3114 9.60623 36.3302 11.4808C37.1536 13.4492 36.2118 14.4557 38.3105 17.6692Z" fill="currentColor"/>
|
||||
<path d="M35.4119 19.3831C35.4119 19.3831 31.4572 19.4494 29.2987 16.8551C28.3315 15.6925 29.0298 13.7285 30.7964 14.8914C32.4347 16.2287 32.0739 17.5559 35.4119 19.3831Z" fill="currentColor"/>
|
||||
<path d="M108 21.5C109.868 21.6574 110.43 21.6774 110.43 21.6774C110.43 21.6774 108.114 17.7503 105 22C103 25.5 107.939 29.7046 112.298 28.9176C123.5 26.5 119.12 14.287 109 13.5C102.149 13.3426 87.0752 23.4088 78.8232 23.7236C70.5713 24.0383 69.7926 18.5295 69.7926 17.4277C69.5 13 76.9999 11.2225 77 15C76.8442 17.3609 74.6193 17.8999 74.6193 17.8999C77.5776 19.3165 79.6017 16.3259 78.2005 13.0206C76.7992 9.7153 68.08 11.4467 68.08 16.3259C68.08 21.2052 70 25.5 79 26C88 26.5 98.597 17.1129 108.562 16.0111C117.125 15.2242 119 27 112.143 27.5011C105.286 28.0021 105.042 21.5 108 21.5Z" fill="currentColor"/>
|
||||
<path d="M80.6895 17.6692C80.6895 17.6692 84.2221 15.843 84.9167 12.5105C85.228 11.0171 83.6886 9.60623 82.6698 11.4808C81.8464 13.4492 82.7882 14.4557 80.6895 17.6692Z" fill="currentColor"/>
|
||||
<path d="M83.5881 19.3831C83.5881 19.3831 87.5428 19.4494 89.7013 16.8551C90.6685 15.6925 89.9702 13.7285 88.2036 14.8914C86.5653 16.2287 86.9261 17.5559 83.5881 19.3831Z" fill="currentColor"/>
|
||||
<path d="M65.5 18H54C54 18 57.8682 16.7591 58.9422 10.1231C49.8164 17.5555 47.4006 2.4253 57.8685 6.93779C53.037 -2.61808 67.5307 -2.08719 61.6261 7.20324C72.3622 2.15986 70.7518 18.3518 60.821 10.1231C61.6258 16.4937 65.5 18 65.5 18Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
src/assets/images/smalllogo.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
src/assets/images/titlecard-mini.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
29
src/components/BoardCard copy/index.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import VoteValue from '../VoteValue';
|
||||
import cardBack from '../../assets/images/cardback.png';
|
||||
import cardFront from '../../assets/images/cardfront.png';
|
||||
import styles from './BoardCard.module.css';
|
||||
|
||||
const BoardCard = ({ vote, isDimmed, isRevealed = true }) => {
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRevealed) {
|
||||
setTimeout(() => setIsFlipped(true), 300);
|
||||
} else if (isFlipped) {
|
||||
setIsFlipped(false);
|
||||
}
|
||||
}, [isFlipped, isRevealed]);
|
||||
|
||||
return (
|
||||
<div className={`${isDimmed ? styles.isDimmed : ''} ${styles.boardCard}`}>
|
||||
<div className={`${styles.card} ${isFlipped ? styles.flip : ''}`}>
|
||||
<img className={styles.cardBack} src={cardBack} alt="card-back" />
|
||||
<img className={styles.cardFront} src={cardFront} alt="card-front" />
|
||||
<VoteValue className={styles.vote} value={vote} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardCard;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import cardBack from '../../assets/images/cardback.png';
|
||||
import { ReactComponent as BracketIcon } from '../../assets/images/bracket.svg';
|
||||
import FrontCard from '../FrontCard/FrontCard';
|
||||
import FrontCard from '../FrontCard';
|
||||
import styles from './BoardCard.module.css';
|
||||
|
||||
const BoardCard = ({ username, vote, isRevealed, isDimmed, isHighlighted }) => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import card from '../../assets/images/card.png';
|
||||
import VoteValue from '../VoteValue/VoteValue';
|
||||
import VoteValue from '../VoteValue';
|
||||
import styles from './FrontCard.module.css';
|
||||
|
||||
const FrontCard = ({ value, className }) => {
|
||||
14
src/components/LoadingRoute/index.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
const LoadingRoute = ({ loading = false, element, ...rest }) => {
|
||||
debugger;
|
||||
console.log('this.type', this.type);
|
||||
return <Route {...rest} element={!loading ? element : <Fragment />} />;
|
||||
};
|
||||
|
||||
// const LoadingRoute = Route;
|
||||
|
||||
// console.log('LoadingRoute', LoadingRoute);
|
||||
|
||||
export default LoadingRoute;
|
||||
@@ -1,11 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ref, onValue, set } from 'firebase/database';
|
||||
import { realtimeDb } from '../../services/firebase';
|
||||
import { generateUniqueId } from '../../utils/crypto';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import appLogo from '../../assets/images/applogo.png';
|
||||
import styles from './Lobby.module.css';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { auth } from '../../services/firebase';
|
||||
import api from '../../services/api';
|
||||
import { generateUniqueId } from '../../shared/utils/crypto';
|
||||
|
||||
const Lobby = ({ onJoinSession = () => {} }) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -18,60 +16,40 @@ const Lobby = ({ onJoinSession = () => {} }) => {
|
||||
// Get or generate the session ID
|
||||
if (!sessionId) {
|
||||
const newSessionId = generateUniqueId();
|
||||
// Update the URL with the session ID if it wasn't already there
|
||||
navigate(`/lobby/${newSessionId}`);
|
||||
}
|
||||
|
||||
const sessionRef = ref(realtimeDb, `sessions/${sessionId}/users`);
|
||||
|
||||
// Listen for changes to the session users data
|
||||
const unsubscribe = onValue(sessionRef, (snapshot) => {
|
||||
const users = snapshot.val();
|
||||
if (!users) return;
|
||||
|
||||
const userList = Object.entries(users).map(([uid, userInfo]) => ({
|
||||
uid,
|
||||
username: userInfo.username,
|
||||
}));
|
||||
setUsers(userList);
|
||||
} else {
|
||||
// Fetch users in the session
|
||||
api
|
||||
.get(`/api/session/${sessionId}/users`)
|
||||
.then((response) => {
|
||||
setUsers(response.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching users:', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
}, [navigate, sessionId]);
|
||||
|
||||
const handleJoinSession = async () => {
|
||||
const user = auth.currentUser;
|
||||
const userId = user ? user.uid : generateUniqueId();
|
||||
const userId = generateUniqueId(); // Generate unique ID for user
|
||||
|
||||
// Check if the uid is already part of the session
|
||||
if (users.includes(userId)) {
|
||||
// Check if the username is already taken
|
||||
if (users.some((user) => user.username === username)) {
|
||||
setIsUsernameTaken(true);
|
||||
return;
|
||||
} else {
|
||||
// Set the initial vote for the user
|
||||
const voteRef = ref(realtimeDb, `sessions/${sessionId}/votes/${userId}`);
|
||||
await set(voteRef, -1);
|
||||
}
|
||||
|
||||
// Add the user to the session's user list with their uid and username
|
||||
const userRef = ref(realtimeDb, `sessions/${sessionId}/users/${userId}`);
|
||||
await set(userRef, {
|
||||
username: username,
|
||||
uid: userId,
|
||||
});
|
||||
|
||||
// Also save the current session ID under the user's record in the database
|
||||
const userSessionRef = ref(realtimeDb, `users/${userId}`);
|
||||
await set(userSessionRef, {
|
||||
currentSessionId: `session/${sessionId}`,
|
||||
});
|
||||
// Add the user to the session
|
||||
try {
|
||||
await api.post(`/api/session/${sessionId}/user`, { userId, username });
|
||||
|
||||
// Call the onJoinSession callback with the username
|
||||
onJoinSession(userId);
|
||||
|
||||
// Navigate to the session
|
||||
navigate(`/session/${sessionId}`);
|
||||
} catch (error) {
|
||||
console.error('Error joining session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,8 +81,8 @@ const Lobby = ({ onJoinSession = () => {} }) => {
|
||||
<br />
|
||||
<h3>Current Users In Session</h3>
|
||||
<section className={styles.list}>
|
||||
{users.map(({ uid, username }) => (
|
||||
<section key={uid}>{username}</section>
|
||||
{users.map(({ id, username }) => (
|
||||
<section key={id}>{username}</section>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 35px;
|
||||
}
|
||||
|
||||
/* Container for the entire menu */
|
||||
.menuContainer {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Label with circular border */
|
||||
.menuLabel {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid #f0dcb1;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Container for the buttons */
|
||||
.menuButtons {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: none;
|
||||
background-color: transparent;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Buttons styling */
|
||||
.menuButtons button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
background-color: #f0dcb1;
|
||||
border: 1px solid #1b191a;
|
||||
border-radius: 4px;
|
||||
color: #1b191a;
|
||||
padding: 4px 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
user-select: none;
|
||||
box-shadow: 2px 2px 5px rgba(0, 0, 0, 1);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.menuButtons button:hover {
|
||||
box-shadow: 2px 2px 5px rgba(0, 0, 0, 1);
|
||||
background-color: #cdba95;
|
||||
}
|
||||
|
||||
.menuButtons button:active {
|
||||
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5);
|
||||
background-color: #f0dcb1;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Show buttons on hover */
|
||||
.menuContainer:hover .menuButtons {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menuContainer .invite {
|
||||
}
|
||||
|
||||
.menuContainer .invite .checkmark {
|
||||
font-weight: 800;
|
||||
}
|
||||
49
src/components/ProfileMenuButton/index.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
import styles from './ProfileMenuButton.module.css';
|
||||
import { getAcronym } from '../../utils/strings';
|
||||
|
||||
const ProfileMenuButton = ({ username, className, onLogout, onInvite }) => {
|
||||
const [isInviteCopied, setIsInviteCopied] = useState(false);
|
||||
const acronym = getAcronym(username).slice(0, 2);
|
||||
const timeoutIdRef = useRef(null);
|
||||
|
||||
async function handleInvite() {
|
||||
try {
|
||||
await onInvite();
|
||||
setIsInviteCopied(true);
|
||||
timeoutIdRef.current = setTimeout(() => {
|
||||
setIsInviteCopied(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup the timeout if the component unmounts before the timeout is complete
|
||||
return () => clearTimeout(timeoutIdRef.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${styles.root} ${className}`}>
|
||||
<div className={styles.menuContainer}>
|
||||
<div className={styles.menuLabel}>{acronym}</div>
|
||||
<div className={styles.menuButtons}>
|
||||
<button onClick={handleInvite} className={styles.invite}>
|
||||
{!isInviteCopied ? (
|
||||
'Copy Invite'
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.checkmark}>✓ </span>Copied!
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={onLogout}>Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileMenuButton;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import styles from './Results.module.css';
|
||||
import VoteValue from '../VoteValue/VoteValue';
|
||||
import VoteValue from '../VoteValue';
|
||||
|
||||
const Results = ({ votes }) => {
|
||||
const voteCounts = Object.values(votes).reduce((acc, vote) => {
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ref, onValue, remove } from 'firebase/database';
|
||||
import { realtimeDb } from '../../services/firebase';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { generateUniqueId } from '../../utils/crypto';
|
||||
import { auth } from '../../services/firebase';
|
||||
|
||||
const UserSessionListener = () => {
|
||||
const navigate = useNavigate();
|
||||
const user = auth.currentUser;
|
||||
const userId = user ? user.uid : generateUniqueId();
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const userRef = ref(realtimeDb, `users/${userId}/currentSessionId`);
|
||||
|
||||
const unsubscribe = onValue(userRef, async (snapshot) => {
|
||||
const currentSessionId = snapshot.val() || generateUniqueId();
|
||||
|
||||
// Only redirect if the user is not already being redirected
|
||||
if (
|
||||
!isRedirecting &&
|
||||
currentSessionId &&
|
||||
currentSessionId.startsWith('lobby')
|
||||
) {
|
||||
setIsRedirecting(true);
|
||||
|
||||
console.log(`Removing user from: users/${userId}`);
|
||||
|
||||
// Remove the entire user entry from the database
|
||||
const fullUserRef = ref(realtimeDb, `users/${userId}`);
|
||||
await remove(fullUserRef);
|
||||
|
||||
navigate(`/${currentSessionId}`);
|
||||
|
||||
setIsRedirecting(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [userId, navigate, isRedirecting]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default UserSessionListener;
|
||||
48
src/components/UserSessionListener/index.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../services/api'; // Import the Axios instance with the interceptor
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { generateUniqueId } from '../../shared/utils/crypto';
|
||||
|
||||
const UserSessionListener = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
const userId = generateUniqueId(); // Use the unique ID generator for anonymous users
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserSession = async () => {
|
||||
try {
|
||||
// Get the user's current session ID from the API
|
||||
const response = await api.get(`/users/${userId}/session`);
|
||||
|
||||
const currentSessionId =
|
||||
response.data.currentSessionId || generateUniqueId();
|
||||
|
||||
// Only redirect if the user is not already being redirected
|
||||
if (
|
||||
!isRedirecting &&
|
||||
currentSessionId &&
|
||||
currentSessionId.startsWith('lobby')
|
||||
) {
|
||||
setIsRedirecting(true);
|
||||
|
||||
// Delete the user from the session via API
|
||||
await api.delete(`/users/${userId}`);
|
||||
|
||||
// Redirect to the session
|
||||
navigate(`/${currentSessionId}`);
|
||||
|
||||
setIsRedirecting(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserSession();
|
||||
}, [userId, navigate, isRedirecting]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default UserSessionListener;
|
||||
@@ -1,3 +1,9 @@
|
||||
button.voteCard {
|
||||
font-family: 'Cardo', serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.voteCard {
|
||||
width: 50px;
|
||||
height: 75px;
|
||||
@@ -31,12 +37,6 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cardo-regular {
|
||||
font-family: 'Cardo', serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
position: absolute;
|
||||
width: 50px;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './VoteCard.module.css';
|
||||
import FrontCard from '../FrontCard/FrontCard';
|
||||
import FrontCard from '../FrontCard';
|
||||
|
||||
const VoteCard = ({ number, isSelected, onClick, className }) => {
|
||||
return (
|
||||
<button
|
||||
className={`${styles.voteCard} ${isSelected ? styles.selected : ''} ${styles['cardo-regular']} ${className}`}
|
||||
className={`${styles.voteCard} ${isSelected ? styles.selected : ''} ${className}`}
|
||||
onClick={() => onClick(number)}
|
||||
>
|
||||
<FrontCard
|
||||
@@ -2,29 +2,27 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%; /* or specify a specific width */
|
||||
height: 100%; /* or specify a specific height */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.25em;
|
||||
}
|
||||
|
||||
.coffee,
|
||||
.infinity {
|
||||
width: 100%; /* Adjust this to the specific size you want */
|
||||
height: auto; /* Maintains aspect ratio */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 2rem; /* Adjust this to fit your design */
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
width: 100%; /* Makes the text conform to the container width */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
/* text-shadow: none; */
|
||||
/* color: #1b191a; */
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { ReactComponent as Coffee } from '../../assets/images/coffee.svg';
|
||||
import { ReactComponent as Infinity } from '../../assets/images/infinity.svg';
|
||||
import styles from './VoteValue.module.css';
|
||||
|
||||
const VoteValue = ({ value, className }) => {
|
||||
function VoteValue({ value, className }) {
|
||||
const isInfinity = `${value}` === '9999';
|
||||
const isCoffee = `${value}` === '0';
|
||||
const isNoVote = `${value}` === '';
|
||||
|
||||
return (
|
||||
<div className={`${styles.voteValueContainer} ${className}`}>
|
||||
@@ -13,11 +14,13 @@ const VoteValue = ({ value, className }) => {
|
||||
<Coffee className={styles.coffee} />
|
||||
) : isInfinity ? (
|
||||
<Infinity className={styles.infinity} />
|
||||
) : isNoVote ? (
|
||||
<div className={styles.value}>?</div>
|
||||
) : (
|
||||
<div className={styles.value}>{value >= 0 ? value : ''}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default VoteValue;
|
||||
@@ -1,65 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ref, set, get } from 'firebase/database';
|
||||
import { realtimeDb } from '../../services/firebase';
|
||||
import VoteCard from '../VoteCard/VoteCard';
|
||||
import styles from './VotingBoard.module.css';
|
||||
|
||||
const fibonacciNumbers = [0, 1, 2, 3, 5, 8, 13, 21, 9999];
|
||||
|
||||
const VotingBoard = ({ sessionId, userId }) => {
|
||||
const [selectedVote, setSelectedVote] = useState(null);
|
||||
|
||||
const handleVote = async (number) => {
|
||||
setSelectedVote(number);
|
||||
|
||||
const userVoteRef = ref(
|
||||
realtimeDb,
|
||||
`sessions/${sessionId}/votes/${userId}`
|
||||
);
|
||||
|
||||
// Get the current vote for this user (if any)
|
||||
const snapshot = await get(userVoteRef);
|
||||
const existingVote =
|
||||
snapshot.exists() && snapshot.val() > -1 ? snapshot.val() : null;
|
||||
|
||||
// Set the new vote for the user
|
||||
await set(userVoteRef, number);
|
||||
|
||||
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
|
||||
const sessionSnapshot = await get(sessionRef);
|
||||
|
||||
if (sessionSnapshot.exists()) {
|
||||
const sessionData = sessionSnapshot.val() || {};
|
||||
|
||||
// Increment votedUsers only if the user hasn't voted before
|
||||
if (existingVote === null) {
|
||||
const votedUsers = sessionData.votedUsers || 0;
|
||||
await set(sessionRef, {
|
||||
...sessionData,
|
||||
votedUsers: votedUsers + 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error("Session doesn't exist");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.votingBoard}>
|
||||
<h2>Select Your Vote</h2>
|
||||
<div className={styles.voteCards}>
|
||||
{fibonacciNumbers.map((num) => (
|
||||
<VoteCard
|
||||
key={num}
|
||||
className={styles.voteCard}
|
||||
number={num}
|
||||
onClick={() => handleVote(num)}
|
||||
isSelected={selectedVote === num}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VotingBoard;
|
||||
45
src/components/VotingBoard/index.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../../services/api'; // Import your Axios instance from `api.js`
|
||||
import VoteCard from '../VoteCard';
|
||||
import styles from './VotingBoard.module.css';
|
||||
|
||||
const fibonacciNumbers = ['', 0, 1, 2, 3, 5, 8, 13, 21, 9999];
|
||||
|
||||
const VotingBoard = ({ sessionId, userId }) => {
|
||||
const [selectedVote, setSelectedVote] = useState(null);
|
||||
|
||||
const handleVote = async (number) => {
|
||||
setSelectedVote(number);
|
||||
|
||||
try {
|
||||
// Send the vote to your server via POST request using the `api.js` instance
|
||||
await api.post(`/session/${sessionId}/votes`, {
|
||||
userId,
|
||||
vote: number,
|
||||
});
|
||||
|
||||
console.log('Vote submitted successfully');
|
||||
} catch (error) {
|
||||
console.error('Error submitting vote:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.votingBoard}>
|
||||
<h2>Select Your Vote</h2>
|
||||
<div className={styles.voteCards}>
|
||||
{fibonacciNumbers.map((num) => (
|
||||
<VoteCard
|
||||
key={num}
|
||||
className={styles.voteCard}
|
||||
number={num}
|
||||
onClick={() => handleVote(num)}
|
||||
isSelected={selectedVote === num}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VotingBoard;
|
||||
@@ -1,294 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { ref, onValue, get, set, remove } from 'firebase/database';
|
||||
import { realtimeDb } from '../../services/firebase';
|
||||
import VotingBoard from '../VotingBoard/VotingBoard';
|
||||
import BoardCard from '../BoardCard/BoardCard';
|
||||
import Results from '../Results/Results';
|
||||
import styles from './VotingTable.module.css';
|
||||
import titleCardSrc from '../../assets/images/titlecard.png';
|
||||
import { ReactComponent as ExitIcon } from '../../assets/images/exit.svg';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { auth } from '../../services/firebase';
|
||||
import ProfileMenuButton from '../ProfileMenuButton';
|
||||
|
||||
const VotingTable = ({ userId }) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [votes, setVotes] = useState({});
|
||||
const [, setVotedUsers] = useState(0);
|
||||
const [allVoted, setAllVoted] = useState(false);
|
||||
const [isFinal, setIsFinal] = useState(false);
|
||||
const { sessionId } = useParams();
|
||||
const [currentUID, setCurrentUID] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const maxUsers = 32;
|
||||
const username =
|
||||
users.find((user) => user.uid === currentUID)?.username || '';
|
||||
|
||||
const goToLobby = useCallback(() => {
|
||||
navigate(`/lobby/${sessionId}`);
|
||||
}, [sessionId, navigate]);
|
||||
|
||||
function handleLogout() {
|
||||
handleDropUser(currentUID);
|
||||
}
|
||||
|
||||
const handleDropUser = async (userId) => {
|
||||
const userVoteRef = ref(
|
||||
realtimeDb,
|
||||
`sessions/${sessionId}/votes/${userId}`
|
||||
);
|
||||
const userRef = ref(realtimeDb, `sessions/${sessionId}/users/${userId}`);
|
||||
|
||||
await remove(userVoteRef);
|
||||
await remove(userRef);
|
||||
|
||||
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
|
||||
const snapshot = await get(sessionRef);
|
||||
|
||||
if (snapshot.exists()) {
|
||||
const sessionData = snapshot.val() || {};
|
||||
|
||||
if (typeof sessionData.users === 'undefined') {
|
||||
sessionData.users = {};
|
||||
}
|
||||
if (typeof sessionData.votes === 'undefined') {
|
||||
sessionData.votes = {};
|
||||
}
|
||||
|
||||
const userList = Object.entries(sessionData.users).reduce(
|
||||
(res, [uid, userInfo]) => {
|
||||
res[uid] = {
|
||||
uid,
|
||||
username: userInfo.username,
|
||||
};
|
||||
return res;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
if (sessionData.votes && sessionData.votes[userId]) {
|
||||
delete sessionData.votes[userId];
|
||||
}
|
||||
|
||||
// Update the session data
|
||||
await set(sessionRef, {
|
||||
...sessionData,
|
||||
votedUsers: Object.entries(sessionData.votes).reduce(
|
||||
(res, [uid, vote]) => (userId !== uid && vote >= 0 ? res + 1 : res),
|
||||
0
|
||||
),
|
||||
users: userList,
|
||||
});
|
||||
|
||||
// Redirect the dropped user to the lobby
|
||||
const userLobbyRef = ref(realtimeDb, `users/${userId}`);
|
||||
await set(userLobbyRef, {
|
||||
currentSessionId: `lobby/${sessionId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalizeVoting = async () => {
|
||||
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
|
||||
const snapshot = await get(sessionRef);
|
||||
if (snapshot.exists()) {
|
||||
const sessionData = snapshot.val() || {};
|
||||
|
||||
await set(sessionRef, {
|
||||
...sessionData,
|
||||
isFinal: true,
|
||||
});
|
||||
setIsFinal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetVoting = async () => {
|
||||
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
|
||||
|
||||
// Get the current session data
|
||||
const snapshot = await get(sessionRef);
|
||||
if (snapshot.exists()) {
|
||||
const sessionData = snapshot.val() || {};
|
||||
|
||||
// Reset the votes while retaining users
|
||||
const resetVotes = Object.keys(sessionData.votes).reduce((acc, user) => {
|
||||
acc[user] = -1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Update the session in the database with reset votes and reset isFinal
|
||||
await set(sessionRef, {
|
||||
...sessionData,
|
||||
votes: resetVotes,
|
||||
votedUsers: 0,
|
||||
isFinal: false,
|
||||
});
|
||||
|
||||
// Update the component state
|
||||
setVotes(resetVotes);
|
||||
setVotedUsers(0);
|
||||
setIsFinal(false);
|
||||
setAllVoted(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const user = auth.currentUser;
|
||||
|
||||
if (!user) {
|
||||
goToLobby();
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentUID(user.uid);
|
||||
|
||||
const sessionRef = ref(realtimeDb, `sessions/${sessionId}`);
|
||||
|
||||
get(sessionRef).then((snapshot) => {
|
||||
if (!snapshot.exists()) {
|
||||
// Initialize the session if it doesn't exist
|
||||
set(sessionRef, {
|
||||
votes: {},
|
||||
maxUsers: maxUsers,
|
||||
votedUsers: 0,
|
||||
isFinal: false,
|
||||
users: {},
|
||||
});
|
||||
} else {
|
||||
// If the session exists, ensure all necessary fields are set
|
||||
const sessionData = snapshot.val() || {};
|
||||
|
||||
// Check and add missing fields
|
||||
if (!sessionData.maxUsers) {
|
||||
sessionData.maxUsers = maxUsers;
|
||||
}
|
||||
if (!sessionData.votedUsers) {
|
||||
sessionData.votedUsers = 0;
|
||||
}
|
||||
if (!sessionData.isFinal) {
|
||||
sessionData.isFinal = false;
|
||||
}
|
||||
if (typeof sessionData.votes === 'undefined') {
|
||||
sessionData.votes = {};
|
||||
}
|
||||
if (typeof sessionData.users === 'undefined') {
|
||||
sessionData.users = {};
|
||||
}
|
||||
|
||||
// Update the session with missing fields if needed
|
||||
set(sessionRef, sessionData);
|
||||
|
||||
const userList = Object.entries(sessionData.users).map(
|
||||
([uid, userInfo]) => ({
|
||||
uid,
|
||||
username: userInfo.username, // Ensure username is passed
|
||||
})
|
||||
);
|
||||
|
||||
// Load the data into the state
|
||||
setUsers(userList);
|
||||
setVotes(sessionData.votes);
|
||||
setVotedUsers(sessionData.votedUsers);
|
||||
setIsFinal(sessionData.isFinal);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for changes to the session data
|
||||
const unsubscribe = onValue(sessionRef, (snapshot) => {
|
||||
const sessionData = snapshot.val();
|
||||
if (!sessionData) return;
|
||||
|
||||
if (typeof sessionData.users === 'undefined') {
|
||||
sessionData.users = {};
|
||||
}
|
||||
if (typeof sessionData.votes === 'undefined') {
|
||||
sessionData.votes = {};
|
||||
}
|
||||
|
||||
const userList = Object.entries(sessionData.users).map(
|
||||
([uid, userInfo]) => ({
|
||||
uid,
|
||||
username: userInfo.username,
|
||||
})
|
||||
);
|
||||
|
||||
setUsers(userList);
|
||||
setVotes(sessionData.votes || {});
|
||||
setVotedUsers(sessionData.votedUsers || 0);
|
||||
setIsFinal(sessionData.isFinal || false);
|
||||
|
||||
// Check if all users have voted
|
||||
setAllVoted(
|
||||
sessionData.votedUsers ===
|
||||
Object.keys(sessionData.votes || {}).length &&
|
||||
Object.keys(sessionData.votes || {}).length > 0
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [sessionId, maxUsers, navigate, setCurrentUID, goToLobby]);
|
||||
|
||||
return (
|
||||
<div className={styles.votingTable}>
|
||||
{/* <ExitIcon
|
||||
className={styles.logout}
|
||||
onClick={() => handleDropUser(currentUID)}
|
||||
title="Logout"
|
||||
/> */}
|
||||
<ProfileMenuButton username={username} onLogout={handleLogout} />
|
||||
<img className={styles.titleCard} src={titleCardSrc} alt="round-table" />
|
||||
<div className={styles.tableCards}>
|
||||
{users.map(({ uid, username }) => (
|
||||
<div key={uid} className={styles.userCardWrapper}>
|
||||
<BoardCard
|
||||
username={username}
|
||||
vote={votes[uid]}
|
||||
isRevealed={isFinal && votes[uid] !== -1}
|
||||
isDimmed={votes[uid] === -1}
|
||||
isHighlighted={currentUID === uid}
|
||||
/>
|
||||
{
|
||||
<ExitIcon
|
||||
className={styles.dropButton}
|
||||
onClick={() => handleDropUser(uid)}
|
||||
title="Drop User"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.form}>
|
||||
{allVoted && !isFinal && (
|
||||
<div
|
||||
className={`${styles.reveal} ${styles.button}`}
|
||||
onClick={handleFinalizeVoting}
|
||||
>
|
||||
Reveal Votes
|
||||
</div>
|
||||
)}
|
||||
{isFinal && (
|
||||
<div
|
||||
className={`${styles.restart} ${styles.button}`}
|
||||
onClick={handleResetVoting}
|
||||
>
|
||||
Restart Voting
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isFinal ? (
|
||||
<VotingBoard
|
||||
sessionId={sessionId}
|
||||
userId={userId}
|
||||
maxUsers={maxUsers}
|
||||
/>
|
||||
) : (
|
||||
<Results votes={votes} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VotingTable;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import VotingTable from './VotingTable';
|
||||
import { generateUniqueId } from '../../utils/crypto';
|
||||
import { generateUniqueId } from '../../shared/utils/crypto';
|
||||
|
||||
export default {
|
||||
title: 'Components/VotingTable',
|
||||
|
||||
203
src/components/VotingTable/index.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import VotingBoard from '../VotingBoard';
|
||||
import BoardCard from '../BoardCard';
|
||||
import Results from '../Results';
|
||||
import styles from './VotingTable.module.css';
|
||||
import titleCardSrc from '../../assets/images/titlecard.png';
|
||||
import { ReactComponent as ExitIcon } from '../../assets/images/exit.svg';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ProfileMenuButton from '../ProfileMenuButton';
|
||||
import api from '../../services/api';
|
||||
|
||||
const VotingTable = ({ userId }) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [votes, setVotes] = useState({});
|
||||
const [, setVotedUsers] = useState(0);
|
||||
const [isFinal, setIsFinal] = useState(false);
|
||||
const { sessionId } = useParams();
|
||||
const [currentUID, setCurrentUID] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const maxUsers = 32;
|
||||
const username =
|
||||
users.find((user) => user.uid === currentUID)?.username || '';
|
||||
|
||||
const goToLobby = useCallback(() => {
|
||||
navigate(`/lobby/${sessionId}`);
|
||||
}, [sessionId, navigate]);
|
||||
|
||||
async function handleLogout() {
|
||||
await handleDropUser(currentUID);
|
||||
}
|
||||
|
||||
async function handleInvite() {
|
||||
try {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy invite link: ', err);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDropUser = async (userId) => {
|
||||
try {
|
||||
// Remove user vote and user from session via API calls
|
||||
await api.delete(`/api/session/${sessionId}/votes/${userId}`);
|
||||
await api.delete(`/api/session/${sessionId}/users/${userId}`);
|
||||
|
||||
// Fetch updated session data after user removal
|
||||
const sessionResponse = await api.get(`/api/session/${sessionId}`);
|
||||
const sessionData = sessionResponse.data;
|
||||
|
||||
if (!sessionData) return;
|
||||
|
||||
// Update state with the new session data
|
||||
const userList = Object.entries(sessionData.users || {}).map(
|
||||
([uid, userInfo]) => ({
|
||||
uid,
|
||||
username: userInfo.username,
|
||||
})
|
||||
);
|
||||
|
||||
setUsers(userList);
|
||||
setVotes(sessionData.votes || {});
|
||||
|
||||
// Redirect the dropped user to the lobby
|
||||
await api.post(`/api/users/${userId}`, {
|
||||
currentSessionId: `lobby/${sessionId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalizeVoting = async () => {
|
||||
try {
|
||||
// Finalize voting by setting the isFinal flag in the session via API call
|
||||
await api.post(`/api/session/${sessionId}/finalize`, {
|
||||
isFinal: true,
|
||||
});
|
||||
setIsFinal(true);
|
||||
} catch (error) {
|
||||
console.error('Error finalizing voting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetVoting = async () => {
|
||||
try {
|
||||
// Reset the session's votes and isFinal flag via API call
|
||||
await api.post(`/api/session/${sessionId}/reset`, {
|
||||
votes: {},
|
||||
isFinal: false,
|
||||
});
|
||||
setVotes({});
|
||||
setVotedUsers(0);
|
||||
setIsFinal(false);
|
||||
} catch (error) {
|
||||
console.error('Error resetting voting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSessionData = async () => {
|
||||
try {
|
||||
// Fetch session data from the API
|
||||
const sessionResponse = await api.get(`/api/session/${sessionId}`);
|
||||
const sessionData = sessionResponse.data;
|
||||
|
||||
if (!sessionData) {
|
||||
// Initialize the session if it doesn't exist
|
||||
await api.post(`/api/session/${sessionId}`, {
|
||||
votes: {},
|
||||
maxUsers: maxUsers,
|
||||
isFinal: false,
|
||||
users: {},
|
||||
});
|
||||
} else {
|
||||
// Load the existing session data
|
||||
const userList = Object.entries(sessionData.users || {}).map(
|
||||
([uid, userInfo]) => ({
|
||||
uid,
|
||||
username: userInfo.username,
|
||||
})
|
||||
);
|
||||
setUsers(userList);
|
||||
setVotes(sessionData.votes || {});
|
||||
setIsFinal(sessionData.isFinal || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching session data:', error);
|
||||
goToLobby(); // Redirect to lobby if the session doesn't exist
|
||||
}
|
||||
};
|
||||
|
||||
fetchSessionData();
|
||||
|
||||
// Polling or WebSocket could be used to listen for real-time updates
|
||||
const intervalId = setInterval(fetchSessionData, 5000); // Poll every 5 seconds
|
||||
return () => clearInterval(intervalId); // Clean up the interval
|
||||
}, [sessionId, maxUsers, navigate, setCurrentUID, goToLobby]);
|
||||
|
||||
const allVoted = Object.values(votes).every((v) => v >= 0);
|
||||
|
||||
return (
|
||||
<div className={styles.votingTable}>
|
||||
<ProfileMenuButton
|
||||
username={username}
|
||||
onLogout={handleLogout}
|
||||
onInvite={handleInvite}
|
||||
/>
|
||||
<img className={styles.titleCard} src={titleCardSrc} alt="title-card" />
|
||||
<div className={styles.tableCards}>
|
||||
{users.map(({ uid, username }) => (
|
||||
<div key={uid} className={styles.userCardWrapper}>
|
||||
<BoardCard
|
||||
username={username}
|
||||
vote={votes[uid]}
|
||||
isRevealed={isFinal && votes[uid] !== -1}
|
||||
isDimmed={votes[uid] === -1}
|
||||
isHighlighted={currentUID === uid}
|
||||
/>
|
||||
{
|
||||
<ExitIcon
|
||||
className={styles.dropButton}
|
||||
onClick={
|
||||
uid === currentUID ? handleLogout : () => handleDropUser(uid)
|
||||
}
|
||||
title="Drop User"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.form}>
|
||||
{allVoted && !isFinal && (
|
||||
<div
|
||||
className={`${styles.reveal} ${styles.button}`}
|
||||
onClick={handleFinalizeVoting}
|
||||
>
|
||||
Reveal Votes
|
||||
</div>
|
||||
)}
|
||||
{isFinal && (
|
||||
<div
|
||||
className={`${styles.restart} ${styles.button}`}
|
||||
onClick={handleResetVoting}
|
||||
>
|
||||
Restart Voting
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isFinal ? (
|
||||
<VotingBoard
|
||||
sessionId={sessionId}
|
||||
userId={userId}
|
||||
maxUsers={maxUsers}
|
||||
/>
|
||||
) : (
|
||||
<Results votes={votes} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VotingTable;
|
||||
@@ -25,6 +25,10 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
39
src/services/api.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Create an Axios instance with base URL and credentials
|
||||
const api = axios.create({
|
||||
baseURL: '/api', // The base URL for your API
|
||||
withCredentials: true, // Send cookies with requests
|
||||
});
|
||||
|
||||
// Set up an Axios interceptor to handle token expiration (401 error)
|
||||
api.interceptors.response.use(
|
||||
(response) => response, // If the response is OK, just return it
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status === 401 &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
originalRequest._retry = true; // Prevent an infinite retry loop
|
||||
|
||||
// Try refreshing the token
|
||||
try {
|
||||
await api.post('/api/auth/refresh-token');
|
||||
// Retry the original request with the new token
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
console.log('originalRequest', originalRequest);
|
||||
console.error('Token refresh failed:', refreshError);
|
||||
// Handle failed refresh (e.g., redirect to login page)
|
||||
window.location.href = '/lobby';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error); // Reject if it's not a 401 or after retry
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
1
src/shared
Symbolic link
@@ -0,0 +1 @@
|
||||
/Users/cmedina/Documents/Developer/Apps/reactApps/fibonacci-fold/shared
|
||||
@@ -1,9 +0,0 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
// Generate a session ID hash
|
||||
export const generateUniqueId = () => {
|
||||
const uuid = uuidv4();
|
||||
const hash = CryptoJS.SHA256(uuid).toString();
|
||||
return hash.substring(0, 28); // Use the first 8 characters of the hash
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
export function mockThis(input) {
|
||||
const defaultValues = {
|
||||
string: 'default string',
|
||||
number: 0,
|
||||
boolean: false,
|
||||
object: {},
|
||||
array: [],
|
||||
function: () => {}, // Default empty function
|
||||
};
|
||||
|
||||
const nativeTypes = new Set([
|
||||
'Map',
|
||||
'Set',
|
||||
'WeakMap',
|
||||
'WeakSet',
|
||||
'Date',
|
||||
'RegExp',
|
||||
'ArrayBuffer',
|
||||
'SharedArrayBuffer',
|
||||
'DataView',
|
||||
'Promise',
|
||||
'Int8Array',
|
||||
'Uint8Array',
|
||||
'Uint8ClampedArray',
|
||||
'Int16Array',
|
||||
'Uint16Array',
|
||||
'Int32Array',
|
||||
'Uint32Array',
|
||||
'Float32Array',
|
||||
'Float64Array',
|
||||
'BigInt64Array',
|
||||
'BigUint64Array',
|
||||
]);
|
||||
|
||||
function getDefaultValue(value) {
|
||||
const valueType = Array.isArray(value) ? 'array' : typeof value;
|
||||
return defaultValues[valueType] !== undefined
|
||||
? defaultValues[valueType]
|
||||
: null;
|
||||
}
|
||||
|
||||
function createGenericMock() {
|
||||
return new Proxy(() => {}, {
|
||||
get(target, prop) {
|
||||
return createGenericMock();
|
||||
},
|
||||
apply() {
|
||||
return createGenericMock();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mockFunction(func) {
|
||||
return new Proxy(func, {
|
||||
apply(target, thisArg, argumentsList) {
|
||||
const returnValue = target.apply(thisArg, argumentsList);
|
||||
return mockValue(returnValue);
|
||||
},
|
||||
get(target, prop) {
|
||||
return createGenericMock();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mockObject(obj) {
|
||||
if (nativeTypes.has(obj.constructor.name)) {
|
||||
return obj; // Return native types without modification
|
||||
}
|
||||
|
||||
return new Proxy(obj, {
|
||||
get(target, prop) {
|
||||
if (prop === 'getProvider') {
|
||||
return () => ({
|
||||
getImmediate: (arg) => {
|
||||
// Handle different argument types passed to getImmediate
|
||||
if (arg && arg.identifier === '(default)') {
|
||||
return {
|
||||
triggerHeartbeat: () => ({
|
||||
isInitialized: () => true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Return a generic mock for other cases
|
||||
return createGenericMock();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (prop in target) {
|
||||
return mockValue(target[prop]);
|
||||
}
|
||||
return createGenericMock();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mockValue(value) {
|
||||
if (typeof value === 'function') {
|
||||
return mockFunction(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
return mockObject(value);
|
||||
} else {
|
||||
return getDefaultValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return mockValue(input);
|
||||
}
|
||||
|
||||
export default mockThis;
|
||||
5
src/utils/strings.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export const getAcronym = (str = '', separator = ' ', union = '') => {
|
||||
const words = str.split(separator).filter((word) => word);
|
||||
const letters = words.map((word) => word[0]);
|
||||
return letters.join(union);
|
||||
};
|
||||
22
yarn.lock
@@ -5982,6 +5982,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^1.7.7":
|
||||
version: 1.7.7
|
||||
resolution: "axios@npm:1.7.7"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.0"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10c0/4499efc89e86b0b49ffddc018798de05fab26e3bf57913818266be73279a6418c3ce8f9e934c7d2d707ab8c095e837fc6c90608fb7715b94d357720b5f568af7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axobject-query@npm:~3.1.1":
|
||||
version: 3.1.1
|
||||
resolution: "axobject-query@npm:3.1.1"
|
||||
@@ -9566,6 +9577,7 @@ __metadata:
|
||||
"@testing-library/jest-dom": "npm:^5.17.0"
|
||||
"@testing-library/react": "npm:^13.4.0"
|
||||
"@testing-library/user-event": "npm:^13.5.0"
|
||||
axios: "npm:^1.7.7"
|
||||
crypto-js: "npm:^4.2.0"
|
||||
dotenv: "npm:^16.4.5"
|
||||
eslint: "npm:^8.52.0"
|
||||
@@ -9956,6 +9968,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"follow-redirects@npm:^1.15.6":
|
||||
version: 1.15.9
|
||||
resolution: "follow-redirects@npm:1.15.9"
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
checksum: 10c0/5829165bd112c3c0e82be6c15b1a58fa9dcfaede3b3c54697a82fe4a62dd5ae5e8222956b448d2f98e331525f05d00404aba7d696de9e761ef6e42fdc780244f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"for-each@npm:^0.3.3":
|
||||
version: 0.3.3
|
||||
resolution: "for-each@npm:0.3.3"
|
||||
|
||||