diff --git a/client/src/actions/presentation.ts b/client/src/actions/presentation.ts index 0c3f2466556ab573341d190c14f83a198c41fcdb..17e2e57d8529de48e183d11d38d5965eac5f8b23 100644 --- a/client/src/actions/presentation.ts +++ b/client/src/actions/presentation.ts @@ -3,8 +3,8 @@ This file handles actions for the presentation redux state */ import axios from 'axios' -import { Timer } from '../interfaces/Timer' -import store, { AppDispatch, RootState } from './../store' +import { TimerState } from '../interfaces/Timer' +import { AppDispatch, RootState } from './../store' import Types from './types' /** Save competition in presentation state from input id */ @@ -35,18 +35,13 @@ export const setCurrentSlideByOrder = (order: number) => (dispatch: AppDispatch, export const setPresentationCode = (code: string) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_PRESENTATION_CODE, payload: code }) } + /** Set timer to input value */ -export const setPresentationTimer = (timer: Timer) => (dispatch: AppDispatch) => { +export const setPresentationTimer = (timer: TimerState) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_PRESENTATION_TIMER, payload: timer }) } -/** Decrement timer */ -export const setPresentationTimerDecrement = () => (dispatch: AppDispatch) => { - dispatch({ - type: Types.SET_PRESENTATION_TIMER, - payload: { - enabled: store.getState().presentation.timer.enabled, - value: store.getState().presentation.timer.value - 1, - }, - }) +/** Set show_scoreboard to input value */ +export const setPresentationShowScoreboard = (show_scoreboard: boolean) => (dispatch: AppDispatch) => { + dispatch({ type: Types.SET_PRESENTATION_SHOW_SCOREBOARD, payload: show_scoreboard }) } diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index eca08ccd5feeaaece2a2a665ba22e51dee9a9677..59dd672d6ac96c210305980d67c27ddf697f3c31 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -44,6 +44,7 @@ export default { SET_PRESENTATION_SLIDE_ID: 'SET_PRESENTATION_SLIDE_ID', SET_PRESENTATION_CODE: 'SET_PRESENTATION_CODE', SET_PRESENTATION_TIMER: 'SET_PRESENTATION_TIMER', + SET_PRESENTATION_SHOW_SCOREBOARD: 'SET_PRESENTATION_SHOW_SCOREBOARD', // Cities action types SET_CITIES: 'SET_CITIES', diff --git a/client/src/interfaces/Timer.ts b/client/src/interfaces/Timer.ts index 49d1909e15692e68bb8cdef32ddd9f59d6b69409..03704c834d1b51075916d4bd4be3dcc99f9b0e53 100644 --- a/client/src/interfaces/Timer.ts +++ b/client/src/interfaces/Timer.ts @@ -1,4 +1,4 @@ -export interface Timer { +export interface TimerState { + value: number | null enabled: boolean - value: number } diff --git a/client/src/pages/admin/AdminPage.tsx b/client/src/pages/admin/AdminPage.tsx index 1609384136c90a68e228f84ef81fe00193ab09ac..f77051b12decd0a0760d490d15ea12b1f135cdda 100644 --- a/client/src/pages/admin/AdminPage.tsx +++ b/client/src/pages/admin/AdminPage.tsx @@ -88,7 +88,6 @@ const AdminView: React.FC = () => { let activeIndex if (isAdmin) activeIndex = menuAdminItems.findIndex((menuItem) => location.pathname.endsWith(menuItem.route)) else activeIndex = menuEditorItems.findIndex((menuItem) => location.pathname.endsWith(menuItem.route)) - console.log(activeIndex, isAdmin, location.pathname, menuEditorItems) if (activeIndex !== -1) setOpenIndex(activeIndex) } diff --git a/client/src/pages/presentationEditor/components/SlideDisplay.tsx b/client/src/pages/presentationEditor/components/SlideDisplay.tsx index 8326bf86a4a381dbe6b680d2f85c9b2fa235c7ed..da5bb92fa481c7d0e07cec900f57525d72e863f2 100644 --- a/client/src/pages/presentationEditor/components/SlideDisplay.tsx +++ b/client/src/pages/presentationEditor/components/SlideDisplay.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '../../../hooks' import PresentationComponent from '../../views/components/PresentationComponent' import Timer from '../../views/components/Timer' import RndComponent from './RndComponent' -import { Center, SlideEditorContainer, SlideEditorContainerRatio, SlideEditorPaper } from './styled' +import { Center, SlideDisplayText, SlideEditorContainer, SlideEditorContainerRatio, SlideEditorPaper } from './styled' type SlideDisplayProps = { //Prop to distinguish between editor and active competition @@ -59,13 +59,13 @@ const SlideDisplay = ({ variant, activeViewTypeId, currentSlideId }: SlideDispla <SlideEditorContainer> <SlideEditorContainerRatio> <SlideEditorPaper ref={editorPaperRef}> - <Typography variant="h3" style={{ position: 'absolute', left: 5, top: 5 }}> - {variant === 'editor' && `Tid kvar: ${slide?.timer}`} + <SlideDisplayText scale={scale}> + {variant === 'editor' && slide?.timer ? `Tid kvar: ${slide?.timer}` : ''} {variant === 'presentation' && <Timer />} - </Typography> - <Typography variant="h3" style={{ position: 'absolute', right: 5, top: 5 }}> + </SlideDisplayText> + <SlideDisplayText scale={scale} right> {slide && `Sida: ${slide?.order + 1} / ${totalSlides}`} - </Typography> + </SlideDisplayText> {(competitionBackgroundImage || slideBackgroundImage) && ( <img src={`/static/images/${ diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index c214a7ce1cdf896d0219e8794f87fb79b641bb6f..e46fc8b9d8d8f3ce6ef370a043f99f2aeb735379 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -145,3 +145,16 @@ export const QuestionComponent = styled.div` export const SettingsItemContainer = styled.div` padding: 5px; ` + +interface SlideDisplayTextProps { + scale: number + right?: boolean +} + +export const SlideDisplayText = styled(Typography)<SlideDisplayTextProps>` + position: absolute; + top: 5px; + left: ${(props) => (props.right ? undefined : 5)}px; + right: ${(props) => (props.right ? 5 : undefined)}px; + font-size: ${(props) => 24 * props.scale}px; +` diff --git a/client/src/pages/views/AudienceViewPage.tsx b/client/src/pages/views/AudienceViewPage.tsx index af1f18b3df30473ee466cd4b0acdf708123d5d89..0024e701d8dea1f14baa2fe88484837744a5f9ee 100644 --- a/client/src/pages/views/AudienceViewPage.tsx +++ b/client/src/pages/views/AudienceViewPage.tsx @@ -2,8 +2,9 @@ import { Snackbar, Typography } from '@material-ui/core' import { Alert } from '@material-ui/lab' import React, { useEffect, useState } from 'react' import { useAppSelector } from '../../hooks' -import { socketConnect, socketJoinPresentation } from '../../sockets' +import { socketConnect } from '../../sockets' import SlideDisplay from '../presentationEditor/components/SlideDisplay' +import Scoreboard from './components/Scoreboard' import { PresentationBackground, PresentationContainer } from './styled' const AudienceViewPage: React.FC = () => { @@ -12,10 +13,11 @@ const AudienceViewPage: React.FC = () => { const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id const [successMessageOpen, setSuccessMessageOpen] = useState(true) const competitionName = useAppSelector((state) => state.presentation.competition.name) + const showScoreboard = useAppSelector((state) => state.presentation.show_scoreboard) + useEffect(() => { if (code && code !== '') { socketConnect('Audience') - socketJoinPresentation() } }, []) if (activeViewTypeId) { @@ -27,6 +29,7 @@ const AudienceViewPage: React.FC = () => { <Snackbar open={successMessageOpen} autoHideDuration={4000} onClose={() => setSuccessMessageOpen(false)}> <Alert severity="success">{`Du har gått med i tävlingen "${competitionName}" som åskådare`}</Alert> </Snackbar> + {showScoreboard && <Scoreboard />} </PresentationBackground> ) } diff --git a/client/src/pages/views/JudgeViewPage.tsx b/client/src/pages/views/JudgeViewPage.tsx index c25b09dd29c6fe7d9930fab352d1006b0ccee7c0..675da803377ce23134485d0c2bf0bbc9c57c87a4 100644 --- a/client/src/pages/views/JudgeViewPage.tsx +++ b/client/src/pages/views/JudgeViewPage.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useState } from 'react' import { getPresentationCompetition } from '../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../hooks' import { RichSlide } from '../../interfaces/ApiRichModels' -import { socketConnect, socketJoinPresentation } from '../../sockets' +import { socketConnect } from '../../sockets' import { renderSlideIcon } from '../../utils/renderSlideIcon' import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { SlideListItem } from '../presentationEditor/styled' @@ -64,7 +64,6 @@ const JudgeViewPage: React.FC = () => { useEffect(() => { if (code && code !== '') { socketConnect('Judge') - socketJoinPresentation() } }, []) useEffect(() => { @@ -75,12 +74,14 @@ const JudgeViewPage: React.FC = () => { dispatch(getPresentationCompetition(competitionId.toString())) } }, [operatorActiveSlideId]) - useEffect(() => { - // Every second tic of the timer, load new answers - if (timer.value % 2 === 0 && competitionId) { - dispatch(getPresentationCompetition(competitionId.toString())) - } - }, [timer.value]) + // useEffect(() => { + // // Every second tic of the timer, load new answers + // // TODO: use a set interval that updates every second ( look in Timer.tsx in clien/src/pages/views/components ) + // // Then clear interval when timer - Date.now() is negative + // if (timer !== null && timer - (Date.now() % 2) === 0 && competitionId) { + // dispatch(getPresentationCompetition(competitionId.toString())) + // } + // }, [timer]) return ( <div style={{ height: '100%' }}> <JudgeAppBar position="fixed"> diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx index 2081249e980a8349807659f69ca3850efc97d623..d99def171227f3a0511760b6fe99dabdbbc57052 100644 --- a/client/src/pages/views/OperatorViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -7,11 +7,9 @@ import { DialogContent, DialogContentText, DialogTitle, - List, ListItem, ListItemText, makeStyles, - Popover, Snackbar, Theme, Tooltip, @@ -30,18 +28,10 @@ import axios from 'axios' import React, { useEffect, useState } from 'react' import { useHistory } from 'react-router-dom' import { useAppSelector } from '../../hooks' -import { RichTeam } from '../../interfaces/ApiRichModels' -import { - socketConnect, - socketEndPresentation, - socketSetSlide, - socketSetSlideNext, - socketSetSlidePrev, - socketStartPresentation, - socketStartTimer, -} from '../../sockets' +import { socketConnect, socketEndPresentation, socketSync } from '../../sockets' import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { Center } from '../presentationEditor/components/styled' +import Scoreboard from './components/Scoreboard' import Timer from './components/Timer' import { OperatorButton, @@ -62,11 +52,6 @@ import { * * =========================================== * TODO: - * - Instead of copying code for others to join the competition, copy URL. - * - * - * - Fix scoreboard - * * - When two userers are connected to the same Localhost:5000 and updates/starts/end competition it * creates a bug where the competition can't be started. * =========================================== @@ -108,9 +93,9 @@ const OperatorViewPage: React.FC = () => { const classes = useStyles() const teams = useAppSelector((state) => state.presentation.competition.teams) const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) - const competitionId = useAppSelector((state) => state.competitionLogin.data?.competition_id) const presentation = useAppSelector((state) => state.presentation) const activeId = useAppSelector((state) => state.presentation.competition.id) + const timer = useAppSelector((state) => state.presentation.timer) const history = useHistory() const viewTypes = useAppSelector((state) => state.types.viewTypes) const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id @@ -119,10 +104,15 @@ const OperatorViewPage: React.FC = () => { (state) => state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.order ) + const slideTimer = useAppSelector((state) => + activeSlideOrder !== undefined ? state.presentation.competition.slides[activeSlideOrder].timer : null + ) + const isFirstSlide = activeSlideOrder === 0 + const isLastSlide = useAppSelector((state) => activeSlideOrder === state.presentation.competition.slides.length - 1) + const showScoreboard = useAppSelector((state) => state.presentation.show_scoreboard) + useEffect(() => { socketConnect('Operator') - socketSetSlide - setTimeout(startCompetition, 1000) // Wait for socket to connect }, []) /** Handles the browsers back button and if pressed cancels the ongoing competition */ @@ -131,22 +121,12 @@ const OperatorViewPage: React.FC = () => { endCompetition() } - const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => { - setAnchorEl(event.currentTarget) - } - const handleClose = () => { setOpen(false) setOpenCode(false) setAnchorEl(null) } - const startCompetition = () => { - socketStartPresentation() // Calls the socket to start competition - console.log('started competition for') - console.log(competitionId) - } - /** Making sure the user wants to exit the competition by displaying a dialog box */ const handleVerifyExit = () => { setOpen(true) @@ -199,11 +179,21 @@ const OperatorViewPage: React.FC = () => { return typeName } - /** Sums the scores for the teams. */ - const addScore = (team: RichTeam) => { - let totalScore = 0 - team.question_scores.forEach((q) => (totalScore += q.score)) - return totalScore + const handleStartTimer = () => { + if (!slideTimer) return + + if (!timer.enabled) socketSync({ timer: { value: Date.now() + 1000 * slideTimer, enabled: true } }) + else socketSync({ timer: { ...timer, enabled: false } }) + } + + const handleSetNextSlide = () => { + if (activeSlideOrder !== undefined) + socketSync({ slide_order: activeSlideOrder + 1, timer: { value: null, enabled: false } }) + } + + const handleSetPrevSlide = () => { + if (activeSlideOrder !== undefined) + socketSync({ slide_order: activeSlideOrder - 1, timer: { value: null, enabled: false } }) } return ( @@ -256,7 +246,6 @@ const OperatorViewPage: React.FC = () => { </Button> </DialogActions> </Dialog> - <OperatorHeader> <Tooltip title="Avsluta tävling" arrow> <OperatorButton onClick={handleVerifyExit} variant="contained" color="secondary"> @@ -298,61 +287,46 @@ const OperatorViewPage: React.FC = () => { <div style={{ height: 0, paddingTop: 140 }} /> <OperatorFooter> <ToolBarContainer> - <Tooltip title="Föregående" arrow> - <OperatorButton onClick={socketSetSlidePrev} variant="contained"> + <Tooltip title="Föregående sida" arrow> + <OperatorButton onClick={handleSetPrevSlide} variant="contained" disabled={isFirstSlide}> <ChevronLeftIcon fontSize="large" /> </OperatorButton> </Tooltip> - <Tooltip title="Starta Timer" arrow> - <OperatorButton onClick={socketStartTimer} variant="contained"> - <TimerIcon fontSize="large" /> - <Timer disableText /> - </OperatorButton> - </Tooltip> + {slideTimer && ( + <Tooltip title="Starta timer" arrow> + <OperatorButton + onClick={handleStartTimer} + variant="contained" + disabled={timer.value !== null && !timer.enabled} + > + <TimerIcon fontSize="large" /> + <Timer disableText /> + </OperatorButton> + </Tooltip> + )} - <Tooltip title="Ställning" arrow> - <OperatorButton onClick={handleOpenPopover} variant="contained"> + <Tooltip title="Visa ställning för publik" arrow> + <OperatorButton onClick={() => socketSync({ show_scoreboard: true })} variant="contained"> <AssignmentIcon fontSize="large" /> </OperatorButton> </Tooltip> + {showScoreboard && <Scoreboard isOperator />} - <Tooltip title="Koder" arrow> + <Tooltip title="Visa koder" arrow> <OperatorButton onClick={handleOpenCodes} variant="contained"> <SupervisorAccountIcon fontSize="large" /> </OperatorButton> </Tooltip> - <Tooltip title="Nästa" arrow> - <OperatorButton onClick={socketSetSlideNext} variant="contained"> + <Tooltip title="Nästa sida" arrow> + <OperatorButton onClick={handleSetNextSlide} variant="contained" disabled={isLastSlide}> <ChevronRightIcon fontSize="large" /> </OperatorButton> </Tooltip> </ToolBarContainer> </OperatorFooter> - <Popover - open={Boolean(anchorEl)} - anchorEl={anchorEl} - onClose={handleClose} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'center', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'center', - }} - > - {(!teams || teams.length === 0) && 'Det finns inga lag i denna tävling'} - <List> - {teams && - teams.map((team) => ( - <ListItem key={team.id}> - {team.name} score:{addScore(team)} - </ListItem> - ))} - </List> - </Popover> + <Snackbar open={successMessageOpen && Boolean(competitionName)} autoHideDuration={4000} diff --git a/client/src/pages/views/TeamViewPage.tsx b/client/src/pages/views/TeamViewPage.tsx index 2ebc09269095480fe231fc3e05f5900eaf57e62a..eb9cb5e6196361bc127bb932412e1a98df357b29 100644 --- a/client/src/pages/views/TeamViewPage.tsx +++ b/client/src/pages/views/TeamViewPage.tsx @@ -2,7 +2,7 @@ import { Snackbar } from '@material-ui/core' import { Alert } from '@material-ui/lab' import React, { useEffect, useState } from 'react' import { useAppSelector } from '../../hooks' -import { socketConnect, socketJoinPresentation } from '../../sockets' +import { socketConnect } from '../../sockets' import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { OperatorContainer, OperatorHeader, PresentationBackground, PresentationContainer } from './styled' @@ -24,7 +24,6 @@ const TeamViewPage: React.FC = () => { useEffect(() => { if (code && code !== '') { socketConnect('Team') - socketJoinPresentation() } }, []) return ( diff --git a/client/src/pages/views/components/Scoreboard.tsx b/client/src/pages/views/components/Scoreboard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4cbc0c36605716b3edc7ab3439c2cd75c4008464 --- /dev/null +++ b/client/src/pages/views/components/Scoreboard.tsx @@ -0,0 +1,69 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItem, + ListItemText, +} from '@material-ui/core' +import React from 'react' +import { useAppSelector } from '../../../hooks' +import { RichTeam } from '../../../interfaces/ApiRichModels' +import { socketSync } from '../../../sockets' +import { Center } from '../../presentationEditor/components/styled' + +type ScoreboardProps = { + isOperator?: boolean +} + +const Scoreboard = ({ isOperator }: ScoreboardProps) => { + const teams = useAppSelector((state) => state.presentation.competition.teams) + + /** Sums the scores for the teams. */ + const addScore = (team: RichTeam) => { + let totalScore = 0 + for (let j = 0; j < team.question_scores.length; j++) { + totalScore = totalScore + team.question_scores[j].score + } + return totalScore + } + + return ( + <Dialog open aria-labelledby="max-width-dialog-title" maxWidth="xl"> + <Center> + <DialogTitle id="max-width-dialog-title" style={{ width: '100%' }}> + <h1>Ställning</h1> + </DialogTitle> + </Center> + <DialogContent> + {(!teams || teams.length === 0) && 'Det finns inga lag i denna tävling'} + <List> + {teams && + teams + .sort((a, b) => (addScore(a) < addScore(b) ? 1 : 0)) + .map((team) => ( + <ListItem key={team.id}> + <ListItemText primary={team.name} /> + <ListItemText + primary={`${addScore(team)} poäng`} + style={{ textAlign: 'right', marginLeft: '25px' }} + /> + </ListItem> + ))} + </List> + </DialogContent> + + {isOperator && ( + <DialogActions> + <Button onClick={() => socketSync({ show_scoreboard: false })} color="primary"> + Stäng + </Button> + </DialogActions> + )} + </Dialog> + ) +} + +export default Scoreboard diff --git a/client/src/pages/views/components/Timer.tsx b/client/src/pages/views/components/Timer.tsx index f96806e27bdad78856a0b0ac4e09dd20b5923098..e04c8bc1842e0bd36cb8543f55c6febff60976bd 100644 --- a/client/src/pages/views/components/Timer.tsx +++ b/client/src/pages/views/components/Timer.tsx @@ -1,22 +1,6 @@ -import React, { useEffect } from 'react' -import { setPresentationTimer, setPresentationTimerDecrement } from '../../../actions/presentation' +import React, { useEffect, useState } from 'react' +import { setPresentationTimer } from '../../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../../hooks' -import store from '../../../store' - -/* const mapStateToProps = (state: any) => { - return { - timer: state.presentation.timer, - timer_start_value: state.presentation.slide.timer, - } -} - -const mapDispatchToProps = (dispatch: any) => { - return { - // tickTimer: () => dispatch(tickTimer(1)), - } -} */ - -let timerIntervalId: NodeJS.Timeout type TimerProps = { disableText?: boolean @@ -24,27 +8,49 @@ type TimerProps = { const Timer = ({ disableText }: TimerProps) => { const dispatch = useAppDispatch() - const slide = store - .getState() - .presentation.competition.slides.find((slide) => slide.id === store.getState().presentation.activeSlideId) - const timerStartValue = slide?.timer const timer = useAppSelector((state) => state.presentation.timer) + const [remainingTimer, setRemainingTimer] = useState<number>(0) + const [timerIntervalId, setTimerIntervalId] = useState<NodeJS.Timeout | null>(null) + const slideTimer = useAppSelector( + (state) => + state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.timer + ) + useEffect(() => { - if (!slide || !slide.timer) return - dispatch(setPresentationTimer({ enabled: false, value: slide.timer })) - }, [timerStartValue]) + if (slideTimer) setRemainingTimer(slideTimer) + }, [slideTimer]) useEffect(() => { - if (timer.enabled) { - timerIntervalId = setInterval(() => { - dispatch(setPresentationTimerDecrement()) - }, 1000) - } else { - clearInterval(timerIntervalId) + if (!timer.enabled) { + if (timerIntervalId !== null) clearInterval(timerIntervalId) + + if (timer.value !== null) { + setRemainingTimer(0) + } else if (slideTimer) { + setRemainingTimer(slideTimer * 1000) + } + + return } - }, [timer.enabled]) - return <div>{`${!disableText ? 'Tid kvar:' : ''} ${timer.value}`}</div> + setTimerIntervalId( + setInterval(() => { + if (timer.value === null) return + if (timer.enabled === false && timerIntervalId !== null) clearInterval(timerIntervalId) + + if (timer.value - Date.now() < 0) { + setRemainingTimer(0) + dispatch(setPresentationTimer({ ...timer, enabled: false })) + if (timerIntervalId !== null) clearInterval(timerIntervalId) + return + } + + setRemainingTimer(timer.value - Date.now()) + }, 500) + ) + }, [timer.enabled, slideTimer]) + + return <div>{`${!disableText ? 'Tid kvar:' : ''} ${Math.round(remainingTimer / 1000)}`}</div> } export default Timer diff --git a/client/src/reducers/presentationReducer.test.ts b/client/src/reducers/presentationReducer.test.ts index e0368f7fc342bcc2b241cbf6eca3d368d34d649f..638d98d924dc14d7b6a2e4fc4c6a81d7db37cab9 100644 --- a/client/src/reducers/presentationReducer.test.ts +++ b/client/src/reducers/presentationReducer.test.ts @@ -14,9 +14,10 @@ const initialState = { activeSlideId: -1, code: '', timer: { + value: null, enabled: false, - value: 0, }, + show_scoreboard: false, } it('should return the initial state', () => { @@ -44,6 +45,7 @@ it('should handle SET_PRESENTATION_COMPETITION', () => { activeSlideId: initialState.activeSlideId, code: initialState.code, timer: initialState.timer, + show_scoreboard: initialState.show_scoreboard, }) }) @@ -59,5 +61,6 @@ it('should handle SET_PRESENTATION_SLIDE_ID', () => { activeSlideId: testSlideId, code: initialState.code, timer: initialState.timer, + show_scoreboard: initialState.show_scoreboard, }) }) diff --git a/client/src/reducers/presentationReducer.ts b/client/src/reducers/presentationReducer.ts index cc183fadade89bd7eb504e8b501be8deb3aa3b04..06baa7d972052ed898f5bcf9f120974739a59d5b 100644 --- a/client/src/reducers/presentationReducer.ts +++ b/client/src/reducers/presentationReducer.ts @@ -1,6 +1,6 @@ import { AnyAction } from 'redux' import Types from '../actions/types' -import { Timer } from '../interfaces/Timer' +import { TimerState } from '../interfaces/Timer' import { RichCompetition } from './../interfaces/ApiRichModels' /** Define a type for the presentation state */ @@ -8,7 +8,8 @@ interface PresentationState { competition: RichCompetition activeSlideId: number code: string - timer: Timer + timer: TimerState + show_scoreboard: boolean } /** Define the initial values for the presentation state */ @@ -25,9 +26,10 @@ const initialState: PresentationState = { activeSlideId: -1, code: '', timer: { + value: null, enabled: false, - value: 0, }, + show_scoreboard: false, } /** Intercept actions for presentation state and update the state */ @@ -41,7 +43,7 @@ export default function (state = initialState, action: AnyAction) { case Types.SET_PRESENTATION_CODE: return { ...state, - code: action.payload, + code: action.payload as string, } case Types.SET_PRESENTATION_SLIDE_ID: return { @@ -49,13 +51,14 @@ export default function (state = initialState, action: AnyAction) { activeSlideId: action.payload as number, } case Types.SET_PRESENTATION_TIMER: - const timer = action.payload as Timer - if (action.payload.value == 0) { - timer.enabled = false + return { + ...state, + timer: action.payload as TimerState, } + case Types.SET_PRESENTATION_SHOW_SCOREBOARD: return { ...state, - timer, + show_scoreboard: action.payload as boolean, } default: return state diff --git a/client/src/sockets.ts b/client/src/sockets.ts index dabaf4b9c26cef497c179aad7df293cac9647bc7..3c27298fa63cf6a18027b013c45f4d469b960498 100644 --- a/client/src/sockets.ts +++ b/client/src/sockets.ts @@ -1,114 +1,46 @@ -/** - * This is a comment on the module level, i.e. the entire file. - * - * For this to appear in the documentation this is needed at the bottom. - * @module - */ - import io from 'socket.io-client' -import { setCurrentSlideByOrder, setPresentationTimer } from './actions/presentation' -import { Timer } from './interfaces/Timer' +import { setCurrentSlideByOrder, setPresentationShowScoreboard, setPresentationTimer } from './actions/presentation' +import { TimerState } from './interfaces/Timer' import store from './store' -interface SetSlideInterface { - slide_order: number -} - -interface TimerInterface { - value: number - enabled: boolean -} - -interface SetTimerInterface { - timer: TimerInterface +interface SyncInterface { + slide_order?: number + timer?: TimerState + show_scoreboard?: boolean } let socket: SocketIOClient.Socket -/** - * You can also comment functions, like usual. This will automatically appear - * in the documentation, no more needed. - */ export const socketConnect = (role: 'Judge' | 'Operator' | 'Team' | 'Audience') => { - if (!socket) { - const token = localStorage[role] - socket = io('localhost:5000', { - transportOptions: { - polling: { - extraHeaders: { - Authorization: token, - }, + if (socket) return + + const token = localStorage[`${role}Token`] + socket = io('localhost:5000', { + transportOptions: { + polling: { + extraHeaders: { + Authorization: token, }, }, - }) - - socket.on('set_slide', (data: SetSlideInterface) => { - setCurrentSlideByOrder(data.slide_order)(store.dispatch, store.getState) - }) - - socket.on('set_timer', (data: SetTimerInterface) => { - setPresentationTimer(data.timer)(store.dispatch) - }) - - socket.on('end_presentation', () => { - socket.disconnect() - }) - } -} - -export const socketStartPresentation = () => { - socket.emit('start_presentation', { competition_id: store.getState().presentation.competition.id }) -} - -export const socketJoinPresentation = () => { - socket.emit('join_presentation', { code: store.getState().presentation.code }) // TODO: Send code gotten from auth/login/<code> api call -} - -export const socketEndPresentation = () => { - socket.emit('end_presentation', { competition_id: store.getState().presentation.competition.id }) -} - -export const socketSetSlideNext = () => { - const activeSlide = store - .getState() - .presentation.competition.slides.find((slide) => slide.id === store.getState().presentation.activeSlideId) - if (!activeSlide) return - socketSetSlide(activeSlide.order + 1) // TODO: Check that this slide exists -} - -export const socketSetSlidePrev = () => { - const activeSlide = store - .getState() - .presentation.competition.slides.find((slide) => slide.id === store.getState().presentation.activeSlideId) - if (!activeSlide) return - socketSetSlide(activeSlide.order - 1) // TODO: Check that this slide exists -} + }, + }) -/** - * You can also comment a function like, adding more information to either - * the paramters or the return value. - * - * @param slide_order This is a parameter to the function. - * @returns This function returns nothing. - */ -export const socketSetSlide = (slide_order: number) => { - if (slide_order < 0 || store.getState().presentation.competition.slides.length <= slide_order) { - return - } + socket.on('sync', (data: SyncInterface) => { + // The order of these is important, for some reason + if (data.timer !== undefined) setPresentationTimer(data.timer)(store.dispatch) + if (data.slide_order !== undefined) setCurrentSlideByOrder(data.slide_order)(store.dispatch, store.getState) + if (data.show_scoreboard !== undefined) setPresentationShowScoreboard(data.show_scoreboard)(store.dispatch) + }) - socket.emit('set_slide', { - competition_id: store.getState().presentation.competition.id, - slide_order: slide_order, + socket.on('end_presentation', () => { + socket.disconnect() }) } -export const socketSetTimer = (timer: Timer) => { - socket.emit('set_timer', { - competition_id: store.getState().presentation.competition.id, - timer: timer, - }) +export const socketEndPresentation = () => { + socket.emit('end_presentation') } -export const socketStartTimer = () => { - socketSetTimer({ enabled: true, value: store.getState().presentation.timer.value }) +export const socketSync = (syncData: SyncInterface) => { + socket.emit('sync', syncData) } diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index 26301d646c98f06fa641223e2e206e6eb544ab1f..e29fd4ee12e9426b6278856cd262faa2df16c2d4 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -8,9 +8,9 @@ from datetime import datetime, timedelta import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, protect_route, text_response -from app.core import sockets from app.core.codes import verify_code from app.core.dto import AuthDTO +from app.core.sockets import is_active_competition from app.database.models import User, Whitelist from flask import current_app, has_app_context from flask_jwt_extended import create_access_token, get_jti, get_raw_jwt @@ -164,7 +164,7 @@ class AuthLoginCode(Resource): item_code = dbc.get.code_by_code(code) if item_code.view_type_id != 4: - if item_code.competition_id not in sockets.presentations: + if not is_active_competition(item_code.competition_id): api.abort(codes.UNAUTHORIZED, "Competition not active") # Create jwt that is only valid for 8 hours diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py index a1675b2f0211aa52ed6327a67b6c54a9ad2d70f4..47af826bb00fe468eaae0b4eca40c8f3c21f196b 100644 --- a/server/app/core/sockets.py +++ b/server/app/core/sockets.py @@ -1,14 +1,10 @@ """ -Contains all functionality related sockets. That is starting and ending a presentation, -joining and leaving a presentation and syncing slides and timer bewteen all clients -connected to the same presentation. +Contains all functionality related sockets. That is starting, joining, ending, +disconnecting from and syncing active competitions. """ import logging -from functools import wraps -from typing import Dict -from app.core import db -from app.database.models import Code, Slide, ViewType +from decorator import decorator from flask.globals import request from flask_jwt_extended import verify_jwt_in_request from flask_jwt_extended.utils import get_jwt_claims @@ -18,258 +14,149 @@ logger = logging.getLogger(__name__) logger.propagate = False logger.setLevel(logging.INFO) -formatter = logging.Formatter("[%(levelname)s] %(funcName)s: %(message)s") +formatter = logging.Formatter("[%(levelname)s] %(message)s") stream_handler = logging.StreamHandler() stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) sio = SocketIO(cors_allowed_origins="http://localhost:3000") -presentations = {} +active_competitions = {} -def _is_allowed(allowed, actual): - return actual and "*" in allowed or actual in allowed - - -def protect_route(allowed_views=None): - def wrapper(f): - @wraps(f) - def inner(*args, **kwargs): - try: - verify_jwt_in_request() - except: - logger.warning("Missing Authorization Header") - return - - nonlocal allowed_views - allowed_views = allowed_views or [] - claims = get_jwt_claims() - view = claims.get("view") - if not _is_allowed(allowed_views, view): - logger.warning(f"View '{view}' is not allowed to access route only accessible by '{allowed_views}'") - return - - return f(*args, **kwargs) - - return inner - - return wrapper - - -@sio.on("connect") -def connect() -> None: - logger.info(f"Client '{request.sid}' connected") - - -@sio.on("disconnect") -def disconnect() -> None: +def _unpack_claims(): """ - Remove client from the presentation it was in. Delete presentation if no - clients are connected to it. + :return: A tuple containing competition_id and view, gotten from claim + :rtype: tuple """ - for competition_id, presentation in presentations.items(): - if request.sid in presentation["clients"]: - del presentation["clients"][request.sid] - logger.debug(f"Client '{request.sid}' left presentation '{competition_id}'") - break - if presentations and not presentations[competition_id]["clients"]: - del presentations[competition_id] - logger.info(f"No people left in presentation '{competition_id}', ended presentation") + claims = get_jwt_claims() + return claims["competition_id"], claims["view"] - logger.info(f"Client '{request.sid}' disconnected") - -@protect_route(allowed_views=["Operator"]) -@sio.on("start_presentation") -def start_presentation(data: Dict) -> None: +def is_active_competition(competition_id): """ - Starts a presentation if that competition is currently not active. + :return: True if competition with competition_id is currently active else False + :rtype: bool """ + return competition_id in active_competitions - competition_id = data["competition_id"] - - if competition_id in presentations: - logger.error( - f"Client '{request.sid}' failed to start competition '{competition_id}', presentation already active" - ) - return - - presentations[competition_id] = { - "clients": {request.sid: {"view_type": "Operator"}}, - "slide": 0, - "timer": {"enabled": False, "start_value": None, "value": None}, - } - - join_room(competition_id) - logger.debug(f"Client '{request.sid}' joined room {competition_id}") - logger.info(f"Client '{request.sid}' started competition '{competition_id}'") +def _get_sync_variables(active_competition, sync_values): + return {key: value for key, value in active_competition.items() if key in sync_values} -@protect_route(allowed_views=["Operator"]) -@sio.on("end_presentation") -def end_presentation(data: Dict) -> None: +@decorator +def authorize_client(f, allowed_views=None, require_active_competition=True, *args, **kwargs): """ - End a presentation by sending end_presentation to all connected clients. - - The only clients allowed to do this is the one that started the presentation. - - Log error message if no presentation exists with the send id or if this - client is not in that presentation. + Decorator used to authorize a client that sends socket events. Check that + the client has authorization headers, that client view gotten from claims + is in allowed_views and that the competition the clients is in is active + if require_active_competition is True. """ - competition_id = data["competition_id"] - - if competition_id not in presentations: - logger.error( - f"Client '{request.sid}' failed to end presentation '{competition_id}', no such presentation exists" - ) - return - - if request.sid not in presentations[competition_id]["clients"]: - logger.error( - f"Client '{request.sid}' failed to end presentation '{competition_id}', client not in presentation" - ) - return - if presentations[competition_id]["clients"][request.sid]["view_type"] != "Operator": - logger.error(f"Client '{request.sid}' failed to end presentation '{competition_id}', client is not operator") + try: + verify_jwt_in_request() + except: + logger.error(f"Won't call function '{f.__name__}': Missing Authorization Header") return - del presentations[competition_id] - logger.debug(f"Deleted presentation {competition_id}") - - emit("end_presentation", room=competition_id, include_self=True) - logger.debug(f"Emitting event 'end_presentation' to room {competition_id} including self") - - logger.info(f"Client '{request.sid}' ended presentation '{competition_id}'") - - -@sio.on("join_presentation") -def join_presentation(data: Dict) -> None: - """ - Join a currently active presentation. - - Log error message if given code doesn't exist, if not presentation associated - with that code exists or if client is already in the presentation. - """ - code = data["code"] - item_code = db.session.query(Code).filter(Code.code == code).first() - - if not item_code: - logger.error(f"Client '{request.sid}' failed to join presentation with code '{code}', no such code exists") - return + def _is_allowed(allowed, actual): + return actual and "*" in allowed or actual in allowed - competition_id = item_code.competition_id + competition_id, view = _unpack_claims() - if competition_id not in presentations: - logger.error( - f"Client '{request.sid}' failed to join presentation '{competition_id}', no such presentation exists" - ) + if require_active_competition and not is_active_competition(competition_id): + logger.error(f"Won't call function '{f.__name__}': Competition '{competition_id}' is not active") return - if request.sid in presentations[competition_id]["clients"]: - logger.error( - f"Client '{request.sid}' failed to join presentation '{competition_id}', client already in presentation" - ) + allowed_views = allowed_views or [] + if not _is_allowed(allowed_views, view): + logger.error(f"Won't call function '{f.__name__}': View '{view}' is not '{' or '.join(allowed_views)}'") return - # TODO: Write function in database controller to do this - view_type_name = db.session.query(ViewType).filter(ViewType.id == item_code.view_type_id).one().name - - presentations[competition_id]["clients"][request.sid] = {"view_type": view_type_name} + return f(*args, **kwargs) - join_room(competition_id) - logger.debug(f"Client '{request.sid}' joined room {competition_id}") - logger.info(f"Client '{request.sid}' joined competition '{competition_id}'") - - emit("set_slide", {"slide_order": presentations[competition_id]["slide"]}) - - -@protect_route(allowed_views=["Operator"]) -@sio.on("set_slide") -def set_slide(data: Dict) -> None: +@sio.event +@authorize_client(require_active_competition=False, allowed_views=["*"]) +def connect() -> None: """ - Sync slides between all clients in the same presentation by sending - set_slide to them. - - Log error if the given competition_id is not active, if client is not in - that presentation or the client is not the one who started the presentation. + Connect to a active competition. If competition with competition_id is not active, + start it if client is an operator, otherwise ignore it. """ - competition_id = data["competition_id"] - slide_order = data["slide_order"] - - if competition_id not in presentations: + competition_id, view = _unpack_claims() + + if is_active_competition(competition_id): + active_competition = active_competitions[competition_id] + active_competition["client_count"] += 1 + join_room(competition_id) + emit("sync", _get_sync_variables(active_competition, ["slide_order", "timer"])) + logger.info(f"Client '{request.sid}' with view '{view}' joined competition '{competition_id}'") + elif view == "Operator": + join_room(competition_id) + active_competitions[competition_id] = { + "client_count": 1, + "slide_order": 0, + "timer": { + "value": None, + "enabled": False, + }, + "show_scoreboard": False, + } + logger.info(f"Client '{request.sid}' with view '{view}' started competition '{competition_id}'") + else: logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', no such presentation exists" + f"Client '{request.sid}' with view '{view}' tried to join non active competition '{competition_id}'" ) - return - if request.sid not in presentations[competition_id]["clients"]: - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client not in presentation" - ) - return - if presentations[competition_id]["clients"][request.sid]["view_type"] != "Operator": - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client is not operator" - ) - return +@sio.event +@authorize_client(allowed_views=["*"]) +def disconnect() -> None: + """ + Remove client from the active_competition it was in. Delete active_competition if no + clients are connected to it. + """ - num_slides = db.session.query(Slide).filter(Slide.competition_id == competition_id).count() + competition_id, _ = _unpack_claims() + active_competitions[competition_id]["client_count"] -= 1 + logger.info(f"Client '{request.sid}' disconnected from competition '{competition_id}'") - if not (0 <= slide_order < num_slides): - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', slide number {slide_order} does not exist" - ) - return + if active_competitions[competition_id]["client_count"] <= 0: + del active_competitions[competition_id] + logger.info(f"No people left in active_competition '{competition_id}', ended active_competition") - presentations[competition_id]["slide"] = slide_order - emit("set_slide", {"slide_order": slide_order}, room=competition_id, include_self=True) - logger.debug(f"Emitting event 'set_slide' to room {competition_id} including self") +@sio.event +@authorize_client(allowed_views=["Operator"]) +def end_presentation() -> None: + """ + End a active_competition by sending end_presentation to all connected clients. + """ - logger.info(f"Client '{request.sid}' set slide '{slide_order}' in competition '{competition_id}'") + competition_id, _ = _unpack_claims() + emit("end_presentation", room=competition_id, include_self=True) -@protect_route(allowed_views=["Operator"]) -@sio.on("set_timer") -def set_timer(data: Dict) -> None: +@sio.event +@authorize_client(allowed_views=["Operator"]) +def sync(data) -> None: """ - Sync slides between all clients in the same presentation by sending - set_timer to them. - - Log error if the given competition_id is not active, if client is not in - that presentation or the client is not the one who started the presentation. + Sync active_competition for all clients connected to competition. """ - competition_id = data["competition_id"] - timer = data["timer"] - - if competition_id not in presentations: - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', no such presentation exists" - ) - return - - if request.sid not in presentations[competition_id]["clients"]: - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client not in presentation" - ) - return - if presentations[competition_id]["clients"][request.sid]["view_type"] != "Operator": - logger.error( - f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client is not operator" - ) - return + competition_id, view = _unpack_claims() + active_competition = active_competitions[competition_id] - # TODO: Save timer in presentation, maybe? + for key, value in data.items(): + if key not in active_competition: + logger.warning(f"Invalid sync data: '{key}':'{value}'") - emit("set_timer", {"timer": timer}, room=competition_id, include_self=True) - logger.debug(f"Emitting event 'set_timer' to room {competition_id} including self") + active_competition[key] = value - logger.info(f"Client '{request.sid}' set timer '{timer}' in presentation '{competition_id}'") + emit("sync", _get_sync_variables(active_competition, data), room=competition_id, include_self=True) + logger.info( + f"Client '{request.sid}' with view '{view}' synced values {_get_sync_variables(active_competition, data)} in competition '{competition_id}'" + ) diff --git a/server/requirements.txt b/server/requirements.txt index 6a9e48fc29b527a9a3609716953d4ba5a52fe4e2..472b12cdf8229664d6d4bc679e6a85cb941341d7 100644 Binary files a/server/requirements.txt and b/server/requirements.txt differ diff --git a/server/tests/test_app.py b/server/tests/test_app.py index ff4a11f76ff2ab9dba7dfb268db3286793cd066f..6439c465deb3b8662df68f457d967d3bfec63759 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -447,7 +447,7 @@ def test_authorization(client): add_default_values() # Fake that competition 1 is active - sockets.presentations[1] = {} + sockets.active_competitions[1] = {} #### TEAM #### # Login in with team code