This commit is contained in:
2024-09-27 22:24:33 -04:00
parent 7f6116450f
commit bb60182164
58 changed files with 2414 additions and 681 deletions

View File

@@ -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,37 +23,67 @@ 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);
} else {
console.log('User is signed out');
}
setLoading(false);
});
// 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 {
// 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);
});
}, []);
return (
<Router>
<UserSessionListener />
<Routes>
<Route
path="/lobby/:sessionId?"
element={<Lobby onJoinSession={handleJoinSession} />}
loading={loading}
/>
<Route
path="/session/:sessionId"
element={<VotingTable userId={userId} />}
/>
<Route path="*" element={<Navigate to={`/lobby`} />} />
</Routes>
<div className={styles.app}>
<section className={styles.header}>
<Routes>
<Route
path="/lobby/:sessionId?"
element={<Lobby onJoinSession={handleJoinSession} />}
loading={loading}
/>
<Route
path="/session/:sessionId"
element={<VotingTable userId={userId} />}
/>
<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
View 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;
}

View File

@@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

View 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

Binary file not shown.

Binary file not shown.

BIN
src/assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

View 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;

View File

@@ -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 }) => {

View File

@@ -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 }) => {

View 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;

View File

@@ -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}`);
} 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);
});
}
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);
});
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>

View File

@@ -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;
}

View 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;

View File

@@ -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) => {

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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; */
}

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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',

View 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;

View File

@@ -25,6 +25,10 @@ body {
height: 100%;
}
#root {
height: 100%;
}
input {
width: 150px;
}

39
src/services/api.js Normal file
View 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
View File

@@ -0,0 +1 @@
/Users/cmedina/Documents/Developer/Apps/reactApps/fibonacci-fold/shared

View File

@@ -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
};

View File

@@ -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
View 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);
};