Merge branch 'client' into 'main'
Merge entire client branch into main See merge request felixalb/dcst1008-2022-group1!3
							
								
								
									
										1
									
								
								src/client/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -17,6 +17,7 @@ node_modules | ||||
| .env.development.local | ||||
| .env.test.local | ||||
| .env.production.local | ||||
| .env | ||||
| 
 | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
|  | ||||
							
								
								
									
										15840
									
								
								src/client/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @ -1,15 +1,30 @@ | ||||
| { | ||||
| 
 | ||||
|   "name": "tournament-server", | ||||
|   "version": "1.0.0", | ||||
|   "description": "DCST1008 Project - Server - Asura Tournament Management System", | ||||
|   "author": "felixalb, kristoju, jonajha, krisleri", | ||||
|   "private": true, | ||||
|   "homepage": "", | ||||
|   "dependencies": { | ||||
|     "@date-io/date-fns": "^2.11.0", | ||||
|     "@emotion/react": "^11.8.2", | ||||
|     "@emotion/styled": "^11.8.1", | ||||
|     "@mui/icons-material": "^5.5.1", | ||||
|     "@mui/lab": "^5.0.0-alpha.61", | ||||
|     "@mui/material": "^5.5.2", | ||||
|     "@mui/styled-engine-sc": "^5.5.2", | ||||
|     "bootstrap": "^5.1.3", | ||||
|     "date-fns": "^2.27.0", | ||||
|     "iarn": "0.0.0", | ||||
|     "less": "^4.1.2", | ||||
|     "react": "^17.0.2", | ||||
|     "react-bootstrap": "^2.2.1", | ||||
|     "react-dom": "^17.0.2", | ||||
|     "react-router-dom": "^6.2.2", | ||||
|     "react-scripts": "5.0.0", | ||||
|     "web-vitals": "^2.1.4" | ||||
|     "styled-components": "^5.3.3", | ||||
|     "web-vitals": "^2.1.4", | ||||
|     "yarn": "^1.22.18" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "react-scripts start", | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								src/client/public/Asura_Rex.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.5 MiB | 
							
								
								
									
										
											BIN
										
									
								
								src/client/public/asura.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 41 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/client/public/banner2.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 648 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/client/public/btn_google_signing_dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.8 KiB | 
| Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 41 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/client/public/favicon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 12 KiB | 
| @ -7,7 +7,7 @@ | ||||
|     <meta name="theme-color" content="#000000" /> | ||||
|     <meta | ||||
|       name="description" | ||||
|       content="Web site created using create-react-app" | ||||
|       content="Asura Tournament System" | ||||
|     /> | ||||
|     <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> | ||||
|     <!-- | ||||
| @ -24,20 +24,12 @@ | ||||
|       work correctly both with client-side routing and a non-root public URL. | ||||
|       Learn how to configure a non-root public URL by running `npm run build`. | ||||
|     --> | ||||
|     <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/> | ||||
|     <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"/> | ||||
|     <title>Asura Tournament System</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <noscript>You need to enable JavaScript to run this app.</noscript> | ||||
|     <div id="root"></div> | ||||
|     <!-- | ||||
|       This HTML file is a template. | ||||
|       If you open it directly in the browser, you will see an empty page. | ||||
| 
 | ||||
|       You can add webfonts, meta tags, or analytics to this file. | ||||
|       The build step will place the bundled scripts into the <body> tag. | ||||
| 
 | ||||
|       To begin the development, run `npm start` or `yarn start`. | ||||
|       To create a production bundle, use `npm run build` or `yarn build`. | ||||
|     --> | ||||
|   </body> | ||||
| </html> | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "short_name": "React App", | ||||
|   "name": "Create React App Sample", | ||||
|   "short_name": "Asura Tournament", | ||||
|   "name": "Asura Tournament", | ||||
|   "icons": [ | ||||
|     { | ||||
|       "src": "favicon.ico", | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								src/client/public/react.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 41 KiB | 
							
								
								
									
										256
									
								
								src/client/src/AdminsOverview.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,256 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes, useParams } from "react-router-dom"; | ||||
| import Appbar from "./components/AsuraBar"; | ||||
| import ErrorSnackbar from "./components/ErrorSnackbar"; | ||||
| import LoginPage from "./LoginPage"; | ||||
| import { Button, Box, TextField, Stack, InputLabel, Paper, TableContainer, Table, TableBody, TableHead, TableCell, TableRow, Typography, Select, MenuItem, FormControl } from '@mui/material'; | ||||
| import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; | ||||
| import AddCircleIcon from '@mui/icons-material/AddCircle'; | ||||
| import DeleteIcon from '@mui/icons-material/Delete'; | ||||
| import EditIcon from '@mui/icons-material/Edit'; | ||||
| import PropTypes from 'prop-types' | ||||
| 
 | ||||
| function AdminCreator(props){ | ||||
|     function postCreate(){ | ||||
|         let adminEmail = document.getElementById("adminEmailInput").value; | ||||
|         if (!adminEmail) { | ||||
|             showError("Admin email is required"); | ||||
|             return; | ||||
|         } | ||||
|      | ||||
|     let formData = new FormData(); | ||||
|     formData.append("email", adminEmail) | ||||
|     let body = new URLSearchParams(formData) | ||||
| 
 | ||||
|     fetch(process.env.REACT_APP_API_URL + `/users/createBlank`, { | ||||
|         method: "POST", | ||||
|         body: body | ||||
|         }) | ||||
|         .then(res => res.json()) | ||||
|         .then(data => { | ||||
|             if (data.status !== "OK") { | ||||
|                 showError(data.data); | ||||
|                 return; | ||||
|             } | ||||
|             document.getElementById("adminEmailInput").value = ""; | ||||
|             props.onAdminCreated(); | ||||
|         } | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <Paper sx={{width: "90vw", margin: "10px auto", padding: "15px", align:'center', justifyContent:'center', flexGrow:1}} component={Stack} direction={['column']} spacing={2}> | ||||
|             <div align="center"> | ||||
|                 <form> | ||||
|                     <TextField id="adminEmailInput" label="Admin Email" variant="outlined" type="email" sx={{width:['auto','50%','60%','70%']}} /> | ||||
|                         {/* <Button variant="contained" color="primary" onClick={postCreate}>Create Team</Button> */} | ||||
|                         <Button type="submit" variant="contained" color="success" onClick={postCreate} sx={{marginLeft:['5px'],width:['fit-content','40%','30%','20%']}}> | ||||
|                             <Box sx={{padding: "10px"}}> | ||||
|                                 Create Admin | ||||
|                             </Box> | ||||
|                             <AddCircleIcon /> | ||||
|                         </Button> | ||||
|                 </form> | ||||
|             </div> | ||||
|         </Paper> | ||||
|     ) | ||||
| } | ||||
| 
 | ||||
| function UserList(props){ | ||||
|     const deleteUser = (userId) => { | ||||
|       openConfirmDialog(function() {  | ||||
|         fetch(process.env.REACT_APP_API_URL + `/users/${userId}`, {method: "DELETE"}) | ||||
|             .then(res => res.json()) | ||||
|             .then(data => { | ||||
|                 if(data.status !== "OK"){ | ||||
|                     showError(data.data); | ||||
|                     console.log("UWU") | ||||
|                     return; | ||||
|                 } | ||||
|                 props.onUserUpdated(); | ||||
|             }) | ||||
|             .catch(error => showError(error)); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     let updateRank = (asuraId) => event => { | ||||
|         event.preventDefault(); | ||||
|         let isManager = event.target.value === "manager"; | ||||
|         let formData = new FormData(); | ||||
|         formData.append("isManager", isManager); | ||||
|         let body = new URLSearchParams(formData); | ||||
|         fetch(process.env.REACT_APP_API_URL + `/users/${asuraId}/changeManagerStatus`, { | ||||
|             method: "POST", | ||||
|             body: body | ||||
|         }) | ||||
|             .then(res => res.json()) | ||||
|             .then(data => { | ||||
|                 if(data.status !== "OK"){ | ||||
|                     showError(data.data); | ||||
|                     return; | ||||
|                 } | ||||
|                 props.onUserUpdated(); | ||||
|             }) | ||||
|             .catch(error => showError(error)); | ||||
|     } | ||||
| 
 | ||||
|     return( | ||||
|         <Paper sx={{minHeight: "30vh", width:"90vw", margin:"10px auto"}} component={Stack} direction="column" justifycontent="center"> | ||||
|             <div align="center"> | ||||
|               <Table aria-label="simple table"> | ||||
|                   <TableHead> | ||||
|                       <TableRow> | ||||
|                           <TableCell>Name</TableCell> | ||||
|                           <TableCell>Email</TableCell> | ||||
|                           <TableCell>Rank</TableCell> | ||||
|                           <TableCell align="center">Actions</TableCell> | ||||
|                       </TableRow> | ||||
|                   </TableHead> | ||||
|                   <TableBody> | ||||
|                       {props.users.map((user) => ( | ||||
|                           <TableRow key={user.asuraId}> | ||||
|                           <TableCell component="th" scope="row">  | ||||
|                           <b> | ||||
|                             {user.name} | ||||
|                           </b> | ||||
|                           </TableCell> | ||||
|                           <TableCell>{user.email}</TableCell> | ||||
|                           {/* TODO Drop down menu for selecting rank */} | ||||
|                           <TableCell> | ||||
|                               <FormControl variant="standard"> | ||||
|                                   <Select onChange={updateRank(user.asuraId)} value={user.isManager ? "manager" : "admin"} aria-label="rank" id="rankSelect"> | ||||
|                                       <MenuItem value={"manager"}>Manager</MenuItem> | ||||
|                                       <MenuItem value={"admin"}>Admin</MenuItem> | ||||
|                                   </Select> | ||||
|                               </FormControl> | ||||
|                           </TableCell> | ||||
|                           {/* <TableCell align="right">{team.members}</TableCell> */} | ||||
|                           <TableCell align="center"> | ||||
|                             {/* <Button variant="contained" sx={{margin: "auto 5px"}} color="primary" onClick={() => props.setSelectedTeamId(team.id)} endIcon={<EditIcon />}>Edit</Button> */} | ||||
|                             <Button variant="contained" sx={{margin: "auto 5px"}} color="error" onClick={() => {deleteUser(user.asuraId)}} endIcon={<DeleteIcon />}>Delete</Button> | ||||
|                           </TableCell> | ||||
|                         </TableRow> | ||||
|                       ))} | ||||
|                   </TableBody> | ||||
|               </Table> | ||||
|             </div> | ||||
|         </Paper> | ||||
|     ) | ||||
| } | ||||
| 
 | ||||
| function ConfirmationDialogRaw(props) { | ||||
|   const { userId } = useParams(); | ||||
|   const { onClose, value: valueProp, open, ...other } = props; | ||||
|   const [value, setValue] = React.useState(valueProp); | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     if (!open) { | ||||
|       setValue(valueProp); | ||||
|     } | ||||
|   }, [valueProp, open]); | ||||
| 
 | ||||
|   const handleCancel = () => { | ||||
|     onClose(); | ||||
|   }; | ||||
|   const handleConfirm = () => { | ||||
|     onClose(); | ||||
|     props.handleconfirm(); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <Dialog | ||||
|       sx={{ '& .MuiDialog-paper': { width: '80%', maxHeight: 435 } }} | ||||
|       maxWidth="xs" | ||||
|       open={open} | ||||
|       keepMounted | ||||
|     > | ||||
|       <DialogTitle>Delete administrator?</DialogTitle> | ||||
|       <DialogContent> | ||||
|         Are you sure you want to delete the administrator? This action is not reversible! | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button autoFocus onClick={handleCancel}> | ||||
|           Cancel | ||||
|         </Button> | ||||
|         <Button onClick={handleConfirm}>Confirm</Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| ConfirmationDialogRaw.propTypes = { | ||||
|   onClose: PropTypes.func.isRequired, | ||||
|   open: PropTypes.bool.isRequired, | ||||
| }; | ||||
| 
 | ||||
| // Confirmation window for "Delete user"
 | ||||
| function openConfirmDialog(callback) {g_setDialogCallback(callback); g_setDialogOpen(true);} | ||||
| let g_setDialogOpen = () => {}; | ||||
| let g_setDialogCallback = (callback) => {}; | ||||
| 
 | ||||
| let showError = (message) => {}; | ||||
| 
 | ||||
| export default function Users(props) { | ||||
| 
 | ||||
|     const [openError, setOpenError] = React.useState(false); | ||||
|     const [errorMessage, setErrorMessage] = React.useState(""); | ||||
|     showError = (message) => { | ||||
|         setOpenError(false); | ||||
|         setErrorMessage(message); | ||||
|         setOpenError(true); | ||||
|     } | ||||
|     const [users, setUsers] = React.useState([]); | ||||
| 
 | ||||
|     const [dialogOpen, setDialogOpen] = React.useState(false); | ||||
|     const handleDialogClose = () => { setDialogOpen(false); }; | ||||
|     const [dialogOnConfirm, setDialogOnConfirm] = React.useState(() => {return function(){}}); | ||||
|     g_setDialogCallback = (callback) => { setDialogOnConfirm(()=>{ return callback })}; | ||||
|     g_setDialogOpen = (value) => { setDialogOpen(value); }; | ||||
| 
 | ||||
|     function getUsers() { | ||||
|         fetch(process.env.REACT_APP_API_URL + `/users/getUsers`) | ||||
|             .then((res) => res.json()) | ||||
|             .then((data) =>{ | ||||
|                 if(data.status !== "OK") { | ||||
|                     showError(data.data); | ||||
|                     return; | ||||
|                 } | ||||
|                 setUsers(data.data); | ||||
|             }) | ||||
|             .catch((err) => showError(err)); | ||||
|     }  | ||||
|     React.useEffect(() => { | ||||
|         getUsers() | ||||
|     }, []); | ||||
| 
 | ||||
|     if (!props.user.isLoggedIn) { return <LoginPage user={props.user} />; } | ||||
|     if (!props.user.isManager) { | ||||
|         return (<> | ||||
|             <Appbar user={props.user} pageTitle="Admins" /> | ||||
|             <Paper sx={{minHeight: "30vh", width:"90vw", margin:"10px auto", padding: "25px"}} component={Stack} direction="column" justifycontent="center"> | ||||
|             <div align="center"> | ||||
|                 <Typography variant="h4">You do not have permission to view this page. If you believe this is incorrect, please contact a manager.</Typography> | ||||
|             </div> | ||||
|             </Paper> | ||||
|         </>); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|         <Appbar user={props.user} pageTitle="Admins" /> | ||||
|         <div className="admins"> | ||||
|             <AdminCreator onAdminCreated={getUsers} onUserUpdated={getUsers} /> | ||||
|             <UserList users={users} setUsers={setUsers} onUserUpdated={getUsers} /> | ||||
|         </div> | ||||
| 
 | ||||
|         <ErrorSnackbar message={errorMessage} open={openError} setOpen={setOpenError} /> | ||||
|         <ConfirmationDialogRaw | ||||
|           id="confirmation-dialog" | ||||
|           keepMounted | ||||
|           open={dialogOpen} | ||||
|           onClose={handleDialogClose} | ||||
|           handleconfirm={dialogOnConfirm} | ||||
|         /> | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								src/client/src/Asura2222.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 22 KiB | 
							
								
								
									
										279
									
								
								src/client/src/FrontPage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,279 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes } from "react-router-dom"; | ||||
| 
 | ||||
| import TournamentCreator from "./TournamentCreator.js"; | ||||
| import TournamentOverview from "./TournamentOverview.js"; | ||||
| import TournamentManager from "./TournamentManager.js"; | ||||
| import TournamentHistory from "./TournamentHistory"; | ||||
| import TournamentTeams from "./TournamentTeams"; | ||||
| import LoginPage from "./LoginPage"; | ||||
| import ProfilePage from "./ProfilePage"; | ||||
| import Appbar from './components/AsuraBar'; | ||||
| import SuccessSnackbar from "./components/SuccessSnackbar"; | ||||
| import ErrorSnackbar from "./components/ErrorSnackbar"; | ||||
| import AdminsOverview from "./AdminsOverview"; | ||||
| 
 | ||||
| import { Button, Container, Typography, Box, Stack, Card, CardContent, CardMedia, Paper, Grid, Icon, TextField } from "@mui/material"; | ||||
| import AddCircleIcon from '@mui/icons-material/AddCircle'; | ||||
| import KeyboardDoubleArrowDownIcon from '@mui/icons-material/KeyboardDoubleArrowDown'; | ||||
| import KeyboardDoubleArrowUpIcon from '@mui/icons-material/KeyboardDoubleArrowUp'; | ||||
| import EditIcon from '@mui/icons-material/Edit'; | ||||
| import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; | ||||
| 
 | ||||
| function CreateButton(props) { | ||||
|   return ( | ||||
|     <Link to="/create"> | ||||
|       <Button variant="contained" color="success"> | ||||
|         <Box sx={{ | ||||
|           paddingRight: '2%', | ||||
|         }}> | ||||
|           Create Tournament | ||||
|         </Box> | ||||
|         <AddCircleIcon /> | ||||
|       </Button> | ||||
|     </Link> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function shorten(description, maxLength) { | ||||
|   if (description.length > maxLength) { | ||||
|     return description.substring(0, maxLength) + "..."; | ||||
|   } | ||||
|   return description; | ||||
| } | ||||
| 
 | ||||
| function TournamentListItem(props) { | ||||
|   const [longDescription, setLongDescription] = React.useState(false); | ||||
|   const maxLength = 200; | ||||
|   function toggleDescription() { | ||||
|     setLongDescription(!longDescription); | ||||
|   } | ||||
|   function Description() { | ||||
|     if (longDescription) { | ||||
|       return( <Box component={Stack} direction="row"> | ||||
|         <Typography variant="body1" onClick={toggleDescription}>{props.tournament.description}</Typography> | ||||
|         <KeyboardDoubleArrowUpIcon onClick={toggleDescription} /> | ||||
|       </Box> )  | ||||
|   } else if (props.tournament.description.length < maxLength) {  | ||||
|     return <Typography variant="body1" color="text.secondary" onClick={toggleDescription}>{props.tournament.description}</Typography> | ||||
|   } else { | ||||
|       return <Box component={Stack} direction="row"> | ||||
|         <Typography variant="body1" color="text.secondary" onClick={toggleDescription}>{shorten(props.tournament.description, maxLength)}</Typography> | ||||
|         <KeyboardDoubleArrowDownIcon onClick={toggleDescription} /> | ||||
|       </Box>; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   function Countdown() { | ||||
|     const [remainingTime, setremainingTime] = React.useState(Math.abs(props.tournament.startTime - new Date())) | ||||
|     React.useEffect(() => { | ||||
|       const interval = setInterval(() =>  | ||||
|         setremainingTime(Math.abs(props.tournament.startTime - new Date())) | ||||
|       , 1000); | ||||
|       return () => { | ||||
|         clearInterval(interval); | ||||
|       }; | ||||
|     }, []); | ||||
| 
 | ||||
|     let remainingDays = Math.floor(remainingTime / (1000 * 60 * 60 * 24)); | ||||
|     let remainingHours = Math.floor(remainingTime / (1000 * 60 * 60)) - remainingDays * 24 | ||||
|     let remainingMins = Math.floor(remainingTime / (1000 * 60)) - (remainingDays * 24 * 60 + remainingHours * 60) | ||||
|     let remainingSecs = Math.floor(remainingTime / 1000) - (remainingDays * 24 * 60 * 60 + remainingHours * 60 * 60 + remainingMins * 60) | ||||
|     if (props.tournament.startTime < new Date()) { | ||||
|       return (<Box> | ||||
|         <Typography variant="body"> Started! </Typography> | ||||
|         </Box>) | ||||
|     } else { | ||||
|       return(<Box> | ||||
|         <Typography variant="body"> Starts in: </Typography> <br /> | ||||
|         { remainingDays > 0 ? <Typography variant="body"> {remainingDays} days</Typography> : null } | ||||
|         { remainingHours > 0 ? <Typography variant="body"> {remainingHours} hours</Typography> : null } | ||||
|         { remainingMins > 0 ? <Typography variant="body"> {remainingMins} mins</Typography> : null } | ||||
|         { remainingSecs > 0 ? <Typography variant="body"> {remainingSecs} secs</Typography> : null } | ||||
|       </Box> | ||||
|       ) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|         <Paper elevation={8} > | ||||
|           <Card> | ||||
|             <CardMedia  | ||||
|               component="img" | ||||
|               alt="tournament image" | ||||
|               height="140" | ||||
|               image="banner2.png" | ||||
|             /> | ||||
|             <CardContent align="left"> | ||||
|               <Typography sx={{fontSize:['2rem','2.5rem','3rem']}} component="div" align="center">{props.tournament.name} </Typography> | ||||
|                | ||||
|               <Box component={Stack} direction="column"> | ||||
|                 <Typography variant="body"> Start: {props.tournament.startTime.toLocaleString()} </Typography> | ||||
|                 <Typography variant="body"> End: {props.tournament.endTime.toLocaleString()} </Typography> | ||||
|               </Box> | ||||
|                | ||||
|               <Typography variant="h5" color="text.primary" gutterBottom> Particpants: {props.tournament.teamCount} / {props.tournament.teamLimit} </Typography> | ||||
|               <Description /> | ||||
|               <Typography variant="body" color="text.primary"><EmojiEventsIcon alt="A trohpy" color="gold"/>  Prize: {props.tournament.prize} </Typography> | ||||
|               <Countdown /> | ||||
|                | ||||
|               <Box sx={{flexGrow: 1, marginTop: "20px"}}> | ||||
|                 <Grid container spacing={2} justifyContent="center" wrap="wrap"> | ||||
|                     { props.user.isLoggedIn && | ||||
|                       <Grid item> | ||||
|                       <Link to={`/tournament/${props.tournament.id}/manage`}> | ||||
|                         <Button className="ManageButton" variant="contained" color="primary" endIcon={<EditIcon />}>Edit Tournament</Button> | ||||
|                       </Link> | ||||
|                     </Grid> | ||||
|                     } | ||||
|                     <Grid item > | ||||
|                     <Link to={`/tournament/${props.tournament.id}`} > | ||||
|                       <Button variant="contained" color="success"> | ||||
|                         View Tournament | ||||
|                       </Button> | ||||
|                     </Link> | ||||
|                     </Grid> | ||||
|                 </Grid> | ||||
|               </Box> | ||||
|                | ||||
|             </CardContent> | ||||
|           </Card>      | ||||
|         </Paper> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function TournamentList(props) { | ||||
|   let [tournamentList, setTournamentList] = React.useState([]); | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     fetch(process.env.REACT_APP_API_URL + `/tournament/getTournaments`) | ||||
|       .then(res => res.json()) | ||||
|       .then(data => { | ||||
|         if (data.status !== "OK") { | ||||
|           console.error(data); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         let today = new Date() | ||||
|         let currenttournaments = [] | ||||
|         let tournaments = Object.values(data.data); | ||||
|         for (let i = 0; i < tournaments.length; i++) { | ||||
|           tournaments[i].startTime = new Date(tournaments[i].startTime); | ||||
|           tournaments[i].endTime = new Date(tournaments[i].endTime); | ||||
|           if(today - tournaments[i].endTime <= 2*60*60*1000) { | ||||
|             currenttournaments.push(tournaments[i]) | ||||
|           } | ||||
|         } | ||||
|         // tournaments.filter((tournament) => today - tournament.endTime < 24*60*60*1000)
 | ||||
|         setTournamentList(currenttournaments); | ||||
|       }) | ||||
|       .catch((err) => console.log(err.message)); | ||||
|   }, []); | ||||
| 
 | ||||
|   return <> | ||||
|   <Stack spacing={3} sx={{margin: "10px auto"}}> | ||||
|     {tournamentList && tournamentList.map((tournamentObject) => <TournamentListItem user={props.user} key={tournamentObject.id.toString()} tournament={tournamentObject} />)} | ||||
|   </Stack> | ||||
|   </>; | ||||
| } | ||||
| 
 | ||||
| function Home(props) { | ||||
|   return ( | ||||
|     <> | ||||
|       <Appbar user={props.user} pageTitle="Asura Tournaments" /> | ||||
|         <Container sx={{minHeight: "30vh", width: "90vw", padding: "20px 20px"}} component={Container} direction="column" align="center"> | ||||
|         <Box component={Stack} direction={['column','row']} sx={{align:'center', justifyContent:'space-between', flexGrow:1}}> | ||||
|             <Typography sx={{fontSize:['1.5rem','2rem','2rem']}}>Tournaments</Typography> | ||||
|             { props.user.isLoggedIn ? | ||||
|               <CreateButton /> : null | ||||
|             } | ||||
|           </Box> | ||||
|           <TournamentList user={props.user} /> | ||||
|           {props.user.isLoggedIn && | ||||
|             <Typography variant="h5" color="#555555"> | ||||
|               Finished tournaments are moved to the <Link to="/history">history-page</Link> | ||||
|             </Typography> | ||||
|           } | ||||
|         </Container> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| let showSuccess = (message) => {}; | ||||
| let showError = (message) => {}; | ||||
| 
 | ||||
| export default function App() { | ||||
|   const [user, setUser] = React.useState({}); | ||||
|   let fetchUser = () => { | ||||
|     fetch(process.env.REACT_APP_API_URL + `/users/getSavedUser`) | ||||
|       .then(res => res.json()) | ||||
|       .then(data => { | ||||
|         if (data.status !== "OK") { | ||||
|           setUser({ isManager: false, isLoggedIn: false }); | ||||
|           console.log(data.data); // "No user logged in"
 | ||||
|           return; | ||||
|         } | ||||
|         let u  = data.data; | ||||
|         u.isLoggedIn = true; | ||||
|         console.log("User is logged in") | ||||
|         setUser(u); | ||||
|       }) | ||||
|       .catch((err) => { | ||||
|         showError(err.message); | ||||
|         setUser({ isManager: false, isLoggedIn: false }); | ||||
|       }); | ||||
|   } | ||||
|   // // Debug mode, allow all:
 | ||||
|   // let fetchUser = () => {
 | ||||
|   //   setUser({
 | ||||
|   //     name: "TEST USERTEST",
 | ||||
|   //     isManager: true,
 | ||||
|   //     isLoggedIn: true,
 | ||||
|   //     email: "testesen@gmail.com",
 | ||||
|   //     asuraId: "123456789",
 | ||||
|   //     googleId: "234"
 | ||||
|   //   });
 | ||||
|   // }
 | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     fetchUser(); | ||||
|   }, []); | ||||
| 
 | ||||
|   const [openError, setOpenError] = React.useState(false); | ||||
|   const [errorMessage, setErrorMessage] = React.useState(""); | ||||
|   showError = (message) => { | ||||
|     setOpenError(false); | ||||
|     setErrorMessage(message); | ||||
|     setOpenError(true); | ||||
|   } | ||||
| 
 | ||||
|   const [openSuccess, setOpenSuccess] = React.useState(false); | ||||
|   const [successMessage, setSuccessMessage] = React.useState(""); | ||||
|   showSuccess = (message) => { | ||||
|     setOpenSuccess(false); | ||||
|     setSuccessMessage(message); | ||||
|     setOpenSuccess(true); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <React.StrictMode> | ||||
|       <Router> | ||||
|       <Routes> | ||||
|         <Route path="/" element={<Home showError={showError} showSuccess={showSuccess} user={user} />} /> | ||||
|         <Route path="/create" element={<TournamentCreator showError={showError} showSuccess={showSuccess} user={user} />} /> | ||||
|         <Route path="/tournament/:tournamentId" element={<TournamentOverview user={user} />} /> | ||||
|         <Route path="/tournament/:tournamentId/manage" element={<TournamentManager showError={showError} showSuccess={showSuccess} user={user} />} /> | ||||
|         <Route path="/tournament/:tournamentId/teams" element={<TournamentTeams showError={showError} showSuccess={showSuccess} user={user} />} /> | ||||
|         <Route path="/history" element={<TournamentHistory showError={showError} showSuccess={showSuccess} user={user} />} /> | ||||
|         <Route path="/login" element={<LoginPage user={user} />} /> | ||||
|         <Route path="/profile" element={<ProfilePage user={user} />} /> | ||||
|         <Route path="/admins" element={<AdminsOverview user={user} />} /> | ||||
|       </Routes> | ||||
|     </Router> | ||||
| 
 | ||||
|     <SuccessSnackbar message={successMessage} open={openSuccess} setOpen={setOpenSuccess} /> | ||||
|     <ErrorSnackbar message={errorMessage} open={openError} setOpen={setOpenError} /> | ||||
|     </React.StrictMode> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										30
									
								
								src/client/src/LoginPage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,30 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes } from "react-router-dom"; | ||||
| import Appbar from "./components/AsuraBar"; | ||||
| import ErrorSnackbar from "./components/ErrorSnackbar"; | ||||
| 
 | ||||
| import {Button, Textfield, Stack, InputLabel, Paper, Typography} from '@mui/material'; | ||||
| 
 | ||||
| export default function LoginPage(props) { | ||||
|     if (props.user.isLoggedIn) { | ||||
|         //Redirect to the front page if the user is logged in
 | ||||
|         window.location.href = "/"; | ||||
|         return; | ||||
|     } | ||||
|      | ||||
|     return ( | ||||
|         <> | ||||
|             <Appbar user={props.user} pageTitle="Login" />  | ||||
|             <Paper sx={{width: "70vw", margin: "1.5% auto", padding: "25px"}} component={Stack} direction="column" justifyContent="center" alignItems="center"> | ||||
|                 <Stack  direction="column" paddingTop={'0.5%'} alignItems={'center'} textAlign={"center"} > | ||||
|                     <Typography variant="h4" component="h4"> | ||||
|                         You must be logged in to access administrator features. | ||||
|                     </Typography> | ||||
|                     <a href={process.env.REACT_APP_LOGIN_URL}> | ||||
|                         <img src="/btn_google_signing_dark.png" alt="Sign in with google" /> | ||||
|                     </a> | ||||
|                 </Stack> | ||||
|             </Paper>             | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										26
									
								
								src/client/src/ProfilePage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,26 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes } from "react-router-dom"; | ||||
| import Appbar from "./components/AsuraBar"; | ||||
| import LoginPage from "./LoginPage"; | ||||
| import ErrorSnackbar from "./components/ErrorSnackbar"; | ||||
| import { Button, TextField, Stack, InputLabel, Select, Container, Slider, Paper, Box, Grid, Typography } from '@mui/material'; | ||||
| 
 | ||||
| export default function ProfilePage(props) { | ||||
|     let user = props.user; | ||||
| 
 | ||||
|     if (!props.user.isLoggedIn) { return <LoginPage user={props.user} />; } | ||||
| 
 | ||||
|     return (<> | ||||
|         <Appbar user={props.user} pageTitle="Profile" /> | ||||
|             <Paper sx={{minHeight: "30vh", width: "90vw", margin: "10px auto"}} component={Stack} direction="column" justifyContent="center"> | ||||
|                 <div align="center"> | ||||
|                 <h2><b>Your profile</b></h2> | ||||
|                 <Box> | ||||
|                     <h3><b>Name: </b> {user.name}</h3> | ||||
|                     <h3><b>Email: </b> {user.email}</h3> | ||||
|                     <h3><b>Role: </b> {user.isManager ? "Manager" : "Administrator"}</h3> | ||||
|                 </Box> | ||||
|                 </div> | ||||
|             </Paper> | ||||
|     </>) | ||||
| } | ||||
							
								
								
									
										182
									
								
								src/client/src/TournamentCreator.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,182 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes } from "react-router-dom"; | ||||
| import Appbar from "./components/AsuraBar"; | ||||
| import ErrorSnackbar from "./components/ErrorSnackbar"; | ||||
| import LoginPage from "./LoginPage"; | ||||
| import { Button, TextField, Stack, InputLabel, Select, Container, Slider, Paper, Box, Grid, Typography } from '@mui/material'; | ||||
| import DateTimePicker from '@mui/lab/DateTimePicker'; | ||||
| import AdapterDateFns from '@mui/lab/AdapterDateFns'; | ||||
| import LocalizationProvider from '@mui/lab/LocalizationProvider'; | ||||
| import { setDate } from "date-fns"; | ||||
| 
 | ||||
| function postTournament(tournamentName, tournamentDescription, tournamentStartDate, tournamentEndDate, tournamentMaxTeams, tournamentPrize) { | ||||
|   if (!tournamentName || tournamentName === "") { | ||||
|     showError("Tournament name cannot be empty"); | ||||
|     return; | ||||
|   } | ||||
|   if (!tournamentDescription || tournamentDescription === "") { | ||||
|     showError("Tournament description cannot be empty"); | ||||
|     return; | ||||
|   } | ||||
|   if (!tournamentStartDate || tournamentStartDate === "" || tournamentStartDate === 0) { | ||||
|     showError("Tournament start date cannot be empty"); | ||||
|     return; | ||||
|   } | ||||
|   if (!tournamentEndDate || tournamentEndDate === "" || tournamentEndDate === 0) { | ||||
|     showError("Tournament end date cannot be empty"); | ||||
|     return; | ||||
|   } | ||||
|   if (!tournamentMaxTeams || isNaN(tournamentMaxTeams) || tournamentMaxTeams < 4) { | ||||
|     showError("Tournament max teams cannot be empty"); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   if (tournamentStartDate > tournamentEndDate) { | ||||
|     showError("Tournament start date cannot be after end date"); | ||||
|     return; | ||||
|   } | ||||
|   let today = new Date(); | ||||
|   if (tournamentStartDate < today || tournamentEndDate < today) { | ||||
|     showError("Tournament start and end date must be after today"); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   let formData = new FormData(); | ||||
|   formData.append("name", tournamentName); | ||||
|   formData.append("description", tournamentDescription); | ||||
|   formData.append("startDate", tournamentStartDate); | ||||
|   formData.append("endDate", tournamentEndDate); | ||||
|   formData.append("teamLimit", tournamentMaxTeams); | ||||
|   formData.append("prize", tournamentPrize) | ||||
|   let body = new URLSearchParams(formData); | ||||
| 
 | ||||
|   fetch(process.env.REACT_APP_API_URL + `/tournament/create`, { | ||||
|     method: "POST", | ||||
|     body: body | ||||
|   }) | ||||
|     .then(response => response.json()) | ||||
|     .then(data => { | ||||
|       if (data.status === "OK") { | ||||
|         alert("Tournament created successfully"); | ||||
|         let tournamentId = data.data.tournamentId; | ||||
|         if (tournamentId) { | ||||
|           window.location.href = "/tournament/" + tournamentId; | ||||
|         } | ||||
|       } else { | ||||
|         showError(data.data) | ||||
|       } | ||||
|     }) | ||||
|     .catch(error => showError(error)); | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| function TournamentForm(props) { | ||||
|   const [maxTeamsExponent, setMaxTeamsExponent] = React.useState(2); | ||||
|   function sliderUpdate(event) { | ||||
|     setMaxTeamsExponent(event.target.value); | ||||
|   } | ||||
| 
 | ||||
|   const [startTime, setStartTime] = React.useState(new Date()); | ||||
|   const [endTime, setEndTime] = React.useState(new Date()); | ||||
| 
 | ||||
|   function submitTournament(event) { | ||||
|     event.preventDefault(); | ||||
|     let maxTeams = Math.pow(2, maxTeamsExponent); | ||||
|     let tournamentStart = new Date(startTime.setSeconds(0, 0, 0)).valueOf() - new Date().getTimezoneOffset() * 60*1000; | ||||
|     let tournamentEnd = new Date(endTime.setSeconds(0, 0, 0)).valueOf() - new Date().getTimezoneOffset() * 60*1000; | ||||
|     postTournament( | ||||
|       document.getElementById("nameInput").value, | ||||
|       document.getElementById("descriptionInput").value, | ||||
|       tournamentStart, | ||||
|       tournamentEnd, | ||||
|       maxTeams, | ||||
|       document.getElementById("prizeInput").value | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   const marks = [ | ||||
|     {  value: 2,  label: "4",}, | ||||
|     {  value: 3,  label: "8",}, | ||||
|     {  value: 4,  label: "16",}, | ||||
|     {  value: 5,  label: "32",}, | ||||
|     {  value: 6,  label: "64",} | ||||
|   ]; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|     <form> | ||||
|     <Stack sx={{minHeight: "30vh", margin: "10px auto"}} direction="column" justifyContent="center" spacing={3} align="center"> | ||||
|         {/* <InputLabel htmlFor="nameInput">Tournament Name: </InputLabel> */} | ||||
|         <TextField type="text" id="nameInput" label="Tournament Name" placeholder="Tournament Name" InputLabelProps={{shrink: true}}/> | ||||
|         {/* <InputLabel htmlFor="descriptionInput">Description: </InputLabel */} | ||||
|         <TextField type="text" multiline={true} id="descriptionInput" label="Description" placeholder="Description" InputLabelProps={{shrink: true}}/>         | ||||
|         <TextField type="text" id="prizeInput" label="Prize" placeholder="Prize" InputLabelProps={{shrink: true}}/>      | ||||
|         <Box flexGrow={1}> | ||||
|         <Grid container spacing={2} justifyContent="center"> | ||||
|           <Grid item xs={6}> | ||||
|           <LocalizationProvider dateAdapter={AdapterDateFns}> | ||||
|             <DateTimePicker label={"Start Time"} inputVariant="outlined" ampm={false} mask="____-__-__ __:__" format="yyyy-MM-dd HH:mm" inputFormat="yyyy-MM-dd HH:mm" value={startTime} | ||||
|               onChange={setStartTime} | ||||
|               renderInput={(params) => <TextField id="startDatePicker" {...params} sx={{margin: "0 2.5%"}} />} | ||||
|             /> | ||||
|             </LocalizationProvider> | ||||
|           </Grid> | ||||
|           <Grid item xs={6}> | ||||
|           <LocalizationProvider dateAdapter={AdapterDateFns}> | ||||
|             <DateTimePicker label={"End Time"} inputVariant="outlined" ampm={false} mask="____-__-__ __:__" format="yyyy-MM-dd HH:mm" inputFormat="yyyy-MM-dd HH:mm" value={endTime} | ||||
|               onChange={setEndTime} | ||||
|               renderInput={(params) => <TextField id="endDatePicker" {...params} sx={{margin: "0 2.5%"}} />} | ||||
|             /> | ||||
|           </LocalizationProvider> | ||||
|           </Grid> | ||||
|           {/* <TextField type="datetime-local" id="startDatePicker" label="Start Time" InputLabelProps={{shrink: true}} sx={{width: "48%", marginRight: "2%"}} /> | ||||
|           <TextField type="datetime-local" id="endDatePicker" label="End Time" InputLabelProps={{shrink: true}} sx={{width: "48%", marginLeft: "2%"}} /> */} | ||||
|         </Grid> | ||||
|         </Box> | ||||
|         <InputLabel id="max-teams-label">Maximum number of teams</InputLabel> | ||||
|          | ||||
|         <Box sx={{flexGrow: 1}}> | ||||
|           <Grid container spacing={2} justifyContent="center"> | ||||
|             <Grid item xs={8}> | ||||
|               <Container> | ||||
|                 <Slider aria-label="Teams" valueLabelDisplay="off" step={1} marks={marks} min={2} max={6} onChange={sliderUpdate} id="max-teams-slider" name="max-teams-slider" > | ||||
|                 </Slider> | ||||
|               </Container> | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|         </Box> | ||||
| 
 | ||||
|         {/* go brrrr */} | ||||
|         <br /><br /> | ||||
|          | ||||
|         <Button type="submit" variant="contained" onClick={submitTournament} color="primary">Create Tournament!</Button> | ||||
|       </Stack> | ||||
|       </form> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| let showError = (message) => {}; | ||||
| 
 | ||||
| export default function TournamentCreator(props) { | ||||
|   const [openError, setOpenError] = React.useState(false); | ||||
|   const [errorMessage, setErrorMessage] = React.useState(""); | ||||
|   showError = (message) => { | ||||
|     setOpenError(false); | ||||
|     setErrorMessage(message); | ||||
|     setOpenError(true); | ||||
|   } | ||||
| 
 | ||||
|   if (!props.user.isLoggedIn) { return <LoginPage user={props.user} />; } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Appbar user={props.user} pageTitle="New tournament" />  | ||||
|       <Paper sx={{minHeight: "30vh", width: "90vw", margin: "20px auto", padding: "20px 20px"}} component={Container} direction="column" align="center"> | ||||
|       <TournamentForm /> | ||||
|       </Paper> | ||||
|        | ||||
|       <ErrorSnackbar message={errorMessage} open={openError} setOpen={setOpenError} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										160
									
								
								src/client/src/TournamentHistory.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,160 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes } from "react-router-dom"; | ||||
| import { Button, Container, Typography, Box, Stack, Card, CardContent, CardMedia, Paper, Grid, Icon, TextField } from "@mui/material"; | ||||
| import AddCircleIcon from '@mui/icons-material/AddCircle'; | ||||
| import KeyboardDoubleArrowDownIcon from '@mui/icons-material/KeyboardDoubleArrowDown'; | ||||
| import KeyboardDoubleArrowUpIcon from '@mui/icons-material/KeyboardDoubleArrowUp'; | ||||
| import Appbar from './components/AsuraBar'; | ||||
| import LoginPage from './LoginPage'; | ||||
| import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; | ||||
| 
 | ||||
| 
 | ||||
| function shorten(description, maxLength) { | ||||
|     if (description.length > maxLength) { | ||||
|       return description.substring(0, maxLength) + "..."; | ||||
|     } | ||||
|     return description; | ||||
|   } | ||||
|    | ||||
|   function TournamentListItem(props) { | ||||
|     const [longDescription, setLongDescription] = React.useState(false); | ||||
|     const maxLength = 200; | ||||
|     function toggleDescription() { | ||||
|       setLongDescription(!longDescription); | ||||
|     } | ||||
|     function Description() { | ||||
|       if (longDescription) { | ||||
|         return( <Box component={Stack} direction="row"> | ||||
|           <Typography variant="body1" onClick={toggleDescription}>{props.tournament.description}</Typography> | ||||
|           <KeyboardDoubleArrowUpIcon onClick={toggleDescription} /> | ||||
|         </Box> )  | ||||
|     } else if (props.tournament.description.length < maxLength) {  | ||||
|       return <Typography variant="body1" color="text.secondary" onClick={toggleDescription}>{props.tournament.description}</Typography> | ||||
|     } else { | ||||
|         return <Box component={Stack} direction="row"> | ||||
|           <Typography variant="body1" color="text.secondary" onClick={toggleDescription}>{shorten(props.tournament.description, maxLength)}</Typography> | ||||
|           <KeyboardDoubleArrowDownIcon onClick={toggleDescription} /> | ||||
|         </Box>; | ||||
|       } | ||||
|     } | ||||
|     return ( | ||||
|           <Paper elevation={8} > | ||||
|             <Card> | ||||
|               <CardMedia  | ||||
|                 component="img" | ||||
|                 alt="tournament image" | ||||
|                 height="140" | ||||
|                 image="banner2.png" | ||||
|               /> | ||||
|               <CardContent align="left"> | ||||
|                 <Typography variant="h3" component="div" align="center">{props.tournament.name} </Typography> | ||||
|                  | ||||
|                 <Box component={Stack} direction="column"> | ||||
|                   <Typography variant="body"> Start: {props.tournament.startTime.toLocaleString()} </Typography> | ||||
|                   <Typography variant="body"> End: {props.tournament.endTime.toLocaleString()} </Typography> | ||||
|                 </Box> | ||||
|                  | ||||
|                 <Typography variant="h5" color="text.primary" gutterBottom> Participants: {props.tournament.teamCount} / {props.tournament.teamLimit} </Typography> | ||||
|                 <Description /> | ||||
|                 <Typography variant="body" color="text.primary"><EmojiEventsIcon alt="A trohpy" color="gold" align="vertical-center"/>  Prize: {props.tournament.prize} </Typography> | ||||
| 
 | ||||
|                 <Box sx={{flexGrow: 1, marginTop: "20px"}}> | ||||
|                   <Grid container spacing={4} justifyContent="center" wrap="wrap"> | ||||
|                       <Grid item > | ||||
|                       <Link to={`/tournament/${props.tournament.id}`} > | ||||
|                         <Button variant="contained" color="success"> | ||||
|                           View Tournament | ||||
|                         </Button> | ||||
|                       </Link> | ||||
|                       </Grid> | ||||
|                   </Grid> | ||||
|                 </Box> | ||||
|               </CardContent> | ||||
|             </Card>      | ||||
|           </Paper> | ||||
|     ); | ||||
|   } | ||||
|    | ||||
|   function TournamentList() { | ||||
|     let [tournamentList, setTournamentList] = React.useState([]); | ||||
|     let [originalList, setOriginalList] = React.useState([]) | ||||
|    | ||||
|     React.useEffect(() => { | ||||
|       fetch(process.env.REACT_APP_API_URL + `/tournament/getTournaments`) | ||||
|         .then(res => res.json()) | ||||
|         .then(data => { | ||||
|           if (data.status !== "OK") { | ||||
|             console.error(data); | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           let tournamenthistory = [] | ||||
|           let today = new Date() | ||||
|           let tournaments = Object.values(data.data); | ||||
|           for (let i = 0; i < tournaments.length; i++) { | ||||
|             tournaments[i].startTime = new Date(tournaments[i].startTime); | ||||
|             tournaments[i].endTime = new Date(tournaments[i].endTime); | ||||
|             if(today - tournaments[i].endTime >= 2*60*60*1000) { | ||||
|                 tournamenthistory.push(tournaments[i]) | ||||
|             } | ||||
|           } | ||||
| 
 | ||||
|           setTournamentList(tournamenthistory); | ||||
|           setOriginalList(tournamenthistory) | ||||
|         }) | ||||
|         .catch((err) => console.log(err.message)); | ||||
|     }, []); | ||||
| 
 | ||||
|   function search() { | ||||
|     let searchBase = [] | ||||
|     let searchResult = [] | ||||
|     originalList.map((tournament) => searchBase.push(tournament.name)) | ||||
|     let input = document.getElementById("searchInput") | ||||
|     let inputUpperCase = input.value.toUpperCase() | ||||
|     for (let i = 0; i < searchBase.length; i++) { | ||||
|       let tournamentName = searchBase[i].toUpperCase() | ||||
|       if(tournamentName.indexOf(inputUpperCase) >= 0) { | ||||
|         searchResult.push(tournamentName) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     let searchedList = [] | ||||
|     for (let i = 0; i < originalList.length; i++) { | ||||
|       let name = originalList[i].name | ||||
|       for (let j = 0; j < searchResult.length; j++) { | ||||
|         if (name.toUpperCase() == searchResult[j]) { | ||||
|           searchedList.push(originalList[i]) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (input.value == "") { | ||||
|       setTournamentList(originalList) | ||||
|     } else { | ||||
|       setTournamentList(searchedList) | ||||
|     } | ||||
|   } | ||||
|    | ||||
|     return <> | ||||
|     <TextField type="text" id="searchInput" label="Search" placeholder="Tournament Name" InputLabelProps={{shrink: true}} onChange={search}/> | ||||
|     <Stack spacing={3} sx={{margin: "10px auto"}}> | ||||
|       {tournamentList && tournamentList.map((tournamentObject) => <TournamentListItem key={tournamentObject.id.toString()} tournament={tournamentObject} />)} | ||||
|     </Stack> | ||||
|        | ||||
|     </>; | ||||
|   } | ||||
| 
 | ||||
| export default function TournamentHistory(props) { | ||||
|   if (!props.user.isLoggedIn) { return <LoginPage user={props.user} />; } | ||||
|   return ( | ||||
|       <> | ||||
|         <Appbar user={props.user} pageTitle="Tournament History" /> | ||||
|           <Container sx={{minHeight: "30vh", width: "90vw", padding: "20px 20px"}} component={Container} direction="column" align="center"> | ||||
|             <Box component={Stack} direction="row" align="center" justifyContent="center" alignItems="center" sx={{flexGrow: 1, margin:'2.5% 0'}}> | ||||
|               <Typography sx={{fontSize:['1.5rem','2rem','2rem']}}>Past Tournaments</Typography> | ||||
|             </Box> | ||||
|             <TournamentList /> | ||||
|           </Container> | ||||
|       </> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										253
									
								
								src/client/src/TournamentManager.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,253 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes } from "react-router-dom"; | ||||
| // import { AlertContainer, alert } from "react-custom-alert";
 | ||||
| import Appbar from "./components/AsuraBar"; | ||||
| import TournamentBar from "./components/TournamentBar"; | ||||
| import LoginPage from "./LoginPage"; | ||||
| import { useParams } from "react-router-dom"; | ||||
| import { Button, TextField, Grid, Box, Container, Paper, Stack } from "@mui/material"; | ||||
| import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; | ||||
| import CloseIcon from '@mui/icons-material/Close'; | ||||
| import DeleteIcon from '@mui/icons-material/Delete'; | ||||
| import DateTimePicker from '@mui/lab/DateTimePicker'; | ||||
| import AdapterDateFns from '@mui/lab/AdapterDateFns'; | ||||
| import LocalizationProvider from '@mui/lab/LocalizationProvider'; | ||||
| import PropTypes from 'prop-types' | ||||
| 
 | ||||
| let submitChanges = curryTournamentId => event => { | ||||
|   event.preventDefault(); | ||||
|   let tournamentId = curryTournamentId; | ||||
|   //TODO: use refs to get values
 | ||||
|   let tournamentName = document.getElementById("editName").value; | ||||
|   let tournamentDescription = document.getElementById("editDesc").value; | ||||
|   // let tournamentImageFile = document.getElementById("editImage").files[0];
 | ||||
|   let tournamentStartDate = document.getElementById("editStartDate").value; | ||||
|   let tournamentEndDate = document.getElementById("editEndDate").value; | ||||
|   let tournamentPrize = document.getElementById("editPrize").value | ||||
| 
 | ||||
|   if (!tournamentName || tournamentName === "") { | ||||
|     showError("Tournament name cannot be empty"); | ||||
|     return; | ||||
|   } | ||||
|   if (!tournamentDescription || tournamentDescription === "") { | ||||
|     showError("Tournament description cannot be empty"); | ||||
|     return; | ||||
|   } | ||||
|   if (!tournamentStartDate || tournamentStartDate === "") { | ||||
|     showError("Tournament start date cannot be empty"); | ||||
|     return; | ||||
|   } | ||||
|   if (!tournamentEndDate || tournamentEndDate === "") { | ||||
|     showError("Tournament end date cannot be empty"); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   if (tournamentStartDate > tournamentEndDate) { | ||||
|     showError("Tournament start date cannot be after end date"); | ||||
|     return; | ||||
|   } | ||||
|   let today = new Date(); | ||||
|   if (tournamentStartDate < today || tournamentEndDate < today) { | ||||
|     showError("Tournament start and end date must be after today"); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   tournamentStartDate = new Date(tournamentStartDate).valueOf() - new Date().getTimezoneOffset() * 60 * 1000; | ||||
|   tournamentEndDate = new Date(tournamentEndDate).valueOf() - new Date().getTimezoneOffset() * 60 * 1000; | ||||
| 
 | ||||
| 
 | ||||
|   let formData = new FormData(); | ||||
|   formData.append("name", tournamentName); | ||||
|   formData.append("description", tournamentDescription); | ||||
|   formData.append("startDate", tournamentStartDate); | ||||
|   formData.append("endDate", tournamentEndDate); | ||||
|   // formData.append("teamLimit", tournamentMaxTeams);
 | ||||
|   formData.append("prize", tournamentPrize) | ||||
|   let body = new URLSearchParams(formData); | ||||
| 
 | ||||
|   fetch(process.env.REACT_APP_API_URL + `/tournament/${tournamentId}/edit`, { | ||||
|     method: "POST", | ||||
|     body: body, | ||||
|   }) | ||||
|     .then((response) => response.json()) | ||||
|     .then((data) => { | ||||
|       if (data.status === "OK") { | ||||
|         showSuccess("Tournament Changed successfully"); | ||||
|         window.location.href = `/tournament/${tournamentId}`; | ||||
|       } else { | ||||
|         showError(data.data); | ||||
|       } | ||||
|     }) | ||||
|     .catch((error) => showError(error)); | ||||
| } | ||||
| 
 | ||||
| let deleteTournament = tournamentId => event => { | ||||
|   console.log(tournamentId); | ||||
|   event.preventDefault(); | ||||
| 
 | ||||
|   fetch(process.env.REACT_APP_API_URL + `/tournament/${tournamentId}`, { | ||||
|     method: "DELETE", | ||||
|   }) | ||||
|     .then((response) => response.json()) | ||||
|     .then((data) => { | ||||
|       if (data.status === "OK") { | ||||
|         showSuccess("Tournament Deleted successfully"); | ||||
|         window.location.href = "/"; | ||||
|       } else { | ||||
|         showError(data.data); | ||||
|       } | ||||
|     }) | ||||
|     .catch((error) => showError(error)); | ||||
| } | ||||
| 
 | ||||
| function ManageTournament(props) { | ||||
| 
 | ||||
|   const [startTime, setStartTime] = React.useState(new Date()); | ||||
|   const [endTime, setEndTime] = React.useState(new Date()); | ||||
|    | ||||
|   React.useEffect(() => { | ||||
|     fetch( | ||||
|       process.env.REACT_APP_API_URL + `/tournament/${props.tournamentId}` | ||||
|     ) | ||||
|       .then((res) => res.json()) | ||||
|       .then((data) => { | ||||
|         if (data.status !== "OK") { | ||||
|           showError(data.data); | ||||
|         } | ||||
|          | ||||
|         document.getElementById("editName").value = data.data.name; | ||||
|         document.getElementById("editDesc").value = data.data.description; | ||||
|         document.getElementById("editPrize").value = data.data.prize | ||||
|         // Get the time from the server, add the local timezone offset and set the input fields
 | ||||
|         let startDate = new Date(data.data.startTime.slice(0, 16)); | ||||
|         let endDate = new Date(data.data.endTime.slice(0, 16)); | ||||
|         let localTimeOffset = new Date().getTimezoneOffset() * 60*1000; // Minutes -> Milliseconds
 | ||||
|         startDate = new Date(startDate.getTime() - localTimeOffset); | ||||
|         endDate = new Date(endDate.getTime() - localTimeOffset); | ||||
| 
 | ||||
|         setStartTime(startDate); | ||||
|         setEndTime(endDate); | ||||
|       }) | ||||
|       .catch((err) => showError(err)); | ||||
|   }, [props.tournamentId]); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|     <form> | ||||
|     <Stack sx={{minHeight: "30vh", margin: "1.5%"}} direction="column" justifyContent="center" spacing={2} align="center"> | ||||
|           <TextField type="text" id="editName" label="Edit Name:" placeholder="Edit Name" InputLabelProps={{shrink: true}}/> | ||||
|           <TextField type="text" multiline={true} id="editDesc" label="Edit Description:" placeholder="Edit Description" InputLabelProps={{shrink: true}} /> | ||||
|           <TextField type="text" id="editPrize" label="Edit Prize:" placeholder="Edit Prize" InputLabelProps={{shrink: true}}/> | ||||
|           <Box sx={{flexGrow: 1}}> | ||||
|           <Grid container spacing={2} justifyContent="center"> | ||||
|             <Grid item xs={6}> | ||||
|               <LocalizationProvider dateAdapter={AdapterDateFns}> | ||||
|                 <DateTimePicker label={"Start Time"} inputVariant="outlined" ampm={false} mask="____-__-__ __:__" format="yyyy-MM-dd HH:mm" inputFormat="yyyy-MM-dd HH:mm" value={startTime} | ||||
|                   onChange={setStartTime} | ||||
|                   renderInput={(params) => <TextField id="editStartDate" {...params} />} | ||||
|                 /> | ||||
|               </LocalizationProvider> | ||||
|               </Grid> | ||||
|               <Grid item xs={6}> | ||||
|               <LocalizationProvider dateAdapter={AdapterDateFns}> | ||||
|                 <DateTimePicker label={"End Time"} inputVariant="outlined" ampm={false} mask="____-__-__ __:__" format="yyyy-MM-dd HH:mm:" inputFormat="yyyy-MM-dd HH:mm" value={endTime}              | ||||
|                   onChange={setEndTime} | ||||
|                   renderInput={(params) => <TextField id="editEndDate" {...params} />} | ||||
|                 /> | ||||
|               </LocalizationProvider> | ||||
|               </Grid> | ||||
|             </Grid> | ||||
|         </Box> | ||||
|           <Button type="submit" variant="contained" onClick={submitChanges(props.tournamentId)} color="primary" > | ||||
|             Save Tournament Details | ||||
|           </Button>    | ||||
|        | ||||
|       </Stack> | ||||
|     </form> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function ConfirmationDialogRaw(props) { | ||||
|   const { tournamentId } = useParams(); | ||||
|   const { onClose, value: valueProp, open, ...other } = props; | ||||
|   const [value, setValue] = React.useState(valueProp); | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     if (!open) { | ||||
|       setValue(valueProp); | ||||
|     } | ||||
|   }, [valueProp, open]); | ||||
| 
 | ||||
|   const handleCancel = () => { | ||||
|     onClose(); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <Dialog | ||||
|       sx={{ '& .MuiDialog-paper': { width: '80%', maxHeight: 435 } }} | ||||
|       maxWidth="xs" | ||||
|       open={open} | ||||
|       {...other} | ||||
|     > | ||||
|       <DialogTitle>Delete tournament?</DialogTitle> | ||||
|       <DialogContent> | ||||
|         Are you sure you want to delete the tournament? This action is not reversible! | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button autoFocus onClick={handleCancel}> | ||||
|           Cancel | ||||
|         </Button> | ||||
|         <Button onClick={deleteTournament(tournamentId)}>Confirm</Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| ConfirmationDialogRaw.propTypes = { | ||||
|   onClose: PropTypes.func.isRequired, | ||||
|   open: PropTypes.bool.isRequired, | ||||
| }; | ||||
| 
 | ||||
| let showError = (message) => {}; | ||||
| let showSuccess = (message) => {}; | ||||
| 
 | ||||
| export default function TournamentManager(props) { | ||||
|   const { tournamentId } = useParams(); | ||||
| 
 | ||||
|   const [dialogOpen, setDialogOpen] = React.useState(false); | ||||
|   const handleDialogClickListItem = () => { setDialogOpen(true); }; | ||||
|   const handleDialogClose = () => { setDialogOpen(false); }; | ||||
| 
 | ||||
|   const [openError, setOpenError] = React.useState(false); | ||||
|   const [errorMessage, setErrorMessage] = React.useState(""); | ||||
|   showError = (message) => { | ||||
|     setOpenError(false); | ||||
|     setErrorMessage(message); | ||||
|     setOpenError(true); | ||||
|   } | ||||
| 
 | ||||
|   if (!props.user.isLoggedIn) { return <LoginPage user={props.user} />; } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|     <Appbar user={props.user} pageTitle="Edit Tournament" /> | ||||
|     <TournamentBar pageTitle="Edit Tournament"/> | ||||
|     <Paper sx={{minHeight: "30vh", width: "90vw", margin: "20px auto", padding: "20px 0"}} component={Container} direction="column" align="center"> | ||||
|       <ManageTournament tournamentId={tournamentId} /> | ||||
|       {/* <AnnounceButton /> */} | ||||
|       <Box sx={{width: "100%"}}> | ||||
|         <Button variant="contained" color="error" onClick={handleDialogClickListItem} sx={{margin: "auto 5px"}} endIcon={<DeleteIcon />}> | ||||
|           Delete Tournament | ||||
|         </Button> | ||||
|         <ConfirmationDialogRaw | ||||
|           id="confirmation-dialog" | ||||
|           keepMounted | ||||
|           open={dialogOpen} | ||||
|           onClose={handleDialogClose} | ||||
|         /> | ||||
|       </Box> | ||||
|     </Paper> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										284
									
								
								src/client/src/TournamentOverview.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,284 @@ | ||||
| import * as React from "react"; | ||||
| import { Link } from "react-router-dom"; | ||||
| import Appbar from './components/AsuraBar'; | ||||
| import TournamentBar from "./components/TournamentBar"; | ||||
| import ErrorSnackbar from "./components/ErrorSnackbar"; | ||||
| import { useParams } from 'react-router-dom' | ||||
| import { Button, IconButton, Paper, Stack, CircularProgress, Box, Grid, Typography, Container } from "@mui/material"; | ||||
| import "./components/tournamentBracket.css"; | ||||
| import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; | ||||
| import DoDisturbIcon from '@mui/icons-material/DoDisturb'; | ||||
| import BackspaceIcon from '@mui/icons-material/Backspace'; | ||||
| import AddCircleIcon from '@mui/icons-material/AddCircle'; | ||||
| import { fontSize } from "@mui/system"; | ||||
| 
 | ||||
| function TournamentTier(props){ | ||||
|   let roundTypes = ["finals", "semifinals", "quarterfinals", "eighthfinals", "sixteenthfinals", "thirtysecondfinals"]; | ||||
|     let matches = []; | ||||
|       for (let i = 0; i < props.matches.length; i++) { | ||||
|         matches.push(<Match tournament={props.tournament} user={props.user} tier={props.tier} roundTypes={roundTypes} teams={props.teams} match={props.matches[i]} key={i} onwinnerchange={props.onwinnerchange} />); | ||||
|       } | ||||
|       return( | ||||
|         <> | ||||
|         <Box component='ul' className={`round ${roundTypes[props.tier]}`} sx={{width:['125px','200px','250px','300px','350px']}}> | ||||
|           <Box component='li' className="spacer"> </Box> | ||||
|           {matches} | ||||
|         </Box> | ||||
|         </> | ||||
|       ) | ||||
|     } | ||||
| 
 | ||||
| function Match(props){ | ||||
|   let team1Name = "TBA"; | ||||
|   let team2Name = "TBA"; | ||||
|   if(props.match.team1Id !== null) { | ||||
|     team1Name = props.teams.find(team => team.id === props.match.team1Id).name; | ||||
|   } | ||||
|   if(props.match.team2Id !== null) { | ||||
|     team2Name = props.teams.find(team => team.id === props.match.team2Id).name; | ||||
|   } | ||||
| 
 | ||||
|   let setWinner = curryTeamId => event => { | ||||
|     let teamId = curryTeamId; | ||||
|     if (!teamId || teamId == null) { | ||||
|       showError("No team selected"); | ||||
|       return; | ||||
|     } | ||||
|     let formData = new FormData(); | ||||
|     formData.append("winnerId",teamId); | ||||
|     let body = new URLSearchParams(formData); | ||||
|     fetch(process.env.REACT_APP_API_URL + `/match/${props.match.id}/setWinner`, { | ||||
|       method: "POST", | ||||
|       body: body | ||||
|     }) | ||||
|       .then(response => response.json()) | ||||
|       .then(data => { | ||||
|         if (data.status === "OK") { | ||||
|           //Refresh when winner is set successfully
 | ||||
|           props.onwinnerchange(); | ||||
|         } else { | ||||
|           showError(data.data) | ||||
|         } | ||||
|       }) | ||||
|       .catch(error => showError(error)); | ||||
|   }; | ||||
| 
 | ||||
|   let curryUnsetContestant = teamId => (e) => { | ||||
|     let formData = new FormData(); | ||||
|     formData.append("teamId", teamId); | ||||
|     let body = new URLSearchParams(formData); | ||||
|     fetch(process.env.REACT_APP_API_URL + `/match/${props.match.id}/unsetContestant`, { | ||||
|       method: "POST",  | ||||
|       body: body | ||||
|     }) | ||||
|       .then(response => response.json()) | ||||
|       .then(data => { | ||||
|         if (data.status === "OK") { | ||||
|           props.onwinnerchange() | ||||
|         } else { | ||||
|           showError(data.data); | ||||
|         } | ||||
|       }) | ||||
|       .catch(error => showError(error)); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|         {/* Team 1 (Winner-status?) (Team name) */} | ||||
|         <Box component='li' className={`game game-top`}> | ||||
|           <Stack direction={"row"} alignItems="center" spacing={1} sx={{justifyContent:['start','space-between']}}> | ||||
|               <Typography noWrap className={`${props.match.winnerId !== null ? (props.match.team1Id === props.match.winnerId) ? "winner"  : "loser" : ""}`} align={'center'} sx={{ maxWidth:'70%', overflow:'hidden', wordWrap:'none', fontSize:['1em','1em','1.5em','1.75em']}}> | ||||
|                 {team1Name} | ||||
|               </Typography> | ||||
|               { props.match.winnerId && (props.match.team1Id === props.match.winnerId) && | ||||
|               <EmojiEventsIcon alt="A trohpy" sx={{width:['0.75em','1em','1.25em'], height:['0.75em','1em','1.25em']}} /> | ||||
|               } | ||||
|               <Box component={Stack} direction={'row'} spacing={-1.25}> | ||||
|               { props.match.team1Id !== null && !props.tournament.hasEnded && props.match.tier !== Math.log2(props.tournament.teamLimit) - 1 && props.match.winnerId === null && props.user.isLoggedIn && | ||||
|                 <IconButton color="error" aria-label="remove winner" component="span" onClick={curryUnsetContestant(props.match.team1Id)}><BackspaceIcon sx={{width:['0.75em','1em','1.25em'], height:['0.75em','1em','1.25em']}} /></IconButton> | ||||
|               } | ||||
|               { props.match.team1Id !== null && props.match.winnerId === null && !props.tournament.hasEnded && props.user.isLoggedIn && | ||||
|               <IconButton onClick={setWinner(props.match.team1Id)} color="success" aria-label="select winner" component="span"><AddCircleIcon sx={{width:['0.75em','1em','1.25em'], height:['0.75em','1em','1.25em']}} /></IconButton> | ||||
|               } | ||||
|               </Box> | ||||
|           </Stack> | ||||
|         </Box> | ||||
|         <Box component='li' className="game game-spacer"> </Box> | ||||
|         {/* Team 2 (Winner-status?) (Team name) */} | ||||
|         <Box component='li' className={`game game-bottom`}> | ||||
|         <Stack direction={"row"} alignItems="center" sx={{justifyContent:['start','space-between']}}> | ||||
|               <Typography noWrap className={`${props.match.winnerId !== null ? (props.match.team2Id === props.match.winnerId) ? "winner" : "loser" : ""}`} sx={{maxWidth:'70%', overflow:'hidden', wordWrap:'none',fontSize:['1em','1em','1.5em','1.75em']}}> | ||||
|                 {team2Name} | ||||
|               </Typography> | ||||
|               { props.match.winnerId && (props.match.team2Id === props.match.winnerId) && | ||||
|               <EmojiEventsIcon alt="A trohpy" sx={{width:['0.75em','1em','1.25em'], height:['0.75em','1em','1.25em']}} /> | ||||
|               } | ||||
|               { props.match.team2Id !== null && !props.tournament.hasEnded && props.match.tier !== Math.log2(props.tournament.teamLimit) - 1 && props.match.winnerId === null && props.user.isLoggedIn && | ||||
|               <IconButton color="error" aria-label="remove winner" component="span" onClick={curryUnsetContestant(props.match.team2Id)}><BackspaceIcon sx={{width:['0.75em','1em','1.25em'], height:['0.75em','1em','1.25em']}} /></IconButton> | ||||
|               } | ||||
|               { props.match.team2Id !== null && props.match.winnerId === null && !props.tournament.hasEnded && props.user.isLoggedIn && | ||||
|               <IconButton onClick={setWinner(props.match.team2Id)} color="success" aria-label="select winner" component="span" ><AddCircleIcon sx={{width:['0.75em','1em','1.25em'], height:['0.75em','1em','1.25em']}} /></IconButton> | ||||
|               } | ||||
|             </Stack> | ||||
|         </Box> | ||||
|         <Box component='li' className="spacer"> </Box> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function WinnerDisplay(props) { | ||||
|   let unsetWinner = event => { | ||||
|     let formData = new FormData(); | ||||
|     formData.append("winnerId","null"); | ||||
|     let body = new URLSearchParams(formData); | ||||
|     fetch(process.env.REACT_APP_API_URL + `/match/${props.finalMatch.id}/setWinner`, { | ||||
|       method: "POST", | ||||
|       body: body | ||||
|     }) | ||||
|       .then(response => response.json()) | ||||
|       .then(data => { | ||||
|         if (data.status !== "OK") { showError(data.data); return;} | ||||
|         props.onwinnerchange(); | ||||
|       }) | ||||
|       .catch(error => showError(error)); | ||||
|   }; | ||||
|            | ||||
| 
 | ||||
| 
 | ||||
|   if (!props.team) { | ||||
|     // Winner is not yet chosen
 | ||||
|     return <div className="winnerDisplay"> | ||||
|       <Typography sx={{fontSize:['1em','1em','1.5em','2em']}}> | ||||
|         Winner is not chosen.<br /> Will it be you? | ||||
|       </Typography> | ||||
|     </div>; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="winnerDisplay"> | ||||
|       <Typography align="center"> | ||||
|         {props.user.isLoggedIn && !props.tournament.hasEnded && <IconButton color="error" aria-label="remove winner" component="span" onClick={unsetWinner}><BackspaceIcon /></IconButton>} | ||||
|       </Typography> | ||||
|       <Typography sx={{fontSize:['1em','1em','1.5em','2em']}} className="winner"> | ||||
|         {props.team.name} | ||||
|       </Typography> | ||||
|       <EmojiEventsIcon alt="A trohpy" /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function BracketViewer(props){ | ||||
|    | ||||
|   const [matches, setMatches] = React.useState(null); | ||||
|   const [teams, setTeams] = React.useState(null); | ||||
| 
 | ||||
|   let getMatches = () => { | ||||
|     fetch(process.env.REACT_APP_API_URL + `/tournament/${props.tournamentId}/getMatches`) | ||||
|       .then(res => res.json()) | ||||
|       .then(data => { | ||||
|         if (data.status !== "OK") { | ||||
|           // Do your error thing
 | ||||
|           console.error(data); | ||||
|           return; | ||||
|         } | ||||
|         let allMatches = data.data; | ||||
|         // Group all matches by their round/tier
 | ||||
|         let tiers = allMatches.reduce((tiers, match) => { | ||||
|           if (!tiers[match.tier]) { | ||||
|             tiers[match.tier] = []; | ||||
|           } | ||||
|           tiers[match.tier].push(match); | ||||
|           return tiers; | ||||
|         }, {}); | ||||
| 
 | ||||
|         tiers = Object.values(tiers); | ||||
|         tiers = tiers.reverse(); | ||||
|         setMatches(tiers); | ||||
|       }) | ||||
|       .catch(err => showError(err)); | ||||
| 
 | ||||
|     fetch(process.env.REACT_APP_API_URL + `/tournament/${props.tournamentId}/getTeams`) | ||||
|       .then(res => res.json()) | ||||
|       .then(data=>{ | ||||
|         if(data.status !== "OK"){ | ||||
|           console.error(data) | ||||
|           return; | ||||
|         } | ||||
|         let teams = data.data; | ||||
|         setTeams(teams); | ||||
|       }) | ||||
|       .catch(err => showError(err)); | ||||
|   } | ||||
|   React.useEffect(() => { | ||||
|     getMatches(); | ||||
|   }, []); | ||||
| 
 | ||||
|   let getFinalMatch = (tierMatches) => { | ||||
|     let finalMatch = tierMatches[tierMatches.length - 1][0]; | ||||
|     return finalMatch; | ||||
|   }; | ||||
|   let getWinnerTeam = (tierMatches) => { | ||||
|     let finalMatch = getFinalMatch(tierMatches); | ||||
|     if (finalMatch.winnerId === null) { return null;} | ||||
|     let winnerTeam = teams.find(team => team.id === finalMatch.winnerId); | ||||
|     return winnerTeam; | ||||
|   }; | ||||
|    | ||||
|   return ( | ||||
|      | ||||
|       (props.tournament && matches && teams) ? | ||||
|         // <div sx={{width: "100vw", height: "80vh", overflow: "scroll"}} className="bracket">
 | ||||
|         <> | ||||
|         <div className="bracket"> | ||||
|         {matches.map(tierMatches => { | ||||
|             let tierNum = tierMatches[0].tier; | ||||
|             return <TournamentTier user={props.user} tournament={props.tournament} key={tierNum} tier={tierNum} matches={tierMatches} teams={teams} onwinnerchange={getMatches} /> | ||||
|           })} | ||||
|          | ||||
|         <WinnerDisplay team={getWinnerTeam(matches)} user={props.user} finalMatch={getFinalMatch(matches)} onwinnerchange={getMatches} tournament={props.tournament} /> | ||||
|         </div> | ||||
|        </> | ||||
|       : <Box sx={{display:'flex', justifyContent:'center', alignItems:'center', position:'relative', marginTop:'5%'}}><CircularProgress size={"20vw"}/></Box>    | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| let showError = (message) => {}; | ||||
| export default function TournamentOverview(props) { | ||||
|   const { tournamentId } = useParams(); | ||||
|   const [tournament, setTournament] = React.useState(false); | ||||
| 
 | ||||
|   const [openError, setOpenError] = React.useState(false); | ||||
|   const [errorMessage, setErrorMessage] = React.useState(""); | ||||
|   showError = (message) => { | ||||
|     setOpenError(false); | ||||
|     setErrorMessage(message); | ||||
|     setOpenError(true); | ||||
|   } | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     fetch(process.env.REACT_APP_API_URL + `/tournament/${tournamentId}`) | ||||
|       .then(res => res.json()) | ||||
|       .then(data => { | ||||
|         if (data.status !== "OK") { | ||||
|           showError(data.data); | ||||
|           return; | ||||
|         } | ||||
|         let tourn = data.data; | ||||
|         let now = new Date(); | ||||
|         let endTime = new Date(tourn.endTime); | ||||
|         tourn.hasEnded = (now - 2*60*60*1000) > endTime; // 2 hours in the past
 | ||||
|         setTournament(tourn); | ||||
|       }) | ||||
|       .catch(err => showError(err)); | ||||
|   }, [tournamentId]); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Appbar user={props.user} pageTitle={tournament.name} /> | ||||
|       { props.user.isLoggedIn && !tournament.hasEnded &&  | ||||
|         <TournamentBar tournamentId={tournamentId} viewTournament={true} /> | ||||
|       } | ||||
|       <BracketViewer tournament={tournament} user={props.user} tournamentId={tournamentId} className="bracketViewer" /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										227
									
								
								src/client/src/TournamentTeams.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,227 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes, useParams } from "react-router-dom"; | ||||
| import Appbar from "./components/AsuraBar"; | ||||
| import TournamentBar from "./components/TournamentBar"; | ||||
| import ErrorSnackbar from "./components/ErrorSnackbar"; | ||||
| import LoginPage from "./LoginPage"; | ||||
| import { Button, TextField, Stack, MenuItem, Box, InputLabel, Select, Container, TableContainer, Table, TableBody, TableHead, TableCell, TableRow, Paper, Typography} from "@mui/material"; | ||||
| import AddCircleIcon from '@mui/icons-material/AddCircle'; | ||||
| import DeleteIcon from '@mui/icons-material/Delete'; | ||||
| import EditIcon from '@mui/icons-material/Edit'; | ||||
| 
 | ||||
| function TeamCreator(props) { | ||||
|   function postCreate() { | ||||
|     let teamName = document.getElementById("teamNameInput").value; | ||||
|     if (!teamName) { | ||||
|       showError("Team name is required"); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     let formData = new FormData(); | ||||
|     formData.append("name", teamName); | ||||
|     let body = new URLSearchParams(formData) | ||||
| 
 | ||||
|     fetch(process.env.REACT_APP_API_URL + `/tournament/${props.tournamentId}/createTeam`, { | ||||
|       method: "POST", | ||||
|       body: body | ||||
|     }) | ||||
|     .then(res => res.json()) | ||||
|     .then(data => { | ||||
|       if (data.status !== "OK") { | ||||
|         showError(data.data); | ||||
|         return; | ||||
|       } | ||||
|       document.getElementById("teamNameInput").value = ""; | ||||
|       props.onTeamCreated(); | ||||
| 
 | ||||
|     } | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Paper sx={{width: "90vw", margin: "10px auto", padding: "15px", align:'center', justifyContent:'center', flexGrow:1}} component={Stack} direction={['column']} spacing={2}> | ||||
|       <div align="center"> | ||||
|         <form> | ||||
|         <TextField id="teamNameInput" sx={{width:['auto','50%','60%','70%'], margin:'1% 0'}} label="Team Name" variant="outlined" /> | ||||
|         {/* <Button variant="contained" color="primary" onClick={postCreate}>Create Team</Button> */} | ||||
|         <Button type="submit" variant="contained" color="success" onClick={postCreate} sx={{ margin:'1% 1%',width:['fit-content','40%','30%','20%']}}> | ||||
|           <Box sx={{padding: "10px"}}> | ||||
|             Create Team | ||||
|           </Box> | ||||
|           <AddCircleIcon /> | ||||
|         </Button> | ||||
|         </form> | ||||
|       </div> | ||||
|        | ||||
|     </Paper> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function TeamList(props) { | ||||
|   const deleteTeam = teamId => { | ||||
|     fetch(process.env.REACT_APP_API_URL + `/team/${teamId}`, {method: "DELETE"}) | ||||
|       .then(res => res.json()) | ||||
|       .then(data => { | ||||
|         if (data.status !== "OK") { | ||||
|           showError(data.data); | ||||
|           return; | ||||
|         } | ||||
|         props.setTeams(props.teams.filter(team => team.id !== teamId)); | ||||
|       }) | ||||
|       .catch(error => showError(error)); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|   <Paper sx={{minHeight: "30vh", width: "90vw", margin: "10px auto"}} component={Stack} direction="column" justifyContent="center"> | ||||
|   <div align="center" > | ||||
|   <h2><b>Teams:</b></h2> | ||||
|   {/* TODO: scroll denne menyen, eventuelt søkefelt */} | ||||
|       <Table aria-label="simple table"> | ||||
|         <TableHead> | ||||
|           <TableRow> | ||||
|             <TableCell>Team Name</TableCell> | ||||
|             {/* <TableCell align="right">Team Members</TableCell> */} | ||||
|             <TableCell align="center">Actions</TableCell> | ||||
|           </TableRow> | ||||
|         </TableHead> | ||||
|         <TableBody> | ||||
|           {props.teams.map((team) => ( | ||||
| 
 | ||||
|             <TableRow key={team.id}> | ||||
|               <TableCell component="th" scope="row"> <b> | ||||
|                 {team.name} | ||||
|               </b></TableCell> | ||||
|               {/* <TableCell align="right">{team.members}</TableCell> */} | ||||
|               <TableCell align="center"> | ||||
|                 <Button variant="contained" sx={{margin: "auto 5px"}} color="primary" onClick={() => {props.setSelectedTeamId(team.id); window.scrollTo(0, document.body.scrollHeight)}} endIcon={<EditIcon />}>Edit</Button> | ||||
|                 <Button variant="contained" sx={{margin: "auto 5px"}} color="error" onClick={() => {deleteTeam(team.id)}} endIcon={<DeleteIcon />}>Delete</Button> | ||||
|               </TableCell> | ||||
|             </TableRow> | ||||
| 
 | ||||
|           ))} | ||||
|         </TableBody> | ||||
|       </Table> | ||||
|   </div> | ||||
|   </Paper> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function TeamEditor(props) { | ||||
|   const [team, setTeam] = React.useState({}); | ||||
|   React.useEffect(() => { | ||||
|     if (props.selectedTeamId === -1) { | ||||
|       setTeam({}); | ||||
|       return; | ||||
|     } | ||||
|     fetch(process.env.REACT_APP_API_URL + `/team/${props.selectedTeamId}`) | ||||
|       .then(res => res.json()) | ||||
|       .then(data => { | ||||
|         if (data.status !== "OK") { | ||||
|           showError(data); | ||||
|           return; | ||||
|         } | ||||
|         setTeam(data.data); | ||||
|       }) | ||||
|       .catch(error => showError(error)); | ||||
|   }, [props.selectedTeamId]); | ||||
| 
 | ||||
|   if (props.selectedTeamId === -1 || !team) { | ||||
|     return ( | ||||
|       <Paper sx={{minHeight: "30vh", width: "90vw", margin: "10px auto"}} component={Stack} direction="column" justifyContent="center"> | ||||
|         <div align="center" > | ||||
|           ... Create a new team or select one from the list above ... | ||||
|         </div> | ||||
|       </Paper> | ||||
|     ) | ||||
|   } | ||||
|    | ||||
|   function nameInputChanged(event) { | ||||
|     let newTeam = {...team}; | ||||
|     newTeam.name = event.target.value; | ||||
|     setTeam(newTeam); | ||||
|   } | ||||
| 
 | ||||
|   function handleFocus(event) { | ||||
|     event.currentTarget.select() | ||||
|   } | ||||
| 
 | ||||
|   function saveTeam() { | ||||
|     let formData = new FormData(); | ||||
|     formData.append("name", team.name); | ||||
|     let body = new URLSearchParams(formData) | ||||
|     fetch(process.env.REACT_APP_API_URL + `/team/${team.id}/edit`, { | ||||
|       method: "POST", | ||||
|       body: body | ||||
|     }) | ||||
|     .then(res => res.json()) | ||||
|     .then(data => { | ||||
|       if (data.status !== "OK") { | ||||
|         showError(data.data); | ||||
|         return; | ||||
|       } | ||||
|       setTeam(data.data); | ||||
|       props.setTeams(props.teams.map(origTeam => origTeam.id === team.id ? team : origTeam)); | ||||
|       props.setSelectedTeamId(-1); | ||||
|     } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Paper sx={{minHeight: "30vh", width: "90vw", margin: "10px auto"}} component={Stack} direction="column" justifyContent="center"> | ||||
|     <div align="center"> | ||||
|       <h2><b>Edit Team:</b></h2> | ||||
|       <form> | ||||
|         <TextField id="newTeamNameInput" label="Team Name" value={team.name || ""} onChange={nameInputChanged} onFocus={handleFocus} sx={{width: "80%"}} /> | ||||
|         <Button type="submit" variant="contained" sx={{margin: "auto 5px"}} color="primary" onClick={saveTeam}>Save</Button> | ||||
|       </form> | ||||
|       </div> | ||||
|     </Paper> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| let showError = (message) => {}; | ||||
| 
 | ||||
| export default function TournamentTeams(props) { | ||||
|   const [teams, setTeams] = React.useState([]); | ||||
|   const [selectedTeamId, setSelectedTeamId] = React.useState(-1); | ||||
|   const { tournamentId } = useParams(); | ||||
| 
 | ||||
|   function getTeams() { | ||||
|     fetch(process.env.REACT_APP_API_URL + `/tournament/${tournamentId}/getTeams`) | ||||
|       .then((res) => res.json()) | ||||
|       .then((data) => { | ||||
|         if (data.status !== "OK") { | ||||
|           showError(data.data); | ||||
|         } | ||||
|         setTeams(data.data); | ||||
|         //setselectedTeamId(teams[0].id);
 | ||||
|       }) | ||||
|       .catch((err) => showError(err)); | ||||
|   } | ||||
|   React.useEffect(() => { | ||||
|     getTeams() | ||||
|   }, []); | ||||
| 
 | ||||
|   const [openError, setOpenError] = React.useState(false); | ||||
|   const [errorMessage, setErrorMessage] = React.useState(""); | ||||
|   showError = (message) => { | ||||
|     setOpenError(false); | ||||
|     setErrorMessage(message); | ||||
|     setOpenError(true); | ||||
|   } | ||||
|    | ||||
|   if (!props.user.isLoggedIn) { return <LoginPage user={props.user} />; } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|     <Appbar user={props.user} pageTitle="Edit teams" /> | ||||
|     <TournamentBar pageTitle="Manage Teams" /> | ||||
|     <div className="tournamentTeams"> | ||||
|       <TeamCreator tournamentId={tournamentId} teams={teams} onTeamCreated={getTeams} /> | ||||
|       <TeamList teams={teams} setTeams={setTeams} selectedTeamId={selectedTeamId} setSelectedTeamId={setSelectedTeamId} /> | ||||
|       <TeamEditor teams={teams} setTeams={setTeams} selectedTeamId={selectedTeamId} setSelectedTeamId={setSelectedTeamId} /> | ||||
|     </div> | ||||
|     <ErrorSnackbar message={errorMessage} open={openError} setOpen={setOpenError} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										97
									
								
								src/client/src/components/AsuraBar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,97 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes, History } from "react-router-dom"; | ||||
| import { AppBar, Typography, Toolbar, CssBaseline, Box, Button, IconButton, Grid, Menu, MenuItem, Container } from "@mui/material" | ||||
| import MenuIcon from '@mui/icons-material/Menu'; | ||||
| import AccountCircleIcon from '@mui/icons-material/AccountCircle'; | ||||
| import HistoryIcon from '@mui/icons-material/History'; | ||||
| import EditIcon from '@mui/icons-material/Edit'; | ||||
| import LogoutIcon from '@mui/icons-material/Logout'; | ||||
| import LoginIcon from '@mui/icons-material/Login'; | ||||
| import logo from "./../Asura2222.png"; | ||||
| 
 | ||||
| function LoggedInMenu(props) { | ||||
|   const [anchorEl, setAnchorEl] = React.useState(null); | ||||
|   const open = Boolean(anchorEl); | ||||
|   const handleClick = (event) => { | ||||
|     setAnchorEl(event.currentTarget); | ||||
|   }; | ||||
|   const handleClose = () => { | ||||
|     setAnchorEl(null); | ||||
|   }; | ||||
| 
 | ||||
|   const logout = () => { | ||||
|     setAnchorEl(null); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <IconButton size="large" edge="start" color="inherit" aria-label="menu"  onClick={handleClick}> | ||||
|         <MenuIcon /> | ||||
|         </IconButton> | ||||
|         <Menu anchorEl={anchorEl} open={open} onClose={handleClose} MenuListProps={{'aria-labelledby': 'basic-button',}} sx={{position:"absolute"}}> | ||||
|           <Link to="/profile" style={{color:"black"}}><MenuItem onClick={handleClose}><Button startIcon={<AccountCircleIcon />}>{props.user.name}</Button></MenuItem></Link> | ||||
|           <Link to="/history" style={{color:"black"}}><MenuItem onClick={handleClose}><Button startIcon={<HistoryIcon />}>History</Button></MenuItem></Link> | ||||
|           { props.user.isManager &&  | ||||
|           <Link to="/admins" style={{color:"black"}}><MenuItem onClick={handleClose}><Button startIcon={<EditIcon />} >Admins</Button></MenuItem></Link> | ||||
|           } | ||||
|            <a href={`${process.env.REACT_APP_API_URL}/users/logout`} style={{color:"black"}}><MenuItem onClick={logout}><Button startIcon={<LogoutIcon />} >Logout</Button></MenuItem></a> | ||||
|       </Menu>  | ||||
|     </>  | ||||
|   );   | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| function NotLoggedInButton() { | ||||
|   const login = () => { | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|     <Link to="/login" style={{color:"white"}}> | ||||
|       <Button sx={{color:"white"}} onClick={login} endIcon={<LoginIcon />}> | ||||
|         Login | ||||
|         </Button> | ||||
|       </Link> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default function Appbar(props) { | ||||
|   return ( | ||||
|     <> | ||||
|     <CssBaseline /> | ||||
|       <AppBar position="static" color="primary"> | ||||
|         <Toolbar> | ||||
|           <Box sx={{ flexGrow: 1 }}> | ||||
|             <Grid container spacing={2} justifyContent="space-between" alignItems="center" align="center"> | ||||
|               <Grid item xs={2}> | ||||
|               <Box sx={{ width:"100%", height: "100%", justifyContent:"left", align: "center", alignItems:"center", margin: "none", padding: "none", color: "white" ,display: "flex", flexFlow: "row"}}> | ||||
|                 <Link to="/"> | ||||
|                   <Box component="img" src={logo} alt="Tournament logo" className="mainIcon" sx={{height:['55px','65px'], width:['55px','65px']}}></Box> | ||||
|                 </Link> | ||||
|                   { props.pageTitle !== "Asura Tournaments" && | ||||
|                   <Link to="/" style={{color:"white"}}> | ||||
|                     <Typography component="div" align="center" sx={{fontSize:['1em','1em','1.5em','2em']}}> | ||||
|                           Home | ||||
|                     </Typography> | ||||
|                   </Link> | ||||
|                   } | ||||
|               </Box> | ||||
|               </Grid>     | ||||
|               <Grid item xs={4} md={6} lg={8}> | ||||
|                 <Typography component="div" sx={{fontSize:['1em','1em','1.5em','2em']}}>{props.pageTitle || ""}</Typography> | ||||
|               </Grid> | ||||
|               { props.pageTitle !== "Login" ? | ||||
|                 <Grid item xs={2}> | ||||
|                   { props.user.isLoggedIn ? <LoggedInMenu user={props.user} /> : <NotLoggedInButton /> }  | ||||
|                 </Grid> :  | ||||
|                 <Grid item xs={2}> | ||||
|                 </Grid> | ||||
|               } | ||||
|             </Grid> | ||||
|           </Box> | ||||
|         </Toolbar> | ||||
|       </AppBar> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										32
									
								
								src/client/src/components/ErrorSnackbar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,32 @@ | ||||
| import * as React from 'react'; | ||||
| import Stack from '@mui/material/Stack'; | ||||
| import Button from '@mui/material/Button'; | ||||
| import Snackbar from '@mui/material/Snackbar'; | ||||
| import MuiAlert from '@mui/material/Alert'; | ||||
| 
 | ||||
| const Alert = React.forwardRef(function Alert(props, ref) { | ||||
|   return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />; | ||||
| }); | ||||
| 
 | ||||
| export default function showError(props) { | ||||
|   const handleClose = (event, reason) => { | ||||
|     if (reason === 'clickaway') { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     props.setOpen(false); | ||||
|   }; | ||||
|   if (props.message && props.message.length > 0) { | ||||
|     console.log(props.message); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Stack spacing={2} sx={{ width: '100%' }}> | ||||
|       <Snackbar open={props.open} autoHideDuration={6000} onClose={handleClose}> | ||||
|         <Alert onClose={handleClose} severity="error" sx={{ width: '100%' }}> | ||||
|           {props.message} | ||||
|         </Alert> | ||||
|       </Snackbar> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/client/src/components/NoSuchPage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,19 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes } from "react-router-dom"; | ||||
| import { Typography } from '@mui/material' | ||||
| 
 | ||||
| export default function NoSuchPage() { | ||||
|     return( | ||||
|     <> | ||||
|         <Typography type="h3"> | ||||
|             This page does not exist | ||||
|         </Typography> | ||||
|         <Typography type="h4"> | ||||
|             The page you are looking for does not exist or has been moved | ||||
|         </Typography> | ||||
|         <Link to="/"> | ||||
|             Return to the home page | ||||
|         </Link> | ||||
|     </> | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										18
									
								
								src/client/src/components/NoUserPage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,18 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes } from "react-router-dom"; | ||||
| import { Typography } from '@mui/material' | ||||
| 
 | ||||
| export default function NoSuchPage() { | ||||
|     return( | ||||
|     <> | ||||
|         <Typography type="h3"> | ||||
|             You are not logged in | ||||
|         </Typography> | ||||
|         <Typography type="h4"> | ||||
|         Your account is not in the administrators list. Try again with another account here: <Link to="/login">Login</Link> | ||||
|         or  | ||||
|         <Link to="/"> Return to the home page</Link> | ||||
|         </Typography> | ||||
|     </> | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										29
									
								
								src/client/src/components/SuccessSnackbar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,29 @@ | ||||
| import * as React from 'react'; | ||||
| import Stack from '@mui/material/Stack'; | ||||
| import Button from '@mui/material/Button'; | ||||
| import Snackbar from '@mui/material/Snackbar'; | ||||
| import MuiAlert from '@mui/material/Alert'; | ||||
| 
 | ||||
| const Alert = React.forwardRef(function Alert(props, ref) { | ||||
|   return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />; | ||||
| }); | ||||
| 
 | ||||
| export default function showError(props) { | ||||
|   const handleClose = (event, reason) => { | ||||
|     if (reason === 'clickaway') { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     props.setOpen(false); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <Stack spacing={2} sx={{ width: '100%' }}> | ||||
|       <Snackbar open={props.open} autoHideDuration={6000} onClose={handleClose}> | ||||
|         <Alert onClose={handleClose} severity="success" sx={{ width: '100%' }}> | ||||
|           {props.message} | ||||
|         </Alert> | ||||
|       </Snackbar> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										63
									
								
								src/client/src/components/TournamentBar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,63 @@ | ||||
| import * as React from "react"; | ||||
| import { useParams } from "react-router-dom"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes, History } from "react-router-dom"; | ||||
| import { Stack, Paper, Typography, Box, Button, Grid, Snackbar, IconButton } from "@mui/material" | ||||
| import CloseIcon from '@mui/icons-material/Close'; | ||||
| import MuiAlert from '@mui/material/Alert'; | ||||
| 
 | ||||
| const Alert = React.forwardRef(function Alert(props, ref) { | ||||
|   return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />; | ||||
| }); | ||||
| 
 | ||||
| function ClipboardButton(props) { | ||||
|   const [open, setOpen] = React.useState(false); | ||||
|   function copyString() { | ||||
|     navigator.clipboard.writeText(props.clipboardContent || ""); | ||||
|     setOpen(true); | ||||
|   } | ||||
|   const handleClose = (event, reason) => { | ||||
|     if (reason === 'clickaway') { return } | ||||
|     setOpen(false); | ||||
|   }; | ||||
|   const closeAction = <> | ||||
|     <IconButton size="small" aria-label="close" color="inherit" onClick={handleClose}> | ||||
|       <CloseIcon fontSize="small" /> | ||||
|     </IconButton> | ||||
|   </> | ||||
|      | ||||
|   return ( | ||||
|     <> | ||||
|       <Button onClick={copyString} variant="outlined" color="primary" sx={{margin:'1.5%', fontSize:['0.75em']}} >Copy {props.name}</Button> | ||||
|       <Snackbar open={open} autoHideDuration={1500} onClose={handleClose} action={closeAction}> | ||||
|         <Alert onClose={handleClose} severity="info" sx={{ width: '100%' }}> | ||||
|           {props.name + " copied to clipboard"} | ||||
|         </Alert> | ||||
|       </Snackbar> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function ButtonLink(props) { | ||||
|   return ( | ||||
|     <Link to={`/tournament/${props.tournamentId}` + props.targetPath} > | ||||
|         <Button variant="contained" color="primary" disabled={props.activeTitle === props.title || props.viewTournament} sx={{fontSize:['0.7em','0.75em']}} >{props.title}</Button> | ||||
|     </Link> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default function TournamentBar(props) { | ||||
|     const { tournamentId } = useParams(); | ||||
|     return ( | ||||
|         <Paper sx={{width: ["90vw",], fontSize:['1rem','1rem','1.5rem','2rem'], margin: "1.5% auto"}} component={Stack} direction="column" justifyContent="center" alignItems="center">  | ||||
|           <Stack direction="row" paddingTop={'0.5%'} sx={{fontSize:['1rem','1rem','1.5rem','2rem'], margin:'1.5%'}} spacing={2}> | ||||
|             <ButtonLink targetPath="" tournamentId={tournamentId} activeTitle={props.pageTitle} title="View Tournament" viewTournament={props.viewTournament}/> | ||||
|             <ButtonLink targetPath="/manage" tournamentId={tournamentId} activeTitle={props.pageTitle} title="Edit Tournament" /> | ||||
|             <ButtonLink targetPath="/teams" tournamentId={tournamentId} activeTitle={props.pageTitle} title="Manage Teams" /> | ||||
|           </Stack> | ||||
|           <Stack direction="row" paddingBottom={'0.5%'}> | ||||
|             <ClipboardButton clipboardContent={"https://discord.gg/asura"} name="Discord Invite Link" /> | ||||
|             <ClipboardButton clipboardContent={"https://asura.feal.no/tournament/" + tournamentId} name="Tournament Link" /> | ||||
|           </Stack> | ||||
|         </Paper> | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/client/src/components/savebutton.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,12 @@ | ||||
| import * as React from "react"; | ||||
| import { BrowserRouter as Router, Link, Route, Routes } from "react-router-dom"; | ||||
| 
 | ||||
| import Button from "@mui/material/Button"; | ||||
| 
 | ||||
| export default function SaveButton(props) { | ||||
|   return ( | ||||
|     <Link to="/"> | ||||
|       <Button variant="outlined" color="primary">Save and Exit</Button> | ||||
|     </Link> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										71
									
								
								src/client/src/components/theme.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,71 @@ | ||||
| import * as React from 'react'; | ||||
| import ReactDOM from 'react-dom'; | ||||
| import { createTheme } from '@mui/material/styles'; | ||||
| 
 | ||||
| const theme = createTheme({ | ||||
|     palette: { | ||||
|     //   primary: {
 | ||||
|     //   },
 | ||||
|     //   secondary: {
 | ||||
|     //   },
 | ||||
|       pewterblue: { | ||||
|         main: '#8fbcbb', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       darkskyblue: { | ||||
|         main: '#88c0d0', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       darkpastelblue:{ | ||||
|         main: '#81a1c1', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       rackley: { | ||||
|         main: '#3270a6', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       gainsboro:{ | ||||
|         main: '#d8dee9', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       brightgrey: { | ||||
|         main: '#e5e9f0', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       lightslategray: { | ||||
|         main: '#79869c', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       cadetblue: { | ||||
|         main: '#a0aaba', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       red: { | ||||
|         main: '#bf616a',  | ||||
|         contrastText: '#fff',  | ||||
|       }, | ||||
|       copper: { | ||||
|         main: '#d08770', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       gold: { | ||||
|         main: '#ebcb8b', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       green: { | ||||
|         main: '#a3be8c', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       grape: { | ||||
|         main: '#b76bbf', | ||||
|         contrastText: '#fff', | ||||
|       }, | ||||
|       background: { | ||||
|         default: '#f0f2f2', | ||||
|       } | ||||
|       // contrastThreshold: 5,
 | ||||
|       // tonalOffset: 0.2,
 | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
| export default theme; | ||||
							
								
								
									
										75
									
								
								src/client/src/components/tournamentBracket.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,75 @@ | ||||
| /* | ||||
|  *  Flex Layout Specifics | ||||
| */ | ||||
| .bracket{ | ||||
| 	display:flex; | ||||
| 	flex-direction:row; | ||||
| 	justify-content: center; | ||||
|   } | ||||
| .round{ | ||||
| 	display:flex; | ||||
| 	flex-direction:column; | ||||
| 	justify-content:center; | ||||
| 	/* width:20vw; */ | ||||
| 	list-style:none; | ||||
| 	padding:0; | ||||
| 	/* font-size: 1.5rem; */ | ||||
| } | ||||
| .round .spacer{ flex-grow:1;} | ||||
| .round .spacer:first-child, | ||||
| .round .spacer:last-child{ flex-grow:.5; } | ||||
| .round .game-spacer{ | ||||
| 	flex-grow:1; | ||||
| } | ||||
|    | ||||
|   /* | ||||
|    *  General Styles | ||||
|   */ | ||||
| /* body{ | ||||
| 	font-family:sans-serif; | ||||
| 	font-size:medium; | ||||
| 	padding:10px; | ||||
| 	line-height:1.4em; | ||||
|   } */ | ||||
|    | ||||
| li.game{ | ||||
| 	padding-left:20px; | ||||
| } | ||||
| 	 | ||||
| .winner{ | ||||
| 	color:green; | ||||
| 	font-weight: bold; | ||||
| } | ||||
| .loser{ | ||||
| 	color:grey; | ||||
| } | ||||
|    | ||||
| li.game-top{ border-bottom:1px solid #aaa; } | ||||
|    | ||||
| li.game-spacer{  | ||||
| 	border-right:1px solid #aaa; | ||||
| 	min-height:10vh; | ||||
| } | ||||
|    | ||||
| li.game-bottom{  | ||||
| 	border-top:1px solid #aaa; | ||||
| } | ||||
|    | ||||
| 
 | ||||
| .winnerDisplay { | ||||
| 	display:flex; | ||||
| 	flex-direction:row; | ||||
| 	align-items: center; | ||||
| 	border: 2px solid gray; | ||||
| 	border-radius: 15px; | ||||
| 	min-height: 10vh; | ||||
| 	max-height: 40vh; | ||||
| 	margin: auto 5px; | ||||
| 	padding: 10px; | ||||
| } | ||||
| .winnerDisplay > p.winner { | ||||
| 	color:green; | ||||
| } | ||||
| .winnerDisplay > div.winner { | ||||
| 	margin-right: 10px; | ||||
| } | ||||
| @ -1,13 +1,38 @@ | ||||
| html, | ||||
| body { | ||||
|   margin: 0; | ||||
|   font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', | ||||
|     'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
| } | ||||
| body { | ||||
|   /* <yeet> */ | ||||
|   overflow-y: auto !important; | ||||
|   margin: 0 !important; | ||||
|   padding: 0 !important; | ||||
|   /* </yeet> */ | ||||
|    | ||||
|   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", | ||||
|     "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", | ||||
|     sans-serif; | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|   -moz-osx-font-smoothing: grayscale; | ||||
|   background-color: #929292; | ||||
| } | ||||
| 
 | ||||
| code { | ||||
|   font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', | ||||
|   font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", | ||||
|     monospace; | ||||
| } | ||||
| 
 | ||||
| .mainIcon{ | ||||
|   border-radius: 50%; | ||||
|   /* border: 5px dotted salmon; */ | ||||
|   border: 3px solid #1ab35a; | ||||
|   background-color: white; | ||||
|   margin: 5px; | ||||
|   float: left; | ||||
|   /* margin: 50% calc(2vw + 50%) 50% 50%; */ | ||||
| } | ||||
| 
 | ||||
| a { | ||||
|   text-decoration: none; | ||||
| } | ||||
| @ -1,13 +1,15 @@ | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom"; | ||||
| import "./index.css"; | ||||
| import ListElement from "./list_component"; | ||||
| import App from "./FrontPage.js"; | ||||
| import theme from './components/theme'; | ||||
| import { ThemeProvider } from "@emotion/react"; | ||||
| 
 | ||||
| ReactDOM.render( | ||||
|   <React.StrictMode> | ||||
|     <ListElement /> | ||||
|     <ListElement /> | ||||
|     <ListElement /> | ||||
|   </React.StrictMode>, | ||||
|   <> | ||||
|   <ThemeProvider theme={theme}> | ||||
|     <App /> | ||||
|   </ThemeProvider> | ||||
|   </>, | ||||
|   document.getElementById("root") | ||||
| ); | ||||
|  | ||||
| @ -1,14 +0,0 @@ | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom"; | ||||
| 
 | ||||
| export default class ListElement extends React.Component { | ||||
|   render() { | ||||
|     return ( | ||||
|       <> | ||||
|         <div>Turnering 1, deltakere, Dato</div> | ||||
|       </> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| ReactDOM.render(<Hello />, document.getElementById("root")); | ||||
| @ -1 +1,87 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> | ||||
| <?xml version="1.0" standalone="no"?> | ||||
| <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" | ||||
|  "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> | ||||
| <svg version="1.0" xmlns="http://www.w3.org/2000/svg" | ||||
|  width="200.000000pt" height="170.000000pt" viewBox="0 0 200.000000 170.000000" | ||||
|  preserveAspectRatio="xMidYMid meet"> | ||||
| 
 | ||||
| <g transform="translate(0.000000,170.000000) scale(0.100000,-0.100000)" | ||||
| fill="#000000" stroke="none"> | ||||
| <path d="M1140 1450 c0 -6 11 -33 25 -60 14 -27 25 -52 25 -55 0 -3 -12 -5 | ||||
| -26 -5 -16 0 -37 -12 -55 -31 -16 -18 -32 -29 -35 -26 -4 3 -14 -1 -22 -10 | ||||
| -15 -15 -17 -14 -22 8 -6 23 -6 23 -17 4 -11 -19 -11 -19 -48 0 -20 11 -70 46 | ||||
| -112 78 -82 61 -83 62 -83 48 0 -5 21 -23 48 -40 26 -17 63 -45 82 -62 l35 | ||||
| -30 -30 13 c-16 7 -58 29 -92 50 -35 21 -63 35 -63 31 0 -4 39 -48 88 -96 55 | ||||
| -57 79 -87 65 -82 -16 4 -23 2 -23 -8 0 -7 16 -18 37 -23 39 -10 50 -32 32 | ||||
| -66 -7 -13 -6 -16 4 -12 26 9 65 -15 96 -59 16 -25 27 -47 24 -51 -4 -3 -1 -6 | ||||
| 5 -6 13 0 57 -79 49 -87 -3 -2 3 -13 14 -24 10 -10 19 -28 19 -39 0 -17 -3 | ||||
| -18 -26 -9 -29 10 -40 7 -29 -10 3 -6 17 -11 30 -11 13 0 27 -4 30 -10 3 -6 | ||||
| 17 -10 30 -10 27 0 89 -20 158 -50 l47 -21 -29 -57 c-16 -31 -37 -65 -48 -74 | ||||
| -26 -24 -91 -50 -117 -46 -11 1 -28 -2 -36 -6 -12 -7 -13 -6 -2 5 23 25 12 30 | ||||
| -18 9 -52 -37 -33 -7 25 39 30 24 55 48 55 53 0 9 -84 -47 -126 -84 -11 -10 | ||||
| -26 -18 -32 -18 -7 0 -12 -5 -12 -11 0 -9 -25 -10 -89 -6 -100 7 -121 13 -121 | ||||
| 33 0 8 -7 14 -15 14 -8 0 -13 -6 -11 -12 1 -7 1 -10 -1 -5 -9 15 -53 6 -63 | ||||
| -12 -14 -28 -13 -31 14 -31 15 0 26 -7 29 -20 5 -19 14 -20 241 -20 l236 0 20 | ||||
| -41 c20 -38 23 -40 53 -34 51 11 67 24 32 25 -16 1 -39 5 -50 10 -17 7 -14 9 | ||||
| 15 11 24 1 28 3 13 6 -13 2 -23 7 -23 9 0 15 32 64 47 73 11 7 13 11 5 11 -11 | ||||
| 0 69 113 89 126 4 2 34 -20 65 -49 43 -40 59 -62 60 -83 0 -16 -4 -33 -10 -37 | ||||
| -6 -5 -2 -6 10 -2 17 4 25 -1 38 -30 15 -30 15 -39 4 -63 l-13 -27 -140 -3 | ||||
| c-77 -2 -141 -5 -143 -7 -2 -2 6 -13 17 -24 20 -20 32 -21 186 -21 158 0 165 | ||||
| 1 165 20 0 23 -553 1135 -570 1145 -5 3 -10 1 -10 -5z m10 -144 c0 -3 -4 -8 | ||||
| -10 -11 -5 -3 -10 -1 -10 4 0 6 5 11 10 11 6 0 10 -2 10 -4z m75 -36 c12 -24 | ||||
| 12 -30 -1 -46 -13 -18 -14 -17 -8 7 4 19 1 28 -12 33 -14 5 -15 4 -5 -8 10 | ||||
| -12 9 -14 -6 -12 -19 3 -28 28 -19 51 8 21 34 8 51 -25z m40 -89 c-8 -8 -31 | ||||
| 25 -27 38 3 10 9 7 18 -10 7 -13 11 -26 9 -28z m169 -324 c36 -64 37 -69 22 | ||||
| -91 -11 -17 -22 -23 -38 -20 -13 2 -17 2 -10 -2 20 -9 14 -34 -8 -34 -14 0 | ||||
| -20 6 -18 16 2 10 -3 18 -10 20 -7 1 -8 0 -2 -3 15 -7 2 -23 -15 -17 -8 4 -12 | ||||
| 10 -9 15 3 5 -1 10 -8 10 -92 8 -121 17 -114 34 3 8 1 15 -4 15 -6 0 -10 -4 | ||||
| -10 -10 0 -5 -4 -10 -10 -10 -5 0 -10 7 -10 15 0 8 -4 15 -8 15 -4 0 -14 21 | ||||
| -21 48 -8 26 -25 59 -37 75 -15 17 -21 34 -16 45 6 16 7 16 10 -1 2 -9 7 -15 | ||||
| 11 -12 4 2 21 -9 36 -25 33 -35 41 -36 69 -13 15 12 13 9 -4 -11 l-25 -29 32 | ||||
| 17 c41 21 43 20 43 -8 0 -15 7 -26 20 -29 21 -5 28 -27 9 -27 -5 0 -7 -4 -4 | ||||
| -10 3 -5 17 -10 29 -10 13 0 30 -9 37 -21 14 -20 46 -31 62 -22 4 3 4 10 -2 | ||||
| 16 -33 40 -77 136 -75 165 1 17 -3 32 -8 32 -5 0 -6 5 -2 12 4 7 16 -6 29 -33 | ||||
| 12 -24 39 -74 59 -112z m-264 112 c0 -6 -4 -7 -10 -4 -5 3 -10 11 -10 16 0 6 | ||||
| 5 7 10 4 6 -3 10 -11 10 -16z m363 -309 c4 -15 0 -20 -14 -20 -20 0 -23 8 -13 | ||||
| 34 7 20 20 13 27 -14z m-73 -8 c0 -11 -58 -92 -65 -92 -9 0 15 40 36 61 11 11 | ||||
| 17 24 14 29 -4 6 -1 10 4 10 6 0 11 -4 11 -8z m-79 -119 c-23 -40 -49 -65 -62 | ||||
| -61 -10 3 -3 15 23 41 39 40 55 48 39 20z m-105 -42 c-5 -8 -76 -14 -76 -6 0 | ||||
| 7 64 23 73 18 4 -2 5 -8 3 -12z"/> | ||||
| <path d="M1099 1378 c-6 -21 -5 -38 2 -38 5 0 9 9 9 20 0 16 -7 28 -11 18z"/> | ||||
| <path d="M710 1130 c0 -5 7 -10 15 -10 8 0 15 5 15 10 0 6 -7 10 -15 10 -8 0 | ||||
| -15 -4 -15 -10z"/> | ||||
| <path d="M777 1129 c7 -7 15 -10 18 -7 3 3 -2 9 -12 12 -14 6 -15 5 -6 -5z"/> | ||||
| <path d="M870 1116 c0 -2 8 -10 18 -17 15 -13 16 -12 3 4 -13 16 -21 21 -21 | ||||
| 13z"/> | ||||
| <path d="M824 1086 c1 -14 6 -29 11 -34 4 -4 5 -2 2 5 -4 6 -3 13 2 15 5 1 3 | ||||
| 11 -4 22 -13 17 -14 17 -11 -8z"/> | ||||
| <path d="M755 1084 c19 -16 19 -16 -5 -10 -23 6 -22 4 10 -24 39 -34 50 -37 | ||||
| 50 -14 0 8 -4 13 -9 10 -5 -3 -14 1 -21 9 -7 8 -9 15 -5 15 4 0 2 7 -5 15 -7 | ||||
| 8 -18 15 -24 15 -6 0 -2 -8 9 -16z"/> | ||||
| <path d="M890 1050 c0 -5 10 -10 23 -10 18 0 19 2 7 10 -19 13 -30 13 -30 0z"/> | ||||
| <path d="M939 1030 c-7 -19 -15 -27 -22 -23 -21 13 -36 11 -30 -3 3 -7 8 -13 | ||||
| 11 -12 21 2 20 -8 -8 -61 l-31 -59 -37 14 c-20 9 -57 24 -81 35 -24 11 -46 18 | ||||
| -49 15 -6 -6 92 -52 133 -62 37 -9 32 -34 -7 -35 -31 -2 -31 -2 -5 -6 15 -2 | ||||
| 27 -7 26 -11 0 -4 -24 -52 -52 -107 l-52 -100 -42 -1 c-24 0 -46 -3 -49 -7 -4 | ||||
| -3 -13 1 -21 9 -12 11 -18 12 -29 3 -22 -18 -16 2 7 27 12 13 18 28 14 34 -6 | ||||
| 9 -10 9 -16 -1 -7 -11 -12 -12 -28 -2 -16 10 -20 9 -25 -2 -6 -17 -36 -20 -36 | ||||
| -5 0 7 -6 7 -17 2 -10 -5 -24 -7 -32 -4 -10 3 -13 -2 -9 -22 3 -14 2 -26 -3 | ||||
| -26 -4 0 -10 10 -12 23 l-4 22 -2 -22 c0 -13 -6 -23 -11 -23 -6 0 -8 -11 -4 | ||||
| -25 4 -17 2 -25 -6 -25 -9 0 -9 -4 1 -17 11 -15 9 -14 -9 1 -22 19 -22 19 -47 | ||||
| -4 -24 -23 -115 -188 -115 -209 0 -6 11 -11 24 -11 15 0 26 -7 29 -20 5 -20 | ||||
| 12 -20 179 -18 l173 3 172 344 c95 190 173 350 173 357 0 20 -22 64 -32 64 -5 | ||||
| 0 -14 -13 -19 -30z m-559 -490 c0 -11 -7 -20 -15 -20 -18 0 -19 12 -3 28 16 | ||||
| 16 18 15 18 -8z"/> | ||||
| <path d="M870 988 c0 -9 5 -20 10 -23 13 -8 13 5 0 25 -8 13 -10 13 -10 -2z"/> | ||||
| <path d="M600 983 c0 -5 12 -16 28 -23 15 -8 31 -16 36 -19 5 -2 6 1 2 7 -3 5 | ||||
| -1 12 6 14 8 3 6 7 -5 11 -9 3 -17 2 -17 -4 0 -5 -11 -2 -25 7 -14 9 -25 12 | ||||
| -25 7z"/> | ||||
| <path d="M1059 933 c-13 -16 -12 -17 4 -4 9 7 17 15 17 17 0 8 -8 3 -21 -13z"/> | ||||
| <path d="M1115 750 c-16 -7 -17 -9 -3 -9 9 -1 20 4 23 9 7 11 7 11 -20 0z"/> | ||||
| <path d="M1190 549 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10 | ||||
| -5 -10 -11z"/> | ||||
| <path d="M1430 529 c-22 -18 -22 -19 -3 -10 12 6 25 16 28 21 9 15 3 12 -25 | ||||
| -11z"/> | ||||
| <path d="M1418 403 c6 -2 18 -2 25 0 6 3 1 5 -13 5 -14 0 -19 -2 -12 -5z"/> | ||||
| </g> | ||||
| </svg> | ||||
|  | ||||
| Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.5 KiB | 
| @ -1,5 +0,0 @@ | ||||
| // jest-dom adds custom jest matchers for asserting on DOM nodes.
 | ||||
| // allows you to do things like:
 | ||||
| // expect(element).toHaveTextContent(/react/i)
 | ||||
| // learn more: https://github.com/testing-library/jest-dom
 | ||||
| import '@testing-library/jest-dom'; | ||||